diff --git a/.core_files.yaml b/.core_files.yaml new file mode 100644 index 00000000000..27daff11f35 --- /dev/null +++ b/.core_files.yaml @@ -0,0 +1,120 @@ +# Defines a list of files that are part of main core of Home Assistant. +# Changes to these files/filters define how our CI test suite is ran. +core: &core + - homeassistant/*.py + - homeassistant/auth/** + - homeassistant/helpers/* + - homeassistant/package_constraints.txt + - homeassistant/util/* + - pyproject.yaml + - requirements.txt + - setup.cfg + +# Our base platforms, that are used by other integrations +base_platforms: &base_platforms + - homeassistant/components/air_quality/* + - homeassistant/components/alarm_control_panel/* + - homeassistant/components/binary_sensor/* + - homeassistant/components/button/* + - homeassistant/components/calendar/* + - homeassistant/components/camera/* + - homeassistant/components/climate/* + - homeassistant/components/cover/* + - homeassistant/components/device_tracker/* + - homeassistant/components/fan/* + - homeassistant/components/geo_location/* + - homeassistant/components/humidifier/* + - homeassistant/components/image_processing/* + - homeassistant/components/light/* + - homeassistant/components/lock/* + - homeassistant/components/media_player/* + - homeassistant/components/notify/* + - homeassistant/components/number/* + - homeassistant/components/remote/* + - homeassistant/components/scene/* + - homeassistant/components/select/* + - homeassistant/components/sensor/* + - homeassistant/components/siren/* + - homeassistant/components/stt/* + - homeassistant/components/switch/* + - homeassistant/components/tts/* + - homeassistant/components/vacuum/* + - homeassistant/components/water_heater/* + - homeassistant/components/weather/* + +# Extra components that trigger the full suite +components: &components + - homeassistant/components/alert/* + - homeassistant/components/alexa/* + - homeassistant/components/auth/* + - homeassistant/components/automation/* + - homeassistant/components/cloud/* + - homeassistant/components/config/* + - homeassistant/components/configurator/* + - homeassistant/components/conversation/* + - homeassistant/components/demo/* + - homeassistant/components/device_automation/* + - homeassistant/components/dhcp/* + - homeassistant/components/discovery/* + - homeassistant/components/energy/* + - homeassistant/components/ffmpeg/* + - homeassistant/components/frontend/* + - homeassistant/components/google_assistant/* + - homeassistant/components/group/* + - homeassistant/components/hassio/* + - homeassistant/components/homeassistant/** + - homeassistant/components/image/* + - homeassistant/components/input_boolean/* + - homeassistant/components/input_datetime/* + - homeassistant/components/input_number/* + - homeassistant/components/input_select/* + - homeassistant/components/input_text/* + - homeassistant/components/logbook/* + - homeassistant/components/logger/* + - homeassistant/components/lovelace/* + - homeassistant/components/media_source/* + - homeassistant/components/mqtt/* + - homeassistant/components/network/* + - homeassistant/components/onboarding/* + - homeassistant/components/otp/* + - homeassistant/components/persistent_notification/* + - homeassistant/components/person/* + - homeassistant/components/recorder/* + - homeassistant/components/safe_mode/* + - homeassistant/components/script/* + - homeassistant/components/shopping_list/* + - homeassistant/components/ssdp/* + - homeassistant/components/stream/* + - homeassistant/components/sun/* + - homeassistant/components/system_health/* + - homeassistant/components/tag/* + - homeassistant/components/template/* + - homeassistant/components/timer/* + - homeassistant/components/usb/* + - homeassistant/components/webhook/* + - homeassistant/components/websocket_api/* + - homeassistant/components/zeroconf/* + - homeassistant/components/zone/* + +# Testing related files that affect the whole test/linting suite +tests: &tests + - codecov.yaml + - requirements_test_pre_commit.txt + - requirements_test.txt + - tests/common.py + - tests/conftest.py + - tests/ignore_uncaught_exceptions.py + - tests/mock/* + - tests/test_util/* + - tests/testing_config/** + +other: &other + - .github/workflows/* + - homeassistant/scripts/** + +any: + - *base_platforms + - *components + - *core + - *other + - *tests diff --git a/.coveragerc b/.coveragerc index bd19ab31b52..05bb880c2f2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -90,6 +90,8 @@ omit = homeassistant/components/azure_devops/sensor.py homeassistant/components/azure_service_bus/* homeassistant/components/baidu/tts.py + homeassistant/components/balboa/__init__.py + homeassistant/components/balboa/entity.py homeassistant/components/beewi_smartclim/sensor.py homeassistant/components/bbb_gpio/* homeassistant/components/bbox/device_tracker.py @@ -123,6 +125,7 @@ omit = homeassistant/components/bosch_shc/__init__.py homeassistant/components/bosch_shc/binary_sensor.py homeassistant/components/bosch_shc/const.py + homeassistant/components/bosch_shc/cover.py homeassistant/components/bosch_shc/entity.py homeassistant/components/bosch_shc/sensor.py homeassistant/components/braviatv/__init__.py @@ -137,7 +140,9 @@ omit = homeassistant/components/broadlink/updater.py homeassistant/components/brottsplatskartan/sensor.py homeassistant/components/browser/* + homeassistant/components/brunt/__init__.py homeassistant/components/brunt/cover.py + homeassistant/components/brunt/const.py homeassistant/components/bsblan/climate.py homeassistant/components/bt_home_hub_5/device_tracker.py homeassistant/components/bt_smarthub/device_tracker.py @@ -281,6 +286,7 @@ omit = homeassistant/components/eq3btsmart/climate.py homeassistant/components/esphome/__init__.py homeassistant/components/esphome/binary_sensor.py + homeassistant/components/esphome/button.py homeassistant/components/esphome/camera.py homeassistant/components/esphome/climate.py homeassistant/components/esphome/cover.py @@ -369,7 +375,6 @@ omit = homeassistant/components/fritzbox_callmonitor/const.py homeassistant/components/fritzbox_callmonitor/base.py homeassistant/components/fritzbox_callmonitor/sensor.py - homeassistant/components/fronius/sensor.py homeassistant/components/frontier_silicon/media_player.py homeassistant/components/futurenow/light.py homeassistant/components/garadget/cover.py @@ -386,10 +391,6 @@ omit = homeassistant/components/glances/sensor.py homeassistant/components/gntp/notify.py homeassistant/components/goalfeed/* - homeassistant/components/goalzero/__init__.py - homeassistant/components/goalzero/binary_sensor.py - homeassistant/components/goalzero/sensor.py - homeassistant/components/goalzero/switch.py homeassistant/components/google/* homeassistant/components/google_cloud/tts.py homeassistant/components/google_maps/device_tracker.py @@ -399,8 +400,6 @@ omit = homeassistant/components/google_travel_time/sensor.py homeassistant/components/gpmdp/media_player.py homeassistant/components/gpsd/sensor.py - homeassistant/components/greeneye_monitor/* - homeassistant/components/greeneye_monitor/sensor.py homeassistant/components/greenwave/light.py homeassistant/components/group/notify.py homeassistant/components/growatt_server/sensor.py @@ -421,9 +420,6 @@ omit = homeassistant/components/harmony/data.py homeassistant/components/harmony/remote.py homeassistant/components/harmony/util.py - homeassistant/components/hassio/binary_sensor.py - homeassistant/components/hassio/entity.py - homeassistant/components/hassio/sensor.py homeassistant/components/haveibeenpwned/sensor.py homeassistant/components/hdmi_cec/* homeassistant/components/heatmiser/climate.py @@ -433,8 +429,9 @@ omit = homeassistant/components/hisense_aehw4a1/climate.py homeassistant/components/hitron_coda/device_tracker.py homeassistant/components/hive/__init__.py - homeassistant/components/hive/climate.py + homeassistant/components/hive/alarm_control_panel.py homeassistant/components/hive/binary_sensor.py + homeassistant/components/hive/climate.py homeassistant/components/hive/light.py homeassistant/components/hive/sensor.py homeassistant/components/hive/switch.py @@ -502,7 +499,6 @@ omit = homeassistant/components/incomfort/* homeassistant/components/intesishome/* homeassistant/components/ios/* - homeassistant/components/iota/* homeassistant/components/iperf3/* homeassistant/components/iqvia/* homeassistant/components/irish_rail_transport/sensor.py @@ -521,6 +517,8 @@ omit = homeassistant/components/isy994/switch.py homeassistant/components/itach/remote.py homeassistant/components/itunes/media_player.py + homeassistant/components/jellyfin/__init__.py + homeassistant/components/jellyfin/media_source.py homeassistant/components/joaoapps_join/* homeassistant/components/juicenet/__init__.py homeassistant/components/juicenet/const.py @@ -541,7 +539,14 @@ omit = homeassistant/components/keyboard_remote/* homeassistant/components/kira/* homeassistant/components/kiwi/lock.py - homeassistant/components/knx/* + homeassistant/components/knx/__init__.py + homeassistant/components/knx/climate.py + homeassistant/components/knx/cover.py + homeassistant/components/knx/expose.py + homeassistant/components/knx/knx_entity.py + homeassistant/components/knx/light.py + homeassistant/components/knx/notify.py + homeassistant/components/knx/schema.py homeassistant/components/kodi/__init__.py homeassistant/components/kodi/browse_media.py homeassistant/components/kodi/const.py @@ -551,7 +556,9 @@ omit = homeassistant/components/kostal_plenticore/__init__.py homeassistant/components/kostal_plenticore/const.py homeassistant/components/kostal_plenticore/helper.py + homeassistant/components/kostal_plenticore/select.py homeassistant/components/kostal_plenticore/sensor.py + homeassistant/components/kostal_plenticore/switch.py homeassistant/components/kwb/sensor.py homeassistant/components/lacrosse/sensor.py homeassistant/components/lametric/* @@ -590,7 +597,6 @@ omit = homeassistant/components/lookin/models.py homeassistant/components/lookin/sensor.py homeassistant/components/lookin/climate.py - homeassistant/components/loopenergy/sensor.py homeassistant/components/luci/device_tracker.py homeassistant/components/luftdaten/__init__.py homeassistant/components/luftdaten/sensor.py @@ -640,7 +646,6 @@ omit = homeassistant/components/miflora/sensor.py homeassistant/components/mikrotik/hub.py homeassistant/components/mikrotik/device_tracker.py - homeassistant/components/mill/__init__.py homeassistant/components/mill/climate.py homeassistant/components/mill/const.py homeassistant/components/mill/sensor.py @@ -669,7 +674,6 @@ omit = homeassistant/components/mutesync/binary_sensor.py homeassistant/components/nest/const.py homeassistant/components/mvglive/sensor.py - homeassistant/components/mychevy/* homeassistant/components/mycroft/* homeassistant/components/mysensors/__init__.py homeassistant/components/mysensors/binary_sensor.py @@ -692,6 +696,8 @@ omit = homeassistant/components/myq/light.py homeassistant/components/nad/media_player.py homeassistant/components/nanoleaf/__init__.py + homeassistant/components/nanoleaf/button.py + homeassistant/components/nanoleaf/entity.py homeassistant/components/nanoleaf/light.py homeassistant/components/neato/__init__.py homeassistant/components/neato/api.py @@ -871,6 +877,8 @@ omit = homeassistant/components/remote_rpi_gpio/* homeassistant/components/rest/notify.py homeassistant/components/rest/switch.py + homeassistant/components/ridwell/__init__.py + homeassistant/components/ridwell/sensor.py homeassistant/components/ring/camera.py homeassistant/components/ripple/sensor.py homeassistant/components/rocketchat/notify.py @@ -906,6 +914,7 @@ omit = homeassistant/components/screenlogic/binary_sensor.py homeassistant/components/screenlogic/climate.py homeassistant/components/screenlogic/light.py + homeassistant/components/screenlogic/number.py homeassistant/components/screenlogic/sensor.py homeassistant/components/screenlogic/services.py homeassistant/components/screenlogic/switch.py @@ -926,6 +935,7 @@ omit = homeassistant/components/shodan/sensor.py homeassistant/components/shelly/__init__.py homeassistant/components/shelly/binary_sensor.py + homeassistant/components/shelly/climate.py homeassistant/components/shelly/entity.py homeassistant/components/shelly/light.py homeassistant/components/shelly/sensor.py @@ -1081,6 +1091,14 @@ omit = homeassistant/components/todoist/calendar.py homeassistant/components/todoist/const.py homeassistant/components/tof/sensor.py + homeassistant/components/tolo/__init__.py + homeassistant/components/tolo/binary_sensor.py + homeassistant/components/tolo/button.py + homeassistant/components/tolo/climate.py + homeassistant/components/tolo/fan.py + homeassistant/components/tolo/light.py + homeassistant/components/tolo/select.py + homeassistant/components/tolo/sensor.py homeassistant/components/tomato/device_tracker.py homeassistant/components/toon/__init__.py homeassistant/components/toon/binary_sensor.py @@ -1115,6 +1133,7 @@ omit = homeassistant/components/tradfri/sensor.py homeassistant/components/tradfri/switch.py homeassistant/components/trafikverket_train/sensor.py + homeassistant/components/trafikverket_weatherstation/__init__.py homeassistant/components/trafikverket_weatherstation/sensor.py homeassistant/components/transmission/sensor.py homeassistant/components/transmission/switch.py @@ -1124,6 +1143,7 @@ omit = homeassistant/components/tuya/__init__.py homeassistant/components/tuya/base.py homeassistant/components/tuya/binary_sensor.py + homeassistant/components/tuya/button.py homeassistant/components/tuya/camera.py homeassistant/components/tuya/climate.py homeassistant/components/tuya/const.py @@ -1139,8 +1159,6 @@ omit = homeassistant/components/tuya/switch.py homeassistant/components/tuya/util.py homeassistant/components/tuya/vacuum.py - homeassistant/components/twentemilieu/const.py - homeassistant/components/twentemilieu/sensor.py homeassistant/components/twilio_call/notify.py homeassistant/components/twilio_sms/notify.py homeassistant/components/twitter/notify.py @@ -1169,7 +1187,9 @@ omit = homeassistant/components/velbus/switch.py homeassistant/components/velux/* homeassistant/components/venstar/__init__.py + homeassistant/components/venstar/binary_sensor.py homeassistant/components/venstar/climate.py + homeassistant/components/venstar/sensor.py homeassistant/components/verisure/__init__.py homeassistant/components/verisure/alarm_control_panel.py homeassistant/components/verisure/binary_sensor.py @@ -1186,7 +1206,12 @@ omit = homeassistant/components/vesync/light.py homeassistant/components/vesync/switch.py homeassistant/components/viaggiatreno/sensor.py - homeassistant/components/vicare/* + homeassistant/components/vicare/binary_sensor.py + homeassistant/components/vicare/climate.py + homeassistant/components/vicare/const.py + homeassistant/components/vicare/__init__.py + homeassistant/components/vicare/sensor.py + homeassistant/components/vicare/water_heater.py homeassistant/components/vilfo/__init__.py homeassistant/components/vilfo/sensor.py homeassistant/components/vilfo/const.py @@ -1263,6 +1288,7 @@ omit = homeassistant/components/yale_smart_alarm/coordinator.py homeassistant/components/yamaha_musiccast/__init__.py homeassistant/components/yamaha_musiccast/media_player.py + homeassistant/components/yamaha_musiccast/number.py homeassistant/components/yandex_transport/* homeassistant/components/yeelightsunflower/light.py homeassistant/components/yi/camera.py diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 708e360d59b..3f8dea3b657 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -23,12 +23,12 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 with: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -56,9 +56,7 @@ jobs: uses: home-assistant/actions/helpers/codenotary@master with: source: file://${{ github.workspace }}/OFFICIAL_IMAGE - user: ${{ secrets.VCN_USER }} - password: ${{ secrets.VCN_PASSWORD }} - organisation: home-assistant.io + token: ${{ secrets.CAS_TOKEN }} build_python: name: Build PyPi package @@ -67,10 +65,10 @@ jobs: if: needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -97,11 +95,11 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -133,15 +131,15 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2021.09.0 + uses: home-assistant/builder@2021.11.4 with: args: | $BUILD_ARGS \ --${{ matrix.arch }} \ --target /data \ - --with-codenotary "${{ secrets.VCN_USER }}" "${{ secrets.VCN_PASSWORD }}" "${{ secrets.VCN_ORG }}" \ - --validate-from "${{ secrets.VCN_ORG }}" \ --generic ${{ needs.init.outputs.version }} + env: + CAS_API_KEY: ${{ secrets.CAS_TOKEN }} build_machine: name: Build ${{ matrix.machine }} machine core image @@ -170,7 +168,7 @@ jobs: - tinker steps: - name: Checkout the repository - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Login to DockerHub uses: docker/login-action@v1.10.0 @@ -186,14 +184,14 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2021.09.0 + uses: home-assistant/builder@2021.11.4 with: args: | $BUILD_ARGS \ --target /data/machine \ - --with-codenotary "${{ secrets.VCN_USER }}" "${{ secrets.VCN_PASSWORD }}" "${{ secrets.VCN_ORG }}" \ - --validate-from "${{ secrets.VCN_ORG }}" \ --machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}" + env: + CAS_API_KEY: ${{ secrets.CAS_TOKEN }} publish_ha: name: Publish version files @@ -201,7 +199,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -233,7 +231,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Login to DockerHub uses: docker/login-action@v1.10.0 @@ -248,8 +246,8 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Install VCN tools - uses: home-assistant/actions/helpers/vcn@master + - name: Install CAS tools + uses: home-assistant/actions/helpers/cas@master - name: Build Meta Image shell: bash @@ -293,8 +291,7 @@ jobs: function validate_image() { local image=${1} - state="$(vcn authenticate --org home-assistant.io --output json docker://${image} | jq '.verification.status // 2')" - if [[ "${state}" != "0" ]]; then + if ! cas authenticate --signerID notary@home-assistant.io "docker://${image}"; then echo "Invalid signature!" exit 1 fi diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index aa5a6ddd3ca..ebbd2b2fb9e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,7 +15,115 @@ env: PRE_COMMIT_CACHE: ~/.cache/pre-commit SQLALCHEMY_WARN_20: 1 +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: + changes: + name: Determine what has changed + outputs: + # In case of issues with the partial run, use the following line instead: + # test_full_suite: 'true' + test_full_suite: ${{ steps.info.outputs.test_full_suite }} + core: ${{ steps.core.outputs.changes }} + integrations: ${{ steps.integrations.outputs.changes }} + integrations_glob: ${{ steps.info.outputs.integrations_glob }} + tests: ${{ steps.info.outputs.tests }} + tests_glob: ${{ steps.info.outputs.tests_glob }} + test_groups: ${{ steps.info.outputs.test_groups }} + test_group_count: ${{ steps.info.outputs.test_group_count }} + runs-on: ubuntu-latest + steps: + - name: Check out code from GitHub + uses: actions/checkout@v2.4.0 + - name: Filter for core changes + uses: dorny/paths-filter@v2.10.2 + id: core + with: + filters: .core_files.yaml + - name: Create a list of integrations to filter for changes + run: | + integrations=$(ls -Ad ./homeassistant/components/[!_]* | xargs -n 1 basename) + touch .integration_paths.yaml + for integration in $integrations; do + echo "${integration}: [homeassistant/components/${integration}/*, tests/components/${integration}/*]" \ + >> .integration_paths.yaml; + done + echo "Result:" + cat .integration_paths.yaml + - name: Filter for integration changes + uses: dorny/paths-filter@v2.10.2 + id: integrations + with: + filters: .integration_paths.yaml + - name: Collect additional information + id: info + run: | + # Defaults + integrations_glob="" + test_full_suite="true" + test_groups="[1, 2, 3, 4, 5, 6]" + test_group_count=6 + tests="[]" + tests_glob="" + + if [[ "${{ steps.integrations.outputs.changes }}" != "[]" ]]; + then + # Create a file glob for the integrations + integrations_glob=$(echo '${{ steps.integrations.outputs.changes }}' | jq -cSr '. | join(",")') + [[ "${integrations_glob}" == *","* ]] && integrations_glob="{${integrations_glob}}" + + # Create list of testable integrations + possible_integrations=$(echo '${{ steps.integrations.outputs.changes }}' | jq -cSr '.[]') + tests=$( + for integration in ${possible_integrations}; + do + if [[ -d "tests/components/${integration}" ]]; then + echo -n "\"${integration}\","; + fi; + done + ) + + [[ ! -z "${tests}" ]] && tests="${tests::-1}" + tests="[${tests}]" + test_groups="${tests}" + # Test group count should be 1, we don't split partial tests + test_group_count=1 + + # Create a file glob for the integrations tests + tests_glob=$(echo "${tests}" | jq -cSr '. | join(",")') + [[ "${tests_glob}" == *","* ]] && tests_glob="{${tests_glob}}" + + test_full_suite="false" + fi + + # We need to run the full suite on certain branches. + # Or, in case core files are touched, for the full suite as well. + if [[ "${{ github.ref }}" == "refs/heads/dev" ]] \ + || [[ "${{ github.ref }}" == "refs/heads/master" ]] \ + || [[ "${{ github.ref }}" == "refs/heads/rc" ]] \ + || [[ "${{ steps.core.outputs.any }}" == "true" ]]; + then + test_groups="[1, 2, 3, 4, 5, 6]" + test_group_count=6 + test_full_suite="true" + fi + + # Output & sent to GitHub Actions + echo "test_full_suite: ${test_full_suite}" + echo "::set-output name=test_full_suite::${test_full_suite}" + echo "integrations_glob: ${integrations_glob}" + echo "::set-output name=integrations_glob::${integrations_glob}" + echo "test_group_count: ${test_group_count}" + echo "::set-output name=test_group_count::${test_group_count}" + echo "test_groups: ${test_groups}" + echo "::set-output name=test_groups::${test_groups}" + echo "tests: ${tests}" + echo "::set-output name=tests::${tests}" + echo "tests_glob: ${tests_glob}" + echo "::set-output name=tests_glob::${tests_glob}" + # Separate job to pre-populate the base dependency cache # This prevent upcoming jobs to do the same individually prepare-base: @@ -26,10 +134,10 @@ jobs: pre-commit-key: ${{ steps.generate-pre-commit-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Generate partial Python venv restore key @@ -41,7 +149,7 @@ jobs: hashFiles('homeassistant/package_constraints.txt') }}" - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: venv key: >- @@ -65,7 +173,7 @@ jobs: hashFiles('.pre-commit-config.yaml') }}" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: ${{ env.PRE_COMMIT_CACHE }} key: >- @@ -78,61 +186,23 @@ jobs: . venv/bin/activate pre-commit install-hooks - lint-bandit: - name: Check bandit - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache@v2.1.6 - with: - path: venv - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.prepare-base.outputs.python-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: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v2.1.6 - with: - path: ${{ env.PRE_COMMIT_CACHE }} - key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} - - name: Fail job if pre-commit cache restore failed - if: steps.cache-precommit.outputs.cache-hit != 'true' - run: | - echo "Failed to restore pre-commit environment from cache" - exit 1 - - name: Run bandit - run: | - . venv/bin/activate - pre-commit run --hook-stage manual bandit --all-files --show-diff-on-failure - lint-black: name: Check black runs-on: ubuntu-latest - needs: prepare-base + needs: + - changes + - prepare-base steps: - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -144,7 +214,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -153,131 +223,35 @@ jobs: run: | echo "Failed to restore pre-commit environment from cache" exit 1 - - name: Run black + - name: Run black (fully) + if: needs.changes.outputs.test_full_suite == 'true' run: | . venv/bin/activate pre-commit run --hook-stage manual black --all-files --show-diff-on-failure - - lint-codespell: - name: Check codespell - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache@v2.1.6 - with: - path: venv - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.prepare-base.outputs.python-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: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v2.1.6 - with: - path: ${{ env.PRE_COMMIT_CACHE }} - key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} - - name: Fail job if pre-commit cache restore failed - if: steps.cache-precommit.outputs.cache-hit != 'true' - run: | - echo "Failed to restore pre-commit environment from cache" - exit 1 - - name: Register codespell problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/codespell.json" - - name: Run codespell + - name: Run black (partially) + if: needs.changes.outputs.test_full_suite == 'false' + shell: bash run: | . venv/bin/activate - pre-commit run --show-diff-on-failure --hook-stage manual codespell --all-files - - lint-dockerfile: - name: Check Dockerfile - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 - - name: Register hadolint problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/hadolint.json" - - name: Check Dockerfile - uses: docker://hadolint/hadolint:v1.18.2 - with: - args: hadolint Dockerfile - - name: Check Dockerfile.dev - uses: docker://hadolint/hadolint:v1.18.2 - with: - args: hadolint Dockerfile.dev - - lint-executable-shebangs: - name: Check executables - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache@v2.1.6 - with: - path: venv - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.prepare-base.outputs.python-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: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v2.1.6 - with: - path: ${{ env.PRE_COMMIT_CACHE }} - key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} - - name: Fail job if pre-commit cache restore failed - if: steps.cache-precommit.outputs.cache-hit != 'true' - run: | - echo "Failed to restore pre-commit environment from cache" - exit 1 - - name: Register check executables problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json" - - name: Run executables check - run: | - . venv/bin/activate - pre-commit run --hook-stage manual check-executables-have-shebangs --all-files + pre-commit run --hook-stage manual black --files {homeassistant,tests}/components/${{ needs.changes.outputs.integrations_glob }}/* --show-diff-on-failure lint-flake8: name: Check flake8 runs-on: ubuntu-latest - needs: prepare-base + needs: + - changes + - prepare-base steps: - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -289,7 +263,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -301,10 +275,17 @@ jobs: - name: Register flake8 problem matcher run: | echo "::add-matcher::.github/workflows/matchers/flake8.json" - - name: Run flake8 + - name: Run flake8 (fully) + if: needs.changes.outputs.test_full_suite == 'true' run: | . venv/bin/activate pre-commit run --hook-stage manual flake8 --all-files + - name: Run flake8 (partially) + if: needs.changes.outputs.test_full_suite == 'false' + shell: bash + run: | + . venv/bin/activate + pre-commit run --hook-stage manual flake8 --files {homeassistant,tests}/components/${{ needs.changes.outputs.integrations_glob }}/* lint-isort: name: Check isort @@ -312,15 +293,15 @@ jobs: needs: prepare-base steps: - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -332,7 +313,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -346,21 +327,23 @@ jobs: . venv/bin/activate pre-commit run --hook-stage manual isort --all-files --show-diff-on-failure - lint-json: - name: Check JSON + lint-other: + name: Check other linters runs-on: ubuntu-latest - needs: prepare-base + needs: + - changes + - prepare-base steps: - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -372,7 +355,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -381,6 +364,27 @@ jobs: run: | echo "Failed to restore pre-commit environment from cache" exit 1 + + - name: Run pyupgrade (fully) + if: needs.changes.outputs.test_full_suite == 'true' + run: | + . venv/bin/activate + pre-commit run --hook-stage manual pyupgrade --all-files --show-diff-on-failure + - name: Run pyupgrade (partially) + if: needs.changes.outputs.test_full_suite == 'false' + shell: bash + run: | + . venv/bin/activate + pre-commit run --hook-stage manual pyupgrade --files {homeassistant,tests}/components/${{ needs.changes.outputs.integrations_glob }}/* --show-diff-on-failure + + - name: Register yamllint problem matcher + run: | + echo "::add-matcher::.github/workflows/matchers/yamllint.json" + - name: Run yamllint + run: | + . venv/bin/activate + pre-commit run --hook-stage manual yamllint --all-files --show-diff-on-failure + - name: Register check-json problem matcher run: | echo "::add-matcher::.github/workflows/matchers/check-json.json" @@ -389,99 +393,45 @@ jobs: . venv/bin/activate pre-commit run --hook-stage manual check-json --all-files - lint-pyupgrade: - name: Check pyupgrade - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache@v2.1.6 - with: - path: venv - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.prepare-base.outputs.python-key }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' + - name: Register check executables problem matcher run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v2.1.6 - with: - path: ${{ env.PRE_COMMIT_CACHE }} - key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} - - name: Fail job if pre-commit cache restore failed - if: steps.cache-precommit.outputs.cache-hit != 'true' - run: | - echo "Failed to restore pre-commit environment from cache" - exit 1 - - name: Run pyupgrade + echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json" + - name: Run executables check run: | . venv/bin/activate - pre-commit run --hook-stage manual pyupgrade --all-files --show-diff-on-failure + pre-commit run --hook-stage manual check-executables-have-shebangs --all-files - # Disabled until we have the existing issues fixed - # lint-shellcheck: - # name: Check ShellCheck - # runs-on: ubuntu-latest - # needs: prepare-base - # steps: - # - name: Check out code from GitHub - # uses: actions/checkout@v2.3.5 - # - name: Run ShellCheck - # uses: ludeeus/action-shellcheck@0.3.0 - - lint-yaml: - name: Check YAML - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache@v2.1.6 - with: - path: venv - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.prepare-base.outputs.python-key }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' + - name: Register codespell problem matcher run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v2.1.6 - with: - path: ${{ env.PRE_COMMIT_CACHE }} - key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} - - name: Fail job if pre-commit cache restore failed - if: steps.cache-precommit.outputs.cache-hit != 'true' - run: | - echo "Failed to restore pre-commit environment from cache" - exit 1 - - name: Register yamllint problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/yamllint.json" - - name: Run yamllint + echo "::add-matcher::.github/workflows/matchers/codespell.json" + - name: Run codespell run: | . venv/bin/activate - pre-commit run --hook-stage manual yamllint --all-files --show-diff-on-failure + pre-commit run --show-diff-on-failure --hook-stage manual codespell --all-files + + - name: Register hadolint problem matcher + run: | + echo "::add-matcher::.github/workflows/matchers/hadolint.json" + - name: Check Dockerfile + uses: docker://hadolint/hadolint:v1.18.2 + with: + args: hadolint Dockerfile + - name: Check Dockerfile.dev + uses: docker://hadolint/hadolint:v1.18.2 + with: + args: hadolint Dockerfile.dev + + - name: Run bandit (fully) + if: needs.changes.outputs.test_full_suite == 'true' + run: | + . venv/bin/activate + pre-commit run --hook-stage manual bandit --all-files --show-diff-on-failure + - name: Run bandit (partially) + if: needs.changes.outputs.test_full_suite == 'false' + shell: bash + run: | + . venv/bin/activate + pre-commit run --hook-stage manual bandit --files {homeassistant,tests}/components/${{ needs.changes.outputs.integrations_glob }}/* --show-diff-on-failure hassfest: name: Check hassfest @@ -493,10 +443,10 @@ jobs: container: homeassistant/ci-azure:${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -517,15 +467,15 @@ jobs: needs: prepare-base steps: - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -551,7 +501,7 @@ jobs: container: homeassistant/ci-azure:${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Generate partial Python venv restore key id: generate-python-key run: >- @@ -561,7 +511,7 @@ jobs: hashFiles('homeassistant/package_constraints.txt') }}" - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: venv key: >- @@ -588,17 +538,19 @@ jobs: pylint: name: Check pylint runs-on: ubuntu-latest - needs: prepare-tests + needs: + - changes + - prepare-tests strategy: matrix: python-version: [3.8] container: homeassistant/ci-azure:${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -611,25 +563,34 @@ jobs: - name: Register pylint problem matcher run: | echo "::add-matcher::.github/workflows/matchers/pylint.json" - - name: Run pylint + - name: Run pylint (fully) + if: needs.changes.outputs.test_full_suite == 'true' run: | . venv/bin/activate pylint homeassistant + - name: Run pylint (partially) + if: needs.changes.outputs.test_full_suite == 'false' + shell: bash + run: | + . venv/bin/activate + pylint homeassistant/components/${{ needs.changes.outputs.integrations_glob }} mypy: name: Check mypy runs-on: ubuntu-latest - needs: prepare-tests + needs: + - changes + - prepare-tests strategy: matrix: python-version: [3.8] container: homeassistant/ci-azure:${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -642,28 +603,44 @@ jobs: - name: Register mypy problem matcher run: | echo "::add-matcher::.github/workflows/matchers/mypy.json" - - name: Run mypy + - name: Run mypy (fully) + if: needs.changes.outputs.test_full_suite == 'true' run: | . venv/bin/activate mypy homeassistant + - name: Run mypy (partially) + if: needs.changes.outputs.test_full_suite == 'false' + shell: bash + run: | + . venv/bin/activate + mypy homeassistant/components/${{ needs.changes.outputs.integrations_glob }} pytest: runs-on: ubuntu-latest - needs: prepare-tests + if: needs.changes.outputs.test_full_suite == 'true' || needs.changes.outputs.tests_glob + needs: + - changes + - gen-requirements-all + - hassfest + - lint-black + - lint-other + - lint-isort + - mypy + - prepare-tests strategy: fail-fast: false matrix: - group: [1, 2, 3, 4] + group: ${{ fromJson(needs.changes.outputs.test_groups) }} python-version: [3.8, 3.9] name: >- - Run tests Python ${{ matrix.python-version }} (group ${{ matrix.group }}) + Run tests Python ${{ matrix.python-version }} (${{ matrix.group }}) container: homeassistant/ci-azure:${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -683,61 +660,82 @@ jobs: # 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: Run pytest + - name: Register pytest slow test problem matcher + run: | + echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" + - name: Run pytest (fully) + if: needs.changes.outputs.test_full_suite == 'true' run: | . venv/bin/activate - python3 -X dev -bb -m pytest \ + python3 -X dev -m pytest \ -qq \ --timeout=9 \ --durations=10 \ -n auto \ --dist=loadfile \ - --test-group-count 4 \ + --test-group-count ${{ needs.changes.outputs.test_group_count }} \ --test-group=${{ matrix.group }} \ --cov homeassistant \ - --cov-report= \ + --cov-report=xml \ -o console_output_style=count \ -p no:sugar \ tests + - name: Run pytest (partially) + if: needs.changes.outputs.test_full_suite == 'false' && matrix.python-version != '3.8' + run: | + . venv/bin/activate + python3 -X dev -m pytest \ + -qq \ + --timeout=9 \ + --durations=10 \ + -n auto \ + --cov homeassistant.components.${{ matrix.group }} \ + --cov-report=xml \ + --cov-report=term-missing \ + -o console_output_style=count \ + --durations=0 \ + --durations-min=1 \ + -p no:sugar \ + tests/components/${{ matrix.group }} + - name: Run pytest (partially); no coverage + if: needs.changes.outputs.test_full_suite == 'false' && matrix.python-version == '3.8' + run: | + . venv/bin/activate + python3 -X dev -m pytest \ + -qq \ + --timeout=9 \ + --durations=10 \ + -n auto \ + -o console_output_style=count \ + --durations=0 \ + --durations-min=1 \ + -p no:sugar \ + tests/components/${{ matrix.group }} - name: Upload coverage artifact uses: actions/upload-artifact@v2.2.4 with: - name: coverage-${{ matrix.python-version }}-group${{ matrix.group }} - path: .coverage + name: coverage-${{ matrix.python-version }}-${{ matrix.group }} + path: coverage.xml - name: Check dirty run: | ./script/check_dirty coverage: - name: Process test coverage + name: Upload test coverage to Codecov runs-on: ubuntu-latest - needs: ["prepare-tests", "pytest"] - strategy: - matrix: - python-version: [3.8] - container: homeassistant/ci-azure:${{ matrix.python-version }} + needs: + - changes + - pytest steps: - name: Check out code from GitHub - uses: actions/checkout@v2.3.5 - - name: Restore full Python ${{ matrix.python-version }} virtual environment - id: cache-venv - uses: actions/cache@v2.1.6 - with: - path: venv - key: ${{ runner.os }}-${{ matrix.python-version }}-${{ - needs.prepare-tests.outputs.python-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 + uses: actions/checkout@v2.4.0 - name: Download all coverage artifacts uses: actions/download-artifact@v2 - - name: Combine coverage results - run: | - . venv/bin/activate - coverage combine coverage*/.coverage* - coverage report --fail-under=94 - coverage xml - - name: Upload coverage to Codecov + - name: Upload coverage to Codecov (full coverage) + if: needs.changes.outputs.test_full_suite == 'true' + uses: codecov/codecov-action@v2.1.0 + with: + flags: full-suite + - name: Upload coverage to Codecov (partial coverage) + if: needs.changes.outputs.test_full_suite == 'false' uses: codecov/codecov-action@v2.1.0 diff --git a/.github/workflows/matchers/pytest-slow.json b/.github/workflows/matchers/pytest-slow.json new file mode 100644 index 00000000000..31f565a594a --- /dev/null +++ b/.github/workflows/matchers/pytest-slow.json @@ -0,0 +1,18 @@ +{ + "problemMatcher": [ + { + "owner": "python", + "pattern": [ + { + "regexp": "^=+ slowest durations =+$" + }, + { + "regexp": "^((.*s)\\s(call|setup|teardown)\\s+(.*)::(.*))$", + "message": 1, + "file": 2, + "loop": true + } + ] + } + ] +} diff --git a/.github/workflows/translations.yaml b/.github/workflows/translations.yaml index 6e734528f0e..6c9c6700e9f 100644 --- a/.github/workflows/translations.yaml +++ b/.github/workflows/translations.yaml @@ -20,10 +20,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -39,10 +39,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v2.3.1 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index ebb4a65b80e..56b09886736 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -21,7 +21,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Get information id: info @@ -68,7 +68,7 @@ jobs: - "3.9-alpine3.14" steps: - name: Checkout the repository - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Download env_file uses: actions/download-artifact@v2 @@ -108,7 +108,7 @@ jobs: - "3.9-alpine3.14" steps: - name: Checkout the repository - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v2.4.0 - name: Download env_file uses: actions/download-artifact@v2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ef58733100b..91216c9efa9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.27.0 + rev: v2.29.0 hooks: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/psf/black - rev: 21.9b0 + rev: 21.11b1 hooks: - id: black args: @@ -17,7 +17,7 @@ repos: hooks: - id: codespell args: - - --ignore-words-list=hass,alot,datas,dof,dur,ether,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort,ba + - --ignore-words-list=hass,alot,datas,dof,dur,ether,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort,ba,haa - --skip="./.*,*.csv,*.json" - --quiet-level=2 exclude_types: [csv, json] @@ -45,7 +45,7 @@ repos: - --configfile=tests/bandit.yaml files: ^(homeassistant|script|tests)/.+\.py$ - repo: https://github.com/PyCQA/isort - rev: 5.9.3 + rev: 5.10.0 hooks: - id: isort - repo: https://github.com/pre-commit/pre-commit-hooks @@ -61,7 +61,7 @@ repos: - --branch=master - --branch=rc - repo: https://github.com/adrienverge/yamllint.git - rev: v1.26.1 + rev: v1.26.3 hooks: - id: yamllint - repo: https://github.com/pre-commit/mirrors-prettier diff --git a/.strict-typing b/.strict-typing index 685c87aa094..caaef80fe38 100644 --- a/.strict-typing +++ b/.strict-typing @@ -24,6 +24,7 @@ homeassistant.components.bmw_connected_drive.* homeassistant.components.bond.* homeassistant.components.braviatv.* homeassistant.components.brother.* +homeassistant.components.button.* homeassistant.components.calendar.* homeassistant.components.camera.* homeassistant.components.canary.* @@ -32,6 +33,7 @@ homeassistant.components.crownstone.* homeassistant.components.device_automation.* homeassistant.components.device_tracker.* homeassistant.components.devolo_home_control.* +homeassistant.components.devolo_home_network.* homeassistant.components.dlna_dmr.* homeassistant.components.dnsip.* homeassistant.components.dsmr.* @@ -40,17 +42,20 @@ homeassistant.components.efergy.* homeassistant.components.elgato.* homeassistant.components.esphome.* homeassistant.components.energy.* +homeassistant.components.evil_genius_labs.* homeassistant.components.fastdotcom.* homeassistant.components.fitbit.* homeassistant.components.flunearyou.* homeassistant.components.flux_led.* homeassistant.components.forecast_solar.* homeassistant.components.fritzbox.* +homeassistant.components.fronius.* homeassistant.components.frontend.* homeassistant.components.fritz.* homeassistant.components.geo_location.* homeassistant.components.gios.* homeassistant.components.goalzero.* +homeassistant.components.greeneye_monitor.* homeassistant.components.group.* homeassistant.components.guardian.* homeassistant.components.history.* @@ -62,6 +67,7 @@ homeassistant.components.image_processing.* homeassistant.components.input_select.* homeassistant.components.integration.* homeassistant.components.iqvia.* +homeassistant.components.jellyfin.* homeassistant.components.jewish_calendar.* homeassistant.components.knx.* homeassistant.components.kraken.* @@ -93,12 +99,14 @@ homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* homeassistant.components.proximity.* homeassistant.components.rainmachine.* +homeassistant.components.rdw.* homeassistant.components.recollect_waste.* homeassistant.components.recorder.purge homeassistant.components.recorder.repack homeassistant.components.recorder.statistics homeassistant.components.remote.* homeassistant.components.renault.* +homeassistant.components.ridwell.* homeassistant.components.rituals_perfume_genie.* homeassistant.components.rpi_power.* homeassistant.components.samsungtv.* @@ -119,18 +127,24 @@ homeassistant.components.switcher_kis.* homeassistant.components.synology_dsm.* homeassistant.components.systemmonitor.* homeassistant.components.tag.* +homeassistant.components.tailscale.* homeassistant.components.tautulli.* homeassistant.components.tcp.* homeassistant.components.tile.* homeassistant.components.tplink.* +homeassistant.components.tolo.* homeassistant.components.tractive.* homeassistant.components.tradfri.* homeassistant.components.tts.* +homeassistant.components.twentemilieu.* homeassistant.components.upcloud.* homeassistant.components.uptime.* homeassistant.components.uptimerobot.* homeassistant.components.vacuum.* homeassistant.components.vallox.* +homeassistant.components.velbus.* +homeassistant.components.vlc_telnet.* +homeassistant.components.wallbox.* homeassistant.components.water_heater.* homeassistant.components.watttime.* homeassistant.components.weather.* diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 5488c3472de..1aea23fc781 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -64,7 +64,7 @@ "label": "Code Coverage", "detail": "Generate code coverage report for a given integration.", "type": "shell", - "command": "pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing", + "command": "pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing --durations-min=1 --durations=0", "group": { "kind": "test", "isDefault": true diff --git a/CODEOWNERS b/CODEOWNERS index 2612cc3fd18..f7333867069 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -67,12 +67,14 @@ homeassistant/components/axis/* @Kane610 homeassistant/components/azure_devops/* @timmo001 homeassistant/components/azure_event_hub/* @eavanvalkenburg homeassistant/components/azure_service_bus/* @hfurubotten +homeassistant/components/balboa/* @garbled1 homeassistant/components/beewi_smartclim/* @alemuro homeassistant/components/bitcoin/* @fabaff homeassistant/components/bizkaibus/* @UgaitzEtxebarria homeassistant/components/blebox/* @bbx-a @bbx-jp homeassistant/components/blink/* @fronzbot homeassistant/components/blueprint/* @home-assistant/core +homeassistant/components/bluesound/* @thrawnarn homeassistant/components/bmp280/* @belidzs homeassistant/components/bmw_connected_drive/* @gerard33 @rikroe homeassistant/components/bond/* @bdraco @prystupa @joshs85 @@ -84,6 +86,7 @@ homeassistant/components/brunt/* @eavanvalkenburg homeassistant/components/bsblan/* @liudger homeassistant/components/bt_smarthub/* @jxwolstenholme homeassistant/components/buienradar/* @mjj4791 @ties @Robbie1221 +homeassistant/components/button/* @home-assistant/core homeassistant/components/cast/* @emontnemery homeassistant/components/cert_expiry/* @Cereal2nd @jjlawren homeassistant/components/circuit/* @braam @@ -118,6 +121,7 @@ homeassistant/components/denonavr/* @ol-iver @starkillerOG homeassistant/components/derivative/* @afaucogney homeassistant/components/device_automation/* @home-assistant/core homeassistant/components/devolo_home_control/* @2Fake @Shutgun +homeassistant/components/devolo_home_network/* @2Fake @Shutgun homeassistant/components/dexcom/* @gagebenne homeassistant/components/dhcp/* @bdraco homeassistant/components/dht/* @thegardenmonkey @@ -157,6 +161,7 @@ homeassistant/components/epson/* @pszafer homeassistant/components/epsonworkforce/* @ThaStealth homeassistant/components/eq3btsmart/* @rytilahti homeassistant/components/esphome/* @OttoWinter @jesserockz +homeassistant/components/evil_genius_labs/* @balloob homeassistant/components/evohome/* @zxdavb homeassistant/components/ezviz/* @RenierM26 @baqs homeassistant/components/faa_delays/* @ntilley905 @@ -182,7 +187,7 @@ homeassistant/components/freebox/* @hacf-fr @Quentame homeassistant/components/freedompro/* @stefano055415 homeassistant/components/fritz/* @mammuth @AaronDavidSchneider @chemelli74 homeassistant/components/fritzbox/* @mib1185 @flabbamann -homeassistant/components/fronius/* @nielstron +homeassistant/components/fronius/* @nielstron @farmio homeassistant/components/frontend/* @home-assistant/frontend homeassistant/components/garages_amsterdam/* @klaasnicolaas homeassistant/components/gdacs/* @exxamalte @@ -227,7 +232,7 @@ homeassistant/components/homematic/* @pvizeli @danielperna84 homeassistant/components/honeywell/* @rdfurman homeassistant/components/http/* @home-assistant/core homeassistant/components/huawei_lte/* @scop @fphammerle -homeassistant/components/hue/* @balloob @frenck +homeassistant/components/hue/* @balloob @marcelveldt homeassistant/components/huisbaasje/* @dennisschroer homeassistant/components/humidifier/* @home-assistant/core @Shulyaka homeassistant/components/hunterdouglas_powerview/* @bdraco @@ -261,6 +266,7 @@ homeassistant/components/irish_rail_transport/* @ttroy50 homeassistant/components/islamic_prayer_times/* @engrbm87 homeassistant/components/isy994/* @bdraco @shbatm homeassistant/components/izone/* @Swamp-Ig +homeassistant/components/jellyfin/* @j-stienstra homeassistant/components/jewish_calendar/* @tsvi homeassistant/components/juicenet/* @jesserockz homeassistant/components/kaiterra/* @Michsior14 @@ -287,7 +293,6 @@ homeassistant/components/local_ip/* @issacg homeassistant/components/logger/* @home-assistant/core homeassistant/components/logi_circle/* @evanjd homeassistant/components/lookin/* @ANMalko -homeassistant/components/loopenergy/* @pavoni homeassistant/components/lovelace/* @home-assistant/frontend homeassistant/components/luci/* @mzdrale homeassistant/components/luftdaten/* @fabaff @@ -422,6 +427,7 @@ homeassistant/components/raincloud/* @vanstinator homeassistant/components/rainforest_eagle/* @gtdiehl @jcalbert homeassistant/components/rainmachine/* @bachya homeassistant/components/random/* @fabaff +homeassistant/components/rdw/* @frenck homeassistant/components/recollect_waste/* @bachya homeassistant/components/recorder/* @home-assistant/core homeassistant/components/rejseplanen/* @DarkFox @@ -429,6 +435,7 @@ homeassistant/components/renault/* @epenet homeassistant/components/repetier/* @MTrab homeassistant/components/rflink/* @javicalle homeassistant/components/rfxtrx/* @danielhiversen @elupus @RobBie1221 +homeassistant/components/ridwell/* @bachya homeassistant/components/ring/* @balloob homeassistant/components/risco/* @OnFreund homeassistant/components/rituals_perfume_genie/* @milanmeu @@ -520,12 +527,14 @@ homeassistant/components/system_bridge/* @timmo001 homeassistant/components/tado/* @michaelarnauts @noltari homeassistant/components/tag/* @balloob @dmulcahey homeassistant/components/tahoma/* @philklei +homeassistant/components/tailscale/* @frenck homeassistant/components/tankerkoenig/* @guillempages homeassistant/components/tapsaff/* @bazwilliams homeassistant/components/tasmota/* @emontnemery homeassistant/components/tautulli/* @ludeeus homeassistant/components/tellduslive/* @fredrike homeassistant/components/template/* @PhracturedBlue @tetienne @home-assistant/core +homeassistant/components/tesla_wall_connector/* @einarhauks homeassistant/components/tfiac/* @fredrike @mellado homeassistant/components/thethingsnetwork/* @fabaff homeassistant/components/threshold/* @fabaff @@ -534,12 +543,12 @@ homeassistant/components/tile/* @bachya homeassistant/components/time_date/* @fabaff homeassistant/components/tmb/* @alemuro homeassistant/components/todoist/* @boralyl +homeassistant/components/tolo/* @MatthiasLohr homeassistant/components/totalconnect/* @austinmroczek homeassistant/components/tplink/* @rytilahti @thegardenmonkey homeassistant/components/traccar/* @ludeeus homeassistant/components/trace/* @home-assistant/core homeassistant/components/tractive/* @Danielhiversen @zhulik @bieniu -homeassistant/components/tradfri/* @janiversen homeassistant/components/trafikverket_train/* @endor-force homeassistant/components/trafikverket_weatherstation/* @endor-force homeassistant/components/transmission/* @engrbm87 @JPHutchins @@ -575,6 +584,7 @@ homeassistant/components/vizio/* @raman325 homeassistant/components/vlc_telnet/* @rodripf @dmcc @MartinHjelmare homeassistant/components/volkszaehler/* @fabaff homeassistant/components/volumio/* @OnFreund +homeassistant/components/volvooncall/* @molobrakos @decompil3d homeassistant/components/wake_on_lan/* @ntilley905 homeassistant/components/wallbox/* @hesselonline homeassistant/components/waqi/* @andrey-git diff --git a/Dockerfile b/Dockerfile index c802ba9b273..a4d5ce3045d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,12 +7,21 @@ ENV \ WORKDIR /usr/src -## Setup Home Assistant +## Setup Home Assistant Core dependencies +COPY requirements.txt homeassistant/ +COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ +RUN \ + pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ + -r homeassistant/requirements.txt +COPY requirements_all.txt homeassistant/ +RUN \ + pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ + -r homeassistant/requirements_all.txt + +## Setup Home Assistant Core COPY . homeassistant/ RUN \ pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - -r homeassistant/requirements_all.txt \ - && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ -e ./homeassistant \ && python3 -m compileall homeassistant/homeassistant diff --git a/Dockerfile.dev b/Dockerfile.dev index 5ebaa644ce5..727358dae9e 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -30,11 +30,12 @@ RUN git clone --depth 1 https://github.com/home-assistant/hass-release \ WORKDIR /workspaces # Install Python dependencies from requirements -COPY requirements.txt requirements_test.txt requirements_test_pre_commit.txt ./ +COPY requirements.txt ./ COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt -RUN pip3 install -r requirements.txt \ - && pip3 install -r requirements_test.txt \ - && rm -rf requirements.txt requirements_test.txt requirements_test_pre_commit.txt homeassistant/ +RUN pip3 install -r requirements.txt +COPY requirements_test.txt requirements_test_pre_commit.txt ./ +RUN pip3 install -r requirements_test.txt +RUN rm -rf requirements.txt requirements_test.txt requirements_test_pre_commit.txt homeassistant/ # Set the default shell to bash instead of sh ENV SHELL /bin/bash diff --git a/build.json b/build.json deleted file mode 100644 index 1b9c72e8675..00000000000 --- a/build.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "image": "homeassistant/{arch}-homeassistant", - "shadow_repository": "ghcr.io/home-assistant", - "build_from": { - "aarch64": "ghcr.io/home-assistant/aarch64-homeassistant-base:2021.09.0", - "armhf": "ghcr.io/home-assistant/armhf-homeassistant-base:2021.09.0", - "armv7": "ghcr.io/home-assistant/armv7-homeassistant-base:2021.09.0", - "amd64": "ghcr.io/home-assistant/amd64-homeassistant-base:2021.09.0", - "i386": "ghcr.io/home-assistant/i386-homeassistant-base:2021.09.0" - }, - "labels": { - "io.hass.type": "core", - "org.opencontainers.image.title": "Home Assistant", - "org.opencontainers.image.description": "Open-source home automation platform running on Python 3", - "org.opencontainers.image.source": "https://github.com/home-assistant/core", - "org.opencontainers.image.authors": "The Home Assistant Authors", - "org.opencontainers.image.url": "https://www.home-assistant.io/", - "org.opencontainers.image.documentation": "https://www.home-assistant.io/docs/", - "org.opencontainers.image.licenses": "Apache License 2.0" - }, - "version_tag": true -} \ No newline at end of file diff --git a/build.yaml b/build.yaml new file mode 100644 index 00000000000..1d0e18c79ea --- /dev/null +++ b/build.yaml @@ -0,0 +1,20 @@ +image: homeassistant/{arch}-homeassistant +shadow_repository: ghcr.io/home-assistant +build_from: + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2021.09.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2021.09.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2021.09.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2021.09.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2021.09.0 +codenotary: + signer: notary@home-assistant.io + base_image: notary@home-assistant.io +labels: + io.hass.type: core + org.opencontainers.image.title: Home Assistant + org.opencontainers.image.description: Open-source home automation platform running on Python 3 + org.opencontainers.image.source: https://github.com/home-assistant/core + org.opencontainers.image.authors: The Home Assistant Authors + org.opencontainers.image.url: https://www.home-assistant.io/ + org.opencontainers.image.documentation: https://www.home-assistant.io/docs/ + org.opencontainers.image.licenses: Apache License 2.0 diff --git a/codecov.yml b/codecov.yml index 7a9eea730d8..2522ccd90e9 100644 --- a/codecov.yml +++ b/codecov.yml @@ -6,4 +6,28 @@ coverage: default: target: 90 threshold: 0.09 + config-flows: + target: auto + threshold: 0 + paths: + - homeassistant/components/*/config_flow.py + patch: + default: + target: auto + config-flows: + target: 100 + threshold: 0 + paths: + - homeassistant/components/*/config_flow.py comment: false + +# To make partial tests possible, +# we need to carry forward. +flag_management: + default_rules: + carryforward: false + individual_flags: + - name: full-suite + paths: + - ".*" + carryforward: true diff --git a/homeassistant/async_timeout_backcompat.py b/homeassistant/async_timeout_backcompat.py new file mode 100644 index 00000000000..189f64020bb --- /dev/null +++ b/homeassistant/async_timeout_backcompat.py @@ -0,0 +1,42 @@ +"""Provide backwards compat for async_timeout.""" +from __future__ import annotations + +import asyncio +from typing import Any + +import async_timeout + +from homeassistant.helpers.frame import report + + +def timeout( + delay: float | None, loop: asyncio.AbstractEventLoop | None = None +) -> async_timeout.Timeout: + """Backwards compatible timeout context manager that warns with loop usage.""" + if loop is None: + loop = asyncio.get_running_loop() + else: + report( + "called async_timeout.timeout with loop keyword argument. The loop keyword argument is deprecated and calls will fail after Home Assistant 2022.2", + error_if_core=False, + ) + if delay is not None: + deadline: float | None = loop.time() + delay + else: + deadline = None + return async_timeout.Timeout(deadline, loop) + + +def current_task(loop: asyncio.AbstractEventLoop) -> asyncio.Task[Any] | None: + """Backwards compatible current_task.""" + report( + "called async_timeout.current_task. The current_task call is deprecated and calls will fail after Home Assistant 2022.2; use asyncio.current_task instead", + error_if_core=False, + ) + return asyncio.current_task() + + +def enable() -> None: + """Enable backwards compat transitions.""" + async_timeout.timeout = timeout + async_timeout.current_task = current_task # type: ignore[attr-defined] diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index abd5ddc71d5..21afef8b1be 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -214,11 +214,19 @@ class AuthManager: return None async def async_create_system_user( - self, name: str, group_ids: list[str] | None = None + self, + name: str, + *, + group_ids: list[str] | None = None, + local_only: bool | None = None, ) -> models.User: """Create a system user.""" user = await self._store.async_create_user( - name=name, system_generated=True, is_active=True, group_ids=group_ids or [] + name=name, + system_generated=True, + is_active=True, + group_ids=group_ids or [], + local_only=local_only, ) self.hass.bus.async_fire(EVENT_USER_ADDED, {"user_id": user.id}) @@ -226,13 +234,18 @@ class AuthManager: return user async def async_create_user( - self, name: str, group_ids: list[str] | None = None + self, + name: str, + *, + group_ids: list[str] | None = None, + local_only: bool | None = None, ) -> models.User: """Create a user.""" kwargs: dict[str, Any] = { "name": name, "is_active": True, "group_ids": group_ids or [], + "local_only": local_only, } if await self._user_should_be_owner(): @@ -304,13 +317,18 @@ class AuthManager: name: str | None = None, is_active: bool | None = None, group_ids: list[str] | None = None, + local_only: bool | None = None, ) -> None: """Update a user.""" kwargs: dict[str, Any] = {} - if name is not None: - kwargs["name"] = name - if group_ids is not None: - kwargs["group_ids"] = group_ids + + for attr_name, value in ( + ("name", name), + ("group_ids", group_ids), + ("local_only", local_only), + ): + if value is not None: + kwargs[attr_name] = value await self._store.async_update_user(user, **kwargs) if is_active is not None: diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index c935a0da7d0..8acb7c44398 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -42,7 +42,7 @@ class AuthStore: self._groups: dict[str, models.Group] | None = None self._perm_lookup: PermissionLookup | None = None self._store = hass.helpers.storage.Store( - STORAGE_VERSION, STORAGE_KEY, private=True + STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True ) self._lock = asyncio.Lock() @@ -86,6 +86,7 @@ class AuthStore: system_generated: bool | None = None, credentials: models.Credentials | None = None, group_ids: list[str] | None = None, + local_only: bool | None = None, ) -> models.User: """Create a new user.""" if self._users is None: @@ -108,14 +109,14 @@ class AuthStore: "perm_lookup": self._perm_lookup, } - if is_owner is not None: - kwargs["is_owner"] = is_owner - - if is_active is not None: - kwargs["is_active"] = is_active - - if system_generated is not None: - kwargs["system_generated"] = system_generated + for attr_name, value in ( + ("is_owner", is_owner), + ("is_active", is_active), + ("local_only", local_only), + ("system_generated", system_generated), + ): + if value is not None: + kwargs[attr_name] = value new_user = models.User(**kwargs) @@ -152,6 +153,7 @@ class AuthStore: name: str | None = None, is_active: bool | None = None, group_ids: list[str] | None = None, + local_only: bool | None = None, ) -> None: """Update a user.""" assert self._groups is not None @@ -166,7 +168,11 @@ class AuthStore: user.groups = groups user.invalidate_permission_cache() - for attr_name, value in (("name", name), ("is_active", is_active)): + for attr_name, value in ( + ("name", name), + ("is_active", is_active), + ("local_only", local_only), + ): if value is not None: setattr(user, attr_name, value) @@ -417,6 +423,8 @@ class AuthStore: is_active=user_dict["is_active"], system_generated=user_dict["system_generated"], perm_lookup=perm_lookup, + # New in 2021.11 + local_only=user_dict.get("local_only", False), ) for cred_dict in data["credentials"]: @@ -502,6 +510,7 @@ class AuthStore: "is_active": user.is_active, "name": user.name, "system_generated": user.system_generated, + "local_only": user.local_only, } for user in self._users.values() ] diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index 0f53ddc900d..0378d3aeaa8 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -100,7 +100,7 @@ class NotifyAuthModule(MultiFactorAuthModule): super().__init__(hass, config) self._user_settings: _UsersDict | None = None self._user_store = hass.helpers.storage.Store( - STORAGE_VERSION, STORAGE_KEY, private=True + STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True ) self._include = config.get(CONF_INCLUDE, []) self._exclude = config.get(CONF_EXCLUDE, []) diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index e0e2b9522d9..c979ba05b5a 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -77,7 +77,7 @@ class TotpAuthModule(MultiFactorAuthModule): super().__init__(hass, config) self._users: dict[str, str] | None = None self._user_store = hass.helpers.storage.Store( - STORAGE_VERSION, STORAGE_KEY, private=True + STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True ) self._init_lock = asyncio.Lock() diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 758bbdb78e2..e604bf9d21c 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -39,6 +39,7 @@ class User: is_owner: bool = attr.ib(default=False) is_active: bool = attr.ib(default=False) system_generated: bool = attr.ib(default=False) + local_only: bool = attr.ib(default=False) groups: list[Group] = attr.ib(factory=list, eq=False, order=False) diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index 1ffed6f87fd..8d682670e01 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -63,7 +63,7 @@ class Data: """Initialize the user data store.""" self.hass = hass self._store = hass.helpers.storage.Store( - STORAGE_VERSION, STORAGE_KEY, private=True + STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True ) self._data: dict[str, Any] | None = None # Legacy mode will allow usernames to start/end with whitespace diff --git a/homeassistant/auth/providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py index 9ad6da27ce3..dc003c3e6f3 100644 --- a/homeassistant/auth/providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -102,7 +102,7 @@ class ExampleLoginFlow(LoginFlow): self, user_input: dict[str, str] | None = None ) -> FlowResult: """Handle the step of the form.""" - errors = {} + errors = None if user_input is not None: try: @@ -110,7 +110,7 @@ class ExampleLoginFlow(LoginFlow): user_input["username"], user_input["password"] ) except InvalidAuthError: - errors["base"] = "invalid_auth" + errors = {"base": "invalid_auth"} if not errors: user_input.pop("password") diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index 0f2b287a227..fa08dde139f 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -194,6 +194,12 @@ class TrustedNetworksAuthProvider(AuthProvider): if any(ip_addr in trusted_proxy for trusted_proxy in self.trusted_proxies): raise InvalidAuthError("Can't allow access from a proxy server") + if "cloud" in self.hass.config.components: + from hass_nabucasa import remote # pylint: disable=import-outside-toplevel + + if remote.is_cloud_request.get(): + raise InvalidAuthError("Can't allow access from Home Assistant Cloud") + @callback def async_validate_refresh_token( self, refresh_token: RefreshToken, remote_ip: str | None = None diff --git a/homeassistant/backports/__init__.py b/homeassistant/backports/__init__.py new file mode 100644 index 00000000000..e3dc736417a --- /dev/null +++ b/homeassistant/backports/__init__.py @@ -0,0 +1 @@ +"""Backports from newer Python versions.""" diff --git a/homeassistant/backports/enum.py b/homeassistant/backports/enum.py new file mode 100644 index 00000000000..3fa9a582f79 --- /dev/null +++ b/homeassistant/backports/enum.py @@ -0,0 +1,33 @@ +"""Enum backports from standard lib.""" +from __future__ import annotations + +from enum import Enum +from typing import Any, TypeVar + +T = TypeVar("T", bound="StrEnum") + + +class StrEnum(str, Enum): + """Partial backport of Python 3.11's StrEnum for our basic use cases.""" + + def __new__(cls: type[T], value: str, *args: Any, **kwargs: Any) -> T: + """Create a new StrEnum instance.""" + if not isinstance(value, str): + raise TypeError(f"{value!r} is not a string") + return super().__new__(cls, value, *args, **kwargs) # type: ignore[call-overload,no-any-return] + + def __str__(self) -> str: + """Return self.value.""" + return str(self.value) + + @staticmethod + def _generate_next_value_( # pylint: disable=arguments-differ # https://github.com/PyCQA/pylint/issues/5371 + name: str, start: int, count: int, last_values: list[Any] + ) -> Any: + """ + Make `auto()` explicitly unsupported. + + We may revisit this when it's very clear that Python 3.11's + `StrEnum.auto()` behavior will no longer change. + """ + raise TypeError("auto() is not supported by this implementation") diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index f111ff6a079..64a6e98aa87 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -252,8 +252,7 @@ async def async_from_config_dict( f"{'.'.join(str(x) for x in sys.version_info[:3])} is deprecated and will " f"be removed in Home Assistant {REQUIRED_NEXT_PYTHON_HA_RELEASE}. " "Please upgrade Python to " - f"{'.'.join(str(x) for x in REQUIRED_NEXT_PYTHON_VER)} or " - "higher." + f"{'.'.join(str(x) for x in REQUIRED_NEXT_PYTHON_VER[:2])}." ) _LOGGER.warning(msg) hass.components.persistent_notification.async_create( diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index 3407d387169..3da4155dafd 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, + Platform, ) from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv @@ -53,14 +54,14 @@ CAPTURE_IMAGE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}) AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}) PLATFORMS = [ - "alarm_control_panel", - "binary_sensor", - "lock", - "switch", - "cover", - "camera", - "light", - "sensor", + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.LOCK, + Platform.SWITCH, + Platform.COVER, + Platform.CAMERA, + Platform.LIGHT, + Platform.SENSOR, ] diff --git a/homeassistant/components/abode/translations/ja.json b/homeassistant/components/abode/translations/ja.json new file mode 100644 index 00000000000..cd498691f4b --- /dev/null +++ b/homeassistant/components/abode/translations/ja.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "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\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_mfa_code": "\u7121\u52b9\u306aMFA\u30b3\u30fc\u30c9" + }, + "step": { + "mfa": { + "data": { + "mfa_code": "MFA\u30b3\u30fc\u30c9(6\u6841)" + }, + "title": "Abode\u306eMFA\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "E\u30e1\u30fc\u30eb" + }, + "title": "Abode\u306e\u30ed\u30b0\u30a4\u30f3\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "E\u30e1\u30fc\u30eb" + }, + "title": "Abode\u306e\u30ed\u30b0\u30a4\u30f3\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/tr.json b/homeassistant/components/abode/translations/tr.json index d469e43f1f4..6214803198a 100644 --- a/homeassistant/components/abode/translations/tr.json +++ b/homeassistant/components/abode/translations/tr.json @@ -27,7 +27,8 @@ "data": { "password": "Parola", "username": "E-posta" - } + }, + "title": "Abode giri\u015f bilgilerinizi doldurun" } } } diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index a2b428cf597..bc8ae459fd7 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -11,7 +11,7 @@ from aiohttp.client_exceptions import ClientConnectorError from async_timeout import timeout from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -20,7 +20,7 @@ from .const import ATTR_FORECAST, CONF_FORECAST, DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor", "weather"] +PLATFORMS = [Platform.SENSOR, Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 5802695afef..deab90de706 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Final -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import SensorStateClass from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -220,7 +220,7 @@ SENSOR_TYPES: Final[tuple[AccuWeatherSensorDescription, ...]] = ( unit_metric=TEMP_CELSIUS, unit_imperial=TEMP_FAHRENHEIT, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), AccuWeatherSensorDescription( key="Ceiling", @@ -228,7 +228,7 @@ SENSOR_TYPES: Final[tuple[AccuWeatherSensorDescription, ...]] = ( name="Cloud Ceiling", unit_metric=LENGTH_METERS, unit_imperial=LENGTH_FEET, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), AccuWeatherSensorDescription( key="CloudCover", @@ -237,7 +237,7 @@ SENSOR_TYPES: Final[tuple[AccuWeatherSensorDescription, ...]] = ( unit_metric=PERCENTAGE, unit_imperial=PERCENTAGE, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), AccuWeatherSensorDescription( key="DewPoint", @@ -246,7 +246,7 @@ SENSOR_TYPES: Final[tuple[AccuWeatherSensorDescription, ...]] = ( unit_metric=TEMP_CELSIUS, unit_imperial=TEMP_FAHRENHEIT, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), AccuWeatherSensorDescription( key="RealFeelTemperature", @@ -254,7 +254,7 @@ SENSOR_TYPES: Final[tuple[AccuWeatherSensorDescription, ...]] = ( name="RealFeel Temperature", unit_metric=TEMP_CELSIUS, unit_imperial=TEMP_FAHRENHEIT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), AccuWeatherSensorDescription( key="RealFeelTemperatureShade", @@ -263,7 +263,7 @@ SENSOR_TYPES: Final[tuple[AccuWeatherSensorDescription, ...]] = ( unit_metric=TEMP_CELSIUS, unit_imperial=TEMP_FAHRENHEIT, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), AccuWeatherSensorDescription( key="Precipitation", @@ -271,7 +271,7 @@ SENSOR_TYPES: Final[tuple[AccuWeatherSensorDescription, ...]] = ( name="Precipitation", unit_metric=LENGTH_MILLIMETERS, unit_imperial=LENGTH_INCHES, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), AccuWeatherSensorDescription( key="PressureTendency", @@ -287,7 +287,7 @@ SENSOR_TYPES: Final[tuple[AccuWeatherSensorDescription, ...]] = ( name="UV Index", unit_metric=UV_INDEX, unit_imperial=UV_INDEX, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), AccuWeatherSensorDescription( key="WetBulbTemperature", @@ -296,7 +296,7 @@ SENSOR_TYPES: Final[tuple[AccuWeatherSensorDescription, ...]] = ( unit_metric=TEMP_CELSIUS, unit_imperial=TEMP_FAHRENHEIT, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), AccuWeatherSensorDescription( key="WindChillTemperature", @@ -305,7 +305,7 @@ SENSOR_TYPES: Final[tuple[AccuWeatherSensorDescription, ...]] = ( unit_metric=TEMP_CELSIUS, unit_imperial=TEMP_FAHRENHEIT, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), AccuWeatherSensorDescription( key="Wind", @@ -313,7 +313,7 @@ SENSOR_TYPES: Final[tuple[AccuWeatherSensorDescription, ...]] = ( name="Wind", unit_metric=SPEED_KILOMETERS_PER_HOUR, unit_imperial=SPEED_MILES_PER_HOUR, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), AccuWeatherSensorDescription( key="WindGust", @@ -322,6 +322,6 @@ SENSOR_TYPES: Final[tuple[AccuWeatherSensorDescription, ...]] = ( unit_metric=SPEED_KILOMETERS_PER_HOUR, unit_imperial=SPEED_MILES_PER_HOUR, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), ) diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 3159293a4cc..62047f801fb 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -7,6 +7,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, DEVICE_CLASS_TEMPERATURE from homeassistant.core import HomeAssistant, callback +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 StateType @@ -95,7 +96,7 @@ class AccuWeatherSensor(CoordinatorEntity, SensorEntity): self._unit_system = API_IMPERIAL self._attr_native_unit_of_measurement = description.unit_imperial self._attr_device_info = DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, coordinator.location_key)}, manufacturer=MANUFACTURER, name=NAME, diff --git a/homeassistant/components/accuweather/translations/bg.json b/homeassistant/components/accuweather/translations/bg.json index 2ac8a444100..b037c01144f 100644 --- a/homeassistant/components/accuweather/translations/bg.json +++ b/homeassistant/components/accuweather/translations/bg.json @@ -1,7 +1,22 @@ { "config": { + "abort": { + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, "error": { - "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \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": { + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447", + "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" + }, + "title": "AccuWeather" + } } } } \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/ja.json b/homeassistant/components/accuweather/translations/ja.json new file mode 100644 index 00000000000..00c659fade9 --- /dev/null +++ b/homeassistant/components/accuweather/translations/ja.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_api_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc", + "requests_exceeded": "Accuweather API\u3078\u306e\u30ea\u30af\u30a8\u30b9\u30c8\u6570\u304c\u8a31\u53ef\u3055\u308c\u305f\u6570\u3092\u8d85\u3048\u307e\u3057\u305f\u3002\u6642\u9593\u3092\u7f6e\u304f\u304b\u3001API\u30ad\u30fc\u3092\u5909\u66f4\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "name": "\u540d\u524d" + }, + "description": "\u8a2d\u5b9a\u306b\u3064\u3044\u3066\u30d8\u30eb\u30d7\u304c\u5fc5\u8981\u306a\u5834\u5408\u306f\u3001https://www.home-assistant.io/integrations/accuweather/ \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044\n\n\u4e00\u90e8\u306e\u30bb\u30f3\u30b5\u30fc\u306f\u30c7\u30d5\u30a9\u30eb\u30c8\u3067\u306f\u6709\u52b9\u306b\u306a\u3063\u3066\u3044\u307e\u305b\u3093\u3002\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u306e\u8a2d\u5b9a\u5f8c\u306b\u3001\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u30ec\u30b8\u30b9\u30c8\u30ea\u3067\u6709\u52b9\u306b\u3059\u308b\u3053\u3068\u304c\u3067\u304d\u307e\u3059\u3002\n\u5929\u6c17\u4e88\u5831\u306f\u30c7\u30d5\u30a9\u30eb\u30c8\u3067\u306f\u6709\u52b9\u306b\u306a\u3063\u3066\u3044\u307e\u305b\u3093\u3002\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u3067\u6709\u52b9\u306b\u3059\u308b\u3053\u3068\u304c\u3067\u304d\u307e\u3059\u3002", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "\u5929\u6c17\u4e88\u5831" + }, + "description": "\u5236\u9650\u306b\u3088\u308a\u7121\u6599\u30d0\u30fc\u30b8\u30e7\u30f3\u306eAccuWeather API\u30ad\u30fc\u3067\u306f\u3001\u5929\u6c17\u4e88\u5831\u3092\u6709\u52b9\u306b\u3057\u3066\u3082\u30c7\u30fc\u30bf\u306e\u66f4\u65b0\u306f40\u5206\u3067\u306f\u306a\u304f80\u5206\u3054\u3068\u3067\u3059\u3002", + "title": "AccuWeather\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "AccuWeather\u30b5\u30fc\u30d0\u30fc\u306b\u5230\u9054", + "remaining_requests": "\u6b8b\u308a\u306e\u8a31\u53ef\u3055\u308c\u305f\u30ea\u30af\u30a8\u30b9\u30c8" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.ja.json b/homeassistant/components/accuweather/translations/sensor.ja.json new file mode 100644 index 00000000000..9db8f685dfe --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.ja.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "\u4e0b\u964d", + "rising": "\u4e0a\u6607", + "steady": "\u5b89\u5b9a" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.tr.json b/homeassistant/components/accuweather/translations/sensor.tr.json new file mode 100644 index 00000000000..53c7067062a --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.tr.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "D\u00fc\u015f\u00fcyor", + "rising": "Y\u00fckseliyor", + "steady": "Sabit" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/tr.json b/homeassistant/components/accuweather/translations/tr.json index f79f9a0e327..7b0fa476458 100644 --- a/homeassistant/components/accuweather/translations/tr.json +++ b/homeassistant/components/accuweather/translations/tr.json @@ -5,7 +5,8 @@ }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", - "invalid_api_key": "Ge\u00e7ersiz API anahtar\u0131" + "invalid_api_key": "Ge\u00e7ersiz API anahtar\u0131", + "requests_exceeded": "Accuweather API i\u00e7in izin verilen istek say\u0131s\u0131 a\u015f\u0131ld\u0131. API Anahtar\u0131n\u0131 beklemeniz veya de\u011fi\u015ftirmeniz gerekir." }, "step": { "user": { @@ -15,6 +16,7 @@ "longitude": "Boylam", "name": "Ad" }, + "description": "Yap\u0131land\u0131rmayla ilgili yard\u0131ma ihtiyac\u0131n\u0131z varsa buraya bak\u0131n: https://www.home-assistant.io/integrations/accuweather/ \n\n Baz\u0131 sens\u00f6rler varsay\u0131lan olarak etkin de\u011fildir. Bunlar\u0131, entegrasyon yap\u0131land\u0131rmas\u0131ndan sonra varl\u0131k kay\u0131t defterinde etkinle\u015ftirebilirsiniz.\n Hava tahmini varsay\u0131lan olarak etkin de\u011fildir. Entegrasyon se\u00e7eneklerinde etkinle\u015ftirebilirsiniz.", "title": "AccuWeather" } } @@ -25,6 +27,7 @@ "data": { "forecast": "Hava Durumu tahmini" }, + "description": "AccuWeather API anahtar\u0131n\u0131n \u00fccretsiz s\u00fcr\u00fcm\u00fcn\u00fcn s\u0131n\u0131rlamalar\u0131 nedeniyle, hava tahminini etkinle\u015ftirdi\u011finizde, veri g\u00fcncellemeleri her 40 dakikada bir yerine 80 dakikada bir ger\u00e7ekle\u015ftirilir.", "title": "AccuWeather Se\u00e7enekleri" } } diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index af2d1c15b2b..c97cc44aea3 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -19,6 +19,7 @@ from homeassistant.components.weather import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT 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 CoordinatorEntity @@ -68,10 +69,16 @@ class AccuWeatherEntity(CoordinatorEntity, WeatherEntity): ) self._attr_attribution = ATTRIBUTION self._attr_device_info = DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, coordinator.location_key)}, manufacturer=MANUFACTURER, name=NAME, + # You don't need to provide specific details for the URL, + # so passing in _ characters is fine if the location key + # is correct + configuration_url="http://accuweather.com/en/" + f"_/_/{coordinator.location_key}/" + f"weather-forecast/{coordinator.location_key}/", ) @property diff --git a/homeassistant/components/acer_projector/switch.py b/homeassistant/components/acer_projector/switch.py index 747c7c98d73..f3e4b3b03e5 100644 --- a/homeassistant/components/acer_projector/switch.py +++ b/homeassistant/components/acer_projector/switch.py @@ -111,8 +111,7 @@ class AcerSwitch(SwitchEntity): """Write msg, obtain answer and format output.""" # answers are formatted as ***\answer\r*** awns = self._write_read(msg) - match = re.search(r"\r(.+)\r", awns) - if match: + if match := re.search(r"\r(.+)\r", awns): return match.group(1) return STATE_UNKNOWN diff --git a/homeassistant/components/acmeda/__init__.py b/homeassistant/components/acmeda/__init__.py index 078c499f2be..49b4d9b85e8 100644 --- a/homeassistant/components/acmeda/__init__.py +++ b/homeassistant/components/acmeda/__init__.py @@ -1,13 +1,14 @@ """The Rollease Acmeda Automate integration.""" from homeassistant import config_entries, core +from homeassistant.const import Platform from .const import DOMAIN from .hub import PulseHub CONF_HUBS = "hubs" -PLATFORMS = ["cover", "sensor"] +PLATFORMS = [Platform.COVER, Platform.SENSOR] async def async_setup_entry( diff --git a/homeassistant/components/acmeda/config_flow.py b/homeassistant/components/acmeda/config_flow.py index 1f288e84bc7..1db629e506a 100644 --- a/homeassistant/components/acmeda/config_flow.py +++ b/homeassistant/components/acmeda/config_flow.py @@ -9,6 +9,7 @@ import async_timeout import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_ID from .const import DOMAIN @@ -27,9 +28,9 @@ class AcmedaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if ( user_input is not None and self.discovered_hubs is not None - and user_input["id"] in self.discovered_hubs + and user_input[CONF_ID] in self.discovered_hubs ): - return await self.async_create(self.discovered_hubs[user_input["id"]]) + return await self.async_create(self.discovered_hubs[user_input[CONF_ID]]) # Already configured hosts already_configured = { @@ -55,7 +56,7 @@ class AcmedaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required("id"): vol.In( + vol.Required(CONF_ID): vol.In( {hub.id: f"{hub.id} {hub.host}" for hub in hubs} ) } @@ -65,4 +66,4 @@ class AcmedaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_create(self, hub): """Create the Acmeda Hub entry.""" await self.async_set_unique_id(hub.id, raise_on_progress=False) - return self.async_create_entry(title=hub.id, data={"host": hub.host}) + return self.async_create_entry(title=hub.id, data={CONF_HOST: hub.host}) diff --git a/homeassistant/components/acmeda/manifest.json b/homeassistant/components/acmeda/manifest.json index ae72df5a323..6313b177f47 100644 --- a/homeassistant/components/acmeda/manifest.json +++ b/homeassistant/components/acmeda/manifest.json @@ -3,7 +3,7 @@ "name": "Rollease Acmeda Automate", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/acmeda", - "requirements": ["aiopulse==0.4.2"], + "requirements": ["aiopulse==0.4.3"], "codeowners": ["@atmurray"], "iot_class": "local_push" } diff --git a/homeassistant/components/acmeda/translations/ja.json b/homeassistant/components/acmeda/translations/ja.json new file mode 100644 index 00000000000..83eb75daebf --- /dev/null +++ b/homeassistant/components/acmeda/translations/ja.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "step": { + "user": { + "data": { + "id": "\u30db\u30b9\u30c8ID" + }, + "title": "\u8ffd\u52a0\u3059\u308b\u30cf\u30d6\u306e\u9078\u629e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/acmeda/translations/tr.json b/homeassistant/components/acmeda/translations/tr.json index aea81abdcba..3a870463feb 100644 --- a/homeassistant/components/acmeda/translations/tr.json +++ b/homeassistant/components/acmeda/translations/tr.json @@ -1,10 +1,14 @@ { "config": { + "abort": { + "no_devices_found": "A\u011fda cihaz bulunamad\u0131" + }, "step": { "user": { "data": { "id": "Ana bilgisayar kimli\u011fi" - } + }, + "title": "Eklemek i\u00e7in bir merkez se\u00e7in" } } } diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py index 8dc3f095437..cc26c191c8c 100644 --- a/homeassistant/components/actiontec/device_tracker.py +++ b/homeassistant/components/actiontec/device_tracker.py @@ -71,8 +71,7 @@ class ActiontecDeviceScanner(DeviceScanner): if not self.success_init: return False - actiontec_data = self.get_actiontec_data() - if actiontec_data is None: + if (actiontec_data := self.get_actiontec_data()) is None: return False self.last_results = [ device for device in actiontec_data if device.timevalid > -60 diff --git a/homeassistant/components/adax/__init__.py b/homeassistant/components/adax/__init__.py index 0a14648af26..bf339e810c6 100644 --- a/homeassistant/components/adax/__init__.py +++ b/homeassistant/components/adax/__init__.py @@ -2,9 +2,10 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -PLATFORMS = ["climate"] +PLATFORMS = [Platform.CLIMATE] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/adax/manifest.json b/homeassistant/components/adax/manifest.json index 3d2c9273d05..cf5cbbd02a5 100644 --- a/homeassistant/components/adax/manifest.json +++ b/homeassistant/components/adax/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/adax", "requirements": [ - "adax==0.1.1" + "adax==0.2.0" ], "codeowners": [ "@danielhiversen" diff --git a/homeassistant/components/adax/translations/bg.json b/homeassistant/components/adax/translations/bg.json index 329b8fd8399..3d3795470ba 100644 --- a/homeassistant/components/adax/translations/bg.json +++ b/homeassistant/components/adax/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" }, "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_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": { diff --git a/homeassistant/components/adax/translations/ja.json b/homeassistant/components/adax/translations/ja.json new file mode 100644 index 00000000000..880b37db94d --- /dev/null +++ b/homeassistant/components/adax/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": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "user": { + "data": { + "account_id": "\u30a2\u30ab\u30a6\u30f3\u30c8ID", + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/tr.json b/homeassistant/components/adax/translations/tr.json new file mode 100644 index 00000000000..af5cdd5d16a --- /dev/null +++ b/homeassistant/components/adax/translations/tr.json @@ -0,0 +1,20 @@ +{ + "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" + }, + "step": { + "user": { + "data": { + "account_id": "Hesap Kimli\u011fi", + "host": "Ana bilgisayar", + "password": "Parola" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index d76d32ce066..9b419c444ce 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -6,7 +6,7 @@ import logging from adguardhome import AdGuardHome, AdGuardHomeConnectionError, AdGuardHomeError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -16,11 +16,13 @@ from homeassistant.const import ( CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo, Entity from .const import ( @@ -45,7 +47,7 @@ SERVICE_REFRESH_SCHEMA = vol.Schema( {vol.Optional(CONF_FORCE, default=False): cv.boolean} ) -PLATFORMS = ["sensor", "switch"] +PLATFORMS = [Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -196,8 +198,16 @@ class AdGuardHomeDeviceEntity(AdGuardHomeEntity): @property def device_info(self) -> DeviceInfo: """Return device information about this AdGuard Home instance.""" + if self._entry.source == SOURCE_HASSIO: + config_url = "homeassistant://hassio/ingress/a0d7b954_adguard" + else: + if self.adguard.tls: + config_url = f"https://{self.adguard.host}:{self.adguard.port}" + else: + config_url = f"http://{self.adguard.host}:{self.adguard.port}" + return DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={ (DOMAIN, self.adguard.host, self.adguard.port, self.adguard.base_path) # type: ignore }, @@ -206,4 +216,5 @@ class AdGuardHomeDeviceEntity(AdGuardHomeEntity): sw_version=self.hass.data[DOMAIN][self._entry.entry_id].get( DATA_ADGUARD_VERSION ), + configuration_url=config_url, ) diff --git a/homeassistant/components/adguard/config_flow.py b/homeassistant/components/adguard/config_flow.py index aa85345179e..aadbed49980 100644 --- a/homeassistant/components/adguard/config_flow.py +++ b/homeassistant/components/adguard/config_flow.py @@ -6,6 +6,7 @@ from typing import Any from adguardhome import AdGuardHome, AdGuardHomeConnectionError import voluptuous as vol +from homeassistant.components.hassio import HassioServiceInfo from homeassistant.config_entries import ConfigFlow from homeassistant.const import ( CONF_HOST, @@ -104,14 +105,14 @@ class AdGuardHomeFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_hassio(self, discovery_info: dict[str, Any]) -> FlowResult: + async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Prepare configuration for a Hass.io AdGuard Home add-on. This flow is triggered by the discovery component. """ await self._async_handle_discovery_without_unique_id() - self._hassio_discovery = discovery_info + self._hassio_discovery = discovery_info.config return await self.async_step_hassio_confirm() async def async_step_hassio_confirm( diff --git a/homeassistant/components/adguard/translations/ja.json b/homeassistant/components/adguard/translations/ja.json new file mode 100644 index 00000000000..33f24b1d8e5 --- /dev/null +++ b/homeassistant/components/adguard/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", + "existing_instance_updated": "\u65e2\u5b58\u306e\u8a2d\u5b9a\u3092\u66f4\u65b0\u3057\u307e\u3057\u305f\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "hassio_confirm": { + "description": "\u30a2\u30c9\u30aa\u30f3 {addon} \u304c\u3001\u63d0\u4f9b\u3059\u308bAdGuard Home\u306b\u63a5\u7d9a\u3059\u308b\u3088\u3046\u306bHome Assistant\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u304b\uff1f", + "title": "Home Assistant\u30a2\u30c9\u30aa\u30f3\u7d4c\u7531\u306eAdGuard Home" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "ssl": "SSL\u8a3c\u660e\u66f8\u3092\u4f7f\u7528\u3059\u308b", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + }, + "description": "\u76e3\u8996\u3068\u5236\u5fa1\u304c\u3067\u304d\u308b\u3088\u3046\u306b\u3001AdGuardHome\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u3057\u307e\u3059\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/tr.json b/homeassistant/components/adguard/translations/tr.json index 065af7b49cf..7d9ddf4db0f 100644 --- a/homeassistant/components/adguard/translations/tr.json +++ b/homeassistant/components/adguard/translations/tr.json @@ -1,16 +1,27 @@ { "config": { + "abort": { + "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "existing_instance_updated": "Mevcut yap\u0131land\u0131rma g\u00fcncellendi." + }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, "step": { + "hassio_confirm": { + "description": "{addon} taraf\u0131ndan sa\u011flanan AdGuard Home'a ba\u011flanmak i\u00e7in Home Assistant'\u0131 yap\u0131land\u0131rmak istiyor musunuz?", + "title": "Home Assistant eklentisi arac\u0131l\u0131\u011f\u0131yla AdGuard Home" + }, "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "password": "Parola", "port": "Port", - "username": "Kullan\u0131c\u0131 Ad\u0131" - } + "ssl": "SSL sertifikas\u0131 kullan\u0131r", + "username": "Kullan\u0131c\u0131 Ad\u0131", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" + }, + "description": "AdGuard Home \u00f6rne\u011finizi, izleme ve kontrole izin verecek \u015fekilde ayarlay\u0131n." } } } diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index d59d1e5aa0c..53687564cd2 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -307,7 +307,7 @@ class AdsEntity(Entity): self._ads_hub.add_device_notification, ads_var, plctype, update ) try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): await self._event.wait() except asyncio.TimeoutError: _LOGGER.debug("Variable %s: Timeout during first update", ads_var) diff --git a/homeassistant/components/advantage_air/__init__.py b/homeassistant/components/advantage_air/__init__.py index 4af0b3e5f60..21b70ab9fc6 100644 --- a/homeassistant/components/advantage_air/__init__.py +++ b/homeassistant/components/advantage_air/__init__.py @@ -5,14 +5,21 @@ import logging from advantage_air import ApiError, advantage_air -from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ADVANTAGE_AIR_RETRY, DOMAIN ADVANTAGE_AIR_SYNC_INTERVAL = 15 -PLATFORMS = ["binary_sensor", "climate", "cover", "select", "sensor", "switch"] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.COVER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 1e6027b8db6..a56273fc10a 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -10,6 +10,7 @@ from homeassistant.components.climate.const import ( HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY, HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, @@ -53,7 +54,7 @@ HASS_FAN_MODES = {v: k for k, v in ADVANTAGE_AIR_FAN_MODES.items()} FAN_SPEEDS = {FAN_LOW: 30, FAN_MEDIUM: 60, FAN_HIGH: 100} ADVANTAGE_AIR_SERVICE_SET_MYZONE = "set_myzone" -ZONE_HVAC_MODES = [HVAC_MODE_OFF, HVAC_MODE_FAN_ONLY] +ZONE_HVAC_MODES = [HVAC_MODE_OFF, HVAC_MODE_HEAT_COOL] PARALLEL_UPDATES = 0 @@ -169,7 +170,7 @@ class AdvantageAirZone(AdvantageAirClimateEntity): def hvac_mode(self): """Return the current state as HVAC mode.""" if self._zone["state"] == ADVANTAGE_AIR_STATE_OPEN: - return HVAC_MODE_FAN_ONLY + return HVAC_MODE_HEAT_COOL return HVAC_MODE_OFF @property diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index d879693fdb5..8b83de2b923 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -3,8 +3,8 @@ import voluptuous as vol from homeassistant.components.sensor import ( DEVICE_CLASS_TEMPERATURE, - STATE_CLASS_MEASUREMENT, SensorEntity, + SensorStateClass, ) from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC, PERCENTAGE, TEMP_CELSIUS from homeassistant.helpers import config_validation as cv, entity_platform @@ -84,7 +84,7 @@ class AdvantageAirZoneVent(AdvantageAirEntity, SensorEntity): """Representation of Advantage Air Zone Vent Sensor.""" _attr_native_unit_of_measurement = PERCENTAGE - _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC def __init__(self, instance, ac_key, zone_key): @@ -114,7 +114,7 @@ class AdvantageAirZoneSignal(AdvantageAirEntity, SensorEntity): """Representation of Advantage Air Zone wireless signal sensor.""" _attr_native_unit_of_measurement = PERCENTAGE - _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC def __init__(self, instance, ac_key, zone_key): @@ -149,7 +149,7 @@ class AdvantageAirZoneTemp(AdvantageAirEntity, SensorEntity): _attr_native_unit_of_measurement = TEMP_CELSIUS _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_registry_enabled_default = False _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC diff --git a/homeassistant/components/advantage_air/translations/ja.json b/homeassistant/components/advantage_air/translations/ja.json new file mode 100644 index 00000000000..9cc6ef69737 --- /dev/null +++ b/homeassistant/components/advantage_air/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": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "user": { + "data": { + "ip_address": "IP\u30a2\u30c9\u30ec\u30b9", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "Advantage Air wall mounted tablet\u306eAPI\u306b\u63a5\u7d9a\u3057\u307e\u3059\u3002", + "title": "\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/advantage_air/translations/tr.json b/homeassistant/components/advantage_air/translations/tr.json index db639c59376..678f9ec8cbc 100644 --- a/homeassistant/components/advantage_air/translations/tr.json +++ b/homeassistant/components/advantage_air/translations/tr.json @@ -9,9 +9,10 @@ "step": { "user": { "data": { - "ip_address": "\u0130p Adresi", + "ip_address": "IP Adresi", "port": "Port" }, + "description": "Advantage Air duvara monte tabletinizin API'sine ba\u011flan\u0131n.", "title": "Ba\u011flan" } } diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index ba37c66da64..40542d88506 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -1,10 +1,7 @@ """Constant values for the AEMET OpenData component.""" from __future__ import annotations -from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, - SensorEntityDescription, -) +from homeassistant.components.sensor import SensorEntityDescription, SensorStateClass from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -36,11 +33,12 @@ from homeassistant.const import ( PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, + Platform, ) ATTRIBUTION = "Powered by AEMET OpenData" CONF_STATION_UPDATES = "station_updates" -PLATFORMS = ["sensor", "weather"] +PLATFORMS = [Platform.SENSOR, Platform.WEATHER] DEFAULT_NAME = "AEMET" DOMAIN = "aemet" ENTRY_NAME = "name" @@ -255,14 +253,14 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=ATTR_API_PRESSURE, name="Pressure", native_unit_of_measurement=PRESSURE_HPA, device_class=DEVICE_CLASS_PRESSURE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=ATTR_API_RAIN, @@ -273,7 +271,7 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key=ATTR_API_RAIN_PROB, name="Rain probability", native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=ATTR_API_SNOW, @@ -284,7 +282,7 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key=ATTR_API_SNOW_PROB, name="Snow probability", native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=ATTR_API_STATION_ID, @@ -303,21 +301,21 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key=ATTR_API_STORM_PROB, name="Storm probability", native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=ATTR_API_TEMPERATURE, name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=ATTR_API_TEMPERATURE_FEELING, name="Temperature feeling", native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=ATTR_API_TOWN_ID, @@ -336,7 +334,7 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key=ATTR_API_WIND_BEARING, name="Wind bearing", native_unit_of_measurement=DEGREE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=ATTR_API_WIND_MAX_SPEED, @@ -347,7 +345,7 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key=ATTR_API_WIND_SPEED, name="Wind speed", native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), ) diff --git a/homeassistant/components/aemet/translations/bg.json b/homeassistant/components/aemet/translations/bg.json new file mode 100644 index 00000000000..62d0a34441a --- /dev/null +++ b/homeassistant/components/aemet/translations/bg.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "invalid_api_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447" + }, + "step": { + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/ja.json b/homeassistant/components/aemet/translations/ja.json new file mode 100644 index 00000000000..a3a0904c223 --- /dev/null +++ b/homeassistant/components/aemet/translations/ja.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "invalid_api_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "name": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u540d\u524d" + }, + "description": "AEMET OpenData\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u3002 API\u30ad\u30fc\u3092\u751f\u6210\u3059\u308b\u306b\u306f\u3001https://opendata.aemet.es/centrodedescargas/altaUsuario \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "AEMET OpenData" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "AEMET weather station\u304b\u3089\u30c7\u30fc\u30bf\u3092\u53ce\u96c6\u3059\u308b" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/ru.json b/homeassistant/components/aemet/translations/ru.json index 1dc0e21b0df..f9278af712b 100644 --- a/homeassistant/components/aemet/translations/ru.json +++ b/homeassistant/components/aemet/translations/ru.json @@ -14,7 +14,7 @@ "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 AEMET OpenData. \u0427\u0442\u043e\u0431\u044b \u0441\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043a\u043b\u044e\u0447 API, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043d\u0430 https://opendata.aemet.es/centrodedescargas/altaUsuario.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 AEMET OpenData. \u0427\u0442\u043e\u0431\u044b \u0441\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043a\u043b\u044e\u0447 API, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043d\u0430 https://opendata.aemet.es/centrodedescargas/altaUsuario.", "title": "AEMET OpenData" } } diff --git a/homeassistant/components/aemet/translations/tr.json b/homeassistant/components/aemet/translations/tr.json new file mode 100644 index 00000000000..7cb0048b0e0 --- /dev/null +++ b/homeassistant/components/aemet/translations/tr.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "invalid_api_key": "Ge\u00e7ersiz API anahtar\u0131" + }, + "step": { + "user": { + "data": { + "api_key": "API Anahtar\u0131", + "latitude": "Enlem", + "longitude": "Boylam", + "name": "Entegrasyonun ad\u0131" + }, + "description": "AEMET OpenData entegrasyonunu ayarlay\u0131n. API anahtar\u0131 olu\u015fturmak i\u00e7in https://opendata.aemet.es/centrodedescargas/altaUsuario adresine gidin.", + "title": "AEMET OpenData" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "AEMET hava istasyonlar\u0131ndan veri toplay\u0131n" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index 77f4a593fb0..d791158b9de 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -140,7 +140,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self): data = {} - with async_timeout.timeout(120): + async with async_timeout.timeout(120): weather_response = await self._get_aemet_weather() data = self._convert_weather_response(weather_response) return data @@ -398,8 +398,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): return None def _convert_forecast_day(self, date, day): - condition = self._get_condition_day(day) - if not condition: + if not (condition := self._get_condition_day(day)): return None return { @@ -415,8 +414,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): } def _convert_forecast_hour(self, date, day, hour): - condition = self._get_condition(day, hour) - if not condition: + if not (condition := self._get_condition(day, hour)): return None forecast_dt = date.replace(hour=hour, minute=0, second=0) @@ -435,13 +433,8 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): def _calc_precipitation(self, day, hour): """Calculate the precipitation.""" - rain_value = self._get_rain(day, hour) - if not rain_value: - rain_value = 0 - - snow_value = self._get_snow(day, hour) - if not snow_value: - snow_value = 0 + rain_value = self._get_rain(day, hour) or 0 + snow_value = self._get_snow(day, hour) or 0 if round(rain_value + snow_value, 1) == 0: return None @@ -449,13 +442,8 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): def _calc_precipitation_prob(self, day, hour): """Calculate the precipitation probability (hour).""" - rain_value = self._get_rain_prob(day, hour) - if not rain_value: - rain_value = 0 - - snow_value = self._get_snow_prob(day, hour) - if not snow_value: - snow_value = 0 + rain_value = self._get_rain_prob(day, hour) or 0 + snow_value = self._get_snow_prob(day, hour) or 0 if rain_value == 0 and snow_value == 0: return None diff --git a/homeassistant/components/agent_dvr/__init__.py b/homeassistant/components/agent_dvr/__init__.py index 5b765da7f8e..373b5c2e291 100644 --- a/homeassistant/components/agent_dvr/__init__.py +++ b/homeassistant/components/agent_dvr/__init__.py @@ -3,6 +3,7 @@ from agent import AgentError from agent.a import Agent +from homeassistant.const import Platform from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -12,7 +13,7 @@ from .const import CONNECTION, DOMAIN as AGENT_DOMAIN, SERVER_URL ATTRIBUTION = "ispyconnect.com" DEFAULT_BRAND = "Agent DVR by ispyconnect.com" -FORWARDS = ["alarm_control_panel", "camera"] +PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.CAMERA] async def async_setup_entry(hass, config_entry): @@ -35,7 +36,7 @@ async def async_setup_entry(hass, config_entry): hass.data[AGENT_DOMAIN][config_entry.entry_id] = {CONNECTION: agent_client} - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -46,14 +47,16 @@ async def async_setup_entry(hass, config_entry): sw_version=agent_client.version, ) - hass.config_entries.async_setup_platforms(config_entry, FORWARDS) + hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) return True async def async_unload_entry(hass, config_entry): """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(config_entry, FORWARDS) + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) await hass.data[AGENT_DOMAIN][config_entry.entry_id][CONNECTION].close() diff --git a/homeassistant/components/agent_dvr/translations/ja.json b/homeassistant/components/agent_dvr/translations/ja.json new file mode 100644 index 00000000000..091f9a89475 --- /dev/null +++ b/homeassistant/components/agent_dvr/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": { + "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" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "title": "Agent DVR\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/agent_dvr/translations/tr.json b/homeassistant/components/agent_dvr/translations/tr.json index 31dddab7795..3ff1b149bdd 100644 --- a/homeassistant/components/agent_dvr/translations/tr.json +++ b/homeassistant/components/agent_dvr/translations/tr.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "port": "Port" }, "title": "Agent DVR'\u0131 kurun" diff --git a/homeassistant/components/air_quality/__init__.py b/homeassistant/components/air_quality/__init__.py index 1e38bad55a8..d0aa1fd4a76 100644 --- a/homeassistant/components/air_quality/__init__.py +++ b/homeassistant/components/air_quality/__init__.py @@ -137,8 +137,7 @@ class AirQualityEntity(Entity): data: dict[str, str | int | float] = {} for prop, attr in PROP_TO_ATTR.items(): - value = getattr(self, prop) - if value is not None: + if (value := getattr(self, prop)) is not None: data[attr] = value return data diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index 0304945e6d2..83a9a50ec7f 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -13,7 +13,7 @@ import async_timeout from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM 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, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -33,7 +33,7 @@ from .const import ( NO_AIRLY_SENSORS, ) -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -167,7 +167,7 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator): measurements = self.airly.create_measurements_session_point( self.latitude, self.longitude ) - with async_timeout.timeout(20): + async with async_timeout.timeout(20): try: await measurements.update() except (AirlyError, ClientConnectorError) as error: diff --git a/homeassistant/components/airly/config_flow.py b/homeassistant/components/airly/config_flow.py index a6fa9f2d1d6..10e4990ee05 100644 --- a/homeassistant/components/airly/config_flow.py +++ b/homeassistant/components/airly/config_flow.py @@ -103,7 +103,7 @@ async def test_location( measurements = airly.create_measurements_session_point( latitude=latitude, longitude=longitude ) - with async_timeout.timeout(10): + async with async_timeout.timeout(10): await measurements.update() current = measurements.current diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 76b4e7d9d48..8da42b86a7c 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -27,6 +27,7 @@ from homeassistant.const import ( TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -154,7 +155,7 @@ class AirlySensor(CoordinatorEntity, SensorEntity): """Initialize.""" super().__init__(coordinator) self._attr_device_info = DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, f"{coordinator.latitude}-{coordinator.longitude}")}, manufacturer=MANUFACTURER, name=DEFAULT_NAME, diff --git a/homeassistant/components/airly/translations/bg.json b/homeassistant/components/airly/translations/bg.json index f0836c8e5ed..955cc8c1ac4 100644 --- a/homeassistant/components/airly/translations/bg.json +++ b/homeassistant/components/airly/translations/bg.json @@ -1,6 +1,10 @@ { "config": { + "abort": { + "already_configured": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, "error": { + "invalid_api_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447", "wrong_location": "\u0412 \u0442\u0430\u0437\u0438 \u043e\u0431\u043b\u0430\u0441\u0442 \u043d\u044f\u043c\u0430 \u0438\u0437\u043c\u0435\u0440\u0432\u0430\u0442\u0435\u043b\u043d\u0438 \u0441\u0442\u0430\u043d\u0446\u0438\u0438 \u043d\u0430 Airly." }, "step": { diff --git a/homeassistant/components/airly/translations/ja.json b/homeassistant/components/airly/translations/ja.json new file mode 100644 index 00000000000..347868bc35e --- /dev/null +++ b/homeassistant/components/airly/translations/ja.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "invalid_api_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc", + "wrong_location": "\u3053\u306e\u30a8\u30ea\u30a2\u306b\u3001Airly\u306e\u6e2c\u5b9a\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u306f\u3042\u308a\u307e\u305b\u3093\u3002" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "name": "\u540d\u524d" + }, + "description": "Airly\u306e\u7a7a\u6c17\u54c1\u8cea\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u3002 API\u30ad\u30fc\u3092\u751f\u6210\u3059\u308b\u306b\u306f\u3001https://developer.airly.eu/register \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "Airly" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "Airly\u30b5\u30fc\u30d0\u30fc\u306b\u5230\u9054", + "requests_per_day": "1\u65e5\u3042\u305f\u308a\u306e\u8a31\u53ef\u3055\u308c\u305f\u30ea\u30af\u30a8\u30b9\u30c8", + "requests_remaining": "\u6b8b\u308a\u306e\u8a31\u53ef\u3055\u308c\u305f\u30ea\u30af\u30a8\u30b9\u30c8" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/tr.json b/homeassistant/components/airly/translations/tr.json index 144acc1e1ae..fcae9294da2 100644 --- a/homeassistant/components/airly/translations/tr.json +++ b/homeassistant/components/airly/translations/tr.json @@ -4,21 +4,27 @@ "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, "error": { - "invalid_api_key": "Ge\u00e7ersiz API anahtar\u0131" + "invalid_api_key": "Ge\u00e7ersiz API anahtar\u0131", + "wrong_location": "Bu b\u00f6lgede Airly \u00f6l\u00e7\u00fcm istasyonu yok." }, "step": { "user": { "data": { "api_key": "API Anahtar\u0131", "latitude": "Enlem", - "longitude": "Boylam" - } + "longitude": "Boylam", + "name": "Ad" + }, + "description": "Airly hava kalitesi entegrasyonunu ayarlay\u0131n. API anahtar\u0131 olu\u015fturmak i\u00e7in https://developer.airly.eu/register adresine gidin.", + "title": "Airly" } } }, "system_health": { "info": { - "can_reach_server": "Airly sunucusuna eri\u015fin" + "can_reach_server": "Airly sunucusuna eri\u015fin", + "requests_per_day": "G\u00fcnl\u00fck izin verilen istek say\u0131s\u0131", + "requests_remaining": "Kalan izin verilen istekler" } } } \ No newline at end of file diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py index be385d19645..9c6babe1136 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -8,7 +8,13 @@ from pyairnow.conv import aqi_to_concentration from pyairnow.errors import AirNowError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -33,7 +39,7 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index b0d8c69cff2..c56530613a2 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -86,7 +86,8 @@ class AirNowSensor(CoordinatorEntity, SensorEntity): @property def native_value(self): """Return the state.""" - self._state = self.coordinator.data[self.entity_description.key] + self._state = self.coordinator.data.get(self.entity_description.key) + return self._state @property diff --git a/homeassistant/components/airnow/translations/ja.json b/homeassistant/components/airnow/translations/ja.json new file mode 100644 index 00000000000..d535f7fc044 --- /dev/null +++ b/homeassistant/components/airnow/translations/ja.json @@ -0,0 +1,26 @@ +{ + "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_location": "\u305d\u306e\u5834\u6240\u306b\u5bfe\u3059\u308b\u7d50\u679c\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "radius": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u534a\u5f84(\u30de\u30a4\u30eb; \u30aa\u30d7\u30b7\u30e7\u30f3)" + }, + "description": "AirNow\u306e\u7a7a\u6c17\u54c1\u8cea\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u3002 API\u30ad\u30fc\u3092\u751f\u6210\u3059\u308b\u306b\u306f\u3001https://docs.airnowapi.org/account/request/ \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "AirNow" + } + } + }, + "title": "AirNow" +} \ No newline at end of file diff --git a/homeassistant/components/airnow/translations/tr.json b/homeassistant/components/airnow/translations/tr.json index 06af714dc87..590332b496c 100644 --- a/homeassistant/components/airnow/translations/tr.json +++ b/homeassistant/components/airnow/translations/tr.json @@ -17,6 +17,7 @@ "longitude": "Boylam", "radius": "\u0130stasyon Yar\u0131\u00e7ap\u0131 (mil; iste\u011fe ba\u011fl\u0131)" }, + "description": "AirNow hava kalitesi entegrasyonunu ayarlay\u0131n. API anahtar\u0131 olu\u015fturmak i\u00e7in https://docs.airnowapi.org/account/request/ adresine gidin.", "title": "AirNow" } } diff --git a/homeassistant/components/airthings/__init__.py b/homeassistant/components/airthings/__init__.py index 601396d36da..352c0249637 100644 --- a/homeassistant/components/airthings/__init__.py +++ b/homeassistant/components/airthings/__init__.py @@ -7,6 +7,7 @@ import logging from airthings import Airthings, AirthingsError from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -15,7 +16,7 @@ from .const import CONF_ID, CONF_SECRET, DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[str] = ["sensor"] +PLATFORMS: list[Platform] = [Platform.SENSOR] SCAN_INTERVAL = timedelta(minutes=6) diff --git a/homeassistant/components/airthings/manifest.json b/homeassistant/components/airthings/manifest.json index 749a5e44992..24585804b45 100644 --- a/homeassistant/components/airthings/manifest.json +++ b/homeassistant/components/airthings/manifest.json @@ -3,7 +3,7 @@ "name": "Airthings", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airthings", - "requirements": ["airthings_cloud==0.0.1"], + "requirements": ["airthings_cloud==0.1.0"], "codeowners": [ "@danielhiversen" ], diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index b2960ff6066..7f3d3693a4b 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -4,9 +4,10 @@ from __future__ import annotations from airthings import AirthingsDevice from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, + SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, StateType, ) from homeassistant.config_entries import ConfigEntry @@ -14,14 +15,6 @@ from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CO2, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_PM1, - DEVICE_CLASS_PM25, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_TEMPERATURE, ENTITY_CATEGORY_DIAGNOSTIC, PERCENTAGE, PRESSURE_MBAR, @@ -46,32 +39,32 @@ SENSORS: dict[str, SensorEntityDescription] = { ), "temp": SensorEntityDescription( key="temp", - device_class=DEVICE_CLASS_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=TEMP_CELSIUS, name="Temperature", ), "humidity": SensorEntityDescription( key="humidity", - device_class=DEVICE_CLASS_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, name="Humidity", ), "pressure": SensorEntityDescription( key="pressure", - device_class=DEVICE_CLASS_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=PRESSURE_MBAR, name="Pressure", ), "battery": SensorEntityDescription( key="battery", - device_class=DEVICE_CLASS_BATTERY, + device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, name="Battery", ), "co2": SensorEntityDescription( key="co2", - device_class=DEVICE_CLASS_CO2, + device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, name="CO2", ), @@ -96,7 +89,7 @@ SENSORS: dict[str, SensorEntityDescription] = { "rssi": SensorEntityDescription( key="rssi", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, name="RSSI", entity_registry_enabled_default=False, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, @@ -104,13 +97,13 @@ SENSORS: dict[str, SensorEntityDescription] = { "pm1": SensorEntityDescription( key="pm1", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - device_class=DEVICE_CLASS_PM1, + device_class=SensorDeviceClass.PM1, name="PM1", ), "pm25": SensorEntityDescription( key="pm25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - device_class=DEVICE_CLASS_PM25, + device_class=SensorDeviceClass.PM25, name="PM25", ), } @@ -140,7 +133,7 @@ async def async_setup_entry( class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): """Representation of a Airthings Sensor device.""" - _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_state_class = SensorStateClass.MEASUREMENT def __init__( self, diff --git a/homeassistant/components/airthings/translations/id.json b/homeassistant/components/airthings/translations/id.json new file mode 100644 index 00000000000..b019ddd0aed --- /dev/null +++ b/homeassistant/components/airthings/translations/id.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "description": "Masuk di {url} untuk menemukan kredensial Anda", + "id": "ID", + "secret": "Kode Rahasia" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/ja.json b/homeassistant/components/airthings/translations/ja.json new file mode 100644 index 00000000000..2621fb437a7 --- /dev/null +++ b/homeassistant/components/airthings/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "description": "{url} \u306b\u30ed\u30b0\u30a4\u30f3\u3057\u3066\u3001\u8cc7\u683c\u60c5\u5831\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044", + "id": "ID", + "secret": "\u30b7\u30fc\u30af\u30ec\u30c3\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/pl.json b/homeassistant/components/airthings/translations/pl.json index 08b4f80938a..6c9cb9b5678 100644 --- a/homeassistant/components/airthings/translations/pl.json +++ b/homeassistant/components/airthings/translations/pl.json @@ -11,6 +11,7 @@ "step": { "user": { "data": { + "description": "Zaloguj si\u0119 pod {url}, aby znale\u017a\u0107 swoje dane uwierzytelniaj\u0105ce", "id": "ID", "secret": "Sekret" } diff --git a/homeassistant/components/airthings/translations/tr.json b/homeassistant/components/airthings/translations/tr.json new file mode 100644 index 00000000000..211b7e6dc89 --- /dev/null +++ b/homeassistant/components/airthings/translations/tr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap 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": { + "description": "Kimlik bilgilerinizi bulmak i\u00e7in {url} adresinden giri\u015f yap\u0131n", + "id": "ID", + "secret": "Gizli" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/__init__.py b/homeassistant/components/airtouch4/__init__.py index 0ec63161ea3..7b0673ecfe4 100644 --- a/homeassistant/components/airtouch4/__init__.py +++ b/homeassistant/components/airtouch4/__init__.py @@ -6,7 +6,7 @@ from airtouch4pyapi.airtouch import AirTouchStatus from homeassistant.components.climate import SCAN_INTERVAL from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -15,7 +15,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["climate"] +PLATFORMS = [Platform.CLIMATE] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/airtouch4/translations/id.json b/homeassistant/components/airtouch4/translations/id.json index c8236f5ec73..9af558b9a45 100644 --- a/homeassistant/components/airtouch4/translations/id.json +++ b/homeassistant/components/airtouch4/translations/id.json @@ -4,13 +4,15 @@ "already_configured": "Perangkat sudah dikonfigurasi" }, "error": { - "cannot_connect": "Gagal terhubung" + "cannot_connect": "Gagal terhubung", + "no_units": "Tidak dapat menemukan Grup AirTouch 4 apa pun." }, "step": { "user": { "data": { "host": "Host" - } + }, + "title": "Siapkan detail koneksi AirTouch 4 Anda." } } } diff --git a/homeassistant/components/airtouch4/translations/ja.json b/homeassistant/components/airtouch4/translations/ja.json new file mode 100644 index 00000000000..9cc4a7464e1 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/ja.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "no_units": "AirTouch 4 Groups\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "title": "AirTouch 4\u63a5\u7d9a\u306e\u8a73\u7d30\u8a2d\u5b9a\u3092\u3057\u307e\u3059\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/tr.json b/homeassistant/components/airtouch4/translations/tr.json new file mode 100644 index 00000000000..852639e6350 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/tr.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "no_units": "Herhangi bir AirTouch 4 Grubu bulunamad\u0131." + }, + "step": { + "user": { + "data": { + "host": "Ana bilgisayar" + }, + "title": "AirTouch 4 ba\u011flant\u0131 ayr\u0131nt\u0131lar\u0131n\u0131z\u0131 ayarlay\u0131n." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index dacc1bc1e38..876cb270e99 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -16,7 +16,6 @@ from pyairvisual.errors import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_API_KEY, CONF_IP_ADDRESS, CONF_LATITUDE, @@ -24,6 +23,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_SHOW_ON_MAP, CONF_STATE, + Platform, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed @@ -52,7 +52,7 @@ from .const import ( LOGGER, ) -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] DEFAULT_ATTRIBUTION = "Data provided by AirVisual" DEFAULT_NODE_PRO_UPDATE_INTERVAL = timedelta(minutes=1) @@ -191,9 +191,6 @@ def _standardize_node_pro_config_entry(hass: HomeAssistant, entry: ConfigEntry) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up AirVisual as config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} - if CONF_API_KEY in entry.data: _standardize_geography_config_entry(hass, entry) @@ -272,7 +269,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] = coordinator + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} # Reassess the interval between 2 server requests if CONF_API_KEY in entry.data: @@ -356,7 +354,7 @@ class AirVisualEntity(CoordinatorEntity): """Initialize.""" super().__init__(coordinator) - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._attr_extra_state_attributes = {} self._entry = entry self.entity_description = description diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py index e9ccb203eaa..85ee4bf6ae5 100644 --- a/homeassistant/components/airvisual/config_flow.py +++ b/homeassistant/components/airvisual/config_flow.py @@ -91,6 +91,7 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, str], integration_type: str ) -> FlowResult: """Validate a Cloud API key.""" + errors = {} websession = aiohttp_client.async_get_clientsession(self.hass) cloud_api = CloudAPI(user_input[CONF_API_KEY], session=websession) @@ -117,27 +118,20 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: await coro except InvalidKeyError: - return self.async_show_form( - step_id=error_step, - data_schema=error_schema, - errors={CONF_API_KEY: "invalid_api_key"}, - ) + errors[CONF_API_KEY] = "invalid_api_key" except NotFoundError: - return self.async_show_form( - step_id=error_step, - data_schema=error_schema, - errors={CONF_CITY: "location_not_found"}, - ) + errors[CONF_CITY] = "location_not_found" except AirVisualError as err: LOGGER.error(err) - return self.async_show_form( - step_id=error_step, - data_schema=error_schema, - errors={"base": "unknown"}, - ) + errors["base"] = "unknown" valid_keys.add(user_input[CONF_API_KEY]) + if errors: + return self.async_show_form( + step_id=error_step, data_schema=error_schema, errors=errors + ) + existing_entry = await self.async_set_unique_id(self._geo_id) if existing_entry: self.hass.config_entries.async_update_entry(existing_entry, data=user_input) diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 08896e13557..f5dfc4f1b11 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -2,9 +2,9 @@ from __future__ import annotations from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -81,7 +81,7 @@ GEOGRAPHY_SENSOR_DESCRIPTIONS = ( name="Air Quality Index", device_class=DEVICE_CLASS_AQI, native_unit_of_measurement="AQI", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_POLLUTANT, @@ -98,7 +98,7 @@ NODE_PRO_SENSOR_DESCRIPTIONS = ( name="Air Quality Index", device_class=DEVICE_CLASS_AQI, native_unit_of_measurement="AQI", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_BATTERY_LEVEL, @@ -112,7 +112,7 @@ NODE_PRO_SENSOR_DESCRIPTIONS = ( name="C02", device_class=DEVICE_CLASS_CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_HUMIDITY, @@ -125,35 +125,35 @@ NODE_PRO_SENSOR_DESCRIPTIONS = ( name="PM 0.1", device_class=DEVICE_CLASS_PM1, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_PM_1_0, name="PM 1.0", device_class=DEVICE_CLASS_PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_PM_2_5, name="PM 2.5", device_class=DEVICE_CLASS_PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_TEMPERATURE, name="Temperature", device_class=DEVICE_CLASS_TEMPERATURE, native_unit_of_measurement=TEMP_CELSIUS, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=SENSOR_KIND_VOC, name="VOC", device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), ) diff --git a/homeassistant/components/airvisual/translations/bg.json b/homeassistant/components/airvisual/translations/bg.json index b2c2b26bad3..114a1547549 100644 --- a/homeassistant/components/airvisual/translations/bg.json +++ b/homeassistant/components/airvisual/translations/bg.json @@ -9,8 +9,14 @@ "invalid_api_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447" }, "step": { + "geography_by_coords": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447" + } + }, "geography_by_name": { "data": { + "api_key": "API \u043a\u043b\u044e\u0447", "city": "\u0413\u0440\u0430\u0434", "country": "\u0421\u0442\u0440\u0430\u043d\u0430" } diff --git a/homeassistant/components/airvisual/translations/ja.json b/homeassistant/components/airvisual/translations/ja.json new file mode 100644 index 00000000000..eafcdc7378d --- /dev/null +++ b/homeassistant/components/airvisual/translations/ja.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u307e\u305f\u306f\u3001Node/Pro ID\u306f\u65e2\u306b\u767b\u9332\u3055\u308c\u3066\u3044\u307e\u3059\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", + "general_error": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc", + "invalid_api_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc", + "location_not_found": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "step": { + "geography_by_coords": { + "data": { + "api_key": "API\u30ad\u30fc", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6" + }, + "description": "AirVisual cloud API\u3092\u4f7f\u7528\u3057\u3066\u3001\u7def\u5ea6/\u7d4c\u5ea6\u3092\u76e3\u8996\u3057\u307e\u3059\u3002", + "title": "Geography\u306e\u8a2d\u5b9a" + }, + "geography_by_name": { + "data": { + "api_key": "API\u30ad\u30fc", + "city": "\u90fd\u5e02", + "country": "\u56fd", + "state": "\u5dde" + }, + "description": "AirVisual cloud API\u3092\u4f7f\u7528\u3057\u3066\u3001\u90fd\u5e02/\u5dde/\u56fd\u3092\u76e3\u8996\u3057\u307e\u3059\u3002", + "title": "Geography\u306e\u8a2d\u5b9a" + }, + "node_pro": { + "data": { + "ip_address": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u500b\u4eba\u306eAirVisual\u30e6\u30cb\u30c3\u30c8\u3092\u76e3\u8996\u3057\u307e\u3059\u3002\u30d1\u30b9\u30ef\u30fc\u30c9\u306f\u3001\u672c\u4f53\u306eUI\u304b\u3089\u53d6\u5f97\u3067\u304d\u307e\u3059\u3002", + "title": "AirVisual Node/Pro\u306e\u8a2d\u5b9a" + }, + "reauth_confirm": { + "data": { + "api_key": "API\u30ad\u30fc" + }, + "title": "AirVisual\u3092\u518d\u8a8d\u8a3c" + }, + "user": { + "description": "\u76e3\u8996\u3057\u305f\u3044\u3001AirVisual\u306e\u30c7\u30fc\u30bf\u306e\u7a2e\u985e\u3092\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "AirVisual\u306e\u8a2d\u5b9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "\u76e3\u8996\u5bfe\u8c61\u306e\u5730\u7406\u3092\u5730\u56f3\u306b\u8868\u793a" + }, + "title": "AirVisual\u306e\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.bg.json b/homeassistant/components/airvisual/translations/sensor.bg.json index 311df560225..428050a2427 100644 --- a/homeassistant/components/airvisual/translations/sensor.bg.json +++ b/homeassistant/components/airvisual/translations/sensor.bg.json @@ -1,9 +1,20 @@ { "state": { "airvisual__pollutant_label": { + "co": "\u0412\u044a\u0433\u043b\u0435\u0440\u043e\u0434\u0435\u043d \u043e\u043a\u0438\u0441", + "n2": "\u0410\u0437\u043e\u0442\u0435\u043d \u0434\u0438\u043e\u043a\u0441\u0438\u0434", "o3": "\u041e\u0437\u043e\u043d", "p1": "PM10", - "p2": "PM2.5" + "p2": "PM2.5", + "s2": "\u0421\u0435\u0440\u0435\u043d \u0434\u0438\u043e\u043a\u0441\u0438\u0434" + }, + "airvisual__pollutant_level": { + "good": "\u0414\u043e\u0431\u0440\u043e", + "hazardous": "\u041e\u043f\u0430\u0441\u043d\u043e", + "moderate": "\u0423\u043c\u0435\u0440\u0435\u043d\u043e", + "unhealthy": "\u041d\u0435\u0437\u0434\u0440\u0430\u0432\u043e\u0441\u043b\u043e\u0432\u043d\u043e", + "unhealthy_sensitive": "\u041d\u0435\u0437\u0434\u0440\u0430\u0432\u043e\u0441\u043b\u043e\u0432\u043d\u043e \u0437\u0430 \u0447\u0443\u0432\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u043d\u0438 \u0433\u0440\u0443\u043f\u0438", + "very_unhealthy": "\u041c\u043d\u043e\u0433\u043e \u043d\u0435\u0437\u0434\u0440\u0430\u0432\u043e\u0441\u043b\u043e\u0432\u043d\u043e" } } } \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.id.json b/homeassistant/components/airvisual/translations/sensor.id.json new file mode 100644 index 00000000000..ad6c9c64b3d --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.id.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Karbon monoksida", + "n2": "Nitrogen dioksida", + "o3": "Ozon", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Sulfur Dioksida" + }, + "airvisual__pollutant_level": { + "good": "Bagus", + "hazardous": "Berbahaya", + "moderate": "Sedang", + "unhealthy": "Tidak sehat", + "unhealthy_sensitive": "Tidak sehat untuk kelompok sensitif", + "very_unhealthy": "Sangat tidak sehat" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.ja.json b/homeassistant/components/airvisual/translations/sensor.ja.json new file mode 100644 index 00000000000..91bd016b0ac --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.ja.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "\u4e00\u9178\u5316\u70ad\u7d20", + "n2": "\u4e8c\u9178\u5316\u7a92\u7d20", + "o3": "\u30aa\u30be\u30f3", + "p1": "PM10", + "p2": "PM2.5", + "s2": "\u4e8c\u9178\u5316\u786b\u9ec4" + }, + "airvisual__pollutant_level": { + "good": "\u826f\u597d", + "hazardous": "\u5371\u967a", + "moderate": "\u9069\u5ea6", + "unhealthy": "\u4e0d\u5065\u5eb7", + "unhealthy_sensitive": "\u654f\u611f\u306a\u30b0\u30eb\u30fc\u30d7\u306b\u3068\u3063\u3066\u306f\u4e0d\u5065\u5eb7", + "very_unhealthy": "\u3068\u3066\u3082\u4e0d\u5065\u5eb7" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.tr.json b/homeassistant/components/airvisual/translations/sensor.tr.json new file mode 100644 index 00000000000..47b8e7402b0 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.tr.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Karbonmonoksit", + "n2": "Nitrojen dioksit", + "o3": "Ozon", + "p1": "PM10", + "p2": "PM2.5", + "s2": "K\u00fck\u00fcrt dioksit" + }, + "airvisual__pollutant_level": { + "good": "\u0130yi", + "hazardous": "Tehlikeli", + "moderate": "Il\u0131ml\u0131", + "unhealthy": "Sa\u011fl\u0131ks\u0131z", + "unhealthy_sensitive": "Hassas gruplar i\u00e7in sa\u011fl\u0131ks\u0131z", + "very_unhealthy": "\u00c7ok sa\u011fl\u0131ks\u0131z" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/tr.json b/homeassistant/components/airvisual/translations/tr.json index 6f27841ea13..6ecfc74ad76 100644 --- a/homeassistant/components/airvisual/translations/tr.json +++ b/homeassistant/components/airvisual/translations/tr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f veya Node/Pro Kimli\u011fi zaten kay\u0131tl\u0131.", "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { @@ -26,25 +27,35 @@ "country": "\u00dclke", "state": "durum" }, + "description": "Bir \u015fehri/eyalet/\u00fclkeyi izlemek i\u00e7in AirVisual bulut API'sini kullan\u0131n.", "title": "Bir Co\u011frafyay\u0131 Yap\u0131land\u0131rma" }, "node_pro": { "data": { - "ip_address": "Ana Bilgisayar", + "ip_address": "Ana bilgisayar", "password": "Parola" }, - "description": "Ki\u015fisel bir AirVisual \u00fcnitesini izleyin. Parola, \u00fcnitenin kullan\u0131c\u0131 aray\u00fcz\u00fcnden al\u0131nabilir." + "description": "Ki\u015fisel bir AirVisual \u00fcnitesini izleyin. Parola, \u00fcnitenin kullan\u0131c\u0131 aray\u00fcz\u00fcnden al\u0131nabilir.", + "title": "Bir AirVisual Node/Pro'yu yap\u0131land\u0131r\u0131n" }, "reauth_confirm": { "data": { "api_key": "API Anahtar\u0131" - } + }, + "title": "AirVisual'\u0131 yeniden do\u011frulay\u0131n" + }, + "user": { + "description": "Ne t\u00fcr AirVisual verilerini izlemek istedi\u011finizi se\u00e7in.", + "title": "AirVisual'\u0131 yap\u0131land\u0131r\u0131n" } } }, "options": { "step": { "init": { + "data": { + "show_on_map": "\u0130zlenen co\u011frafyay\u0131 haritada g\u00f6ster" + }, "title": "AirVisual'\u0131 yap\u0131land\u0131r\u0131n" } } diff --git a/homeassistant/components/alarm_control_panel/device_condition.py b/homeassistant/components/alarm_control_panel/device_condition.py index 9367ef8f811..cc01f4a5954 100644 --- a/homeassistant/components/alarm_control_panel/device_condition.py +++ b/homeassistant/components/alarm_control_panel/device_condition.py @@ -27,7 +27,7 @@ from homeassistant.const import ( STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv, entity_registry from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_supported_features @@ -104,12 +104,11 @@ async def async_get_conditions( return conditions +@callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" - if config_validation: - config = CONDITION_SCHEMA(config) if config[CONF_TYPE] == CONDITION_TRIGGERED: state = STATE_ALARM_TRIGGERED elif config[CONF_TYPE] == CONDITION_DISARMED: diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index 92c73b07bbd..9eea745862a 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -157,7 +157,7 @@ async def async_attach_trigger( } if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] - state_config = state_trigger.TRIGGER_SCHEMA(state_config) + state_config = await state_trigger.async_validate_trigger_config(hass, state_config) return await state_trigger.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) diff --git a/homeassistant/components/alarm_control_panel/translations/id.json b/homeassistant/components/alarm_control_panel/translations/id.json index f1676ce8c75..ee079ff7435 100644 --- a/homeassistant/components/alarm_control_panel/translations/id.json +++ b/homeassistant/components/alarm_control_panel/translations/id.json @@ -4,6 +4,7 @@ "arm_away": "Aktifkan {entity_name} untuk keluar", "arm_home": "Aktifkan {entity_name} untuk di rumah", "arm_night": "Aktifkan {entity_name} untuk malam", + "arm_vacation": "Aktifkan {entity_name} untuk liburan", "disarm": "Nonaktifkan {entity_name}", "trigger": "Picu {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} diaktifkan untuk keluar", "is_armed_home": "{entity_name} diaktifkan untuk di rumah", "is_armed_night": "{entity_name} diaktifkan untuk malam", + "is_armed_vacation": "{entity_name} diaktifkan untuk liburan", "is_disarmed": "{entity_name} dinonaktifkan", "is_triggered": "{entity_name} dipicu" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} diaktifkan untuk keluar", "armed_home": "{entity_name} diaktifkan untuk di rumah", "armed_night": "{entity_name} diaktifkan untuk malam", + "armed_vacation": "{entity_name} diaktifkan untuk liburan", "disarmed": "{entity_name} dinonaktifkan", "triggered": "{entity_name} dipicu" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Diaktifkan khusus", "armed_home": "Diaktifkan untuk di rumah", "armed_night": "Diaktifkan untuk malam", + "armed_vacation": "Diaktifkan untuk liburan", "arming": "Mengaktifkan", "disarmed": "Dinonaktifkan", "disarming": "Dinonaktifkan", diff --git a/homeassistant/components/alarm_control_panel/translations/ja.json b/homeassistant/components/alarm_control_panel/translations/ja.json index 3eceb75b597..875d24fd3fe 100644 --- a/homeassistant/components/alarm_control_panel/translations/ja.json +++ b/homeassistant/components/alarm_control_panel/translations/ja.json @@ -1,7 +1,44 @@ { + "device_automation": { + "action_type": { + "arm_away": "\u8b66\u6212 {entity_name} \u96e2\u5e2d(away)", + "arm_home": "\u8b66\u6212 {entity_name} \u5728\u5b85", + "arm_night": "\u8b66\u6212 {entity_name} \u591c", + "arm_vacation": "\u8b66\u6212 {entity_name} \u4f11\u6687", + "disarm": "\u89e3\u9664 {entity_name}", + "trigger": "\u30c8\u30ea\u30ac\u30fc {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} \u306f\u8b66\u6212 \u96e2\u5e2d(away)", + "is_armed_home": "{entity_name} \u306f\u8b66\u6212 \u5728\u5b85", + "is_armed_night": "{entity_name} \u306f\u8b66\u6212 \u591c", + "is_armed_vacation": "{entity_name} \u306f\u8b66\u6212 \u4f11\u6687", + "is_disarmed": "{entity_name} \u306f\u89e3\u9664", + "is_triggered": "{entity_name} \u304c\u30c8\u30ea\u30ac\u30fc\u3055\u308c\u307e\u3059" + }, + "trigger_type": { + "armed_away": "{entity_name} \u8b66\u6212 \u96e2\u5e2d(away)", + "armed_home": "{entity_name} \u8b66\u6212 \u5728\u5b85", + "armed_night": "{entity_name} \u8b66\u6212 \u591c", + "armed_vacation": "{entity_name} \u8b66\u6212 \u4f11\u6687", + "disarmed": "{entity_name} \u89e3\u9664", + "triggered": "{entity_name} \u304c\u30c8\u30ea\u30ac\u30fc\u3055\u308c\u307e\u3057\u305f" + } + }, "state": { "_": { + "armed": "\u8b66\u6212", + "armed_away": "\u8b66\u6212 \u96e2\u5e2d(away)", + "armed_custom_bypass": "\u8b66\u6212 \u30ab\u30b9\u30bf\u30e0 \u30d0\u30a4\u30d1\u30b9", + "armed_home": "\u8b66\u6212 \u5728\u5b85", + "armed_night": "\u8b66\u6212 \u591c", + "armed_vacation": "\u8b66\u6212 \u4f11\u6687", + "arming": "\u8b66\u6212\u4e2d", + "disarmed": "\u89e3\u9664", + "disarming": "\u89e3\u9664", + "pending": "\u4fdd\u7559\u4e2d", "triggered": "\u30c8\u30ea\u30ac\u30fc" } - } + }, + "title": "\u30a2\u30e9\u30fc\u30e0\u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u30d1\u30cd\u30eb" } \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/tr.json b/homeassistant/components/alarm_control_panel/translations/tr.json index c7a8235d5c9..e07c4daba29 100644 --- a/homeassistant/components/alarm_control_panel/translations/tr.json +++ b/homeassistant/components/alarm_control_panel/translations/tr.json @@ -1,9 +1,10 @@ { "device_automation": { "action_type": { - "arm_away": "D\u0131\u015farda", - "arm_home": "Evde", - "arm_night": "Gece", + "arm_away": "{entity_name} Uzakta Alarm", + "arm_home": "{entity_name} Evde Alarm", + "arm_night": "{entity_name} Gece Alarm", + "arm_vacation": "{entity_name} Alarm - Tatil Modu", "disarm": "Devre d\u0131\u015f\u0131 {entity_name}", "trigger": "Tetikle {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} D\u0131\u015farda Modu Aktif", "is_armed_home": "{entity_name} Evde Modu Aktif", "is_armed_night": "{entity_name} Gece Modu Aktif", + "is_armed_vacation": "{entity_name} Alarm a\u00e7\u0131k - Tatil Modu", "is_disarmed": "{entity_name} Devre D\u0131\u015f\u0131", "is_triggered": "{entity_name} tetiklendi" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} D\u0131\u015farda Modu Aktif", "armed_home": "{entity_name} Evde Modu Aktif", "armed_night": "{entity_name} Gece Modu Aktif", + "armed_vacation": "{entity_name} Alarm Tatil Modunda", "disarmed": "{entity_name} Devre D\u0131\u015f\u0131", "triggered": "{entity_name} tetiklendi" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "\u00d6zel Mod Aktif", "armed_home": "Evde Aktif", "armed_night": "Gece Aktif", + "armed_vacation": "Alarm - Tatil Modu", "arming": "Alarm etkinle\u015fiyor", "disarmed": "Devre D\u0131\u015f\u0131", "disarming": "Alarm devre d\u0131\u015f\u0131", diff --git a/homeassistant/components/alarmdecoder/__init__.py b/homeassistant/components/alarmdecoder/__init__.py index 69870450869..fd0b76a5c8a 100644 --- a/homeassistant/components/alarmdecoder/__init__.py +++ b/homeassistant/components/alarmdecoder/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_PORT, CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -35,7 +36,11 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["alarm_control_panel", "sensor", "binary_sensor"] +PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, + Platform.SENSOR, + Platform.BINARY_SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/alarmdecoder/translations/ja.json b/homeassistant/components/alarmdecoder/translations/ja.json new file mode 100644 index 00000000000..461c3bcd42d --- /dev/null +++ b/homeassistant/components/alarmdecoder/translations/ja.json @@ -0,0 +1,74 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "create_entry": { + "default": "AlarmDecoder\u3068\u306e\u63a5\u7d9a\u306b\u6210\u529f\u3057\u307e\u3057\u305f\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "protocol": { + "data": { + "device_baudrate": "\u30c7\u30d0\u30a4\u30b9\u306e\u30dc\u30fc\u30ec\u30fc\u30c8", + "device_path": "\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9", + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "title": "\u63a5\u7d9a\u8a2d\u5b9a\u306e\u69cb\u6210" + }, + "user": { + "data": { + "protocol": "\u30d7\u30ed\u30c8\u30b3\u30eb" + }, + "title": "AlarmDecoder\u30d7\u30ed\u30c8\u30b3\u30eb\u3092\u9078\u629e" + } + } + }, + "options": { + "error": { + "int": "\u4ee5\u4e0b\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u306f\u6574\u6570\u3067\u3042\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "loop_range": "RF\u30eb\u30fc\u30d7\u306f1\u304b\u30894\u307e\u3067\u306e\u6574\u6570\u3067\u306a\u3051\u308c\u3070\u306a\u308a\u307e\u305b\u3093\u3002", + "loop_rfid": "RF\u30eb\u30fc\u30d7\u306fRF\u30b7\u30ea\u30a2\u30eb\u306a\u3057\u3067\u306f\u4f7f\u7528\u3067\u304d\u307e\u305b\u3093\u3002", + "relay_inclusive": "\u30ea\u30ec\u30fc\u30a2\u30c9\u30ec\u30b9\u3068\u30ea\u30ec\u30fc\u30c1\u30e3\u30cd\u30eb\u306f\u76f8\u4e92\u306b\u4f9d\u5b58\u3057\u3066\u3044\u308b\u305f\u3081\u3001\u4e00\u7dd2\u306b\u542b\u3081\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "step": { + "arm_settings": { + "data": { + "alt_night_mode": "\u4ee3\u66ff\u30ca\u30a4\u30c8\u30e2\u30fc\u30c9", + "auto_bypass": "\u8b66\u6212\u306e\u30aa\u30fc\u30c8\u30d0\u30a4\u30d1\u30b9", + "code_arm_required": "\u8b66\u6212\u306b\u5fc5\u8981\u306a\u30b3\u30fc\u30c9" + }, + "title": "AlarmDecoder\u306e\u8a2d\u5b9a" + }, + "init": { + "data": { + "edit_select": "\u7de8\u96c6" + }, + "description": "\u4f55\u3092\u7de8\u96c6\u3057\u307e\u3059\u304b\uff1f", + "title": "AlarmDecoder\u306e\u8a2d\u5b9a" + }, + "zone_details": { + "data": { + "zone_loop": "RF\u30eb\u30fc\u30d7", + "zone_name": "\u30be\u30fc\u30f3\u540d", + "zone_relayaddr": "\u30ea\u30ec\u30fc\u30a2\u30c9\u30ec\u30b9", + "zone_relaychan": "\u30ea\u30ec\u30fc\u30c1\u30e3\u30cd\u30eb", + "zone_rfid": "RF\u30b7\u30ea\u30a2\u30eb", + "zone_type": "\u30be\u30fc\u30f3\u306e\u7a2e\u985e" + }, + "description": "{zone_number} \u306e\u8a73\u7d30\u3092\u5165\u529b\u3057\u307e\u3059\u3002 {zone_number} \u3092\u524a\u9664\u3059\u308b\u306b\u306f\u3001\u30be\u30fc\u30f3\u540d\u3092\u7a7a\u767d\u306e\u307e\u307e\u306b\u3057\u307e\u3059\u3002", + "title": "AlarmDecoder\u306e\u8a2d\u5b9a" + }, + "zone_select": { + "data": { + "zone_number": "\u30be\u30fc\u30f3\u756a\u53f7" + }, + "description": "\u8ffd\u52a0\u3001\u7de8\u96c6\u3001\u307e\u305f\u306f\u524a\u9664\u3059\u308b\u30be\u30fc\u30f3\u756a\u53f7\u3092\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "AlarmDecoder\u306e\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarmdecoder/translations/tr.json b/homeassistant/components/alarmdecoder/translations/tr.json index 2334f9fb99f..ec30201055b 100644 --- a/homeassistant/components/alarmdecoder/translations/tr.json +++ b/homeassistant/components/alarmdecoder/translations/tr.json @@ -3,46 +3,70 @@ "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, + "create_entry": { + "default": "AlarmDecoder'a ba\u015far\u0131yla ba\u011fland\u0131." + }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, "step": { "protocol": { "data": { - "host": "Ana Bilgisayar", + "device_baudrate": "Cihaz Baud H\u0131z\u0131", + "device_path": "Cihaz Yolu", + "host": "Ana bilgisayar", "port": "Port" - } + }, + "title": "Ba\u011flant\u0131 ayarlar\u0131n\u0131 yap\u0131land\u0131r\u0131n" + }, + "user": { + "data": { + "protocol": "Protokol" + }, + "title": "AlarmDecoder Protokol\u00fcn\u00fc Se\u00e7in" } } }, "options": { "error": { + "int": "A\u015fa\u011f\u0131daki alan bir tamsay\u0131 olmal\u0131d\u0131r.", + "loop_range": "RF D\u00f6ng\u00fcs\u00fc 1 ile 4 aras\u0131nda bir tam say\u0131 olmal\u0131d\u0131r.", + "loop_rfid": "RF D\u00f6ng\u00fcs\u00fc, RF Seri olmadan kullan\u0131lamaz.", "relay_inclusive": "R\u00f6le Adresi ve R\u00f6le Kanal\u0131 birbirine ba\u011fl\u0131d\u0131r ve birlikte eklenmelidir." }, "step": { "arm_settings": { "data": { - "alt_night_mode": "Alternatif Gece Modu" - } + "alt_night_mode": "Alternatif Gece Modu", + "auto_bypass": "Alarm a\u00e7\u0131kken otomatik Atlatma", + "code_arm_required": "Kurmak i\u00e7in Gerekli Kod" + }, + "title": "AlarmDecoder'\u0131 yap\u0131land\u0131r\u0131n" }, "init": { "data": { "edit_select": "D\u00fczenle" - } + }, + "description": "Ne d\u00fczenlemek istersiniz?", + "title": "AlarmDecoder'\u0131 yap\u0131land\u0131r\u0131n" }, "zone_details": { "data": { + "zone_loop": "RF D\u00f6ng\u00fcs\u00fc", "zone_name": "B\u00f6lge Ad\u0131", "zone_relayaddr": "R\u00f6le Adresi", "zone_relaychan": "R\u00f6le Kanal\u0131", "zone_rfid": "RF Id", "zone_type": "B\u00f6lge Tipi" - } + }, + "description": "{zone_number} b\u00f6lgesi i\u00e7in ayr\u0131nt\u0131lar\u0131 girin. {zone_number} b\u00f6lgesini silmek i\u00e7in B\u00f6lge Ad\u0131n\u0131 bo\u015f b\u0131rak\u0131n.", + "title": "AlarmDecoder'\u0131 yap\u0131land\u0131r\u0131n" }, "zone_select": { "data": { "zone_number": "B\u00f6lge Numaras\u0131" }, + "description": "Eklemek, d\u00fczenlemek veya kald\u0131rmak istedi\u011finiz b\u00f6lge numaras\u0131n\u0131 girin.", "title": "AlarmDecoder'\u0131 yap\u0131land\u0131r\u0131n" } } diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index e72a1bcffa4..e5ee7ee6911 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -211,7 +211,7 @@ class Alert(ToggleEntity): ) @property - def state(self): + def state(self): # pylint: disable=overridden-final-method """Return the alert status.""" if self._firing: if self._ack: diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py index 91729763804..d888b91a39e 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -105,7 +105,7 @@ class Auth: try: session = aiohttp_client.async_get_clientsession(self.hass) - with async_timeout.timeout(10): + async with async_timeout.timeout(10): response = await session.post( LWA_TOKEN_URI, headers=LWA_HEADERS, diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index ea8a1ed8681..0182d2aa085 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -695,6 +695,7 @@ class AlexaSpeaker(AlexaCapability): "en-US", "es-ES", "es-MX", + "fr-FR", # Not documented as of 2021-12-04, see PR #60489 "it-IT", "ja-JP", } @@ -752,6 +753,7 @@ class AlexaStepSpeaker(AlexaCapability): "en-IN", "en-US", "es-ES", + "fr-FR", # Not documented as of 2021-12-04, see PR #60489 "it-IT", } diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index cc5c604dc8c..739ce6be6a3 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -64,8 +64,7 @@ class AbstractConfig(ABC): async def async_disable_proactive_mode(self): """Disable proactive mode.""" - unsub_func = await self._unsub_proactive_report - if unsub_func: + if unsub_func := await self._unsub_proactive_report: unsub_func() self._unsub_proactive_report = None diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index d74f9329812..2f7f6dc996b 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -9,6 +9,7 @@ from homeassistant.components import ( alert, automation, binary_sensor, + button, camera, cover, fan, @@ -424,6 +425,22 @@ class SwitchCapabilities(AlexaEntity): ] +@ENTITY_ADAPTERS.register(button.DOMAIN) +class ButtonCapabilities(AlexaEntity): + """Class to represent Button capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.ACTIVITY_TRIGGER] + + def interfaces(self): + """Yield the supported interfaces.""" + return [ + AlexaSceneController(self.entity, supports_deactivation=False), + Alexa(self.hass), + ] + + @ENTITY_ADAPTERS.register(climate.DOMAIN) class ClimateCapabilities(AlexaEntity): """Class to represent Climate capabilities.""" diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index edf900bb18f..edd510f7844 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -4,6 +4,7 @@ import math from homeassistant import core as ha from homeassistant.components import ( + button, camera, cover, fan, @@ -313,9 +314,13 @@ async def async_api_activate(hass, config, directive, context): entity = directive.entity domain = entity.domain + service = SERVICE_TURN_ON + if domain == button.DOMAIN: + service = button.SERVICE_PRESS + await hass.services.async_call( domain, - SERVICE_TURN_ON, + service, {ATTR_ENTITY_ID: entity.entity_id}, blocking=False, context=context, diff --git a/homeassistant/components/alexa/smart_home_http.py b/homeassistant/components/alexa/smart_home_http.py index df4f95f12f2..237828987e8 100644 --- a/homeassistant/components/alexa/smart_home_http.py +++ b/homeassistant/components/alexa/smart_home_http.py @@ -3,12 +3,7 @@ import logging from homeassistant import core from homeassistant.components.http.view import HomeAssistantView -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, -) +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, ENTITY_CATEGORIES from homeassistant.helpers import entity_registry as er from .auth import Auth @@ -71,10 +66,7 @@ class AlexaConfig(AbstractConfig): entity_registry = er.async_get(self.hass) if registry_entry := entity_registry.async_get(entity_id): - auxiliary_entity = registry_entry.entity_category in ( - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, - ) + auxiliary_entity = registry_entry.entity_category in ENTITY_CATEGORIES else: auxiliary_entity = False return not auxiliary_entity diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index e611960b9d9..767bfa18224 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -132,7 +132,7 @@ async def async_send_changereport_message( session = hass.helpers.aiohttp_client.async_get_clientsession() try: - with async_timeout.timeout(DEFAULT_TIMEOUT): + async with async_timeout.timeout(DEFAULT_TIMEOUT): response = await session.post( config.endpoint, headers=headers, @@ -182,12 +182,13 @@ async def async_send_add_or_update_message(hass, config, entity_ids): endpoints = [] for entity_id in entity_ids: - domain = entity_id.split(".", 1)[0] - - if domain not in ENTITY_ADAPTERS: + if (domain := entity_id.split(".", 1)[0]) not in ENTITY_ADAPTERS: continue - alexa_entity = ENTITY_ADAPTERS[domain](hass, config, hass.states.get(entity_id)) + if (state := hass.states.get(entity_id)) is None: + continue + + alexa_entity = ENTITY_ADAPTERS[domain](hass, config, state) endpoints.append(alexa_entity.serialize_discovery()) payload = {"endpoints": endpoints, "scope": {"type": "BearerToken", "token": token}} @@ -263,7 +264,7 @@ async def async_send_doorbell_event_message(hass, config, alexa_entity): session = hass.helpers.aiohttp_client.async_get_clientsession() try: - with async_timeout.timeout(DEFAULT_TIMEOUT): + async with async_timeout.timeout(DEFAULT_TIMEOUT): response = await session.post( config.endpoint, headers=headers, diff --git a/homeassistant/components/almond/__init__.py b/homeassistant/components/almond/__init__.py index 6a5449e3d51..0dd7a76d4c4 100644 --- a/homeassistant/components/almond/__init__.py +++ b/homeassistant/components/almond/__init__.py @@ -177,7 +177,9 @@ async def _configure_almond_for_ha( user = await hass.auth.async_get_user(data["almond_user"]) if user is None: - user = await hass.auth.async_create_system_user("Almond", [GROUP_ID_ADMIN]) + user = await hass.auth.async_create_system_user( + "Almond", group_ids=[GROUP_ID_ADMIN] + ) data["almond_user"] = user.id await store.async_save(data) @@ -192,7 +194,7 @@ async def _configure_almond_for_ha( # Store token in Almond try: - with async_timeout.timeout(30): + async with async_timeout.timeout(30): await api.async_create_device( { "kind": "io.home-assistant", diff --git a/homeassistant/components/almond/config_flow.py b/homeassistant/components/almond/config_flow.py index b7b56f93864..ba6fcb6d83c 100644 --- a/homeassistant/components/almond/config_flow.py +++ b/homeassistant/components/almond/config_flow.py @@ -12,6 +12,7 @@ import voluptuous as vol from yarl import URL from homeassistant import config_entries, core, data_entry_flow +from homeassistant.components.hassio import HassioServiceInfo from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow @@ -24,7 +25,7 @@ async def async_verify_local_connection(hass: core.HomeAssistant, host: str): api = WebAlmondAPI(AlmondLocalAuth(host, websession)) try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): await api.async_list_apps() return True @@ -94,12 +95,12 @@ class AlmondFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler): data={"type": TYPE_LOCAL, "host": user_input["host"]}, ) - async def async_step_hassio(self, discovery_info): + async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Receive a Hass.io discovery.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - self.hassio_discovery = discovery_info + self.hassio_discovery = discovery_info.config return await self.async_step_hassio_confirm() diff --git a/homeassistant/components/almond/translations/ja.json b/homeassistant/components/almond/translations/ja.json new file mode 100644 index 00000000000..4d51bbc2f13 --- /dev/null +++ b/homeassistant/components/almond/translations/ja.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "missing_configuration": "\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u306b\u5f93\u3063\u3066\u304f\u3060\u3055\u3044\u3002", + "no_url_available": "\u4f7f\u7528\u53ef\u80fd\u306aURL\u304c\u3042\u308a\u307e\u305b\u3093\u3002\u3053\u306e\u30a8\u30e9\u30fc\u306e\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001[\u30d8\u30eb\u30d7\u30bb\u30af\u30b7\u30e7\u30f3\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044]({docs_url})", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "hassio_confirm": { + "description": "\u30a2\u30c9\u30aa\u30f3 {addon} \u304c\u3001\u63d0\u4f9b\u3059\u308b\u3001Almond\u306b\u63a5\u7d9a\u3059\u308b\u3088\u3046\u306bHome Assistant\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u304b\uff1f", + "title": "Home Assistant\u30a2\u30c9\u30aa\u30f3\u7d4c\u7531\u306eAlmond" + }, + "pick_implementation": { + "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/tr.json b/homeassistant/components/almond/translations/tr.json index dc270099fcd..a0808fde8ef 100644 --- a/homeassistant/components/almond/translations/tr.json +++ b/homeassistant/components/almond/translations/tr.json @@ -2,7 +2,18 @@ "config": { "abort": { "cannot_connect": "Ba\u011flanma hatas\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." + }, + "step": { + "hassio_confirm": { + "description": "{addon} taraf\u0131ndan sa\u011flanan Almond'a ba\u011flanacak \u015fekilde yap\u0131land\u0131rmak istiyor musunuz?", + "title": "Home Assistant eklentisi arac\u0131l\u0131\u011f\u0131yla Almond" + }, + "pick_implementation": { + "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7" + } } } } \ No newline at end of file diff --git a/homeassistant/components/amazon_polly/const.py b/homeassistant/components/amazon_polly/const.py index 5ae8c73881d..91882dd386c 100644 --- a/homeassistant/components/amazon_polly/const.py +++ b/homeassistant/components/amazon_polly/const.py @@ -78,6 +78,7 @@ SUPPORTED_VOICES: Final[list[str]] = [ "Maja", # Polish "Ricardo", "Vitoria", # Portuguese, Brazilian + "Camila", # Portuguese, Brazilian "Cristiano", "Ines", # Portuguese, European "Carmen", # Romanian diff --git a/homeassistant/components/ambee/__init__.py b/homeassistant/components/ambee/__init__.py index 362dc26d851..4481afb09ca 100644 --- a/homeassistant/components/ambee/__init__.py +++ b/homeassistant/components/ambee/__init__.py @@ -3,16 +3,15 @@ from __future__ import annotations from ambee import AirQuality, Ambee, AmbeeAuthenticationError, Pollen -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN 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, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER, SCAN_INTERVAL, SERVICE_AIR_QUALITY, SERVICE_POLLEN -PLATFORMS = (SENSOR_DOMAIN,) +PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/ambee/const.py b/homeassistant/components/ambee/const.py index 42b19a52995..a63aa4b804d 100644 --- a/homeassistant/components/ambee/const.py +++ b/homeassistant/components/ambee/const.py @@ -21,8 +21,6 @@ DOMAIN: Final = "ambee" LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(hours=1) -ENTRY_TYPE_SERVICE: Final = "service" - DEVICE_CLASS_AMBEE_RISK: Final = "ambee__risk" SERVICE_AIR_QUALITY: Final = "air_quality" diff --git a/homeassistant/components/ambee/sensor.py b/homeassistant/components/ambee/sensor.py index 2ddd60a9168..bf9cfe74f31 100644 --- a/homeassistant/components/ambee/sensor.py +++ b/homeassistant/components/ambee/sensor.py @@ -8,6 +8,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry 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.typing import StateType @@ -16,7 +17,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import DOMAIN, ENTRY_TYPE_SERVICE, SENSORS, SERVICES +from .const import DOMAIN, SENSORS, SERVICES async def async_setup_entry( @@ -59,7 +60,7 @@ class AmbeeSensorEntity(CoordinatorEntity, SensorEntity): self._attr_unique_id = f"{entry_id}_{service_key}_{description.key}" self._attr_device_info = DeviceInfo( - entry_type=ENTRY_TYPE_SERVICE, + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, f"{entry_id}_{service_key}")}, manufacturer="Ambee", name=service, diff --git a/homeassistant/components/ambee/translations/ja.json b/homeassistant/components/ambee/translations/ja.json new file mode 100644 index 00000000000..e320189e010 --- /dev/null +++ b/homeassistant/components/ambee/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "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_api_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API\u30ad\u30fc", + "description": "Ambee\u30a2\u30ab\u30a6\u30f3\u30c8\u3067\u518d\u8a8d\u8a3c\u3057\u307e\u3059\u3002" + } + }, + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "name": "\u540d\u524d" + }, + "description": "Ambee \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3001Home Assistant\u3068\u9023\u643a\u3059\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/ru.json b/homeassistant/components/ambee/translations/ru.json index 02458c7609f..c229c2d6020 100644 --- a/homeassistant/components/ambee/translations/ru.json +++ b/homeassistant/components/ambee/translations/ru.json @@ -21,7 +21,7 @@ "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Ambee." + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Ambee." } } } diff --git a/homeassistant/components/ambee/translations/sensor.bg.json b/homeassistant/components/ambee/translations/sensor.bg.json new file mode 100644 index 00000000000..07977ca4abf --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.bg.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "\u0412\u0438\u0441\u043e\u043a\u043e", + "low": "\u041d\u0438\u0441\u043a\u043e", + "moderate": "\u0423\u043c\u0435\u0440\u0435\u043d\u043e", + "very high": "\u041c\u043d\u043e\u0433\u043e \u0432\u0438\u0441\u043e\u043a\u043e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.id.json b/homeassistant/components/ambee/translations/sensor.id.json index 61bdea468ee..5cb74694da5 100644 --- a/homeassistant/components/ambee/translations/sensor.id.json +++ b/homeassistant/components/ambee/translations/sensor.id.json @@ -3,7 +3,8 @@ "ambee__risk": { "high": "Tinggi", "low": "Rendah", - "moderate": "Sedang" + "moderate": "Sedang", + "very high": "Sangat Tinggi" } } } \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.ja.json b/homeassistant/components/ambee/translations/sensor.ja.json new file mode 100644 index 00000000000..a750a257864 --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.ja.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "\u9ad8\u3044", + "low": "\u4f4e\u3044", + "moderate": "\u9069\u5ea6", + "very high": "\u975e\u5e38\u306b\u9ad8\u3044" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.tr.json b/homeassistant/components/ambee/translations/sensor.tr.json new file mode 100644 index 00000000000..087bea4ed99 --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.tr.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "Y\u00fcksek", + "low": "D\u00fc\u015f\u00fck", + "moderate": "Moderate", + "very high": "\u00c7ok y\u00fcksek" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/tr.json b/homeassistant/components/ambee/translations/tr.json new file mode 100644 index 00000000000..45eacf30987 --- /dev/null +++ b/homeassistant/components/ambee/translations/tr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_api_key": "Ge\u00e7ersiz API anahtar\u0131" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API Anahtar\u0131", + "description": "Ambee hesab\u0131n\u0131zla yeniden kimlik do\u011frulamas\u0131 yap\u0131n." + } + }, + "user": { + "data": { + "api_key": "API Anahtar\u0131", + "latitude": "Enlem", + "longitude": "Boylam", + "name": "Ad" + }, + "description": "Ambee'yi Home Assistant ile entegre olacak \u015fekilde ayarlay\u0131n." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/binary_sensor.py b/homeassistant/components/amberelectric/binary_sensor.py index fe6edea18f8..422ff66db59 100644 --- a/homeassistant/components/amberelectric/binary_sensor.py +++ b/homeassistant/components/amberelectric/binary_sensor.py @@ -61,7 +61,7 @@ class AmberPriceSpikeBinarySensor(AmberPriceGridSensor): return self.coordinator.data["grid"]["price_spike"] == "spike" @property - def device_state_attributes(self) -> Mapping[str, Any] | None: + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return additional pieces of information about the price spike.""" spike_status = self.coordinator.data["grid"]["price_spike"] diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index fe2e5f9bb88..f3cda887150 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -1,6 +1,8 @@ """Amber Electric Constants.""" import logging +from homeassistant.const import Platform + DOMAIN = "amberelectric" CONF_API_TOKEN = "api_token" CONF_SITE_NAME = "site_name" @@ -10,4 +12,4 @@ CONF_SITE_NMI = "site_nmi" ATTRIBUTION = "Data provided by Amber Electric" LOGGER = logging.getLogger(__package__) -PLATFORMS = ["sensor", "binary_sensor"] +PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR] diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index a1644fb7924..7cee95dcfcf 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -86,7 +86,7 @@ class AmberPriceSensor(AmberSensor): return format_cents_to_dollars(interval.per_kwh) @property - def device_state_attributes(self) -> Mapping[str, Any] | None: + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return additional pieces of information about the price.""" interval = self.coordinator.data[self.entity_description.key][self.channel_type] @@ -133,7 +133,7 @@ class AmberForecastSensor(AmberSensor): return format_cents_to_dollars(interval.per_kwh) @property - def device_state_attributes(self) -> Mapping[str, Any] | None: + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return additional pieces of information about the price.""" intervals = self.coordinator.data[self.entity_description.key].get( self.channel_type diff --git a/homeassistant/components/amberelectric/translations/id.json b/homeassistant/components/amberelectric/translations/id.json new file mode 100644 index 00000000000..4920ee7b177 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/id.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Nama Situs", + "site_nmi": "Situs NMI" + }, + "description": "Pilih NMI dari situs yang ingin ditambahkan", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "Token API", + "site_id": "ID Site" + }, + "description": "Buka {api_url} untuk membuat kunci API", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/ja.json b/homeassistant/components/amberelectric/translations/ja.json index e0a3590c8b4..1fc5f6c58c1 100644 --- a/homeassistant/components/amberelectric/translations/ja.json +++ b/homeassistant/components/amberelectric/translations/ja.json @@ -1,10 +1,21 @@ { "config": { "step": { + "site": { + "data": { + "site_name": "\u30b5\u30a4\u30c8\u540d", + "site_nmi": "\u30b5\u30a4\u30c8NMI" + }, + "description": "\u8ffd\u52a0\u3057\u305f\u3044\u30b5\u30a4\u30c8\u306eNMI\u3092\u9078\u629e", + "title": "Amber Electric" + }, "user": { "data": { - "api_token": "API\u30c8\u30fc\u30af\u30f3" - } + "api_token": "API\u30c8\u30fc\u30af\u30f3", + "site_id": "\u30b5\u30a4\u30c8ID" + }, + "description": "API\u30ad\u30fc\u3092\u751f\u6210\u3059\u308b\u305f\u3081\u306b {api_url} \u306b\u30a2\u30af\u30bb\u30b9\u3057\u307e\u3059", + "title": "Amber Electric" } } } diff --git a/homeassistant/components/amberelectric/translations/pl.json b/homeassistant/components/amberelectric/translations/pl.json index 1054014ea49..1e9b66e3c3c 100644 --- a/homeassistant/components/amberelectric/translations/pl.json +++ b/homeassistant/components/amberelectric/translations/pl.json @@ -1,7 +1,20 @@ { "config": { "step": { + "site": { + "data": { + "site_name": "Nazwa obiektu", + "site_nmi": "Numer identyfikacyjny (NMI) obiektu" + }, + "description": "Wybierz NMI obiektu, kt\u00f3ry chcesz doda\u0107", + "title": "Amber Electric" + }, "user": { + "data": { + "api_token": "Token API", + "site_id": "Identyfikator obiektu" + }, + "description": "Przejd\u017a do {api_url}, aby wygenerowa\u0107 klucz API", "title": "Amber Electric" } } diff --git a/homeassistant/components/amberelectric/translations/tr.json b/homeassistant/components/amberelectric/translations/tr.json new file mode 100644 index 00000000000..274a347e578 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/tr.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "site": { + "data": { + "site_name": "Site Ad\u0131", + "site_nmi": "Site NMI" + }, + "description": "Eklemek istedi\u011finiz sitenin NMI'sini se\u00e7in", + "title": "Amber Electric" + }, + "user": { + "data": { + "api_token": "API Anahtar\u0131", + "site_id": "Site Kimli\u011fi" + }, + "description": "API anahtar\u0131 olu\u015fturmak i\u00e7in {api_url} konumuna gidin", + "title": "Amber Electric" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/config_flow.py b/homeassistant/components/ambiclimate/config_flow.py index 623e96a4a67..00fa339b0d8 100644 --- a/homeassistant/components/ambiclimate/config_flow.py +++ b/homeassistant/components/ambiclimate/config_flow.py @@ -86,9 +86,7 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Received code for authentication.""" self._async_abort_entries_match() - token_info = await self._get_token_info(code) - - if token_info is None: + if await self._get_token_info(code) is None: return self.async_abort(reason="access_token") config = self.hass.data[DATA_AMBICLIMATE_IMPL].copy() @@ -126,7 +124,7 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) def _cb_url(self): - return f"{get_url(self.hass)}{AUTH_CALLBACK_PATH}" + return f"{get_url(self.hass, prefer_external=True)}{AUTH_CALLBACK_PATH}" async def _get_authorize_url(self): oauth = self._generate_oauth() diff --git a/homeassistant/components/ambiclimate/translations/hu.json b/homeassistant/components/ambiclimate/translations/hu.json index 597645658d8..1e67873f1aa 100644 --- a/homeassistant/components/ambiclimate/translations/hu.json +++ b/homeassistant/components/ambiclimate/translations/hu.json @@ -10,7 +10,7 @@ }, "error": { "follow_link": "K\u00e9rem, k\u00f6vesse a hivatkoz\u00e1st \u00e9s hiteles\u00edtse mag\u00e1t miel\u0151tt megnyomn\u00e1 a K\u00fcld\u00e9s gombot", - "no_token": "Nem hiteles\u00edtett Ambiclimate" + "no_token": "Ambiclimate-al nem siker\u00fclt a hiteles\u00edt\u00e9s" }, "step": { "auth": { diff --git a/homeassistant/components/ambiclimate/translations/ja.json b/homeassistant/components/ambiclimate/translations/ja.json new file mode 100644 index 00000000000..a6fbfc256ea --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/ja.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "access_token": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3\u306e\u751f\u6210\u4e2d\u306b\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002", + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "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" + }, + "create_entry": { + "default": "\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f" + }, + "error": { + "follow_link": "\u9001\u4fe1(submit) \u3092\u30af\u30ea\u30c3\u30af\u3059\u308b\u524d\u306b\u3001\u4e8b\u524d\u306b\u30ea\u30f3\u30af\u3092\u305f\u3069\u3063\u3066\u8a8d\u8a3c\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "no_token": "Ambiclimate\u3067\u8a8d\u8a3c\u3055\u308c\u3066\u3044\u307e\u305b\u3093" + }, + "step": { + "auth": { + "description": "\u3053\u306e[\u30ea\u30f3\u30af]({authorization_url}) \u306b\u5f93\u3044\u3001Ambiclimate\u30a2\u30ab\u30a6\u30f3\u30c8\u3078\u306e\u30a2\u30af\u30bb\u30b9\u3092 **\u8a31\u53ef(Allow)** \u3057\u3066\u304b\u3089\u3001\u623b\u3063\u3066\u304d\u3066\u4ee5\u4e0b\u306e **\u9001\u4fe1(submit)** \u3092\u62bc\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n(\u6307\u5b9a\u3055\u308c\u305f\u30b3\u30fc\u30eb\u30d0\u30c3\u30afURL\u304c {cb_url} \u3067\u3042\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044)", + "title": "Ambiclimate\u3092\u8a8d\u8a3c\u3059\u308b" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/translations/tr.json b/homeassistant/components/ambiclimate/translations/tr.json index bcaeba84558..76d0292dd3d 100644 --- a/homeassistant/components/ambiclimate/translations/tr.json +++ b/homeassistant/components/ambiclimate/translations/tr.json @@ -1,7 +1,22 @@ { "config": { "abort": { - "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "access_token": "Eri\u015fim anahtar\u0131 olu\u015ftururken bilinmeyen hata.", + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "missing_configuration": "Bile\u015fen yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin." + }, + "create_entry": { + "default": "Ba\u015far\u0131yla do\u011fruland\u0131" + }, + "error": { + "follow_link": "L\u00fctfen ba\u011flant\u0131y\u0131 takip edin ve G\u00f6nder'e basmadan \u00f6nce kimlik do\u011frulamas\u0131 yap\u0131n", + "no_token": "Ambiclimate ile kimli\u011fi do\u011frulanmad\u0131" + }, + "step": { + "auth": { + "description": "L\u00fctfen bu [ba\u011flant\u0131y\u0131]( {authorization_url} ) takip edin ve Ambiclimate hesab\u0131n\u0131za **izin verin**, ard\u0131ndan geri d\u00f6n\u00fcn ve a\u015fa\u011f\u0131daki **G\u00f6nder**'e bas\u0131n.\n (Belirtilen geri \u00e7a\u011f\u0131rma URL'sinin {cb_url} oldu\u011fundan emin olun)", + "title": "Ambiclimate kimlik do\u011frulamas\u0131" + } } } } \ No newline at end of file diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 190ed6dc59e..6406b5a10df 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -5,6 +5,7 @@ from typing import Any from aioambient import Websocket from aioambient.errors import WebsocketError +from aioambient.util import get_public_device_id from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -12,16 +13,18 @@ from homeassistant.const import ( ATTR_NAME, CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, + Platform, ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription -from homeassistant.helpers.event import async_call_later +import homeassistant.helpers.entity_registry as er from .const import ( ATTR_LAST_DATA, @@ -32,7 +35,7 @@ from .const import ( TYPE_SOLARRADIATION_LX, ) -PLATFORMS = ["binary_sensor", "sensor"] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] DATA_CONFIG = "config" @@ -58,26 +61,25 @@ def async_hydrate_station_data(data: dict[str, Any]) -> dict[str, Any]: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Ambient PWS as config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} - if not entry.unique_id: hass.config_entries.async_update_entry( entry, unique_id=entry.data[CONF_APP_KEY] ) + ambient = AmbientStation( + hass, + entry, + Websocket(entry.data[CONF_APP_KEY], entry.data[CONF_API_KEY]), + ) + try: - ambient = AmbientStation( - hass, - entry, - Websocket(entry.data[CONF_APP_KEY], entry.data[CONF_API_KEY]), - ) - hass.loop.create_task(ambient.ws_connect()) - hass.data[DOMAIN][entry.entry_id] = ambient + await ambient.ws_connect() except WebsocketError as err: LOGGER.error("Config entry failed: %s", err) raise ConfigEntryNotReady from err + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ambient + async def _async_disconnect_websocket(_: Event) -> None: await ambient.websocket.disconnect() @@ -108,14 +110,15 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 1 -> 2: Unique ID format changed, so delete and re-import: if version == 1: - dev_reg = await hass.helpers.device_registry.async_get_registry() - dev_reg.async_clear_config_entry(entry) + dev_reg = dr.async_get(hass) + dev_reg.async_clear_config_entry(entry.entry_id) - en_reg = await hass.helpers.entity_registry.async_get_registry() - en_reg.async_clear_config_entry(entry) + en_reg = er.async_get(hass) + en_reg.async_clear_config_entry(entry.entry_id) version = entry.version = 2 hass.config_entries.async_update_entry(entry) + LOGGER.info("Migration to version %s successful", version) return True @@ -135,20 +138,6 @@ class AmbientStation: self.stations: dict[str, dict] = {} self.websocket = websocket - async def _attempt_connect(self) -> None: - """Attempt to connect to the socket (retrying later on fail).""" - - async def connect(timestamp: int | None = None) -> None: - """Connect.""" - await self.websocket.connect() - - try: - await connect() - except WebsocketError as err: - LOGGER.error("Error with the websocket connection: %s", err) - self._ws_reconnect_delay = min(2 * self._ws_reconnect_delay, 480) - async_call_later(self._hass, self._ws_reconnect_delay, connect) - async def ws_connect(self) -> None: """Register handlers and connect to the websocket.""" @@ -199,7 +188,7 @@ class AmbientStation: self.websocket.on_disconnect(on_disconnect) self.websocket.on_subscribed(on_subscribed) - await self._attempt_connect() + await self.websocket.connect() async def ws_disconnect(self) -> None: """Disconnect from the websocket.""" @@ -220,11 +209,15 @@ class AmbientWeatherEntity(Entity): ) -> None: """Initialize the entity.""" self._ambient = ambient + + public_device_id = get_public_device_id(mac_address) self._attr_device_info = DeviceInfo( + configuration_url=f"https://ambientweather.net/dashboard/{public_device_id}", identifiers={(DOMAIN, mac_address)}, manufacturer="Ambient Weather", name=station_name, ) + self._attr_name = f"{station_name}_{description.name}" self._attr_unique_id = f"{mac_address}_{description.key}" self._mac_address = mac_address diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index 857ce6de585..33cb84706ff 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -3,7 +3,7 @@ "name": "Ambient Weather Station", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ambient_station", - "requirements": ["aioambient==2021.10.1"], + "requirements": ["aioambient==2021.11.0"], "codeowners": ["@bachya"], "iot_class": "cloud_push" } diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index 66bccb30b55..58ac081efbf 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -1,6 +1,8 @@ """Support for Ambient Weather Station sensors.""" from __future__ import annotations +from datetime import datetime + from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, @@ -643,6 +645,11 @@ class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity): @callback def update_from_latest_data(self) -> None: """Fetch new state data for the sensor.""" - self._attr_native_value = self._ambient.stations[self._mac_address][ - ATTR_LAST_DATA - ][self.entity_description.key] + raw = self._ambient.stations[self._mac_address][ATTR_LAST_DATA][ + self.entity_description.key + ] + + if self.entity_description.key == TYPE_LASTRAIN: + self._attr_native_value = datetime.strptime(raw, "%Y-%m-%dT%H:%M:%S.%f%z") + else: + self._attr_native_value = raw diff --git a/homeassistant/components/ambient_station/translations/ja.json b/homeassistant/components/ambient_station/translations/ja.json new file mode 100644 index 00000000000..63a42f88a64 --- /dev/null +++ b/homeassistant/components/ambient_station/translations/ja.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "invalid_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc", + "no_devices": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "app_key": "\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u30ad\u30fc" + }, + "title": "\u3042\u306a\u305f\u306e\u60c5\u5831\u3092\u5165\u529b" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/translations/tr.json b/homeassistant/components/ambient_station/translations/tr.json index 908d97f5758..ed4b15c1449 100644 --- a/homeassistant/components/ambient_station/translations/tr.json +++ b/homeassistant/components/ambient_station/translations/tr.json @@ -4,13 +4,16 @@ "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, "error": { - "invalid_key": "Ge\u00e7ersiz API anahtar\u0131" + "invalid_key": "Ge\u00e7ersiz API anahtar\u0131", + "no_devices": "Hesapta cihaz bulunamad\u0131" }, "step": { "user": { "data": { - "api_key": "API Anahtar\u0131" - } + "api_key": "API Anahtar\u0131", + "app_key": "Uygulama Anahtar\u0131" + }, + "title": "Bilgilerinizi doldurun" } } } diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 18aa2006f72..3ee6e685eb5 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -18,6 +18,7 @@ from homeassistant.auth.permissions.const import POLICY_CONTROL from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.camera import DOMAIN as CAMERA from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.components.switch import DOMAIN as SWITCH from homeassistant.const import ( ATTR_ENTITY_ID, CONF_AUTHENTICATION, @@ -28,6 +29,7 @@ from homeassistant.const import ( CONF_PORT, CONF_SCAN_INTERVAL, CONF_SENSORS, + CONF_SWITCHES, CONF_USERNAME, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE, @@ -56,6 +58,7 @@ from .const import ( ) from .helpers import service_signal from .sensor import SENSOR_KEYS +from .switch import SWITCH_KEYS _LOGGER = logging.getLogger(__name__) @@ -111,6 +114,9 @@ AMCREST_SCHEMA = vol.Schema( vol.Unique(), check_binary_sensors, ), + vol.Optional(CONF_SWITCHES): vol.All( + cv.ensure_list, [vol.In(SWITCH_KEYS)], vol.Unique() + ), vol.Optional(CONF_SENSORS): vol.All( cv.ensure_list, [vol.In(SENSOR_KEYS)], vol.Unique() ), @@ -273,6 +279,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: resolution = RESOLUTION_LIST[device[CONF_RESOLUTION]] binary_sensors = device.get(CONF_BINARY_SENSORS) sensors = device.get(CONF_SENSORS) + switches = device.get(CONF_SWITCHES) stream_source = device[CONF_STREAM_SOURCE] control_light = device.get(CONF_CONTROL_LIGHT) @@ -320,6 +327,11 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, SENSOR, DOMAIN, {CONF_NAME: name, CONF_SENSORS: sensors}, config ) + if switches: + discovery.load_platform( + hass, SWITCH, DOMAIN, {CONF_NAME: name, CONF_SWITCHES: switches}, config + ) + if not hass.data[DATA_AMCREST][DEVICES]: return False diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 8333ece1030..e250b8ef59b 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -412,7 +412,7 @@ class AmcrestCam(Camera): f"{serial_number}-{self._resolution}-{self._channel}" ) _LOGGER.debug("Assigned unique_id=%s", self._attr_unique_id) - self.is_streaming = self._get_video() + self._attr_is_streaming = self._get_video() self._is_recording = self._get_recording() self._motion_detection_enabled = self._get_motion_detection() self._audio_enabled = self._get_audio() diff --git a/homeassistant/components/amcrest/switch.py b/homeassistant/components/amcrest/switch.py new file mode 100644 index 00000000000..876deeacf91 --- /dev/null +++ b/homeassistant/components/amcrest/switch.py @@ -0,0 +1,90 @@ +"""Support for Amcrest Switches.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import CONF_NAME, CONF_SWITCHES +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .const import DATA_AMCREST, DEVICES + +if TYPE_CHECKING: + from . import AmcrestDevice + +PRIVACY_MODE_KEY = "privacy_mode" + +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key=PRIVACY_MODE_KEY, + name="Privacy Mode", + icon="mdi:eye-off", + ), +) + +SWITCH_KEYS: list[str] = [desc.key for desc in SWITCH_TYPES] + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up amcrest platform switches.""" + if discovery_info is None: + return + + name = discovery_info[CONF_NAME] + device = hass.data[DATA_AMCREST][DEVICES][name] + switches = discovery_info[CONF_SWITCHES] + async_add_entities( + [ + AmcrestSwitch(name, device, description) + for description in SWITCH_TYPES + if description.key in switches + ], + True, + ) + + +class AmcrestSwitch(SwitchEntity): + """Representation of an Amcrest Camera Switch.""" + + def __init__( + self, + name: str, + device: AmcrestDevice, + entity_description: SwitchEntityDescription, + ) -> None: + """Initialize switch.""" + self._api = device.api + self.entity_description = entity_description + self._attr_name = f"{name} {entity_description.name}" + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._api.available + + def turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + self._turn_switch(True) + + def turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + self._turn_switch(False) + + def _turn_switch(self, mode: bool) -> None: + """Set privacy mode.""" + lower_str = str(mode).lower() + self._api.command( + f"configManager.cgi?action=setConfig&LeLensMask[0].Enable={lower_str}" + ) + + def update(self) -> None: + """Update switch.""" + io_res = self._api.privacy_config().splitlines()[0].split("=")[1] + self._attr_is_on = io_res == "true" diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index d7fa781945d..d1b8879bf7c 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -255,7 +255,7 @@ class Analytics: ) try: - with async_timeout.timeout(30): + async with async_timeout.timeout(30): response = await self.session.post(self.endpoint, json=payload) if response.status == 200: LOGGER.info( diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index f7a350925ec..6f3b83a4867 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -120,7 +120,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( icon="mdi:timer-outline", ), SensorEntityDescription( - key="endapc", + key="end apc", name="Date and Time", icon="mdi:calendar-clock", ), diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 229311ff6d9..d2201ccace0 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -131,7 +131,7 @@ class APIEventStream(HomeAssistantView): while True: try: - with async_timeout.timeout(STREAM_PING_INTERVAL): + async with async_timeout.timeout(STREAM_PING_INTERVAL): payload = await to_write.get() if payload is stop_obj: diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index fbf02fcfdff..b710a753da9 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -317,7 +317,7 @@ class AppleTVManager: self._dispatch_send(SIGNAL_CONNECTED, self.atv) self._address_updated(str(conf.address)) - await self._async_setup_device_registry() + self._async_setup_device_registry() self._connection_attempts = 0 if self._connection_was_lost: @@ -327,7 +327,8 @@ class AppleTVManager: ) self._connection_was_lost = False - async def _async_setup_device_registry(self): + @callback + def _async_setup_device_registry(self): attrs = { ATTR_IDENTIFIERS: {(DOMAIN, self.config_entry.unique_id)}, ATTR_MANUFACTURER: "Apple", @@ -351,7 +352,7 @@ class AppleTVManager: if dev_info.mac: attrs[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, dev_info.mac)} - device_registry = await dr.async_get_registry(self.hass) + device_registry = dr.async_get(self.hass) device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, **attrs ) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 56ad1e83e23..11c41740c69 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -9,15 +9,10 @@ from pyatv.convert import protocol_str import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( - CONF_ADDRESS, - CONF_NAME, - CONF_PIN, - CONF_PROTOCOL, - CONF_TYPE, -) +from homeassistant.components import zeroconf +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PIN, CONF_PROTOCOL from homeassistant.core import callback -from homeassistant.data_entry_flow import AbortFlow +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -148,16 +143,18 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={"devices": self._devices_str()}, ) - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle device found via zeroconf.""" - service_type = discovery_info[CONF_TYPE] - properties = discovery_info["properties"] + service_type = discovery_info.type + properties = discovery_info.properties if service_type == "_mediaremotetv._tcp.local.": identifier = properties["UniqueIdentifier"] name = properties["Name"] elif service_type == "_touch-able._tcp.local.": - identifier = discovery_info["name"].split(".")[0] + identifier = discovery_info.name.split(".")[0] name = properties["CtlN"] else: return self.async_abort(reason="unknown") diff --git a/homeassistant/components/apple_tv/translations/id.json b/homeassistant/components/apple_tv/translations/id.json index 209ecbf8a83..443a0dd49f1 100644 --- a/homeassistant/components/apple_tv/translations/id.json +++ b/homeassistant/components/apple_tv/translations/id.json @@ -24,14 +24,14 @@ }, "pair_no_pin": { "description": "Pemasangan diperlukan untuk layanan `{protocol}`. Masukkan PIN {pin} di Apple TV untuk melanjutkan.", - "title": "Memasangkan" + "title": "Pemasangan" }, "pair_with_pin": { "data": { "pin": "Kode PIN" }, "description": "Pemasangan diperlukan untuk protokol `{protocol}`. Masukkan kode PIN yang ditampilkan pada layar. Angka nol di awal harus dihilangkan. Misalnya, masukkan 123 jika kode yang ditampilkan adalah 0123.", - "title": "Memasangkan" + "title": "Pemasangan" }, "reconfigure": { "description": "Apple TV ini mengalami masalah koneksi dan harus dikonfigurasi ulang.", diff --git a/homeassistant/components/apple_tv/translations/ja.json b/homeassistant/components/apple_tv/translations/ja.json new file mode 100644 index 00000000000..a73cb4cbbd6 --- /dev/null +++ b/homeassistant/components/apple_tv/translations/ja.json @@ -0,0 +1,64 @@ +{ + "config": { + "abort": { + "already_configured_device": "\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", + "backoff": "\u73fe\u5728\u3001\u30c7\u30d0\u30a4\u30b9\u306f\u30da\u30a2\u30ea\u30f3\u30b0\u8981\u6c42\u3092\u53d7\u3051\u4ed8\u3051\u3066\u3044\u307e\u305b\u3093(\u7121\u52b9\u306aPIN\u30b3\u30fc\u30c9\u3092\u4f55\u5ea6\u3082\u5165\u529b\u3057\u305f\u53ef\u80fd\u6027\u304c\u3042\u308a\u307e\u3059)\u3001\u5f8c\u3067\u3082\u3046\u4e00\u5ea6\u8a66\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "device_did_not_pair": "\u30c7\u30d0\u30a4\u30b9\u304b\u3089\u30da\u30a2\u30ea\u30f3\u30b0\u30d7\u30ed\u30bb\u30b9\u3092\u7d42\u4e86\u3059\u308b\u8a66\u307f\u306f\u884c\u308f\u308c\u307e\u305b\u3093\u3067\u3057\u305f\u3002", + "invalid_config": "\u3053\u306e\u30c7\u30d0\u30a4\u30b9\u306e\u8a2d\u5b9a\u306f\u4e0d\u5b8c\u5168\u3067\u3059\u3002\u3082\u3046\u4e00\u5ea6\u8ffd\u52a0\u3057\u3066\u307f\u3066\u304f\u3060\u3055\u3044\u3002", + "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" + }, + "error": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "no_usable_service": "\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u3057\u305f\u304c\u3001\u30c7\u30d0\u30a4\u30b9\u3078\u306e\u63a5\u7d9a\u3092\u78ba\u7acb\u3059\u308b\u65b9\u6cd5\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002\u3053\u306e\u30e1\u30c3\u30bb\u30fc\u30b8\u304c\u5f15\u304d\u7d9a\u304d\u8868\u793a\u3055\u308c\u308b\u5834\u5408\u306f\u3001IP\u30a2\u30c9\u30ec\u30b9\u3092\u6307\u5b9a\u3059\u308b\u304b\u3001Apple TV\u3092\u518d\u8d77\u52d5\u3057\u3066\u307f\u3066\u304f\u3060\u3055\u3044\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "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\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\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": { + "description": "`{protocol}` \u30b5\u30fc\u30d3\u30b9\u306e\u30da\u30a2\u30ea\u30f3\u30b0\u304c\u5fc5\u8981\u3067\u3059\u3002\u7d9a\u884c\u3059\u308b\u306b\u306f\u3001Apple TV\u3067\u3001PIN {pin}\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30da\u30a2\u30ea\u30f3\u30b0" + }, + "pair_with_pin": { + "data": { + "pin": "PIN\u30b3\u30fc\u30c9" + }, + "description": "`{protocol}` \u30d7\u30ed\u30c8\u30b3\u30eb\u306b\u306f\u30da\u30a2\u30ea\u30f3\u30b0\u304c\u5fc5\u8981\u3067\u3059\u3002\u753b\u9762\u306b\u8868\u793a\u3055\u308c\u3066\u3044\u308bPIN\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u306a\u304a\u5148\u982d\u306e\u30bc\u30ed\u306f\u7701\u7565\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u3064\u307e\u308a\u3001\u8868\u793a\u3055\u308c\u308b\u30b3\u30fc\u30c9\u304c0123\u306e\u5834\u5408\u306f123\u3068\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "\u30da\u30a2\u30ea\u30f3\u30b0" + }, + "reconfigure": { + "description": "\u3053\u306eApple TV\u306b\u306f\u63a5\u7d9a\u969c\u5bb3\u304c\u767a\u751f\u3057\u3066\u3044\u308b\u305f\u3081\u3001\u518d\u8a2d\u5b9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "title": "\u30c7\u30d0\u30a4\u30b9\u306e\u518d\u69cb\u6210" + }, + "service_problem": { + "description": "\u30d7\u30ed\u30c8\u30b3\u30eb `{protocol}`\u306e\u30da\u30a2\u30ea\u30f3\u30b0\u4e2d\u306b\u554f\u984c\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002\u3053\u306e\u554f\u984c\u306f\u7121\u8996\u3055\u308c\u307e\u3059\u3002", + "title": "\u30b5\u30fc\u30d3\u30b9\u306e\u8ffd\u52a0\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "user": { + "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}", + "title": "\u65b0\u3057\u3044Apple TV\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "start_off": "Home Assistant\u306e\u8d77\u52d5\u6642\u306b\u30c7\u30d0\u30a4\u30b9\u306e\u96fb\u6e90\u3092\u5165\u308c\u306a\u3044" + }, + "description": "\u30c7\u30d0\u30a4\u30b9\u306e\u4e00\u822c\u7684\u306a\u8a2d\u5b9a" + } + } + }, + "title": "Apple TV" +} \ No newline at end of file diff --git a/homeassistant/components/apple_tv/translations/tr.json b/homeassistant/components/apple_tv/translations/tr.json index f33e3998af6..007397de8d5 100644 --- a/homeassistant/components/apple_tv/translations/tr.json +++ b/homeassistant/components/apple_tv/translations/tr.json @@ -3,6 +3,8 @@ "abort": { "already_configured_device": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "backoff": "Cihaz \u015fu anda e\u015fle\u015ftirme isteklerini kabul etmiyor (\u00e7ok say\u0131da ge\u00e7ersiz PIN kodu girmi\u015f olabilirsiniz), daha sonra tekrar deneyin.", + "device_did_not_pair": "Cihazdan e\u015fle\u015ftirme i\u015flemini bitirmek i\u00e7in herhangi bir giri\u015fimde bulunulmad\u0131.", "invalid_config": "Bu ayg\u0131t\u0131n yap\u0131land\u0131rmas\u0131 tamamlanmad\u0131. L\u00fctfen tekrar eklemeyi deneyin.", "no_devices_found": "A\u011fda cihaz bulunamad\u0131", "unknown": "Beklenmeyen hata" @@ -11,20 +13,24 @@ "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "no_devices_found": "A\u011fda cihaz bulunamad\u0131", + "no_usable_service": "Bir ayg\u0131t bulundu, ancak ba\u011flant\u0131 kurman\u0131n herhangi bir yolunu tan\u0131mlayamad\u0131. Bu iletiyi g\u00f6rmeye devam ederseniz, IP adresini belirtmeye veya Apple TV'nizi yeniden ba\u015flatmaya \u00e7al\u0131\u015f\u0131n.", "unknown": "Beklenmeyen hata" }, - "flow_title": "Apple TV: {name}", + "flow_title": "{name}", "step": { "confirm": { + "description": "{name} ` adl\u0131 Apple TV'yi Ev Asistan\u0131na eklemek \u00fczeresiniz. \n\n **\u0130\u015flemi tamamlamak i\u00e7in birden fazla PIN kodu girmeniz gerekebilir.** \n\n Bu entegrasyonla Apple TV'nizi *kapatamayaca\u011f\u0131n\u0131z\u0131* l\u00fctfen unutmay\u0131n. Yaln\u0131zca Home Assistant'taki medya oynat\u0131c\u0131 kapanacak!", "title": "Apple TV eklemeyi onaylay\u0131n" }, "pair_no_pin": { + "description": "{protocol} ` hizmeti i\u00e7in e\u015fle\u015ftirme gereklidir. Devam etmek i\u00e7in l\u00fctfen Apple TV'nizde PIN {pin}", "title": "E\u015fle\u015ftirme" }, "pair_with_pin": { "data": { "pin": "PIN Kodu" }, + "description": "{protocol} ` protokol\u00fc i\u00e7in e\u015fle\u015ftirme gereklidir. L\u00fctfen ekranda g\u00f6r\u00fcnt\u00fclenen PIN kodunu girin. Ba\u015ftaki s\u0131f\u0131rlar atlanmal\u0131d\u0131r, yani g\u00f6r\u00fcnt\u00fclenen kod 0123 ise 123 girin.", "title": "E\u015fle\u015ftirme" }, "reconfigure": { @@ -32,12 +38,14 @@ "title": "Cihaz\u0131n yeniden yap\u0131land\u0131r\u0131lmas\u0131" }, "service_problem": { + "description": "{protocol} ` e\u015fle\u015ftirilirken bir sorun olu\u015ftu. G\u00f6z ard\u0131 edilecek.", "title": "Hizmet eklenemedi" }, "user": { "data": { "device_input": "Cihaz" }, + "description": "Eklemek istedi\u011finiz Apple TV'nin cihaz ad\u0131n\u0131 (\u00f6rn. Mutfak veya Yatak Odas\u0131) veya IP adresini girerek ba\u015flay\u0131n. A\u011f\u0131n\u0131zda otomatik olarak herhangi bir cihaz bulunduysa, bunlar a\u015fa\u011f\u0131da g\u00f6sterilmi\u015ftir. \n\n Cihaz\u0131n\u0131z\u0131 g\u00f6remiyorsan\u0131z veya herhangi bir sorun ya\u015f\u0131yorsan\u0131z, cihaz\u0131n IP adresini belirtmeyi deneyin. \n\n {devices}", "title": "Yeni bir Apple TV kurun" } } diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index e92c826faaa..4e0209cc337 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -2,7 +2,7 @@ "domain": "apprise", "name": "Apprise", "documentation": "https://www.home-assistant.io/integrations/apprise", - "requirements": ["apprise==0.9.5.1"], + "requirements": ["apprise==0.9.6"], "codeowners": ["@caronc"], "iot_class": "cloud_push" } diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py index d28de3b92aa..125dd7af6ae 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -8,7 +8,7 @@ from arcam.fmj.client import Client import async_timeout from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.deprecated(DOMAIN) -PLATFORMS = ["media_player"] +PLATFORMS = [Platform.MEDIA_PLAYER] async def _await_cancel(task): @@ -85,7 +85,7 @@ async def _run_client(hass, client, interval): while True: try: - with async_timeout.timeout(interval): + async with async_timeout.timeout(interval): await client.start() _LOGGER.debug("Client connected %s", client.host) diff --git a/homeassistant/components/arcam_fmj/config_flow.py b/homeassistant/components/arcam_fmj/config_flow.py index cbf707c14e6..2570fd1aea5 100644 --- a/homeassistant/components/arcam_fmj/config_flow.py +++ b/homeassistant/components/arcam_fmj/config_flow.py @@ -6,8 +6,9 @@ from arcam.fmj.utils import get_uniqueid_from_host, get_uniqueid_from_udn import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.ssdp import ATTR_SSDP_LOCATION, ATTR_UPNP_UDN +from homeassistant.components import ssdp from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN, DOMAIN_DATA_ENTRIES @@ -84,11 +85,11 @@ class ArcamFmjFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="confirm", description_placeholders=placeholders ) - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered device.""" - host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname + host = urlparse(discovery_info.ssdp_location).hostname port = DEFAULT_PORT - uuid = get_uniqueid_from_udn(discovery_info[ATTR_UPNP_UDN]) + uuid = get_uniqueid_from_udn(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) await self._async_set_unique_id_and_update(host, port, uuid) diff --git a/homeassistant/components/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py index b63279d9c26..553524dbcdf 100644 --- a/homeassistant/components/arcam_fmj/media_player.py +++ b/homeassistant/components/arcam_fmj/media_player.py @@ -255,8 +255,7 @@ class ArcamFmj(MediaPlayerEntity): @property def source(self): """Return the current input source.""" - value = self._state.get_source() - if value is None: + if (value := self._state.get_source()) is None: return None return value.name @@ -268,32 +267,28 @@ class ArcamFmj(MediaPlayerEntity): @property def sound_mode(self): """Name of the current sound mode.""" - value = self._state.get_decode_mode() - if value is None: + if (value := self._state.get_decode_mode()) is None: return None return value.name @property def sound_mode_list(self): """List of available sound modes.""" - values = self._state.get_decode_modes() - if values is None: + if (values := self._state.get_decode_modes()) is None: return None return [x.name for x in values] @property def is_volume_muted(self): """Boolean if volume is currently muted.""" - value = self._state.get_mute() - if value is None: + if (value := self._state.get_mute()) is None: return None return value @property def volume_level(self): """Volume level of device.""" - value = self._state.get_volume() - if value is None: + if (value := self._state.get_volume()) is None: return None return value / 99.0 @@ -314,8 +309,7 @@ class ArcamFmj(MediaPlayerEntity): """Content type of current playing media.""" source = self._state.get_source() if source in (SourceCodes.DAB, SourceCodes.FM): - preset = self._state.get_tuner_preset() - if preset: + if preset := self._state.get_tuner_preset(): value = f"preset:{preset}" else: value = None @@ -339,8 +333,7 @@ class ArcamFmj(MediaPlayerEntity): @property def media_artist(self): """Artist of current playing media, music track only.""" - source = self._state.get_source() - if source == SourceCodes.DAB: + if self._state.get_source() == SourceCodes.DAB: value = self._state.get_dls_pdt() else: value = None @@ -349,8 +342,7 @@ class ArcamFmj(MediaPlayerEntity): @property def media_title(self): """Title of current playing media.""" - source = self._state.get_source() - if source is None: + if (source := self._state.get_source()) is None: return None if channel := self.media_channel: diff --git a/homeassistant/components/arcam_fmj/translations/bg.json b/homeassistant/components/arcam_fmj/translations/bg.json index 4983c9a14b2..f24b5481b2c 100644 --- a/homeassistant/components/arcam_fmj/translations/bg.json +++ b/homeassistant/components/arcam_fmj/translations/bg.json @@ -1,10 +1,16 @@ { "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" + }, + "flow_title": "{host}", "step": { "user": { "data": { + "host": "\u0425\u043e\u0441\u0442", "port": "\u041f\u043e\u0440\u0442" - } + }, + "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u0438\u043c\u0435\u0442\u043e \u043d\u0430 \u0445\u043e\u0441\u0442\u0430 \u0438\u043b\u0438 IP \u0430\u0434\u0440\u0435\u0441\u0430 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e." } } } diff --git a/homeassistant/components/arcam_fmj/translations/ja.json b/homeassistant/components/arcam_fmj/translations/ja.json new file mode 100644 index 00000000000..2ba5cd17aa0 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/ja.json @@ -0,0 +1,27 @@ +{ + "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" + }, + "flow_title": "{host}", + "step": { + "confirm": { + "description": "Home Assistant\u306bArcam FMJ on `{host}` \u3092\u8ffd\u52a0\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "\u30c7\u30d0\u30a4\u30b9\u306e\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + }, + "device_automation": { + "trigger_type": { + "turn_on": "{entity_name} \u3092\u30aa\u30f3\u306b\u3059\u308b\u3088\u3046\u306b\u30ea\u30af\u30a8\u30b9\u30c8\u3055\u308c\u307e\u3057\u305f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/tr.json b/homeassistant/components/arcam_fmj/translations/tr.json index dd15f57212c..7943dece765 100644 --- a/homeassistant/components/arcam_fmj/translations/tr.json +++ b/homeassistant/components/arcam_fmj/translations/tr.json @@ -5,13 +5,27 @@ "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", "cannot_connect": "Ba\u011flanma hatas\u0131" }, + "error": { + "one": "Bo\u015f", + "other": "Bo\u015f" + }, + "flow_title": "{host}", "step": { + "confirm": { + "description": "Arcam FMJ'yi ` {host} ` \u00fczerinde Home Assistant'a eklemek istiyor musunuz?" + }, "user": { "data": { "host": "Ana Bilgisayar", "port": "Port" - } + }, + "description": "L\u00fctfen cihaz\u0131n ana bilgisayar ad\u0131n\u0131 veya IP adresini girin." } } + }, + "device_automation": { + "trigger_type": { + "turn_on": "{entity_name} nin a\u00e7\u0131lmas\u0131 istendi" + } } } \ No newline at end of file diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index 49074dba3be..721585fa391 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -74,8 +74,7 @@ class ArubaDeviceScanner(DeviceScanner): if not self.success_init: return False - data = self.get_aruba_data() - if not data: + if not (data := self.get_aruba_data()): return False self.last_results = data.values() @@ -125,8 +124,7 @@ class ArubaDeviceScanner(DeviceScanner): devices = {} for device in devices_result: - match = _DEVICES_REGEX.search(device.decode("utf-8")) - if match: + if match := _DEVICES_REGEX.search(device.decode("utf-8")): devices[match.group("ip")] = { "ip": match.group("ip"), "mac": match.group("mac").upper(), diff --git a/homeassistant/components/asuswrt/__init__.py b/homeassistant/components/asuswrt/__init__.py index 2d067d0e608..fb6c547cc6b 100644 --- a/homeassistant/components/asuswrt/__init__.py +++ b/homeassistant/components/asuswrt/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_SENSORS, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -33,7 +34,7 @@ from .const import ( ) from .router import AsusWrtRouter -PLATFORMS = ["device_tracker", "sensor"] +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] CONF_PUB_KEY = "pub_key" SECRET_GROUP = "Password or SSH Key" @@ -92,8 +93,7 @@ async def async_setup(hass, config): return True # remove not required config keys - pub_key = conf.pop(CONF_PUB_KEY, "") - if pub_key: + if pub_key := conf.pop(CONF_PUB_KEY, ""): conf[CONF_SSH_KEY] = pub_key conf.pop(CONF_REQUIRE_IP, True) diff --git a/homeassistant/components/asuswrt/const.py b/homeassistant/components/asuswrt/const.py index e41d683a7df..95e93e0ff25 100644 --- a/homeassistant/components/asuswrt/const.py +++ b/homeassistant/components/asuswrt/const.py @@ -25,3 +25,4 @@ SENSORS_BYTES = ["sensor_rx_bytes", "sensor_tx_bytes"] SENSORS_CONNECTED_DEVICE = ["sensor_connected_device"] SENSORS_LOAD_AVG = ["sensor_load_avg1", "sensor_load_avg5", "sensor_load_avg15"] SENSORS_RATES = ["sensor_rx_rates", "sensor_tx_rates"] +SENSORS_TEMPERATURES = ["2.4GHz", "5.0GHz", "CPU"] diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index 380f7a60c32..bb96cb184a1 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -84,6 +84,11 @@ class AsusWrtDevice(ScannerEntity): """Return the hostname of device.""" return self._device.name + @property + def icon(self) -> str: + """Return device icon.""" + return "mdi:lan-connect" if self._device.is_connected else "mdi:lan-disconnect" + @property def ip_address(self) -> str: """Return the primary ip address of the device.""" diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index b66c3bb5db9..1470c075b04 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -3,7 +3,7 @@ "name": "ASUSWRT", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/asuswrt", - "requirements": ["aioasuswrt==1.3.4"], + "requirements": ["aioasuswrt==1.4.0"], "codeowners": ["@kennedyshead", "@ollo69"], "iot_class": "local_polling" } diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 03a15f80110..da314b12b65 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -24,6 +24,8 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.event import async_track_time_interval @@ -45,6 +47,7 @@ from .const import ( SENSORS_CONNECTED_DEVICE, SENSORS_LOAD_AVG, SENSORS_RATES, + SENSORS_TEMPERATURES, ) CONF_REQ_RELOAD = [CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP] @@ -58,6 +61,7 @@ SENSORS_TYPE_BYTES = "sensors_bytes" SENSORS_TYPE_COUNT = "sensors_count" SENSORS_TYPE_LOAD_AVG = "sensors_load_avg" SENSORS_TYPE_RATES = "sensors_rates" +SENSORS_TYPE_TEMPERATURES = "sensors_temperatures" _LOGGER = logging.getLogger(__name__) @@ -112,6 +116,15 @@ class AsusWrtSensorDataHandler: return _get_dict(SENSORS_LOAD_AVG, avg) + async def _get_temperatures(self): + """Fetch temperatures information from the router.""" + try: + temperatures = await self._api.async_get_temperature() + except (OSError, ValueError) as exc: + raise UpdateFailed(exc) from exc + + return temperatures + def update_device_count(self, conn_devices: int): """Update connected devices attribute.""" if self._connected_devices == conn_devices: @@ -129,6 +142,8 @@ class AsusWrtSensorDataHandler: method = self._get_load_avg elif sensor_type == SENSORS_TYPE_RATES: method = self._get_rates + elif sensor_type == SENSORS_TYPE_TEMPERATURES: + method = self._get_temperatures else: raise RuntimeError(f"Invalid sensor type: {sensor_type}") @@ -249,17 +264,32 @@ class AsusWrtRouter: self._sw_v = f"{firmware['firmver']} (build {firmware['buildno']})" # Load tracked entities from registry - entity_registry = await self.hass.helpers.entity_registry.async_get_registry() - track_entries = ( - self.hass.helpers.entity_registry.async_entries_for_config_entry( - entity_registry, self._entry.entry_id - ) + entity_reg = er.async_get(self.hass) + track_entries = er.async_entries_for_config_entry( + entity_reg, self._entry.entry_id ) for entry in track_entries: - if entry.domain == TRACKER_DOMAIN: - self._devices[entry.unique_id] = AsusWrtDevInfo( - entry.unique_id, entry.original_name + + if entry.domain != TRACKER_DOMAIN: + continue + device_mac = format_mac(entry.unique_id) + + # migrate entity unique ID if wrong formatted + if device_mac != entry.unique_id: + existing_entity_id = entity_reg.async_get_entity_id( + DOMAIN, TRACKER_DOMAIN, device_mac ) + if existing_entity_id: + # entity with uniqueid properly formatted already + # exists in the registry, we delete this duplicate + entity_reg.async_remove(entry.entity_id) + continue + + entity_reg.async_update_entity( + entry.entity_id, new_unique_id=device_mac + ) + + self._devices[device_mac] = AsusWrtDevInfo(device_mac, entry.original_name) # Update devices await self.update_devices() @@ -280,7 +310,7 @@ class AsusWrtRouter: new_device = False _LOGGER.debug("Checking devices for ASUS router %s", self._host) try: - wrt_devices = await self._api.async_get_connected_devices() + api_devices = await self._api.async_get_connected_devices() except OSError as exc: if not self._connect_error: self._connect_error = True @@ -295,18 +325,18 @@ class AsusWrtRouter: self._connect_error = False _LOGGER.info("Reconnected to ASUS router %s", self._host) + self._connected_devices = len(api_devices) consider_home = self._options.get( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() ) track_unknown = self._options.get(CONF_TRACK_UNKNOWN, DEFAULT_TRACK_UNKNOWN) + wrt_devices = {format_mac(mac): dev for mac, dev in api_devices.items()} for device_mac, device in self._devices.items(): - dev_info = wrt_devices.get(device_mac) + dev_info = wrt_devices.pop(device_mac, None) device.update(dev_info, consider_home) for device_mac, dev_info in wrt_devices.items(): - if device_mac in self._devices: - continue if not track_unknown and not dev_info.name: continue new_device = True @@ -317,8 +347,6 @@ class AsusWrtRouter: async_dispatcher_send(self.hass, self.signal_device_update) if new_device: async_dispatcher_send(self.hass, self.signal_device_new) - - self._connected_devices = len(wrt_devices) await self._update_unpolled_sensors() async def init_sensors_coordinator(self) -> None: @@ -334,9 +362,14 @@ class AsusWrtRouter: SENSORS_TYPE_COUNT: SENSORS_CONNECTED_DEVICE, SENSORS_TYPE_LOAD_AVG: SENSORS_LOAD_AVG, SENSORS_TYPE_RATES: SENSORS_RATES, + SENSORS_TYPE_TEMPERATURES: SENSORS_TEMPERATURES, } for sensor_type, sensor_names in sensors_types.items(): + if sensor_type == SENSORS_TYPE_TEMPERATURES: + sensor_names = await self._get_available_temperature_sensors() + if not sensor_names: + continue coordinator = await self._sensors_data_handler.get_coordinator( sensor_type, sensor_type != SENSORS_TYPE_COUNT ) @@ -355,6 +388,23 @@ class AsusWrtRouter: if self._sensors_data_handler.update_device_count(self._connected_devices): await coordinator.async_refresh() + async def _get_available_temperature_sensors(self): + """Check which temperature information is available on the router.""" + try: + availability = await self._api.async_find_temperature_commands() + available_sensors = [ + SENSORS_TEMPERATURES[i] for i in range(3) if availability[i] + ] + except Exception as exc: # pylint: disable=broad-except + _LOGGER.debug( + "Failed checking temperature sensor availability for ASUS router %s. Exception: %s", + self._host, + exc, + ) + return [] + + return available_sensors + async def close(self) -> None: """Close the connection.""" if self._api is not None and self._protocol == PROTOCOL_TELNET: @@ -431,7 +481,7 @@ async def _get_nvram_info(api: AsusWrt, info_type: str) -> dict[str, Any]: info = {} try: info = await api.async_get_nvram(info_type) - except (OSError, UnicodeDecodeError) as exc: + except OSError as exc: _LOGGER.warning("Error calling method async_get_nvram(%s): %s", info_type, exc) return info diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index 8beb5d3d9ee..2c3b022cb9d 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -5,18 +5,19 @@ from dataclasses import dataclass from numbers import Real from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DATA_GIGABYTES, DATA_RATE_MEGABITS_PER_SECOND, - ENTITY_CATEGORY_DIAGNOSTIC, + TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -29,6 +30,7 @@ from .const import ( SENSORS_CONNECTED_DEVICE, SENSORS_LOAD_AVG, SENSORS_RATES, + SENSORS_TEMPERATURES, ) from .router import KEY_COORDINATOR, KEY_SENSORS, AsusWrtRouter @@ -49,14 +51,14 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_CONNECTED_DEVICE[0], name="Devices Connected", icon="mdi:router-network", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UNIT_DEVICES, ), AsusWrtSensorEntityDescription( key=SENSORS_RATES[0], name="Download Speed", icon="mdi:download-network", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, entity_registry_enabled_default=False, factor=125000, @@ -65,7 +67,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_RATES[1], name="Upload Speed", icon="mdi:upload-network", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, entity_registry_enabled_default=False, factor=125000, @@ -74,7 +76,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_BYTES[0], name="Download", icon="mdi:download", - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=DATA_GIGABYTES, entity_registry_enabled_default=False, factor=1000000000, @@ -83,7 +85,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_BYTES[1], name="Upload", icon="mdi:upload", - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=DATA_GIGABYTES, entity_registry_enabled_default=False, factor=1000000000, @@ -92,8 +94,8 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_LOAD_AVG[0], name="Load Avg (1m)", icon="mdi:cpu-32-bit", - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, factor=1, precision=1, @@ -102,8 +104,8 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_LOAD_AVG[1], name="Load Avg (5m)", icon="mdi:cpu-32-bit", - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, factor=1, precision=1, @@ -112,8 +114,41 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( key=SENSORS_LOAD_AVG[2], name="Load Avg (15m)", icon="mdi:cpu-32-bit", - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + factor=1, + precision=1, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_TEMPERATURES[0], + name="2.4GHz Temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + factor=1, + precision=1, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_TEMPERATURES[1], + name="5GHz Temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + factor=1, + precision=1, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_TEMPERATURES[2], + name="CPU Temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, factor=1, precision=1, diff --git a/homeassistant/components/asuswrt/translations/ja.json b/homeassistant/components/asuswrt/translations/ja.json new file mode 100644 index 00000000000..ab253e324ce --- /dev/null +++ b/homeassistant/components/asuswrt/translations/ja.json @@ -0,0 +1,45 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9", + "pwd_and_ssh": "\u30d1\u30b9\u30ef\u30fc\u30c9\u307e\u305f\u306fSSH\u30ad\u30fc\u30d5\u30a1\u30a4\u30eb\u306e\u307f\u63d0\u4f9b", + "pwd_or_ssh": "\u30d1\u30b9\u30ef\u30fc\u30c9\u307e\u305f\u306fSSH\u30ad\u30fc\u30d5\u30a1\u30a4\u30eb\u3092\u63d0\u4f9b\u3057\u3066\u304f\u3060\u3055\u3044", + "ssh_not_file": "SSH\u30ad\u30fc\u30d5\u30a1\u30a4\u30eb\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "mode": "\u30e2\u30fc\u30c9", + "name": "\u540d\u524d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "protocol": "\u4f7f\u7528\u3059\u308b\u901a\u4fe1\u30d7\u30ed\u30c8\u30b3\u30eb", + "ssh_key": "SSH\u30ad\u30fc \u30d5\u30a1\u30a4\u30eb\u3078\u306e\u30d1\u30b9 (\u30d1\u30b9\u30ef\u30fc\u30c9\u306e\u4ee3\u308f\u308a)", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u30eb\u30fc\u30bf\u30fc\u306b\u63a5\u7d9a\u3059\u308b\u305f\u3081\u306b\u5fc5\u8981\u306a\u30d1\u30e9\u30e1\u30fc\u30bf\u30fc\u3092\u8a2d\u5b9a\u3057\u307e\u3059", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u30c7\u30d0\u30a4\u30b9\u306e\u96e2\u8131\u3092\u691c\u8a0e\u3059\u308b\u307e\u3067\u306e\u5f85\u3061\u6642\u9593(\u79d2)", + "dnsmasq": "dnsmasq.leases\u30d5\u30a1\u30a4\u30eb\u306e\u30eb\u30fc\u30bf\u30fc\u5185\u306e\u5834\u6240", + "interface": "\u7d71\u8a08\u3092\u53d6\u5f97\u3057\u305f\u3044\u30a4\u30f3\u30bf\u30d5\u30a7\u30fc\u30b9(\u4f8b: eth0\u3001eth1\u306a\u3069)", + "require_ip": "\u30c7\u30d0\u30a4\u30b9\u306b\u306fIP\u304c\u5fc5\u8981\u3067\u3059(\u30a2\u30af\u30bb\u30b9\u30dd\u30a4\u30f3\u30c8\u30e2\u30fc\u30c9\u306e\u5834\u5408)", + "track_unknown": "\u8ffd\u8de1\u4e0d\u660e/\u540d\u524d\u306e\u306a\u3044\u30c7\u30d0\u30a4\u30b9" + }, + "title": "AsusWRT\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/ru.json b/homeassistant/components/asuswrt/translations/ru.json index f77fcb4fb3a..35254821f23 100644 --- a/homeassistant/components/asuswrt/translations/ru.json +++ b/homeassistant/components/asuswrt/translations/ru.json @@ -20,10 +20,10 @@ "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "port": "\u041f\u043e\u0440\u0442", "protocol": "\u041f\u0440\u043e\u0442\u043e\u043a\u043e\u043b \u0441\u0432\u044f\u0437\u0438", - "ssh_key": "\u041f\u0443\u0442\u044c \u0444\u0430\u0439\u043b\u0443 \u043a\u043b\u044e\u0447\u0435\u0439 SSH (\u0432\u043c\u0435\u0441\u0442\u043e \u043f\u0430\u0440\u043e\u043b\u044f)", + "ssh_key": "\u041f\u0443\u0442\u044c \u043a \u0444\u0430\u0439\u043b\u0443 \u043a\u043b\u044e\u0447\u0435\u0439 SSH (\u0432\u043c\u0435\u0441\u0442\u043e \u043f\u0430\u0440\u043e\u043b\u044f)", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b, \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u044b\u0435 \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0412\u0430\u0448\u0435\u043c\u0443 \u0440\u043e\u0443\u0442\u0435\u0440\u0443.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 \u0440\u043e\u0443\u0442\u0435\u0440\u043e\u043c AsusWRT.", "title": "AsusWRT" } } diff --git a/homeassistant/components/asuswrt/translations/tr.json b/homeassistant/components/asuswrt/translations/tr.json new file mode 100644 index 00000000000..2bae8499c02 --- /dev/null +++ b/homeassistant/components/asuswrt/translations/tr.json @@ -0,0 +1,45 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi", + "pwd_and_ssh": "Yaln\u0131zca parola veya SSH anahtar dosyas\u0131 sa\u011flay\u0131n", + "pwd_or_ssh": "L\u00fctfen parola veya SSH anahtar dosyas\u0131 sa\u011flay\u0131n", + "ssh_not_file": "SSH anahtar dosyas\u0131 bulunamad\u0131", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "host": "Ana bilgisayar", + "mode": "Mod", + "name": "Ad", + "password": "Parola", + "port": "Port", + "protocol": "Kullan\u0131lacak ileti\u015fim protokol\u00fc", + "ssh_key": "SSH anahtar dosyan\u0131z\u0131n yolu (\u015fifre yerine)", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "Y\u00f6nlendiricinize ba\u011flanmak i\u00e7in gerekli parametreyi ayarlay\u0131n", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Bir cihaz uzakta iken beklenecek saniyeler", + "dnsmasq": "dnsmasq.leases dosyalar\u0131n\u0131n y\u00f6nlendiricisindeki konum", + "interface": "\u0130statistik almak istedi\u011finiz aray\u00fcz (\u00f6rn. eth0,eth1 vb.)", + "require_ip": "Cihazlar\u0131n IP'si olmal\u0131d\u0131r (eri\u015fim noktas\u0131 modu i\u00e7in)", + "track_unknown": "Bilinmeyen / ads\u0131z cihazlar\u0131 takip edin" + }, + "title": "AsusWRT Se\u00e7enekleri" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/atag/__init__.py b/homeassistant/components/atag/__init__.py index 69880da5a39..82340032a1a 100644 --- a/homeassistant/components/atag/__init__.py +++ b/homeassistant/components/atag/__init__.py @@ -5,10 +5,8 @@ import logging import async_timeout from pyatag import AtagException, AtagOne -from homeassistant.components.climate import DOMAIN as CLIMATE -from homeassistant.components.sensor import DOMAIN as SENSOR -from homeassistant.components.water_heater import DOMAIN as WATER_HEATER from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo @@ -21,7 +19,7 @@ from homeassistant.helpers.update_coordinator import ( _LOGGER = logging.getLogger(__name__) DOMAIN = "atag" -PLATFORMS = [CLIMATE, WATER_HEATER, SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.WATER_HEATER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -29,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_data(): """Update data via library.""" - with async_timeout.timeout(20): + async with async_timeout.timeout(20): try: await atag.update() except AtagException as err: diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py index 1a5e2a597cf..d58d475d506 100644 --- a/homeassistant/components/atag/climate.py +++ b/homeassistant/components/atag/climate.py @@ -12,9 +12,9 @@ from homeassistant.components.climate.const import ( SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.const import ATTR_TEMPERATURE, Platform -from . import CLIMATE, DOMAIN, AtagEntity +from . import DOMAIN, AtagEntity PRESET_MAP = { "Manual": "manual", @@ -31,7 +31,7 @@ HVAC_MODES = [HVAC_MODE_AUTO, HVAC_MODE_HEAT] async def async_setup_entry(hass, entry, async_add_entities): """Load a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([AtagThermostat(coordinator, CLIMATE)]) + async_add_entities([AtagThermostat(coordinator, Platform.CLIMATE)]) class AtagThermostat(AtagEntity, ClimateEntity): diff --git a/homeassistant/components/atag/translations/ja.json b/homeassistant/components/atag/translations/ja.json new file mode 100644 index 00000000000..5aecec86168 --- /dev/null +++ b/homeassistant/components/atag/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": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unauthorized": "\u30da\u30a2\u30ea\u30f3\u30b0\u304c\u62d2\u5426\u3055\u308c\u307e\u3057\u305f\u3002\u30c7\u30d0\u30a4\u30b9\u3067\u8a8d\u8a3c\u8981\u6c42\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "title": "\u30c7\u30d0\u30a4\u30b9\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/tr.json b/homeassistant/components/atag/translations/tr.json index 577ed02cdca..d4c5dd8acad 100644 --- a/homeassistant/components/atag/translations/tr.json +++ b/homeassistant/components/atag/translations/tr.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "port": "Port" }, "title": "Cihaza ba\u011flan\u0131n" diff --git a/homeassistant/components/atag/water_heater.py b/homeassistant/components/atag/water_heater.py index 5fce2abf63e..b45e877a310 100644 --- a/homeassistant/components/atag/water_heater.py +++ b/homeassistant/components/atag/water_heater.py @@ -5,9 +5,9 @@ from homeassistant.components.water_heater import ( STATE_PERFORMANCE, WaterHeaterEntity, ) -from homeassistant.const import STATE_OFF, TEMP_CELSIUS +from homeassistant.const import STATE_OFF, TEMP_CELSIUS, Platform -from . import DOMAIN, WATER_HEATER, AtagEntity +from . import DOMAIN, AtagEntity SUPPORT_FLAGS_HEATER = 0 OPERATION_LIST = [STATE_OFF, STATE_ECO, STATE_PERFORMANCE] @@ -16,7 +16,7 @@ OPERATION_LIST = [STATE_OFF, STATE_ECO, STATE_PERFORMANCE] async def async_setup_entry(hass, config_entry, async_add_entities): """Initialize DHW device from config entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([AtagWaterHeater(coordinator, WATER_HEATER)]) + async_add_entities([AtagWaterHeater(coordinator, Platform.WATER_HEATER)]) class AtagWaterHeater(AtagEntity, WaterHeaterEntity): diff --git a/homeassistant/components/atome/sensor.py b/homeassistant/components/atome/sensor.py index 59d193ec8e2..0402e80949e 100644 --- a/homeassistant/components/atome/sensor.py +++ b/homeassistant/components/atome/sensor.py @@ -111,11 +111,13 @@ class AtomeData: """Return latest active power value.""" return self._is_connected - @Throttle(LIVE_SCAN_INTERVAL) - def update_live_usage(self): - """Return current power value.""" - try: - values = self.atome_client.get_live() + def _retrieve_live(self): + values = self.atome_client.get_live() + if ( + values.get("last") + and values.get("subscribed") + and (values.get("isConnected") is not None) + ): self._live_power = values["last"] self._subscribed_power = values["subscribed"] self._is_connected = values["isConnected"] @@ -125,9 +127,47 @@ class AtomeData: self._is_connected, self._subscribed_power, ) + return True - except KeyError as error: - _LOGGER.error("Missing last value in values: %s: %s", values, error) + _LOGGER.error("Live Data : Missing last value in values: %s", values) + return False + + @Throttle(LIVE_SCAN_INTERVAL) + def update_live_usage(self): + """Return current power value.""" + if not self._retrieve_live(): + _LOGGER.debug("Perform Reconnect during live request") + self.atome_client.login() + self._retrieve_live() + + def _retrieve_period_usage(self, period_type): + """Return current daily/weekly/monthly/yearly power usage.""" + values = self.atome_client.get_consumption(period_type) + if values.get("total") and values.get("price"): + period_usage = values["total"] / 1000 + period_price = values["price"] + _LOGGER.debug("Updating Atome %s data. Got: %d", period_type, period_usage) + return True, period_usage, period_price + + _LOGGER.error("%s : Missing last value in values: %s", period_type, values) + return False, None, None + + def _retrieve_period_usage_with_retry(self, period_type): + """Return current daily/weekly/monthly/yearly power usage with one retry.""" + ( + retrieve_success, + period_usage, + period_price, + ) = self._retrieve_period_usage(period_type) + if not retrieve_success: + _LOGGER.debug("Perform Reconnect during %s", period_type) + self.atome_client.login() + ( + retrieve_success, + period_usage, + period_price, + ) = self._retrieve_period_usage(period_type) + return (period_usage, period_price) @property def day_usage(self): @@ -142,14 +182,10 @@ class AtomeData: @Throttle(DAILY_SCAN_INTERVAL) def update_day_usage(self): """Return current daily power usage.""" - try: - values = self.atome_client.get_consumption(DAILY_TYPE) - self._day_usage = values["total"] / 1000 - self._day_price = values["price"] - _LOGGER.debug("Updating Atome daily data. Got: %d", self._day_usage) - - except KeyError as error: - _LOGGER.error("Missing last value in values: %s: %s", values, error) + ( + self._day_usage, + self._day_price, + ) = self._retrieve_period_usage_with_retry(DAILY_TYPE) @property def week_usage(self): @@ -164,14 +200,10 @@ class AtomeData: @Throttle(WEEKLY_SCAN_INTERVAL) def update_week_usage(self): """Return current weekly power usage.""" - try: - values = self.atome_client.get_consumption(WEEKLY_TYPE) - self._week_usage = values["total"] / 1000 - self._week_price = values["price"] - _LOGGER.debug("Updating Atome weekly data. Got: %d", self._week_usage) - - except KeyError as error: - _LOGGER.error("Missing last value in values: %s: %s", values, error) + ( + self._week_usage, + self._week_price, + ) = self._retrieve_period_usage_with_retry(WEEKLY_TYPE) @property def month_usage(self): @@ -186,14 +218,10 @@ class AtomeData: @Throttle(MONTHLY_SCAN_INTERVAL) def update_month_usage(self): """Return current monthly power usage.""" - try: - values = self.atome_client.get_consumption(MONTHLY_TYPE) - self._month_usage = values["total"] / 1000 - self._month_price = values["price"] - _LOGGER.debug("Updating Atome monthly data. Got: %d", self._month_usage) - - except KeyError as error: - _LOGGER.error("Missing last value in values: %s: %s", values, error) + ( + self._month_usage, + self._month_price, + ) = self._retrieve_period_usage_with_retry(MONTHLY_TYPE) @property def year_usage(self): @@ -208,14 +236,10 @@ class AtomeData: @Throttle(YEARLY_SCAN_INTERVAL) def update_year_usage(self): """Return current yearly power usage.""" - try: - values = self.atome_client.get_consumption(YEARLY_TYPE) - self._year_usage = values["total"] / 1000 - self._year_price = values["price"] - _LOGGER.debug("Updating Atome yearly data. Got: %d", self._year_usage) - - except KeyError as error: - _LOGGER.error("Missing last value in values: %s: %s", values, error) + ( + self._year_usage, + self._year_price, + ) = self._retrieve_period_usage_with_retry(YEARLY_TYPE) class AtomeSensor(SensorEntity): diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 700b03a85da..474e69db435 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -272,19 +272,13 @@ class AugustData(AugustSubscriberMixin): def _remove_inoperative_doorbells(self): for doorbell in list(self.doorbells): device_id = doorbell.device_id - doorbell_is_operative = False - doorbell_detail = self._device_detail_by_id.get(device_id) - if doorbell_detail is None: - _LOGGER.info( - "The doorbell %s could not be setup because the system could not fetch details about the doorbell", - doorbell.device_name, - ) - else: - doorbell_is_operative = True - - if not doorbell_is_operative: - del self._doorbells_by_id[device_id] - del self._device_detail_by_id[device_id] + if self._device_detail_by_id.get(device_id): + continue + _LOGGER.info( + "The doorbell %s could not be setup because the system could not fetch details about the doorbell", + doorbell.device_name, + ) + del self._doorbells_by_id[device_id] def _remove_inoperative_locks(self): # Remove non-operative locks as there must @@ -292,7 +286,6 @@ class AugustData(AugustSubscriberMixin): # be usable for lock in list(self.locks): device_id = lock.device_id - lock_is_operative = False lock_detail = self._device_detail_by_id.get(device_id) if lock_detail is None: _LOGGER.info( @@ -304,14 +297,12 @@ class AugustData(AugustSubscriberMixin): "The lock %s could not be setup because it does not have a bridge (Connect)", lock.device_name, ) + del self._device_detail_by_id[device_id] # Bridge may come back online later so we still add the device since we will # have a pubnub subscription to tell use when it recovers else: - lock_is_operative = True - - if not lock_is_operative: - del self._locks_by_id[device_id] - del self._device_detail_by_id[device_id] + continue + del self._locks_by_id[device_id] def _save_live_attrs(lock_detail): diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py index 57e0d5a7fb7..dfe9cc0f700 100644 --- a/homeassistant/components/august/const.py +++ b/homeassistant/components/august/const.py @@ -2,6 +2,8 @@ from datetime import timedelta +from homeassistant.const import Platform + DEFAULT_TIMEOUT = 10 CONF_ACCESS_TOKEN_CACHE_FILE = "access_token_cache_file" @@ -43,4 +45,9 @@ ACTIVITY_UPDATE_INTERVAL = timedelta(seconds=10) LOGIN_METHODS = ["phone", "email"] -PLATFORMS = ["camera", "binary_sensor", "lock", "sensor"] +PLATFORMS = [ + Platform.CAMERA, + Platform.BINARY_SENSOR, + Platform.LOCK, + Platform.SENSOR, +] diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py index 8da7fe3d418..a0fe44838c2 100644 --- a/homeassistant/components/august/entity.py +++ b/homeassistant/components/august/entity.py @@ -25,6 +25,7 @@ class AugustEntityMixin(Entity): name=device.device_name, sw_version=self._detail.firmware_version, suggested_area=_remove_device_types(device.device_name, DEVICE_TYPES), + configuration_url="https://account.august.com", ) @property diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 5f4fe85bc71..665b0036557 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -119,8 +119,7 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): """Restore ATTR_CHANGED_BY on startup since it is likely no longer in the activity log.""" await super().async_added_to_hass() - last_state = await self.async_get_last_state() - if not last_state: + if not (last_state := await self.async_get_last_state()): return if ATTR_CHANGED_BY in last_state.attributes: diff --git a/homeassistant/components/august/translations/ja.json b/homeassistant/components/august/translations/ja.json new file mode 100644 index 00000000000..f9d62163a8b --- /dev/null +++ b/homeassistant/components/august/translations/ja.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "reauth_validate": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "{username} \u306e\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "August\u30a2\u30ab\u30a6\u30f3\u30c8\u306e\u518d\u8a8d\u8a3c" + }, + "user_validate": { + "data": { + "login_method": "\u30ed\u30b0\u30a4\u30f3\u65b9\u6cd5", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u30ed\u30b0\u30a4\u30f3\u65b9\u6cd5\u304c \"\u96fb\u5b50\u30e1\u30fc\u30eb\" \u306e\u5834\u5408\u3001\u30e6\u30fc\u30b6\u30fc\u540d\u306f\u30e1\u30fc\u30eb\u30a2\u30c9\u30ec\u30b9\u3067\u3059\u3002\u30ed\u30b0\u30a4\u30f3\u65b9\u6cd5\u304c \"\u96fb\u8a71\" \u306e\u5834\u5408\u3001\u30e6\u30fc\u30b6\u30fc\u540d\u306f \"+NNNNNNNNN\" \u5f62\u5f0f\u306e\u96fb\u8a71\u756a\u53f7\u3067\u3059\u3002", + "title": "August account\u306e\u8a2d\u5b9a" + }, + "validation": { + "data": { + "code": "\u8a8d\u8a3c\u30b3\u30fc\u30c9" + }, + "description": "{login_method} ({username}) \u3092\u78ba\u8a8d\u3057\u3066\u3001\u4ee5\u4e0b\u306b\u78ba\u8a8d\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "2\u8981\u7d20\u8a8d\u8a3c" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/tr.json b/homeassistant/components/august/translations/tr.json index d3b32080466..93c21154faa 100644 --- a/homeassistant/components/august/translations/tr.json +++ b/homeassistant/components/august/translations/tr.json @@ -10,6 +10,22 @@ "unknown": "Beklenmeyen hata" }, "step": { + "reauth_validate": { + "data": { + "password": "Parola" + }, + "description": "{username} i\u00e7in \u015fifreyi girin.", + "title": "Bir August hesab\u0131n\u0131 yeniden do\u011frulay\u0131n" + }, + "user_validate": { + "data": { + "login_method": "Giri\u015f Y\u00f6ntemi", + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "Giri\u015f Y\u00f6ntemi 'e-posta' ise, Kullan\u0131c\u0131 Ad\u0131 e-posta adresidir. Giri\u015f Y\u00f6ntemi 'telefon' ise, Kullan\u0131c\u0131 Ad\u0131 '+ NNNNNNNNN' bi\u00e7imindeki telefon numaras\u0131d\u0131r.", + "title": "Bir August hesab\u0131 olu\u015fturun" + }, "validation": { "data": { "code": "Do\u011frulama kodu" diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index 1cc378983ca..a029f2cf61b 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -7,9 +7,10 @@ from aiohttp import ClientError from auroranoaa import AuroraForecast from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, Platform 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.update_coordinator import ( CoordinatorEntity, @@ -29,7 +30,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["binary_sensor", "sensor"] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -140,7 +141,7 @@ class AuroraEntity(CoordinatorEntity): def device_info(self) -> DeviceInfo: """Define the device based on name.""" return DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, str(self.unique_id))}, manufacturer="NOAA", model="Aurora Visibility Sensor", diff --git a/homeassistant/components/aurora/translations/ja.json b/homeassistant/components/aurora/translations/ja.json new file mode 100644 index 00000000000..86e38f981f9 --- /dev/null +++ b/homeassistant/components/aurora/translations/ja.json @@ -0,0 +1,26 @@ +{ + "config": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "user": { + "data": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "name": "\u540d\u524d" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "threshold": "\u3057\u304d\u3044\u5024(%)" + } + } + } + }, + "title": "NOAA\u30aa\u30fc\u30ed\u30e9\u30bb\u30f3\u30b5\u30fc" +} \ No newline at end of file diff --git a/homeassistant/components/aurora/translations/tr.json b/homeassistant/components/aurora/translations/tr.json index 0c3bb75ed6e..78dc0f86d0f 100644 --- a/homeassistant/components/aurora/translations/tr.json +++ b/homeassistant/components/aurora/translations/tr.json @@ -12,5 +12,15 @@ } } } - } + }, + "options": { + "step": { + "init": { + "data": { + "threshold": "E\u015fik (%)" + } + } + } + }, + "title": "NOAA Aurora Sens\u00f6r\u00fc" } \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index 2c3d0c546cd..08103193e7b 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -13,24 +13,24 @@ import logging from aurorapy.client import AuroraError, AuroraSerialClient from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_PORT +from homeassistant.const import CONF_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .config_flow import validate_and_connect from .const import ATTR_SERIAL_NUMBER, DOMAIN -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Aurora ABB PowerOne from a config entry.""" comport = entry.data[CONF_PORT] address = entry.data[CONF_ADDRESS] - serclient = AuroraSerialClient(address, comport, parity="N", timeout=1) + ser_client = AuroraSerialClient(address, comport, parity="N", timeout=1) # To handle yaml import attempts in darkeness, (re)try connecting only if # unique_id not yet assigned. if entry.unique_id is None: @@ -67,19 +67,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return False hass.config_entries.async_update_entry(entry, unique_id=new_id) - hass.data.setdefault(DOMAIN, {})[entry.unique_id] = serclient + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ser_client hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) # It should not be necessary to close the serial port because we close # it after every use in sensor.py, i.e. no need to do entry["client"].close() if unload_ok: - hass.data[DOMAIN].pop(entry.unique_id) + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/aurora_abb_powerone/aurora_device.py b/homeassistant/components/aurora_abb_powerone/aurora_device.py index d2aed5ec7a8..d9cfb744231 100644 --- a/homeassistant/components/aurora_abb_powerone/aurora_device.py +++ b/homeassistant/components/aurora_abb_powerone/aurora_device.py @@ -1,11 +1,13 @@ """Top level class for AuroraABBPowerOneSolarPV inverters and sensors.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any from aurorapy.client import AuroraSerialClient -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import ( ATTR_DEVICE_NAME, @@ -20,10 +22,10 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -class AuroraDevice(Entity): +class AuroraEntity(Entity): """Representation of an Aurora ABB PowerOne device.""" - def __init__(self, client: AuroraSerialClient, data) -> None: + def __init__(self, client: AuroraSerialClient, data: Mapping[str, Any]) -> None: """Initialise the basic device.""" self._data = data self.type = "device" @@ -44,7 +46,7 @@ class AuroraDevice(Entity): return self._available @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device specific attributes.""" return { "identifiers": {(DOMAIN, self._data[ATTR_SERIAL_NUMBER])}, diff --git a/homeassistant/components/aurora_abb_powerone/config_flow.py b/homeassistant/components/aurora_abb_powerone/config_flow.py index 012fe7b14bb..3d292857266 100644 --- a/homeassistant/components/aurora_abb_powerone/config_flow.py +++ b/homeassistant/components/aurora_abb_powerone/config_flow.py @@ -1,5 +1,8 @@ """Config flow for Aurora ABB PowerOne integration.""" +from __future__ import annotations + import logging +from typing import Any from aurorapy.client import AuroraError, AuroraSerialClient import serial.tools.list_ports @@ -7,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries, core from homeassistant.const import CONF_ADDRESS, CONF_PORT +from homeassistant.data_entry_flow import FlowResult from .const import ( ATTR_FIRMWARE, @@ -22,7 +26,9 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -def validate_and_connect(hass: core.HomeAssistant, data): +def validate_and_connect( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, str]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -50,15 +56,15 @@ def validate_and_connect(hass: core.HomeAssistant, data): return ret -def scan_comports(): +def scan_comports() -> tuple[list[str] | None, str | None]: """Find and store available com ports for the GUI dropdown.""" - comports = serial.tools.list_ports.comports(include_links=True) - comportslist = [] - for port in comports: - comportslist.append(port.device) + com_ports = serial.tools.list_ports.comports(include_links=True) + com_ports_list = [] + for port in com_ports: + com_ports_list.append(port.device) _LOGGER.debug("COM port option: %s", port.device) - if len(comportslist) > 0: - return comportslist, comportslist[0] + if len(com_ports_list) > 0: + return com_ports_list, com_ports_list[0] _LOGGER.warning("No com ports found. Need a valid RS485 device to communicate") return None, None @@ -67,18 +73,17 @@ class AuroraABBConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Aurora ABB PowerOne.""" VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL def __init__(self): """Initialise the config flow.""" self.config = None - self._comportslist = None - self._defaultcomport = None + self._com_ports_list = None + self._default_com_port = None - async def async_step_import(self, config: dict): + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: """Import a configuration from config.yaml.""" if self.hass.config_entries.async_entries(DOMAIN): - return self.async_abort(reason="already_setup") + return self.async_abort(reason="already_configured") conf = {} conf[CONF_PORT] = config["device"] @@ -87,14 +92,16 @@ class AuroraABBConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=DEFAULT_INTEGRATION_TITLE, data=conf) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialised by the user.""" errors = {} - if self._comportslist is None: + if self._com_ports_list is None: result = await self.hass.async_add_executor_job(scan_comports) - self._comportslist, self._defaultcomport = result - if self._defaultcomport is None: + self._com_ports_list, self._default_com_port = result + if self._default_com_port is None: return self.async_abort(reason="no_serial_ports") # Handle the initial step. @@ -103,14 +110,6 @@ class AuroraABBConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): info = await self.hass.async_add_executor_job( validate_and_connect, self.hass, user_input ) - info.update(user_input) - # Bomb out early if someone has already set up this device. - device_unique_id = info["serial_number"] - await self.async_set_unique_id(device_unique_id) - self._abort_if_unique_id_configured() - - return self.async_create_entry(title=info["title"], data=info) - except OSError as error: if error.errno == 19: # No such device. errors["base"] = "invalid_serial_port" @@ -127,10 +126,18 @@ class AuroraABBConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): error, ) errors["base"] = "cannot_connect" + else: + info.update(user_input) + # Bomb out early if someone has already set up this device. + device_unique_id = info["serial_number"] + await self.async_set_unique_id(device_unique_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=info) + # If no user input, must be first pass through the config. Show initial form. config_options = { - vol.Required(CONF_PORT, default=self._defaultcomport): vol.In( - self._comportslist + vol.Required(CONF_PORT, default=self._default_com_port): vol.In( + self._com_ports_list ), vol.Required(CONF_ADDRESS, default=DEFAULT_ADDRESS): vol.In( range(MIN_ADDRESS, MAX_ADDRESS + 1) diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index 4f196c39630..35be4e2b7d7 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv -from .aurora_device import AuroraDevice +from .aurora_device import AuroraEntity from .const import DEFAULT_ADDRESS, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -84,7 +84,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities) -> None: """Set up aurora_abb_powerone sensor based on a config entry.""" entities = [] - client = hass.data[DOMAIN][config_entry.unique_id] + client = hass.data[DOMAIN][config_entry.entry_id] data = config_entry.data for sens in SENSOR_TYPES: @@ -94,7 +94,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities) -> None: async_add_entities(entities, True) -class AuroraSensor(AuroraDevice, SensorEntity): +class AuroraSensor(AuroraEntity, SensorEntity): """Representation of a Sensor on a Aurora ABB PowerOne Solar inverter.""" def __init__( @@ -106,7 +106,7 @@ class AuroraSensor(AuroraDevice, SensorEntity): """Initialize the sensor.""" super().__init__(client, data) self.entity_description = entity_description - self.availableprev = True + self.available_prev = True def update(self): """Fetch new state data for the sensor. @@ -114,7 +114,7 @@ class AuroraSensor(AuroraDevice, SensorEntity): This is the only method that should fetch new data for Home Assistant. """ try: - self.availableprev = self._attr_available + self.available_prev = self._attr_available self.client.connect() if self.entity_description.key == "instantaneouspower": # read ADC channel 3 (grid power output) @@ -145,7 +145,7 @@ class AuroraSensor(AuroraDevice, SensorEntity): else: raise error finally: - if self._attr_available != self.availableprev: + if self._attr_available != self.available_prev: if self._attr_available: _LOGGER.info("Communication with %s back online", self.name) else: diff --git a/homeassistant/components/aurora_abb_powerone/strings.json b/homeassistant/components/aurora_abb_powerone/strings.json index b705c5f69a5..bed403bd641 100644 --- a/homeassistant/components/aurora_abb_powerone/strings.json +++ b/homeassistant/components/aurora_abb_powerone/strings.json @@ -12,11 +12,10 @@ "error": { "cannot_connect": "Unable to connect, please check serial port, address, electrical connection and that inverter is on (in daylight)", "invalid_serial_port": "Serial port is not a valid device or could not be openned", - "cannot_open_serial_port": "Cannot open serial port, please check and try again", - "unknown": "[%key:common::config_flow::error::unknown%]" + "cannot_open_serial_port": "Cannot open serial port, please check and try again" }, "abort": { - "already_configured": "Device is already configured", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_serial_ports": "No com ports found. Need a valid RS485 device to communicate." } } diff --git a/homeassistant/components/aurora_abb_powerone/translations/bg.json b/homeassistant/components/aurora_abb_powerone/translations/bg.json new file mode 100644 index 00000000000..88f52d84269 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/bg.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/ca.json b/homeassistant/components/aurora_abb_powerone/translations/ca.json index 6976430a9b3..98f77dad13b 100644 --- a/homeassistant/components/aurora_abb_powerone/translations/ca.json +++ b/homeassistant/components/aurora_abb_powerone/translations/ca.json @@ -5,14 +5,18 @@ "no_serial_ports": "No s'han trobat ports COM. Es necessita un dispositiu de comunicaci\u00f3 RS485 v\u00e0lid." }, "error": { + "cannot_connect": "No s'ha pogut connectar, comprova el port s\u00e8rie, l'adre\u00e7a, la connexi\u00f3 el\u00e8ctrica i que l'inversor estigui enc\u00e8s", "cannot_open_serial_port": "No s'ha pogut obrir el port s\u00e8rie, comprova'l i torna-ho a provar", + "invalid_serial_port": "El port s\u00e8rie no t\u00e9 un dispositiu v\u00e0lid o no s'ha pogut obrir", "unknown": "Error inesperat" }, "step": { "user": { "data": { - "address": "Adre\u00e7a de l'inversor" - } + "address": "Adre\u00e7a de l'inversor", + "port": "Port RS485 o adaptador USB-RS485" + }, + "description": "L'inversor ha d'estar connectat mitjan\u00e7ant un adaptador RS485. Selecciona el port s\u00e8rie i l'adre\u00e7a de l'inversor tal com estan configurats a la pantalla LCD de l'aparell" } } } diff --git a/homeassistant/components/aurora_abb_powerone/translations/he.json b/homeassistant/components/aurora_abb_powerone/translations/he.json new file mode 100644 index 00000000000..ea40181bd9a --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/he.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "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/aurora_abb_powerone/translations/id.json b/homeassistant/components/aurora_abb_powerone/translations/id.json new file mode 100644 index 00000000000..4157502c2ad --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/id.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "no_serial_ports": "Tidak ada port com yang ditemukan. Perlu perangkat RS485 yang valid untuk berkomunikasi." + }, + "error": { + "cannot_connect": "Tidak dapat terhubung, periksa port serial, alamat, koneksi listrik dan apakah inverter sedang nyala (di siang hari)", + "cannot_open_serial_port": "Tidak dapat membuka port serial, periksa dan coba lagi", + "invalid_serial_port": "Port serial bukan perangkat yang valid atau tidak dapat dibuka", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "address": "Alamat Inverter", + "port": "Port Adaptor RS485 atau USB-RS485" + }, + "description": "Inverter harus terhubung melalui adaptor RS485, pilih port serial dan alamat inverter seperti yang dikonfigurasi pada panel LCD" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/it.json b/homeassistant/components/aurora_abb_powerone/translations/it.json new file mode 100644 index 00000000000..a16c655d282 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/it.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "no_serial_ports": "Nessuna porta COM trovata. Serve un dispositivo RS485 valido per comunicare." + }, + "error": { + "cannot_connect": "Impossibile connettersi, controllare la porta seriale, l'indirizzo, la connessione elettrica e che l'inverter sia acceso (alla luce del giorno)", + "cannot_open_serial_port": "Impossibile aprire la porta seriale, controllare e riprovare", + "invalid_serial_port": "La porta seriale non \u00e8 un dispositivo valido o non pu\u00f2 essere aperta", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "address": "Indirizzo dell'inverter", + "port": "Porta adattatore RS485 o USB-RS485" + }, + "description": "L'inverter deve essere collegato tramite un adattatore RS485, selezionare la porta seriale e l'indirizzo dell'inverter come configurato sul pannello LCD" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/ja.json b/homeassistant/components/aurora_abb_powerone/translations/ja.json new file mode 100644 index 00000000000..a558f088f07 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/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", + "no_serial_ports": "COM\u30dd\u30fc\u30c8\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3002\u901a\u4fe1\u3059\u308b\u306b\u306f\u6709\u52b9\u306aRS485\u30c7\u30d0\u30a4\u30b9\u304c\u5fc5\u8981\u3067\u3059\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u3067\u304d\u306a\u3044\u5834\u5408\u306f\u3001\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8\u3001\u30a2\u30c9\u30ec\u30b9\u3001\u96fb\u6c17\u7684\u63a5\u7d9a\u3092\u78ba\u8a8d\u3057\u3001\u30a4\u30f3\u30d0\u30fc\u30bf\u30fc\u306e\u96fb\u6e90\u304c\u5165\u3063\u3066\u3044\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044(\u663c\u9593)", + "cannot_open_serial_port": "\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8\u3092\u958b\u3051\u307e\u305b\u3093\u3002\u78ba\u8a8d\u3057\u3066\u304b\u3089\u3082\u3046\u4e00\u5ea6\u8a66\u3057\u3066\u304f\u3060\u3055\u3044", + "invalid_serial_port": "\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8\u304c\u6709\u52b9\u306a\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u306a\u3044\u3001\u3082\u3057\u304f\u306f\u958b\u304f\u3053\u3068\u304c\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "address": "\u30a4\u30f3\u30d0\u30fc\u30bf\u30fc\u30a2\u30c9\u30ec\u30b9", + "port": "RS485\u3001\u307e\u305f\u306f USB-RS485 \u30a2\u30c0\u30d7\u30bf\u30fc \u30dd\u30fc\u30c8" + }, + "description": "\u30a4\u30f3\u30d0\u30fc\u30bf\u30fc\u306fRS485\u30a2\u30c0\u30d7\u30bf\u30fc\u3092\u4ecb\u3057\u3066\u63a5\u7d9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002LCD\u30d1\u30cd\u30eb\u3067\u8a2d\u5b9a\u3057\u305f\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8\u3068\u30a4\u30f3\u30d0\u30fc\u30bf\u30fc\u306e\u30a2\u30c9\u30ec\u30b9\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/nl.json b/homeassistant/components/aurora_abb_powerone/translations/nl.json new file mode 100644 index 00000000000..d70113e9c19 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "no_serial_ports": "Geen com-poorten gevonden. Een geldig RS485-apparaat is nodig om te communiceren." + }, + "error": { + "cannot_connect": "Kan geen verbinding maken, controleer de seri\u00eble poort, het adres, de elektrische aansluiting en of de omvormer aan staat (bij daglicht)", + "cannot_open_serial_port": "Kan seri\u00eble poort niet openen, controleer en probeer het opnieuw", + "invalid_serial_port": "Seri\u00eble poort is geen geldig apparaat of kan niet worden geopend", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "address": "Omvormer adres", + "port": "RS485 of USB-RS485 adapter poort" + }, + "description": "De omvormer moet worden aangesloten via een RS485-adapter, selecteer de seri\u00eble poort en het adres van de omvormer zoals geconfigureerd op het LCD-paneel" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/no.json b/homeassistant/components/aurora_abb_powerone/translations/no.json new file mode 100644 index 00000000000..9d4cd656f45 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "no_serial_ports": "Ingen com-porter funnet. Trenger en gyldig RS485-enhet for \u00e5 kommunisere." + }, + "error": { + "cannot_connect": "Kan ikke koble til, sjekk seriell port, adresse, elektrisk tilkobling og at omformeren er p\u00e5 (i dagslys)", + "cannot_open_serial_port": "Kan ikke \u00e5pne serieporten, sjekk og pr\u00f8v igjen", + "invalid_serial_port": "Seriell port er ikke en gyldig enhet eller kunne ikke \u00e5pnes", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "address": "Inverter adresse", + "port": "RS485- eller USB-RS485-adapterport" + }, + "description": "Omformeren m\u00e5 kobles til via en RS485-adapter, velg seriell port og omformerens adresse som konfigurert p\u00e5 LCD-panelet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/pl.json b/homeassistant/components/aurora_abb_powerone/translations/pl.json index c931afdae8d..d6131cfa195 100644 --- a/homeassistant/components/aurora_abb_powerone/translations/pl.json +++ b/homeassistant/components/aurora_abb_powerone/translations/pl.json @@ -1,10 +1,23 @@ { "config": { "abort": { - "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "no_serial_ports": "Nie znaleziono port\u00f3w COM. Do komunikacji potrzebne jest prawid\u0142owe urz\u0105dzenie RS485." }, "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, sprawd\u017a port szeregowy, adres, po\u0142\u0105czenie elektryczne i czy falownik jest w\u0142\u0105czony (w \u015bwietle dziennym)", + "cannot_open_serial_port": "Nie mo\u017cna otworzy\u0107 portu szeregowego, sprawd\u017a i spr\u00f3buj ponownie", + "invalid_serial_port": "Port szeregowy nie jest prawid\u0142owym urz\u0105dzeniem lub nie mo\u017cna go otworzy\u0107", "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "address": "Adres falownika", + "port": "Port RS485 lub adaptera USB-RS485" + }, + "description": "Falownik musi by\u0107 pod\u0142\u0105czony przez adapter RS485. Wybierz port szeregowy i adres falownika zgodnie z konfiguracj\u0105 na panelu LCD." + } } } } \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/sl.json b/homeassistant/components/aurora_abb_powerone/translations/sl.json new file mode 100644 index 00000000000..87fce100c77 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/sl.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "unknown": "Nepri\u010dakovana napaka" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/th.json b/homeassistant/components/aurora_abb_powerone/translations/th.json new file mode 100644 index 00000000000..5db99ad99e4 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/th.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "port": "\u0e2d\u0e30\u0e41\u0e14\u0e1b\u0e40\u0e15\u0e2d\u0e23\u0e4c \u0e1e\u0e2d\u0e23\u0e4c\u0e15 RS485 \u0e2b\u0e23\u0e37\u0e2d USB-RS485" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora_abb_powerone/translations/tr.json b/homeassistant/components/aurora_abb_powerone/translations/tr.json new file mode 100644 index 00000000000..ec8ee6da4a3 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/tr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "no_serial_ports": "com ba\u011flant\u0131 noktas\u0131 bulunamad\u0131. \u0130leti\u015fim kurmak i\u00e7in ge\u00e7erli bir RS485 cihaz\u0131na ihtiyac\u0131n\u0131z var." + }, + "error": { + "cannot_connect": "Ba\u011flant\u0131 kurulam\u0131yor, l\u00fctfen seri portu, adresi, elektrik ba\u011flant\u0131s\u0131n\u0131 ve invert\u00f6r\u00fcn a\u00e7\u0131k oldu\u011funu (g\u00fcn \u0131\u015f\u0131\u011f\u0131nda) kontrol edin.", + "cannot_open_serial_port": "Seri ba\u011flant\u0131 noktas\u0131 a\u00e7\u0131lam\u0131yor, l\u00fctfen kontrol edip tekrar deneyin", + "invalid_serial_port": "Seri ba\u011flant\u0131 noktas\u0131 ge\u00e7erli bir ayg\u0131t de\u011fil veya a\u00e7\u0131lamad\u0131", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "address": "R\u00f6le Adresi", + "port": "RS485 veya USB-RS485 Adapt\u00f6r Ba\u011flant\u0131 Noktas\u0131" + }, + "description": "\u0130nverter bir RS485 adapt\u00f6r\u00fc ile ba\u011flanmal\u0131d\u0131r, l\u00fctfen seri portu ve inverterin adresini LCD panelde konfig\u00fcre edildi\u011fi \u015fekilde se\u00e7iniz." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index bcdcf4de747..374a36683da 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -124,13 +124,12 @@ from aiohttp import web import voluptuous as vol from homeassistant.auth import InvalidAuthError -from homeassistant.auth.models import ( - TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, - Credentials, - User, -) +from homeassistant.auth.models import TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, Credentials from homeassistant.components import websocket_api -from homeassistant.components.http.auth import async_sign_path +from homeassistant.components.http.auth import ( + async_sign_path, + async_user_not_allowed_do_auth, +) from homeassistant.components.http.ban import log_invalid_auth from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView @@ -179,15 +178,12 @@ SCHEMA_WS_SIGN_PATH = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( ) RESULT_TYPE_CREDENTIALS = "credentials" -RESULT_TYPE_USER = "user" @bind_hass -def create_auth_code( - hass, client_id: str, credential_or_user: Credentials | User -) -> str: +def create_auth_code(hass, client_id: str, credential: Credentials) -> str: """Create an authorization code to fetch tokens.""" - return hass.data[DOMAIN](client_id, credential_or_user) + return hass.data[DOMAIN](client_id, credential) async def async_setup(hass, config): @@ -296,7 +292,7 @@ class TokenView(HomeAssistantView): status_code=HTTPStatus.BAD_REQUEST, ) - credential = self._retrieve_auth(client_id, RESULT_TYPE_CREDENTIALS, code) + credential = self._retrieve_auth(client_id, code) if credential is None or not isinstance(credential, Credentials): return self.json( @@ -306,9 +302,12 @@ class TokenView(HomeAssistantView): user = await hass.auth.async_get_or_create_user(credential) - if not user.is_active: + if user_access_error := async_user_not_allowed_do_auth(hass, user): return self.json( - {"error": "access_denied", "error_description": "User is not active"}, + { + "error": "access_denied", + "error_description": user_access_error, + }, status_code=HTTPStatus.FORBIDDEN, ) @@ -362,6 +361,17 @@ class TokenView(HomeAssistantView): {"error": "invalid_request"}, status_code=HTTPStatus.BAD_REQUEST ) + if user_access_error := async_user_not_allowed_do_auth( + hass, refresh_token.user + ): + return self.json( + { + "error": "access_denied", + "error_description": user_access_error, + }, + status_code=HTTPStatus.FORBIDDEN, + ) + try: access_token = hass.auth.async_create_access_token( refresh_token, remote_addr @@ -399,9 +409,7 @@ class LinkUserView(HomeAssistantView): hass = request.app["hass"] user = request["hass_user"] - credentials = self._retrieve_credentials( - data["client_id"], RESULT_TYPE_CREDENTIALS, data["code"] - ) + credentials = self._retrieve_credentials(data["client_id"], data["code"]) if credentials is None: return self.json_message("Invalid code", status_code=HTTPStatus.BAD_REQUEST) @@ -426,30 +434,25 @@ def _create_auth_code_store(): @callback def store_result(client_id, result): """Store flow result and return a code to retrieve it.""" - if isinstance(result, User): - result_type = RESULT_TYPE_USER - elif isinstance(result, Credentials): - result_type = RESULT_TYPE_CREDENTIALS - else: - raise ValueError("result has to be either User or Credentials") + if not isinstance(result, Credentials): + raise ValueError("result has to be a Credentials instance") code = uuid.uuid4().hex - temp_results[(client_id, result_type, code)] = ( + temp_results[(client_id, code)] = ( dt_util.utcnow(), - result_type, result, ) return code @callback - def retrieve_result(client_id, result_type, code): + def retrieve_result(client_id, code): """Retrieve flow result.""" - key = (client_id, result_type, code) + key = (client_id, code) if key not in temp_results: return None - created, _, result = temp_results.pop(key) + created, result = temp_results.pop(key) # OAuth 4.2.1 # The authorization code MUST expire shortly after it is issued to diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index ed5c544499e..a21854b7770 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -74,6 +74,8 @@ import voluptuous as vol import voluptuous_serialize from homeassistant import data_entry_flow +from homeassistant.auth.models import Credentials +from homeassistant.components.http.auth import async_user_not_allowed_do_auth from homeassistant.components.http.ban import ( log_invalid_auth, process_success_login, @@ -81,6 +83,7 @@ from homeassistant.components.http.ban import ( ) from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView +from homeassistant.core import HomeAssistant from . import indieauth @@ -138,11 +141,9 @@ def _prepare_result_json(result): return data -class LoginFlowIndexView(HomeAssistantView): - """View to create a config flow.""" +class LoginFlowBaseView(HomeAssistantView): + """Base class for the login views.""" - url = "/auth/login_flow" - name = "api:auth:login_flow" requires_auth = False def __init__(self, flow_mgr, store_result): @@ -150,6 +151,50 @@ class LoginFlowIndexView(HomeAssistantView): self._flow_mgr = flow_mgr self._store_result = store_result + async def _async_flow_result_to_response(self, request, client_id, result): + """Convert the flow result to a response.""" + if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + # @log_invalid_auth does not work here since it returns HTTP 200. + # We need to manually log failed login attempts. + if ( + result["type"] == data_entry_flow.RESULT_TYPE_FORM + and (errors := result.get("errors")) + and errors.get("base") + in ( + "invalid_auth", + "invalid_code", + ) + ): + await process_wrong_login(request) + return self.json(_prepare_result_json(result)) + + result.pop("data") + + hass: HomeAssistant = request.app["hass"] + result_obj: Credentials = result.pop("result") + + # Result can be None if credential was never linked to a user before. + user = await hass.auth.async_get_user_by_credentials(result_obj) + + if user is not None and ( + user_access_error := async_user_not_allowed_do_auth(hass, user) + ): + return self.json_message( + f"Login blocked: {user_access_error}", HTTPStatus.FORBIDDEN + ) + + await process_success_login(request) + result["result"] = self._store_result(client_id, result_obj) + + return self.json(result) + + +class LoginFlowIndexView(LoginFlowBaseView): + """View to create a config flow.""" + + url = "/auth/login_flow" + name = "api:auth:login_flow" + async def get(self, request): """Do not allow index of flows in progress.""" # pylint: disable=no-self-use @@ -195,26 +240,16 @@ class LoginFlowIndexView(HomeAssistantView): "Handler does not support init", HTTPStatus.BAD_REQUEST ) - if result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: - await process_success_login(request) - result.pop("data") - result["result"] = self._store_result(data["client_id"], result["result"]) - return self.json(result) - - return self.json(_prepare_result_json(result)) + return await self._async_flow_result_to_response( + request, data["client_id"], result + ) -class LoginFlowResourceView(HomeAssistantView): +class LoginFlowResourceView(LoginFlowBaseView): """View to interact with the flow manager.""" url = "/auth/login_flow/{flow_id}" name = "api:auth:login_flow:resource" - requires_auth = False - - def __init__(self, flow_mgr, store_result): - """Initialize the login flow resource view.""" - self._flow_mgr = flow_mgr - self._store_result = store_result async def get(self, request): """Do not allow getting status of a flow in progress.""" @@ -240,20 +275,7 @@ class LoginFlowResourceView(HomeAssistantView): except vol.Invalid: return self.json_message("User input malformed", HTTPStatus.BAD_REQUEST) - if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: - # @log_invalid_auth does not work here since it returns HTTP 200 - # need manually log failed login attempts - if result.get("errors") is not None and result["errors"].get("base") in ( - "invalid_auth", - "invalid_code", - ): - await process_wrong_login(request) - return self.json(_prepare_result_json(result)) - - result.pop("data") - result["result"] = self._store_result(client_id, result["result"]) - - return self.json(result) + return await self._async_flow_result_to_response(request, client_id, result) async def delete(self, request, flow_id): """Cancel a flow in progress.""" diff --git a/homeassistant/components/auth/translations/ja.json b/homeassistant/components/auth/translations/ja.json index 1ef902e6fe2..182e56114d6 100644 --- a/homeassistant/components/auth/translations/ja.json +++ b/homeassistant/components/auth/translations/ja.json @@ -1,6 +1,34 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u5229\u7528\u3067\u304d\u308b\u901a\u77e5\u30b5\u30fc\u30d3\u30b9\u304c\u3042\u308a\u307e\u305b\u3093\u3002" + }, + "error": { + "invalid_code": "\u7121\u52b9\u306a\u30b3\u30fc\u30c9\u3067\u3059\u3002\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "init": { + "description": "\u3069\u308c\u304b1\u3064\u3001\u901a\u77e5\u30b5\u30fc\u30d3\u30b9\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044:", + "title": "\u901a\u77e5\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u306b\u3088\u3063\u3066\u914d\u4fe1\u3055\u308c\u308b\u30ef\u30f3\u30bf\u30a4\u30e0\u30d1\u30b9\u30ef\u30fc\u30c9\u306e\u8a2d\u5b9a" + }, + "setup": { + "description": "\u30ef\u30f3\u30bf\u30a4\u30e0\u30d1\u30b9\u30ef\u30fc\u30c9\u304c **notify.{notify_service}** \u3092\u4ecb\u3057\u3066\u9001\u4fe1\u3055\u308c\u307e\u3057\u305f\u3002\u4ee5\u4e0b\u306b\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044:", + "title": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u306e\u78ba\u8a8d" + } + }, + "title": "\u30ef\u30f3\u30bf\u30a4\u30e0\u30d1\u30b9\u30ef\u30fc\u30c9\u306e\u901a\u77e5" + }, "totp": { + "error": { + "invalid_code": "\u7121\u52b9\u306a\u30b3\u30fc\u30c9\u3067\u3059\u3001\u518d\u8a66\u884c\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u3053\u306e\u30a8\u30e9\u30fc\u304c\u5e38\u306b\u767a\u751f\u3059\u308b\u5834\u5408\u306f\u3001Home Assistant\u306e\u30b7\u30b9\u30c6\u30e0\u6642\u8a08\u304c\u6b63\u78ba\u3067\u3042\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "init": { + "description": "\u30bf\u30a4\u30e0\u30d9\u30fc\u30b9\u306e\u30ef\u30f3\u30bf\u30a4\u30e0\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u4f7f\u7528\u3057\u30662\u8981\u7d20\u8a8d\u8a3c\u3092\u6709\u52b9\u306b\u3059\u308b\u306b\u306f\u3001\u8a8d\u8a3c\u30a2\u30d7\u30ea\u3067QR\u30b3\u30fc\u30c9\u3092\u30b9\u30ad\u30e3\u30f3\u3057\u307e\u3059\u3002\u8a8d\u8a3c\u30a2\u30d7\u30ea\u3092\u304a\u6301\u3061\u306e\u5834\u5408\u306f\u3001[Google \u8a8d\u8a3c\u30b7\u30b9\u30c6\u30e0](https://support.google.com/accounts/answer/1066447)\u307e\u305f\u306f\u3001[Authy](https://authy.com/)\u306e\u3069\u3061\u3089\u304b\u3092\u63a8\u5968\u3057\u307e\u3059\u3002\n\n{qr_code}\n\n\u30b3\u30fc\u30c9\u3092\u30b9\u30ad\u30e3\u30f3\u3057\u305f\u5f8c\u3001\u30a2\u30d7\u30ea\u304b\u30896\u6841\u306e\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u8a2d\u5b9a\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002QR\u30b3\u30fc\u30c9\u306e\u30b9\u30ad\u30e3\u30f3\u3067\u554f\u984c\u304c\u767a\u751f\u3057\u305f\u5834\u5408\u306f\u3001\u30b3\u30fc\u30c9 **`{code}`** \u3092\u4f7f\u7528\u3057\u3066\u624b\u52d5\u3067\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u884c\u3063\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "TOTP\u3092\u4f7f\u7528\u3057\u30662\u8981\u7d20\u8a8d\u8a3c\u3092\u8a2d\u5b9a\u3059\u308b" + } + }, "title": "TOTP" } } diff --git a/homeassistant/components/auth/translations/tr.json b/homeassistant/components/auth/translations/tr.json index 7d273214574..1cab13d4dc6 100644 --- a/homeassistant/components/auth/translations/tr.json +++ b/homeassistant/components/auth/translations/tr.json @@ -1,22 +1,35 @@ { "mfa_setup": { "notify": { + "abort": { + "no_available_service": "Kullan\u0131labilir bildirim hizmeti yok." + }, + "error": { + "invalid_code": "Ge\u00e7ersiz kod, l\u00fctfen tekrar deneyiniz." + }, "step": { "init": { + "description": "L\u00fctfen bildirim hizmetlerinden birini se\u00e7in:", "title": "Bilgilendirme bile\u015feni taraf\u0131ndan verilen tek seferlik parolay\u0131 ayarlay\u0131n" }, "setup": { - "description": "**bildirim yoluyla tek seferlik bir parola g\u00f6nderildi. {notify_service}**. L\u00fctfen a\u015fa\u011f\u0131da girin:" + "description": "**bildirim yoluyla tek seferlik bir parola g\u00f6nderildi. {notify_service}**. L\u00fctfen a\u015fa\u011f\u0131da girin:", + "title": "Kurulumu do\u011frulay\u0131n" } }, "title": "Tek Seferlik Parolay\u0131 Bildir" }, "totp": { + "error": { + "invalid_code": "Ge\u00e7ersiz kod, l\u00fctfen tekrar deneyiniz. S\u00fcrekli olarak bu hatay\u0131 al\u0131yorsan\u0131z, l\u00fctfen Home Assistant sisteminizin saatinin do\u011fru oldu\u011fundan emin olun." + }, "step": { "init": { - "description": "Zamana dayal\u0131 tek seferlik parolalar\u0131 kullanarak iki fakt\u00f6rl\u00fc kimlik do\u011frulamay\u0131 etkinle\u015ftirmek i\u00e7in kimlik do\u011frulama uygulaman\u0131zla QR kodunu taray\u0131n. Hesab\u0131n\u0131z yoksa, [Google Authenticator] (https://support.google.com/accounts/answer/1066447) veya [Authy] (https://authy.com/) \u00f6neririz. \n\n {qr_code}\n\n Kodu tarad\u0131ktan sonra, kurulumu do\u011frulamak i\u00e7in uygulaman\u0131zdan alt\u0131 haneli kodu girin. QR kodunu taramayla ilgili sorun ya\u015f\u0131yorsan\u0131z, ** ` {code} ` manuel kurulum yap\u0131n." + "description": "Zamana dayal\u0131 tek seferlik parolalar\u0131 kullanarak iki fakt\u00f6rl\u00fc kimlik do\u011frulamay\u0131 etkinle\u015ftirmek i\u00e7in kimlik do\u011frulama uygulaman\u0131zla QR kodunu taray\u0131n. Hesab\u0131n\u0131z yoksa, [Google Authenticator] (https://support.google.com/accounts/answer/1066447) veya [Authy] (https://authy.com/) \u00f6neririz. \n\n {qr_code}\n\n Kodu tarad\u0131ktan sonra, kurulumu do\u011frulamak i\u00e7in uygulaman\u0131zdan alt\u0131 haneli kodu girin. QR kodunu taramayla ilgili sorun ya\u015f\u0131yorsan\u0131z, ** ` {code} ` manuel kurulum yap\u0131n.", + "title": "TOTP kullanarak iki fakt\u00f6rl\u00fc kimlik do\u011frulamay\u0131 ayarlay\u0131n" } - } + }, + "title": "TOTP" } } } \ No newline at end of file diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 92fbd0e8b04..64c6b335fbd 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -156,9 +156,7 @@ def entities_in_automation(hass: HomeAssistant, entity_id: str) -> list[str]: component = hass.data[DOMAIN] - automation_entity = component.get_entity(entity_id) - - if automation_entity is None: + if (automation_entity := component.get_entity(entity_id)) is None: return [] return list(automation_entity.referenced_entities) @@ -187,9 +185,7 @@ def devices_in_automation(hass: HomeAssistant, entity_id: str) -> list[str]: component = hass.data[DOMAIN] - automation_entity = component.get_entity(entity_id) - - if automation_entity is None: + if (automation_entity := component.get_entity(entity_id)) is None: return [] return list(automation_entity.referenced_devices) @@ -218,9 +214,7 @@ def areas_in_automation(hass: HomeAssistant, entity_id: str) -> list[str]: component = hass.data[DOMAIN] - automation_entity = component.get_entity(entity_id) - - if automation_entity is None: + if (automation_entity := component.get_entity(entity_id)) is None: return [] return list(automation_entity.referenced_areas) @@ -262,8 +256,7 @@ async def async_setup(hass, config): async def reload_service_handler(service_call): """Remove all automations and load new ones from config.""" - conf = await component.async_prepare_reload() - if conf is None: + if (conf := await component.async_prepare_reload()) is None: return async_get_blueprints(hass).async_reset_cache() await _async_process_config(hass, conf, component) @@ -392,8 +385,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): ) self.action_script.update_logger(self._logger) - state = await self.async_get_last_state() - if state: + if state := await self.async_get_last_state(): enable_automation = state.state == STATE_ON last_triggered = state.attributes.get("last_triggered") if last_triggered is not None: @@ -722,7 +714,7 @@ async def _async_process_if(hass, name, config, p_config): checks = [] for if_config in if_configs: try: - checks.append(await condition.async_from_config(hass, if_config, False)) + checks.append(await condition.async_from_config(hass, if_config)) except HomeAssistantError as ex: LOGGER.warning("Invalid condition: %s", ex) return None diff --git a/homeassistant/components/automation/translations/ca.json b/homeassistant/components/automation/translations/ca.json index 7d96a6a466d..c1d35331e2b 100644 --- a/homeassistant/components/automation/translations/ca.json +++ b/homeassistant/components/automation/translations/ca.json @@ -1,8 +1,8 @@ { "state": { "_": { - "off": "off", - "on": "on" + "off": "OFF", + "on": "ON" } }, "title": "Automatitzaci\u00f3" diff --git a/homeassistant/components/avea/light.py b/homeassistant/components/avea/light.py index d1df7ba3e46..ceb66ff39b6 100644 --- a/homeassistant/components/avea/light.py +++ b/homeassistant/components/avea/light.py @@ -59,7 +59,6 @@ class AveaLight(LightEntity): This is the only method that should fetch new data for Home Assistant. """ - brightness = self._light.get_brightness() - if brightness is not None: + if (brightness := self._light.get_brightness()) is not None: self._attr_is_on = brightness != 0 self._attr_brightness = round(255 * (brightness / 4095)) diff --git a/homeassistant/components/avion/light.py b/homeassistant/components/avion/light.py index 0bf1787aac7..e8f42e6a816 100644 --- a/homeassistant/components/avion/light.py +++ b/homeassistant/components/avion/light.py @@ -68,6 +68,7 @@ class AvionLight(LightEntity): _attr_supported_features = SUPPORT_AVION_LED _attr_should_poll = False _attr_assumed_state = True + _attr_is_on = True def __init__(self, device): """Initialize the light.""" diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py index 39853dab9de..2cfaa88022d 100644 --- a/homeassistant/components/awair/__init__.py +++ b/homeassistant/components/awair/__init__.py @@ -8,14 +8,14 @@ from async_timeout import timeout from python_awair import Awair from python_awair.exceptions import AuthError -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import API_TIMEOUT, DOMAIN, LOGGER, UPDATE_INTERVAL, AwairResult -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass, config_entry) -> bool: @@ -58,7 +58,7 @@ class AwairDataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> Any | None: """Update data via Awair client library.""" - with timeout(API_TIMEOUT): + async with timeout(API_TIMEOUT): try: LOGGER.debug("Fetching users and devices") user = await self._awair.user() diff --git a/homeassistant/components/awair/config_flow.py b/homeassistant/components/awair/config_flow.py index 2214fc30519..4c4ccad8f52 100644 --- a/homeassistant/components/awair/config_flow.py +++ b/homeassistant/components/awair/config_flow.py @@ -69,6 +69,7 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN): if error is None: entry = await self.async_set_unique_id(self.unique_id) + assert entry self.hass.config_entries.async_update_entry(entry, data=user_input) return self.async_abort(reason="reauth_successful") diff --git a/homeassistant/components/awair/const.py b/homeassistant/components/awair/const.py index 4968e86bcf5..68ca3335d97 100644 --- a/homeassistant/components/awair/const.py +++ b/homeassistant/components/awair/const.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from datetime import timedelta import logging +from python_awair.air_data import AirData from python_awair.devices import AwairDevice from homeassistant.components.sensor import SensorEntityDescription @@ -134,4 +135,4 @@ class AwairResult: """Wrapper class to hold an awair device and set of air data.""" device: AwairDevice - air_data: dict + air_data: AirData diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 1ff1b6e0efb..4e67e56cfe3 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -1,12 +1,13 @@ """Support for Awair sensors.""" from __future__ import annotations +from python_awair.air_data import AirData from python_awair.devices import AwairDevice import voluptuous as vol from homeassistant.components.awair import AwairDataUpdateCoordinator, AwairResult from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_CONNECTIONS, @@ -18,7 +19,6 @@ from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -59,7 +59,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ): """Set up Awair sensor entity based on a config entry.""" @@ -131,6 +131,7 @@ class AwairSensor(CoordinatorEntity, SensorEntity): # for users with first-gen devices that are upgrading. if ( self.entity_description.key == API_PM25 + and self._air_data and API_DUST in self._air_data.sensors ): unique_id_tag = "DUST" @@ -161,8 +162,11 @@ class AwairSensor(CoordinatorEntity, SensorEntity): return False @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Return the state, rounding off to reasonable values.""" + if not self._air_data: + return None + state: float sensor_type = self.entity_description.key @@ -206,6 +210,8 @@ class AwairSensor(CoordinatorEntity, SensorEntity): """ sensor_type = self.entity_description.key attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} + if not self._air_data: + return attrs if sensor_type in self._air_data.indices: attrs["awair_index"] = abs(self._air_data.indices[sensor_type]) elif sensor_type in DUST_ALIASES and API_DUST in self._air_data.indices: @@ -233,7 +239,7 @@ class AwairSensor(CoordinatorEntity, SensorEntity): return info @property - def _air_data(self) -> AwairResult | None: + def _air_data(self) -> AirData | None: """Return the latest data for our device, or None.""" result: AwairResult | None = self.coordinator.data.get(self._device.uuid) if result: diff --git a/homeassistant/components/awair/translations/bg.json b/homeassistant/components/awair/translations/bg.json new file mode 100644 index 00000000000..1d5233cabbf --- /dev/null +++ b/homeassistant/components/awair/translations/bg.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "error": { + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "reauth": { + "data": { + "email": "Email" + } + }, + "user": { + "data": { + "email": "Email" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/awair/translations/ca.json b/homeassistant/components/awair/translations/ca.json index ac69e06df1e..12384b088bb 100644 --- a/homeassistant/components/awair/translations/ca.json +++ b/homeassistant/components/awair/translations/ca.json @@ -6,7 +6,7 @@ "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { - "invalid_access_token": "Token d'acc\u00e9s no v\u00e0lid", + "invalid_access_token": "Token d'acc\u00e9s inv\u00e0lid", "unknown": "Error inesperat" }, "step": { diff --git a/homeassistant/components/awair/translations/ja.json b/homeassistant/components/awair/translations/ja.json new file mode 100644 index 00000000000..83121c9fe42 --- /dev/null +++ b/homeassistant/components/awair/translations/ja.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "invalid_access_token": "\u7121\u52b9\u306a\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "reauth": { + "data": { + "access_token": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", + "email": "E\u30e1\u30fc\u30eb" + }, + "description": "Awair developer access token\u3092\u518d\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "user": { + "data": { + "access_token": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", + "email": "E\u30e1\u30fc\u30eb" + }, + "description": "Awair developer access token\u306e\u767b\u9332\u306f\u4ee5\u4e0b\u306e\u30b5\u30a4\u30c8\u3067\u884c\u3046\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059: https://developer.getawair.com/onboard/login" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/awair/translations/tr.json b/homeassistant/components/awair/translations/tr.json index 84da92b97d3..e8fa8ca1027 100644 --- a/homeassistant/components/awair/translations/tr.json +++ b/homeassistant/components/awair/translations/tr.json @@ -2,22 +2,24 @@ "config": { "abort": { "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { - "invalid_access_token": "Ge\u00e7ersiz eri\u015fim belirteci", + "invalid_access_token": "Ge\u00e7ersiz eri\u015fim anahtar\u0131", "unknown": "Beklenmeyen hata" }, "step": { "reauth": { "data": { - "access_token": "Eri\u015fim Belirteci", + "access_token": "Eri\u015fim Anahtar\u0131", "email": "E-posta" - } + }, + "description": "L\u00fctfen Awair geli\u015ftirici eri\u015fim anahtar\u0131n\u0131 yeniden girin." }, "user": { "data": { - "access_token": "Eri\u015fim Belirteci", + "access_token": "Eri\u015fim Anahtar\u0131", "email": "E-posta" }, "description": "Awair geli\u015ftirici eri\u015fim belirteci i\u00e7in \u015fu adresten kaydolmal\u0131s\u0131n\u0131z: https://developer.getawair.com/onboard/login" diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index d313e4b2745..a47592fc877 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -6,7 +6,7 @@ from urllib.parse import urlsplit import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import ( CONF_HOST, @@ -17,6 +17,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import format_mac from homeassistant.util.network import is_link_local @@ -151,37 +152,39 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=AXIS_DOMAIN): return await self.async_step_user() - async def async_step_dhcp(self, discovery_info: dict): + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Prepare configuration for a DHCP discovered Axis device.""" return await self._process_discovered_device( { - CONF_HOST: discovery_info[IP_ADDRESS], - CONF_MAC: format_mac(discovery_info.get(MAC_ADDRESS, "")), - CONF_NAME: discovery_info.get(HOSTNAME), + CONF_HOST: discovery_info.ip, + CONF_MAC: format_mac(discovery_info.macaddress), + CONF_NAME: discovery_info.hostname, CONF_PORT: DEFAULT_PORT, } ) - async def async_step_ssdp(self, discovery_info: dict): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Prepare configuration for a SSDP discovered Axis device.""" - url = urlsplit(discovery_info["presentationURL"]) + url = urlsplit(discovery_info.upnp[ssdp.ATTR_UPNP_PRESENTATION_URL]) return await self._process_discovered_device( { CONF_HOST: url.hostname, - CONF_MAC: format_mac(discovery_info["serialNumber"]), - CONF_NAME: f"{discovery_info['friendlyName']}", + CONF_MAC: format_mac(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]), + CONF_NAME: f"{discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]}", CONF_PORT: url.port, } ) - async def async_step_zeroconf(self, discovery_info: dict): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Prepare configuration for a Zeroconf discovered Axis device.""" return await self._process_discovered_device( { - CONF_HOST: discovery_info[CONF_HOST], - CONF_MAC: format_mac(discovery_info["properties"]["macaddress"]), - CONF_NAME: discovery_info["name"].split(".", 1)[0], - CONF_PORT: discovery_info[CONF_PORT], + CONF_HOST: discovery_info.host, + CONF_MAC: format_mac(discovery_info.properties["macaddress"]), + CONF_NAME: discovery_info.name.split(".", 1)[0], + CONF_PORT: discovery_info.port, } ) diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 823593ecacb..a2eceff6870 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -280,7 +280,7 @@ async def get_device(hass, host, port, username, password): ) try: - with async_timeout.timeout(30): + async with async_timeout.timeout(30): await device.vapix.initialize() return device diff --git a/homeassistant/components/axis/translations/ja.json b/homeassistant/components/axis/translations/ja.json new file mode 100644 index 00000000000..bb9fa910122 --- /dev/null +++ b/homeassistant/components/axis/translations/ja.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "link_local_address": "\u30ed\u30fc\u30ab\u30eb\u30a2\u30c9\u30ec\u30b9\u306e\u30ea\u30f3\u30af\u306b\u306f\u5bfe\u5fdc\u3057\u3066\u3044\u307e\u305b\u3093", + "not_axis_device": "\u691c\u51fa\u3055\u308c\u305f\u30c7\u30d0\u30a4\u30b9\u306fAxis\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u3042\u308a\u307e\u305b\u3093" + }, + "error": { + "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Axis\u30c7\u30d0\u30a4\u30b9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "configure_stream": { + "data": { + "stream_profile": "\u4f7f\u7528\u3059\u308b\u30b9\u30c8\u30ea\u30fc\u30e0\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u306e\u9078\u629e" + }, + "title": "Axis\u30c7\u30d0\u30a4\u30b9\u306e\u30d3\u30c7\u30aa\u30b9\u30c8\u30ea\u30fc\u30e0\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/translations/tr.json b/homeassistant/components/axis/translations/tr.json index b2d609747d1..2ec66ab688d 100644 --- a/homeassistant/components/axis/translations/tr.json +++ b/homeassistant/components/axis/translations/tr.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "link_local_address": "Ba\u011flant\u0131 yerel adresleri desteklenmiyor", + "not_axis_device": "Bulunan cihaz bir Axis cihaz\u0131 de\u011fil" }, "error": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", @@ -9,14 +11,26 @@ "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" }, + "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "password": "Parola", "port": "Port", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "Axis cihaz\u0131n\u0131 kurun" + } + } + }, + "options": { + "step": { + "configure_stream": { + "data": { + "stream_profile": "Kullan\u0131lacak ak\u0131\u015f profilini se\u00e7in" + }, + "title": "Axis cihaz\u0131 video ak\u0131\u015f\u0131 se\u00e7enekleri" } } } diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py index fe27ec8bcec..213dc19ff9e 100644 --- a/homeassistant/components/azure_devops/__init__.py +++ b/homeassistant/components/azure_devops/__init__.py @@ -1,43 +1,86 @@ """Support for Azure DevOps.""" from __future__ import annotations +from dataclasses import dataclass +from datetime import timedelta import logging +from typing import Final +from aioazuredevops.builds import DevOpsBuild from aioazuredevops.client import DevOpsClient +from aioazuredevops.core import DevOpsProject import aiohttp from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) -from .const import CONF_ORG, CONF_PAT, CONF_PROJECT, DATA_AZURE_DEVOPS_CLIENT, DOMAIN +from .const import CONF_ORG, CONF_PAT, CONF_PROJECT, DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] + +BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" + + +@dataclass +class AzureDevOpsEntityDescription(EntityDescription): + """Class describing Azure DevOps entities.""" + + organization: str = "" + project: DevOpsProject = None async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Azure DevOps from a config entry.""" client = DevOpsClient() - try: - if entry.data[CONF_PAT] is not None: - await client.authorize(entry.data[CONF_PAT], entry.data[CONF_ORG]) - if not client.authorized: - raise ConfigEntryAuthFailed( - "Could not authorize with Azure DevOps. You may need to update your token" - ) - await client.get_project(entry.data[CONF_ORG], entry.data[CONF_PROJECT]) - except aiohttp.ClientError as exception: - _LOGGER.warning(exception) - raise ConfigEntryNotReady from exception + if entry.data.get(CONF_PAT) is not None: + await client.authorize(entry.data[CONF_PAT], entry.data[CONF_ORG]) + if not client.authorized: + raise ConfigEntryAuthFailed( + "Could not authorize with Azure DevOps. You will need to update your token" + ) - instance_key = f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}" - hass.data.setdefault(instance_key, {})[DATA_AZURE_DEVOPS_CLIENT] = client + project = await client.get_project( + entry.data[CONF_ORG], + entry.data[CONF_PROJECT], + ) + + async def async_update_data() -> list[DevOpsBuild]: + """Fetch data from Azure DevOps.""" + + try: + return await client.get_builds( + entry.data[CONF_ORG], + entry.data[CONF_PROJECT], + BUILDS_QUERY, + ) + except (aiohttp.ClientError, aiohttp.ClientError) as exception: + raise UpdateFailed from exception + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"{DOMAIN}_coordinator", + update_method=async_update_data, + update_interval=timedelta(seconds=300), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator, project - # Setup components hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -45,36 +88,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Azure DevOps config entry.""" - del hass.data[f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}"] - - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + return unload_ok -class AzureDevOpsEntity(Entity): +class AzureDevOpsEntity(CoordinatorEntity): """Defines a base Azure DevOps entity.""" - def __init__(self, organization: str, project: str, name: str, icon: str) -> None: + coordinator: DataUpdateCoordinator[list[DevOpsBuild]] + entity_description: AzureDevOpsEntityDescription + + def __init__( + self, + coordinator: DataUpdateCoordinator[list[DevOpsBuild]], + entity_description: AzureDevOpsEntityDescription, + ) -> None: """Initialize the Azure DevOps entity.""" - self._attr_name = name - self._attr_icon = icon - self.organization = organization - self.project = project - - async def async_update(self) -> None: - """Update Azure DevOps entity.""" - if await self._azure_devops_update(): - self._attr_available = True - else: - if self._attr_available: - _LOGGER.debug( - "An error occurred while updating Azure DevOps sensor", - exc_info=True, - ) - self._attr_available = False - - async def _azure_devops_update(self) -> bool: - """Update Azure DevOps entity.""" - raise NotImplementedError() + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id: str = "_".join( + [entity_description.organization, entity_description.key] + ) + self._organization: str = entity_description.organization + self._project_name: str = entity_description.project.name class AzureDevOpsDeviceEntity(AzureDevOpsEntity): @@ -84,8 +122,8 @@ class AzureDevOpsDeviceEntity(AzureDevOpsEntity): def device_info(self) -> DeviceInfo: """Return device information about this Azure DevOps instance.""" return DeviceInfo( - entry_type="service", - identifiers={(DOMAIN, self.organization, self.project)}, # type: ignore - manufacturer=self.organization, - name=self.project, + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._organization, self._project_name)}, # type: ignore + manufacturer=self._organization, + name=self._project_name, ) diff --git a/homeassistant/components/azure_devops/const.py b/homeassistant/components/azure_devops/const.py index 40610ba7baa..adaf5ebe767 100644 --- a/homeassistant/components/azure_devops/const.py +++ b/homeassistant/components/azure_devops/const.py @@ -1,11 +1,6 @@ """Constants for the Azure DevOps integration.""" DOMAIN = "azure_devops" -DATA_AZURE_DEVOPS_CLIENT = "azure_devops_client" -DATA_ORG = "organization" -DATA_PROJECT = "project" -DATA_PAT = "personal_access_token" - CONF_ORG = "organization" CONF_PROJECT = "project" CONF_PAT = "personal_access_token" diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py index 67d472abc1e..d249e8a8088 100644 --- a/homeassistant/components/azure_devops/sensor.py +++ b/homeassistant/components/azure_devops/sensor.py @@ -1,126 +1,95 @@ """Support for Azure DevOps sensors.""" from __future__ import annotations -from datetime import timedelta -import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any from aioazuredevops.builds import DevOpsBuild -from aioazuredevops.client import DevOpsClient -import aiohttp -from homeassistant.components.azure_devops import AzureDevOpsDeviceEntity -from homeassistant.components.azure_devops.const import ( - CONF_ORG, - CONF_PROJECT, - DATA_AZURE_DEVOPS_CLIENT, - DATA_ORG, - DATA_PROJECT, - DOMAIN, +from homeassistant.components.azure_devops import ( + AzureDevOpsDeviceEntity, + AzureDevOpsEntityDescription, ) -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.azure_devops.const import CONF_ORG, DOMAIN +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType -_LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=300) -PARALLEL_UPDATES = 4 +@dataclass +class AzureDevOpsSensorEntityDescriptionMixin: + """Mixin class for required Azure DevOps sensor description keys.""" -BUILDS_QUERY = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" + build_key: int + + +@dataclass +class AzureDevOpsSensorEntityDescription( + AzureDevOpsEntityDescription, + SensorEntityDescription, + AzureDevOpsSensorEntityDescriptionMixin, +): + """Class describing Azure DevOps sensor entities.""" + + attrs: Callable[[DevOpsBuild], Any] = round + value: Callable[[DevOpsBuild], StateType] = round async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Azure DevOps sensor based on a config entry.""" - instance_key = f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}" - client = hass.data[instance_key][DATA_AZURE_DEVOPS_CLIENT] - organization = entry.data[DATA_ORG] - project = entry.data[DATA_PROJECT] - sensors = [] + coordinator, project = hass.data[DOMAIN][entry.entry_id] - try: - builds: list[DevOpsBuild] = await client.get_builds( - organization, project, BUILDS_QUERY - ) - except aiohttp.ClientError as exception: - _LOGGER.warning(exception) - raise PlatformNotReady from exception - - for build in builds: - sensors.append( - AzureDevOpsLatestBuildSensor(client, organization, project, build) + sensors = [ + AzureDevOpsSensor( + coordinator, + AzureDevOpsSensorEntityDescription( + key=f"{build.project.id}_{build.definition.id}_latest_build", + name=f"{build.project.name} {build.definition.name} Latest Build", + icon="mdi:pipe", + attrs=lambda build: { + "definition_id": build.definition.id, + "definition_name": build.definition.name, + "id": build.id, + "reason": build.reason, + "result": build.result, + "source_branch": build.source_branch, + "source_version": build.source_version, + "status": build.status, + "url": build.links.web, + "queue_time": build.queue_time, + "start_time": build.start_time, + "finish_time": build.finish_time, + }, + build_key=key, + organization=entry.data[CONF_ORG], + project=project, + value=lambda build: build.build_number, + ), ) + for key, build in enumerate(coordinator.data) + ] async_add_entities(sensors, True) class AzureDevOpsSensor(AzureDevOpsDeviceEntity, SensorEntity): - """Defines a Azure DevOps sensor.""" + """Define a Azure DevOps sensor.""" - def __init__( - self, - client: DevOpsClient, - organization: str, - project: str, - key: str, - name: str, - icon: str, - measurement: str = "", - unit_of_measurement: str = "", - ) -> None: - """Initialize Azure DevOps sensor.""" - self._attr_native_unit_of_measurement = unit_of_measurement - self.client = client - self.organization = organization - self.project = project - self._attr_unique_id = "_".join([organization, key]) + entity_description: AzureDevOpsSensorEntityDescription - super().__init__(organization, project, name, icon) + @property + def native_value(self) -> StateType: + """Return the state.""" + build: DevOpsBuild = self.coordinator.data[self.entity_description.build_key] + return self.entity_description.value(build) - -class AzureDevOpsLatestBuildSensor(AzureDevOpsSensor): - """Defines a Azure DevOps card count sensor.""" - - def __init__( - self, client: DevOpsClient, organization: str, project: str, build: DevOpsBuild - ) -> None: - """Initialize Azure DevOps sensor.""" - self.build: DevOpsBuild = build - super().__init__( - client, - organization, - project, - f"{build.project.id}_{build.definition.id}_latest_build", - f"{build.project.name} {build.definition.name} Latest Build", - "mdi:pipe", - ) - - async def _azure_devops_update(self) -> bool: - """Update Azure DevOps entity.""" - try: - build: DevOpsBuild = await self.client.get_build( - self.organization, self.project, self.build.id - ) - except aiohttp.ClientError as exception: - _LOGGER.warning(exception) - self._attr_available = False - return False - self._attr_native_value = build.build_number - self._attr_extra_state_attributes = { - "definition_id": build.definition.id, - "definition_name": build.definition.name, - "id": build.id, - "reason": build.reason, - "result": build.result, - "source_branch": build.source_branch, - "source_version": build.source_version, - "status": build.status, - "url": build.links.web, - "queue_time": build.queue_time, - "start_time": build.start_time, - "finish_time": build.finish_time, - } - self._attr_available = True - return True + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the entity.""" + build: DevOpsBuild = self.coordinator.data[self.entity_description.build_key] + return self.entity_description.attrs(build) diff --git a/homeassistant/components/azure_devops/translations/bg.json b/homeassistant/components/azure_devops/translations/bg.json index d9f03d82592..60c6d07d013 100644 --- a/homeassistant/components/azure_devops/translations/bg.json +++ b/homeassistant/components/azure_devops/translations/bg.json @@ -1,8 +1,20 @@ { "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, "error": { "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \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": "{project_url}", + "step": { + "user": { + "data": { + "organization": "\u041e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044f" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/ja.json b/homeassistant/components/azure_devops/translations/ja.json new file mode 100644 index 00000000000..659caaa9317 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/ja.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "project_error": "\u30d7\u30ed\u30b8\u30a7\u30af\u30c8\u60c5\u5831\u3092\u53d6\u5f97\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002" + }, + "flow_title": "{project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "\u30d1\u30fc\u30bd\u30ca\u30eb \u30a2\u30af\u30bb\u30b9 \u30c8\u30fc\u30af\u30f3(PAT)" + }, + "description": "{project_url} \u306e\u8a8d\u8a3c\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u73fe\u5728\u306e\u8a8d\u8a3c\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "organization": "\u7d44\u7e54", + "personal_access_token": "\u30d1\u30fc\u30bd\u30ca\u30eb \u30a2\u30af\u30bb\u30b9 \u30c8\u30fc\u30af\u30f3(PAT)", + "project": "\u30d7\u30ed\u30b8\u30a7\u30af\u30c8" + }, + "description": "\u30d7\u30ed\u30b8\u30a7\u30af\u30c8\u306b\u30a2\u30af\u30bb\u30b9\u3059\u308b\u305f\u3081\u306bAzureDevOps\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u3002\u30d1\u30fc\u30bd\u30ca\u30eb\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3\u306f\u3001\u30d7\u30e9\u30a4\u30d9\u30fc\u30c8\u30d7\u30ed\u30b8\u30a7\u30af\u30c8\u306e\u5834\u5408\u306e\u307f\u5fc5\u8981\u3067\u3059\u3002", + "title": "Azure DevOps\u30d7\u30ed\u30b8\u30a7\u30af\u30c8\u306e\u8ffd\u52a0" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/ru.json b/homeassistant/components/azure_devops/translations/ru.json index 5e629b6d558..5fe2aa58b5c 100644 --- a/homeassistant/components/azure_devops/translations/ru.json +++ b/homeassistant/components/azure_devops/translations/ru.json @@ -24,7 +24,7 @@ "personal_access_token": "\u041f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 (PAT)", "project": "\u041f\u0440\u043e\u0435\u043a\u0442" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 \u043f\u0440\u043e\u0435\u043a\u0442\u043e\u043c Azure DevOps. \u041f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u0447\u0430\u0441\u0442\u043d\u044b\u0445 \u043f\u0440\u043e\u0435\u043a\u0442\u043e\u0432.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 \u043f\u0440\u043e\u0435\u043a\u0442\u043e\u043c Azure DevOps. \u041f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u0447\u0430\u0441\u0442\u043d\u044b\u0445 \u043f\u0440\u043e\u0435\u043a\u0442\u043e\u0432.", "title": "Azure DevOps" } } diff --git a/homeassistant/components/azure_devops/translations/tr.json b/homeassistant/components/azure_devops/translations/tr.json index 11a15956f63..407d0aafdbb 100644 --- a/homeassistant/components/azure_devops/translations/tr.json +++ b/homeassistant/components/azure_devops/translations/tr.json @@ -9,9 +9,13 @@ "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "project_error": "Proje bilgileri al\u0131namad\u0131." }, - "flow_title": "Azure DevOps: {project_url}", + "flow_title": "{project_url}", "step": { "reauth": { + "data": { + "personal_access_token": "Ki\u015fisel Eri\u015fim Anahtar\u0131 (PAT)" + }, + "description": "{project_url} i\u00e7in kimlik do\u011frulama ba\u015far\u0131s\u0131z oldu. L\u00fctfen mevcut kimlik bilgilerinizi girin.", "title": "Yeniden kimlik do\u011frulama" }, "user": { diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py new file mode 100644 index 00000000000..0922218aa5c --- /dev/null +++ b/homeassistant/components/balboa/__init__.py @@ -0,0 +1,102 @@ +"""The Balboa Spa Client integration.""" +import asyncio +from datetime import timedelta +import time + +from pybalboa import BalboaSpaWifi + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +import homeassistant.util.dt as dt_util + +from .const import ( + _LOGGER, + CONF_SYNC_TIME, + DEFAULT_SYNC_TIME, + DOMAIN, + PLATFORMS, + SIGNAL_UPDATE, +) + +SYNC_TIME_INTERVAL = timedelta(days=1) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Balboa Spa from a config entry.""" + host = entry.data[CONF_HOST] + + _LOGGER.debug("Attempting to connect to %s", host) + spa = BalboaSpaWifi(host) + + connected = await spa.connect() + if not connected: + _LOGGER.error("Failed to connect to spa at %s", host) + raise ConfigEntryNotReady + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = spa + + # send config requests, and then listen until we are configured. + await spa.send_mod_ident_req() + await spa.send_panel_req(0, 1) + + async def _async_balboa_update_cb(): + """Primary update callback called from pybalboa.""" + _LOGGER.debug("Primary update callback triggered") + async_dispatcher_send(hass, SIGNAL_UPDATE.format(entry.entry_id)) + + # set the callback so we know we have new data + spa.new_data_cb = _async_balboa_update_cb + + _LOGGER.debug("Starting listener and monitor tasks") + hass.loop.create_task(spa.listen()) + await spa.spa_configured() + asyncio.create_task(spa.check_connection_status()) + + # At this point we have a configured spa. + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + # call update_listener on startup and for options change as well. + await async_setup_time_sync(hass, entry) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + _LOGGER.debug("Disconnecting from spa") + spa = hass.data[DOMAIN][entry.entry_id] + await spa.disconnect() + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_setup_time_sync(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Set up the time sync.""" + if not entry.options.get(CONF_SYNC_TIME, DEFAULT_SYNC_TIME): + return + + _LOGGER.debug("Setting up daily time sync") + spa = hass.data[DOMAIN][entry.entry_id] + + async def sync_time(): + _LOGGER.debug("Syncing time with Home Assistant") + await spa.set_time(time.strptime(str(dt_util.now()), "%Y-%m-%d %H:%M:%S.%f%z")) + + await sync_time() + entry.async_on_unload( + async_track_time_interval(hass, sync_time, SYNC_TIME_INTERVAL) + ) diff --git a/homeassistant/components/balboa/binary_sensor.py b/homeassistant/components/balboa/binary_sensor.py new file mode 100644 index 00000000000..133ed2da9f4 --- /dev/null +++ b/homeassistant/components/balboa/binary_sensor.py @@ -0,0 +1,61 @@ +"""Support for Balboa Spa binary sensors.""" +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOVING, + BinarySensorEntity, +) + +from .const import CIRC_PUMP, DOMAIN, FILTER +from .entity import BalboaEntity + +FILTER_STATES = [ + [False, False], # self.FILTER_OFF + [True, False], # self.FILTER_1 + [False, True], # self.FILTER_2 + [True, True], # self.FILTER_1_2 +] + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the spa's binary sensors.""" + spa = hass.data[DOMAIN][entry.entry_id] + entities = [ + BalboaSpaFilter(hass, entry, spa, FILTER, index) for index in range(1, 3) + ] + if spa.have_circ_pump(): + entities.append(BalboaSpaCircPump(hass, entry, spa, CIRC_PUMP)) + + async_add_entities(entities) + + +class BalboaSpaBinarySensor(BalboaEntity, BinarySensorEntity): + """Representation of a Balboa Spa binary sensor entity.""" + + _attr_device_class = DEVICE_CLASS_MOVING + + +class BalboaSpaCircPump(BalboaSpaBinarySensor): + """Representation of a Balboa Spa circulation pump.""" + + @property + def is_on(self) -> bool: + """Return true if the filter is on.""" + return self._client.get_circ_pump() + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return "mdi:water-pump" if self.is_on else "mdi:water-pump-off" + + +class BalboaSpaFilter(BalboaSpaBinarySensor): + """Representation of a Balboa Spa Filter.""" + + @property + def is_on(self) -> bool: + """Return true if the filter is on.""" + return FILTER_STATES[self._client.get_filtermode()][self._num - 1] + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return "mdi:sync" if self.is_on else "mdi:sync-off" diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py new file mode 100644 index 00000000000..567c65d6388 --- /dev/null +++ b/homeassistant/components/balboa/climate.py @@ -0,0 +1,161 @@ +"""Support for Balboa Spa Wifi adaptor.""" +from __future__ import annotations + +import asyncio + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_HALVES, + PRECISION_WHOLE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.exceptions import HomeAssistantError + +from .const import CLIMATE, CLIMATE_SUPPORTED_FANSTATES, CLIMATE_SUPPORTED_MODES, DOMAIN +from .entity import BalboaEntity + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the spa climate device.""" + async_add_entities( + [ + BalboaSpaClimate( + hass, + entry, + hass.data[DOMAIN][entry.entry_id], + CLIMATE, + ) + ], + ) + + +class BalboaSpaClimate(BalboaEntity, ClimateEntity): + """Representation of a Balboa Spa Climate device.""" + + _attr_icon = "mdi:hot-tub" + _attr_fan_modes = CLIMATE_SUPPORTED_FANSTATES + _attr_hvac_modes = CLIMATE_SUPPORTED_MODES + + def __init__(self, hass, entry, client, devtype, num=None): + """Initialize the climate entity.""" + super().__init__(hass, entry, client, devtype, num) + self._balboa_to_ha_blower_map = { + self._client.BLOWER_OFF: FAN_OFF, + self._client.BLOWER_LOW: FAN_LOW, + self._client.BLOWER_MEDIUM: FAN_MEDIUM, + self._client.BLOWER_HIGH: FAN_HIGH, + } + self._ha_to_balboa_blower_map = { + value: key for key, value in self._balboa_to_ha_blower_map.items() + } + self._balboa_to_ha_heatmode_map = { + self._client.HEATMODE_READY: HVAC_MODE_HEAT, + self._client.HEATMODE_RNR: HVAC_MODE_AUTO, + self._client.HEATMODE_REST: HVAC_MODE_OFF, + } + self._ha_heatmode_to_balboa_map = { + value: key for key, value in self._balboa_to_ha_heatmode_map.items() + } + scale = self._client.get_tempscale() + self._attr_preset_modes = self._client.get_heatmode_stringlist() + self._attr_supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE + if self._client.have_blower(): + self._attr_supported_features |= SUPPORT_FAN_MODE + self._attr_min_temp = self._client.tmin[self._client.TEMPRANGE_LOW][scale] + self._attr_max_temp = self._client.tmax[self._client.TEMPRANGE_HIGH][scale] + self._attr_temperature_unit = TEMP_FAHRENHEIT + self._attr_precision = PRECISION_WHOLE + if self._client.get_tempscale() == self._client.TSCALE_C: + self._attr_temperature_unit = TEMP_CELSIUS + self._attr_precision = PRECISION_HALVES + + @property + def hvac_mode(self) -> str: + """Return the current HVAC mode.""" + mode = self._client.get_heatmode() + return self._balboa_to_ha_heatmode_map[mode] + + @property + def hvac_action(self) -> str: + """Return the current operation mode.""" + state = self._client.get_heatstate() + if state >= self._client.ON: + return CURRENT_HVAC_HEAT + return CURRENT_HVAC_IDLE + + @property + def fan_mode(self) -> str: + """Return the current fan mode.""" + fanmode = self._client.get_blower() + return self._balboa_to_ha_blower_map.get(fanmode, FAN_OFF) + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._client.get_curtemp() + + @property + def target_temperature(self): + """Return the target temperature we try to reach.""" + return self._client.get_settemp() + + @property + def preset_mode(self): + """Return current preset mode.""" + return self._client.get_heatmode(True) + + async def async_set_temperature(self, **kwargs): + """Set a new target temperature.""" + scale = self._client.get_tempscale() + newtemp = kwargs[ATTR_TEMPERATURE] + if newtemp > self._client.tmax[self._client.TEMPRANGE_LOW][scale]: + await self._client.change_temprange(self._client.TEMPRANGE_HIGH) + await asyncio.sleep(1) + if newtemp < self._client.tmin[self._client.TEMPRANGE_HIGH][scale]: + await self._client.change_temprange(self._client.TEMPRANGE_LOW) + await asyncio.sleep(1) + await self._client.send_temp_change(newtemp) + + async def async_set_preset_mode(self, preset_mode) -> None: + """Set new preset mode.""" + modelist = self._client.get_heatmode_stringlist() + self._async_validate_mode_or_raise(preset_mode) + if preset_mode not in modelist: + raise HomeAssistantError(f"{preset_mode} is not a valid preset mode") + await self._client.change_heatmode(modelist.index(preset_mode)) + + async def async_set_fan_mode(self, fan_mode): + """Set new fan mode.""" + await self._client.change_blower(self._ha_to_balboa_blower_map[fan_mode]) + + def _async_validate_mode_or_raise(self, mode): + """Check that the mode can be set.""" + if mode == self._client.HEATMODE_RNR: + raise HomeAssistantError(f"{mode} can only be reported but not set") + + async def async_set_hvac_mode(self, hvac_mode): + """Set new target hvac mode. + + OFF = Rest + AUTO = Ready in Rest (can't be set, only reported) + HEAT = Ready + """ + mode = self._ha_heatmode_to_balboa_map[hvac_mode] + self._async_validate_mode_or_raise(mode) + await self._client.change_heatmode(self._ha_heatmode_to_balboa_map[hvac_mode]) diff --git a/homeassistant/components/balboa/config_flow.py b/homeassistant/components/balboa/config_flow.py new file mode 100644 index 00000000000..1c91376d76e --- /dev/null +++ b/homeassistant/components/balboa/config_flow.py @@ -0,0 +1,99 @@ +"""Config flow for Balboa Spa Client integration.""" +from pybalboa import BalboaSpaWifi +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_HOST +from homeassistant.core import callback +from homeassistant.helpers.device_registry import format_mac + +from .const import _LOGGER, CONF_SYNC_TIME, DOMAIN + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect.""" + + _LOGGER.debug("Attempting to connect to %s", data[CONF_HOST]) + spa = BalboaSpaWifi(data[CONF_HOST]) + connected = await spa.connect() + _LOGGER.debug("Got connected = %d", connected) + if not connected: + raise CannotConnect + + # send config requests, and then listen until we are configured. + await spa.send_mod_ident_req() + await spa.send_panel_req(0, 1) + + hass.loop.create_task(spa.listen()) + + await spa.spa_configured() + + macaddr = format_mac(spa.get_macaddr()) + model = spa.get_model_name() + await spa.disconnect() + + return {"title": model, "formatted_mac": macaddr} + + +class BalboaSpaClientFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Balboa Spa Client config flow.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return BalboaSpaClientOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info["formatted_mac"]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class BalboaSpaClientOptionsFlowHandler(config_entries.OptionsFlow): + """Handle Balboa Spa Client options.""" + + def __init__(self, config_entry): + """Initialize Balboa Spa Client options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage Balboa Spa Client options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_SYNC_TIME, + default=self.config_entry.options.get(CONF_SYNC_TIME, False), + ): bool, + } + ), + ) diff --git a/homeassistant/components/balboa/const.py b/homeassistant/components/balboa/const.py new file mode 100644 index 00000000000..f5b28804952 --- /dev/null +++ b/homeassistant/components/balboa/const.py @@ -0,0 +1,36 @@ +"""Constants for the Balboa Spa Client integration.""" +import logging + +from homeassistant.components.climate.const import ( + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, +) +from homeassistant.components.fan import SPEED_HIGH, SPEED_LOW, SPEED_OFF +from homeassistant.const import Platform + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "balboa" + +CLIMATE_SUPPORTED_FANSTATES = [FAN_OFF, FAN_LOW, FAN_MEDIUM, FAN_HIGH] +CLIMATE_SUPPORTED_MODES = [HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF] +CONF_SYNC_TIME = "sync_time" +DEFAULT_SYNC_TIME = False +FAN_SUPPORTED_SPEEDS = [SPEED_OFF, SPEED_LOW, SPEED_HIGH] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE] + +AUX = "Aux" +CIRC_PUMP = "Circ Pump" +CLIMATE = "Climate" +FILTER = "Filter" +LIGHT = "Light" +MISTER = "Mister" +PUMP = "Pump" +TEMP_RANGE = "Temp Range" + +SIGNAL_UPDATE = "balboa_update_{}" diff --git a/homeassistant/components/balboa/entity.py b/homeassistant/components/balboa/entity.py new file mode 100644 index 00000000000..016beadac5c --- /dev/null +++ b/homeassistant/components/balboa/entity.py @@ -0,0 +1,57 @@ +"""Base class for Balboa Spa Client integration.""" +import time + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import SIGNAL_UPDATE + + +class BalboaEntity(Entity): + """Abstract class for all Balboa platforms. + + Once you connect to the spa's port, it continuously sends data (at a rate + of about 5 per second!). The API updates the internal states of things + from this stream, and all we have to do is read the values out of the + accessors. + """ + + _attr_should_poll = False + + def __init__(self, hass, entry, client, devtype, num=None): + """Initialize the spa entity.""" + self._client = client + self._device_name = self._client.get_model_name() + self._type = devtype + self._num = num + self._entry = entry + self._attr_unique_id = f'{self._device_name}-{self._type}{self._num or ""}-{self._client.get_macaddr().replace(":","")[-6:]}' + self._attr_name = f'{self._device_name}: {self._type}{self._num or ""}' + self._attr_device_info = DeviceInfo( + name=self._device_name, + manufacturer="Balboa Water Group", + model=self._client.get_model_name(), + sw_version=self._client.get_ssid(), + connections={(CONNECTION_NETWORK_MAC, self._client.get_macaddr())}, + ) + + async def async_added_to_hass(self) -> None: + """Set up a listener for the entity.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_UPDATE.format(self._entry.entry_id), + self.async_write_ha_state, + ) + ) + + @property + def assumed_state(self) -> bool: + """Return whether the state is based on actual reading from device.""" + return (self._client.lastupd + 5 * 60) < time.time() + + @property + def available(self) -> bool: + """Return whether the entity is available or not.""" + return self._client.connected diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json new file mode 100644 index 00000000000..aa52bee230d --- /dev/null +++ b/homeassistant/components/balboa/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "balboa", + "name": "Balboa Spa Client", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/balboa", + "requirements": [ + "pybalboa==0.13" + ], + "codeowners": [ + "@garbled1" + ], + "iot_class": "local_push" +} diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json new file mode 100644 index 00000000000..68bd4ddef7b --- /dev/null +++ b/homeassistant/components/balboa/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to the Balboa Wi-Fi device", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Keep your Balboa Spa Client's time synchronized with Home Assistant" + } + } + } + } +} diff --git a/homeassistant/components/balboa/translations/bg.json b/homeassistant/components/balboa/translations/bg.json new file mode 100644 index 00000000000..cbf1e2ae7c9 --- /dev/null +++ b/homeassistant/components/balboa/translations/bg.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/ca.json b/homeassistant/components/balboa/translations/ca.json new file mode 100644 index 00000000000..139f706c878 --- /dev/null +++ b/homeassistant/components/balboa/translations/ca.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3" + }, + "title": "Connexi\u00f3 amb dispositiu Wi-Fi Balboa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Mantingues l'hora del client Balboa Spa sincronitzada amb Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/de.json b/homeassistant/components/balboa/translations/de.json new file mode 100644 index 00000000000..7b5961040e7 --- /dev/null +++ b/homeassistant/components/balboa/translations/de.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Verbinde dich mit dem Balboa Wi-Fi Ger\u00e4t" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Synchronisiere die Zeit deines Balboa Spa-Clients mit Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/en.json b/homeassistant/components/balboa/translations/en.json new file mode 100644 index 00000000000..bad5167fc5e --- /dev/null +++ b/homeassistant/components/balboa/translations/en.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Connect to the Balboa Wi-Fi device" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Keep your Balboa Spa Client's time synchronized with Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/et.json b/homeassistant/components/balboa/translations/et.json new file mode 100644 index 00000000000..264855023f9 --- /dev/null +++ b/homeassistant/components/balboa/translations/et.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "\u00dchendu Balboa Wi-Fi seadmega" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Hoia oma Balboa Spa kliendi aeg Home Assistantiga s\u00fcnkroonis" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/he.json b/homeassistant/components/balboa/translations/he.json new file mode 100644 index 00000000000..1699e0f8e19 --- /dev/null +++ b/homeassistant/components/balboa/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", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/hu.json b/homeassistant/components/balboa/translations/hu.json new file mode 100644 index 00000000000..d9ae0e9c403 --- /dev/null +++ b/homeassistant/components/balboa/translations/hu.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "host": "C\u00edm" + }, + "title": "Csatlakoz\u00e1s a Balboa Wi-Fi eszk\u00f6zh\u00f6z" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Balboa Spa kliens \u00f3r\u00e1j\u00e1nak szinkroniz\u00e1l\u00e1sa Home Assistanthoz" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/id.json b/homeassistant/components/balboa/translations/id.json new file mode 100644 index 00000000000..8f8cb97780a --- /dev/null +++ b/homeassistant/components/balboa/translations/id.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Hubungkan ke perangkat Wi-Fi Balboa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Selalu sinkronkan waktu Balboa Spa Client Anda dengan Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/ja.json b/homeassistant/components/balboa/translations/ja.json new file mode 100644 index 00000000000..f6e799cd6af --- /dev/null +++ b/homeassistant/components/balboa/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "title": "Balboa Wi-Fi device\u306b\u63a5\u7d9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Balboa Spa Client's\u306e\u6642\u9593\u3092\u3001Home Assistant\u3068\u540c\u671f\u3055\u305b\u307e\u3059" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/nl.json b/homeassistant/components/balboa/translations/nl.json new file mode 100644 index 00000000000..04dc0decd0d --- /dev/null +++ b/homeassistant/components/balboa/translations/nl.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Maak verbinding met het Balboa Wi-Fi-apparaat" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Houd de tijd van uw Balboa Spa Client gesynchroniseerd met Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/no.json b/homeassistant/components/balboa/translations/no.json new file mode 100644 index 00000000000..46f2c9284b8 --- /dev/null +++ b/homeassistant/components/balboa/translations/no.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "host": "Vert" + }, + "title": "Koble til Balboa Wi-Fi-enheten" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Hold Balboa Spa-klientens tid synkronisert med Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/pl.json b/homeassistant/components/balboa/translations/pl.json new file mode 100644 index 00000000000..cc5429ad077 --- /dev/null +++ b/homeassistant/components/balboa/translations/pl.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + }, + "title": "Po\u0142\u0105cz si\u0119 z urz\u0105dzeniem Balboa Wi-Fi" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Synchronizacja czasu klienta Balboa Spa z Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/ru.json b/homeassistant/components/balboa/translations/ru.json new file mode 100644 index 00000000000..07732f3796e --- /dev/null +++ b/homeassistant/components/balboa/translations/ru.json @@ -0,0 +1,28 @@ +{ + "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." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "\u0421\u0438\u043d\u0445\u0440\u043e\u043d\u0438\u0437\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0432\u0440\u0435\u043c\u044f Balboa Spa \u0441 Home Assistant" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/sl.json b/homeassistant/components/balboa/translations/sl.json new file mode 100644 index 00000000000..0eec93b817d --- /dev/null +++ b/homeassistant/components/balboa/translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "host": "Gostitelj" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/tr.json b/homeassistant/components/balboa/translations/tr.json new file mode 100644 index 00000000000..f83ec51d377 --- /dev/null +++ b/homeassistant/components/balboa/translations/tr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "host": "Ana bilgisayar" + }, + "title": "Balboa Wi-Fi cihaz\u0131na ba\u011flan\u0131n" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "Balboa Spa \u0130stemcisi'nin zaman\u0131n\u0131 Home Assistant ile senkronize tutun" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/zh-Hant.json b/homeassistant/components/balboa/translations/zh-Hant.json new file mode 100644 index 00000000000..78aa2e7c0b9 --- /dev/null +++ b/homeassistant/components/balboa/translations/zh-Hant.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "title": "\u9023\u7dda\u81f3 Balboa Wi-Fi \u88dd\u7f6e" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "sync_time": "\u5c07 Balboa Spa \u5ba2\u6236\u7aef\u6642\u9593\u8207 Home Assistant \u4fdd\u6301\u540c\u6b65" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bbox/sensor.py b/homeassistant/components/bbox/sensor.py index 53a7e8720b1..fc9c8982733 100644 --- a/homeassistant/components/bbox/sensor.py +++ b/homeassistant/components/bbox/sensor.py @@ -131,10 +131,9 @@ class BboxUptimeSensor(SensorEntity): def update(self): """Get the latest data from Bbox and update the state.""" self.bbox_data.update() - uptime = utcnow() - timedelta( + self._attr_native_value = utcnow() - timedelta( seconds=self.bbox_data.router_infos["device"]["uptime"] ) - self._attr_native_value = uptime.replace(microsecond=0).isoformat() class BboxSensor(SensorEntity): diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index aff7f9a3135..0604d5da586 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -8,6 +8,7 @@ from typing import Any, final import voluptuous as vol +from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -26,118 +27,124 @@ SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" -# On means low, Off means normal -DEVICE_CLASS_BATTERY = "battery" -# On means charging, Off means not charging -DEVICE_CLASS_BATTERY_CHARGING = "battery_charging" +class BinarySensorDeviceClass(StrEnum): + """Device class for binary sensors.""" -# On means cold, Off means normal -DEVICE_CLASS_COLD = "cold" + # On means low, Off means normal + BATTERY = "battery" -# On means connected, Off means disconnected -DEVICE_CLASS_CONNECTIVITY = "connectivity" + # On means charging, Off means not charging + BATTERY_CHARGING = "battery_charging" -# On means open, Off means closed -DEVICE_CLASS_DOOR = "door" + # On means cold, Off means normal + COLD = "cold" -# On means open, Off means closed -DEVICE_CLASS_GARAGE_DOOR = "garage_door" + # On means connected, Off means disconnected + CONNECTIVITY = "connectivity" -# On means gas detected, Off means no gas (clear) -DEVICE_CLASS_GAS = "gas" + # On means open, Off means closed + DOOR = "door" -# On means hot, Off means normal -DEVICE_CLASS_HEAT = "heat" + # On means open, Off means closed + GARAGE_DOOR = "garage_door" -# On means light detected, Off means no light -DEVICE_CLASS_LIGHT = "light" + # On means gas detected, Off means no gas (clear) + GAS = "gas" -# On means open (unlocked), Off means closed (locked) -DEVICE_CLASS_LOCK = "lock" + # On means hot, Off means normal + HEAT = "heat" -# On means wet, Off means dry -DEVICE_CLASS_MOISTURE = "moisture" + # On means light detected, Off means no light + LIGHT = "light" -# On means motion detected, Off means no motion (clear) -DEVICE_CLASS_MOTION = "motion" + # On means open (unlocked), Off means closed (locked) + LOCK = "lock" -# On means moving, Off means not moving (stopped) -DEVICE_CLASS_MOVING = "moving" + # On means wet, Off means dry + MOISTURE = "moisture" -# On means occupied, Off means not occupied (clear) -DEVICE_CLASS_OCCUPANCY = "occupancy" + # On means motion detected, Off means no motion (clear) + MOTION = "motion" -# On means open, Off means closed -DEVICE_CLASS_OPENING = "opening" + # On means moving, Off means not moving (stopped) + MOVING = "moving" -# On means plugged in, Off means unplugged -DEVICE_CLASS_PLUG = "plug" + # On means occupied, Off means not occupied (clear) + OCCUPANCY = "occupancy" -# On means power detected, Off means no power -DEVICE_CLASS_POWER = "power" + # On means open, Off means closed + OPENING = "opening" -# On means home, Off means away -DEVICE_CLASS_PRESENCE = "presence" + # On means plugged in, Off means unplugged + PLUG = "plug" -# On means problem detected, Off means no problem (OK) -DEVICE_CLASS_PROBLEM = "problem" + # On means power detected, Off means no power + POWER = "power" -# On means running, Off means not running -DEVICE_CLASS_RUNNING = "running" + # On means home, Off means away + PRESENCE = "presence" -# On means unsafe, Off means safe -DEVICE_CLASS_SAFETY = "safety" + # On means problem detected, Off means no problem (OK) + PROBLEM = "problem" -# On means smoke detected, Off means no smoke (clear) -DEVICE_CLASS_SMOKE = "smoke" + # On means running, Off means not running + RUNNING = "running" -# On means sound detected, Off means no sound (clear) -DEVICE_CLASS_SOUND = "sound" + # On means unsafe, Off means safe + SAFETY = "safety" -# On means tampering detected, Off means no tampering (clear) -DEVICE_CLASS_TAMPER = "tamper" + # On means smoke detected, Off means no smoke (clear) + SMOKE = "smoke" -# On means update available, Off means up-to-date -DEVICE_CLASS_UPDATE = "update" + # On means sound detected, Off means no sound (clear) + SOUND = "sound" -# On means vibration detected, Off means no vibration -DEVICE_CLASS_VIBRATION = "vibration" + # On means tampering detected, Off means no tampering (clear) + TAMPER = "tamper" -# On means open, Off means closed -DEVICE_CLASS_WINDOW = "window" + # On means update available, Off means up-to-date + UPDATE = "update" -DEVICE_CLASSES = [ - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_BATTERY_CHARGING, - DEVICE_CLASS_COLD, - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_DOOR, - DEVICE_CLASS_GARAGE_DOOR, - DEVICE_CLASS_GAS, - DEVICE_CLASS_HEAT, - DEVICE_CLASS_LIGHT, - DEVICE_CLASS_LOCK, - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_MOVING, - DEVICE_CLASS_OCCUPANCY, - DEVICE_CLASS_OPENING, - DEVICE_CLASS_PLUG, - DEVICE_CLASS_POWER, - DEVICE_CLASS_PRESENCE, - DEVICE_CLASS_PROBLEM, - DEVICE_CLASS_RUNNING, - DEVICE_CLASS_SAFETY, - DEVICE_CLASS_SMOKE, - DEVICE_CLASS_SOUND, - DEVICE_CLASS_TAMPER, - DEVICE_CLASS_UPDATE, - DEVICE_CLASS_VIBRATION, - DEVICE_CLASS_WINDOW, -] + # On means vibration detected, Off means no vibration + VIBRATION = "vibration" -DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) + # On means open, Off means closed + WINDOW = "window" + + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(BinarySensorDeviceClass)) + +# DEVICE_CLASS* below are deprecated as of 2021.12 +# use the BinarySensorDeviceClass enum instead. +DEVICE_CLASSES = [cls.value for cls in BinarySensorDeviceClass] +DEVICE_CLASS_BATTERY = BinarySensorDeviceClass.BATTERY.value +DEVICE_CLASS_BATTERY_CHARGING = BinarySensorDeviceClass.BATTERY_CHARGING.value +DEVICE_CLASS_COLD = BinarySensorDeviceClass.COLD.value +DEVICE_CLASS_CONNECTIVITY = BinarySensorDeviceClass.CONNECTIVITY.value +DEVICE_CLASS_DOOR = BinarySensorDeviceClass.DOOR.value +DEVICE_CLASS_GARAGE_DOOR = BinarySensorDeviceClass.GARAGE_DOOR.value +DEVICE_CLASS_GAS = BinarySensorDeviceClass.GAS.value +DEVICE_CLASS_HEAT = BinarySensorDeviceClass.HEAT.value +DEVICE_CLASS_LIGHT = BinarySensorDeviceClass.LIGHT.value +DEVICE_CLASS_LOCK = BinarySensorDeviceClass.LOCK.value +DEVICE_CLASS_MOISTURE = BinarySensorDeviceClass.MOISTURE.value +DEVICE_CLASS_MOTION = BinarySensorDeviceClass.MOTION.value +DEVICE_CLASS_MOVING = BinarySensorDeviceClass.MOVING.value +DEVICE_CLASS_OCCUPANCY = BinarySensorDeviceClass.OCCUPANCY.value +DEVICE_CLASS_OPENING = BinarySensorDeviceClass.OPENING.value +DEVICE_CLASS_PLUG = BinarySensorDeviceClass.PLUG.value +DEVICE_CLASS_POWER = BinarySensorDeviceClass.POWER.value +DEVICE_CLASS_PRESENCE = BinarySensorDeviceClass.PRESENCE.value +DEVICE_CLASS_PROBLEM = BinarySensorDeviceClass.PROBLEM.value +DEVICE_CLASS_RUNNING = BinarySensorDeviceClass.RUNNING.value +DEVICE_CLASS_SAFETY = BinarySensorDeviceClass.SAFETY.value +DEVICE_CLASS_SMOKE = BinarySensorDeviceClass.SMOKE.value +DEVICE_CLASS_SOUND = BinarySensorDeviceClass.SOUND.value +DEVICE_CLASS_TAMPER = BinarySensorDeviceClass.TAMPER.value +DEVICE_CLASS_UPDATE = BinarySensorDeviceClass.UPDATE.value +DEVICE_CLASS_VIBRATION = BinarySensorDeviceClass.VIBRATION.value +DEVICE_CLASS_WINDOW = BinarySensorDeviceClass.WINDOW.value async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -166,14 +173,26 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class BinarySensorEntityDescription(EntityDescription): """A class that describes binary sensor entities.""" + device_class: BinarySensorDeviceClass | str | None = None + class BinarySensorEntity(Entity): """Represent a binary sensor.""" entity_description: BinarySensorEntityDescription + _attr_device_class: BinarySensorDeviceClass | str | None _attr_is_on: bool | None = None _attr_state: None = None + @property + def device_class(self) -> BinarySensorDeviceClass | str | None: + """Return the class of this entity.""" + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class + return None + @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" diff --git a/homeassistant/components/binary_sensor/device_condition.py b/homeassistant/components/binary_sensor/device_condition.py index 8351234182d..6f1a0ba4f5f 100644 --- a/homeassistant/components/binary_sensor/device_condition.py +++ b/homeassistant/components/binary_sensor/device_condition.py @@ -265,11 +265,9 @@ async def async_get_conditions( @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Evaluate state based on configuration.""" - if config_validation: - config = CONDITION_SCHEMA(config) condition_type = config[CONF_TYPE] if condition_type in IS_ON: stat = "on" @@ -282,6 +280,8 @@ def async_condition_from_config( } if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] + state_config = cv.STATE_CONDITION_SCHEMA(state_config) + state_config = condition.state_validate_config(hass, state_config) return condition.state_from_config(state_config) diff --git a/homeassistant/components/binary_sensor/device_trigger.py b/homeassistant/components/binary_sensor/device_trigger.py index 72cd885d467..0f2c7a836a2 100644 --- a/homeassistant/components/binary_sensor/device_trigger.py +++ b/homeassistant/components/binary_sensor/device_trigger.py @@ -220,7 +220,7 @@ async def async_attach_trigger(hass, config, action, automation_info): if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] - state_config = state_trigger.TRIGGER_SCHEMA(state_config) + state_config = await state_trigger.async_validate_trigger_config(hass, state_config) return await state_trigger.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index eb97b370105..e2167c24f8a 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -90,8 +90,8 @@ "no_smoke": "{entity_name} stopped detecting smoke", "sound": "{entity_name} started detecting sound", "no_sound": "{entity_name} stopped detecting sound", - "is_tampered": "{entity_name} started detecting tampering", - "is_not_tampered": "{entity_name} stopped detecting tampering", + "tampered": "{entity_name} started detecting tampering", + "not_tampered": "{entity_name} stopped detecting tampering", "update": "{entity_name} got an update available", "no_update": "{entity_name} became up-to-date", "vibration": "{entity_name} started detecting vibration", diff --git a/homeassistant/components/binary_sensor/translations/af.json b/homeassistant/components/binary_sensor/translations/af.json index c0988c3aa68..37dab20a22e 100644 --- a/homeassistant/components/binary_sensor/translations/af.json +++ b/homeassistant/components/binary_sensor/translations/af.json @@ -1,4 +1,15 @@ { + "device_class": { + "cold": "hideg", + "gas": "g\u00e1z", + "heat": "h\u0151", + "moisture": "p\u00e1ratartalom", + "motion": "mozg\u00e1s", + "problem": "probl\u00e9ma", + "smoke": "f\u00fcst", + "sound": "hang", + "vibration": "rezg\u00e9s" + }, "state": { "_": { "off": "Af", diff --git a/homeassistant/components/binary_sensor/translations/bg.json b/homeassistant/components/binary_sensor/translations/bg.json index b1b3d766dc4..621625cb457 100644 --- a/homeassistant/components/binary_sensor/translations/bg.json +++ b/homeassistant/components/binary_sensor/translations/bg.json @@ -93,6 +93,17 @@ "vibration": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u0434\u0430 \u0437\u0430\u0441\u0438\u0447\u0430 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u0438" } }, + "device_class": { + "cold": "\u0441\u0442\u0443\u0434", + "gas": "\u0433\u0430\u0437", + "heat": "\u0442\u043e\u043f\u043b\u0438\u043d\u0430", + "moisture": "\u0432\u043b\u0430\u0433\u0430", + "motion": "\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "problem": "\u043f\u0440\u043e\u0431\u043b\u0435\u043c", + "smoke": "\u0434\u0438\u043c", + "sound": "\u0437\u0432\u0443\u043a", + "vibration": "\u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044f" + }, "state": { "_": { "off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d", diff --git a/homeassistant/components/binary_sensor/translations/ca.json b/homeassistant/components/binary_sensor/translations/ca.json index 17c22571c14..2aa89dfc134 100644 --- a/homeassistant/components/binary_sensor/translations/ca.json +++ b/homeassistant/components/binary_sensor/translations/ca.json @@ -31,6 +31,7 @@ "is_not_plugged_in": "{entity_name} est\u00e0 desendollat", "is_not_powered": "{entity_name} no est\u00e0 alimentat", "is_not_present": "{entity_name} no est\u00e0 present", + "is_not_running": "{entity_name} no est\u00e0 funcionant", "is_not_tampered": "{entity_name} no detecta manipulaci\u00f3", "is_not_unsafe": "{entity_name} \u00e9s segur", "is_occupied": "{entity_name} est\u00e0 ocupat", @@ -41,6 +42,7 @@ "is_powered": "{entity_name} est\u00e0 alimentat", "is_present": "{entity_name} est\u00e0 present", "is_problem": "{entity_name} est\u00e0 detectant un problema", + "is_running": "{entity_name} est\u00e0 funcionant", "is_smoke": "{entity_name} est\u00e0 detectant fum", "is_sound": "{entity_name} est\u00e0 detectant so", "is_tampered": "{entity_name} detecta manipulaci\u00f3", @@ -81,6 +83,7 @@ "not_plugged_in": "{entity_name} desendollat", "not_powered": "{entity_name} no est\u00e0 alimentat", "not_present": "{entity_name} no est\u00e0 present", + "not_running": "{entity_name} para de funcionar", "not_unsafe": "{entity_name} es torna segur", "occupied": "{entity_name} s'ocupa", "opened": "{entity_name} s'ha obert", @@ -88,6 +91,7 @@ "powered": "{entity_name} alimentat", "present": "{entity_name} present", "problem": "{entity_name} ha comen\u00e7at a detectar un problema", + "running": "{entity_name} comen\u00e7a a funcionar", "smoke": "{entity_name} ha comen\u00e7at a detectar fum", "sound": "{entity_name} ha comen\u00e7at a detectar so", "turned_off": "{entity_name} apagat", @@ -97,10 +101,23 @@ "vibration": "{entity_name} ha comen\u00e7at a detectar vibraci\u00f3" } }, + "device_class": { + "cold": "fred", + "gas": "gas", + "heat": "calor", + "moisture": "humitat", + "motion": "moviment", + "occupancy": "ocupaci\u00f3", + "power": "pot\u00e8ncia", + "problem": "problema", + "smoke": "fum", + "sound": "so", + "vibration": "vibraci\u00f3" + }, "state": { "_": { - "off": "off", - "on": "on" + "off": "OFF", + "on": "ON" }, "battery": { "off": "Normal", @@ -175,6 +192,7 @@ "on": "Problema" }, "running": { + "off": "No funcionant", "on": "En funcionament" }, "safety": { diff --git a/homeassistant/components/binary_sensor/translations/he.json b/homeassistant/components/binary_sensor/translations/he.json index f6018cfe08a..43b3c4ff246 100644 --- a/homeassistant/components/binary_sensor/translations/he.json +++ b/homeassistant/components/binary_sensor/translations/he.json @@ -31,6 +31,8 @@ "is_not_plugged_in": "{entity_name} \u05de\u05e0\u05d5\u05ea\u05e7", "is_not_powered": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05de\u05d5\u05e4\u05e2\u05dc", "is_not_present": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05e7\u05d9\u05d9\u05dd", + "is_not_running": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05e4\u05d5\u05e2\u05dc", + "is_not_tampered": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05de\u05d6\u05d4\u05d4 \u05d7\u05d1\u05dc\u05d4", "is_not_unsafe": "{entity_name} \u05d1\u05d8\u05d5\u05d7", "is_occupied": "{entity_name} \u05ea\u05e4\u05d5\u05e1", "is_off": "{entity_name} \u05db\u05d1\u05d5\u05d9", @@ -40,8 +42,10 @@ "is_powered": "{entity_name} \u05de\u05d5\u05e4\u05e2\u05dc", "is_present": "{entity_name} \u05e0\u05d5\u05db\u05d7", "is_problem": "{entity_name} \u05de\u05d6\u05d4\u05d4 \u05d1\u05e2\u05d9\u05d4", + "is_running": "{entity_name} \u05e4\u05d5\u05e2\u05dc", "is_smoke": "{entity_name} \u05de\u05d6\u05d4\u05d4 \u05e2\u05e9\u05df", "is_sound": "{entity_name} \u05de\u05d6\u05d4\u05d4 \u05e6\u05dc\u05d9\u05dc", + "is_tampered": "{entity_name} \u05de\u05d6\u05d4\u05d4 \u05d7\u05d1\u05dc\u05d4", "is_unsafe": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05d1\u05d8\u05d5\u05d7", "is_update": "\u05e2\u05d3\u05db\u05d5\u05df \u05d6\u05de\u05d9\u05df \u05e2\u05d1\u05d5\u05e8 {entity_name}", "is_vibration": "{entity_name} \u05de\u05d6\u05d4\u05d4 \u05e8\u05d8\u05d8" @@ -52,6 +56,8 @@ "connected": "{entity_name} \u05de\u05d7\u05d5\u05d1\u05e8", "gas": "{entity_name} \u05d4\u05d7\u05dc \u05dc\u05d6\u05d4\u05d5\u05ea \u05d2\u05d6", "hot": "{entity_name} \u05e0\u05e2\u05e9\u05d4 \u05d7\u05dd", + "is_not_tampered": "{entity_name} \u05d4\u05e4\u05e1\u05d9\u05e7 \u05dc\u05d6\u05d4\u05d5\u05ea \u05d7\u05d1\u05dc\u05d4", + "is_tampered": "{entity_name} \u05d4\u05d7\u05dc \u05dc\u05d6\u05d4\u05d5\u05ea \u05d7\u05d1\u05dc\u05d4", "light": "{entity_name} \u05d4\u05ea\u05d7\u05d9\u05dc \u05dc\u05d6\u05d4\u05d5\u05ea \u05d0\u05d5\u05e8", "locked": "{entity_name} \u05e0\u05e2\u05d5\u05dc", "moist": "{entity_name} \u05d4\u05e4\u05da \u05dc\u05d7", @@ -66,17 +72,18 @@ "no_update": "{entity_name} \u05d4\u05e4\u05da \u05dc\u05de\u05e2\u05d5\u05d3\u05db\u05df", "no_vibration": "{entity_name} \u05d4\u05e4\u05e1\u05d9\u05e7 \u05dc\u05d6\u05d4\u05d5\u05ea \u05e8\u05d8\u05d8", "not_bat_low": "{entity_name} \u05e1\u05d5\u05dc\u05dc\u05d4 \u05e8\u05d2\u05d9\u05dc\u05d4", - "not_cold": "{entity_name} \u05e0\u05d4\u05d9\u05d4 \u05dc\u05d0 \u05e7\u05e8", + "not_cold": "{entity_name} \u05e0\u05e2\u05e9\u05d4 \u05dc\u05d0 \u05e7\u05e8", "not_connected": "{entity_name} \u05de\u05e0\u05d5\u05ea\u05e7", "not_hot": "{entity_name} \u05d4\u05e4\u05da \u05dc\u05d0 \u05d7\u05dd", "not_locked": "{entity_name} \u05dc\u05d0 \u05e0\u05e2\u05d5\u05dc", "not_moist": "{entity_name} \u05d4\u05ea\u05d9\u05d9\u05d1\u05e9", "not_moving": "{entity_name} \u05d4\u05e4\u05e1\u05d9\u05e7 \u05dc\u05d6\u05d5\u05d6", "not_occupied": "{entity_name} \u05dc\u05d0 \u05e0\u05ea\u05e4\u05e1", - "not_opened": "{entity_name} \u05e1\u05d2\u05d5\u05e8", + "not_opened": "{entity_name} \u05e0\u05e1\u05d2\u05e8", "not_plugged_in": "{entity_name} \u05de\u05e0\u05d5\u05ea\u05e7", "not_powered": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05de\u05d5\u05e4\u05e2\u05dc", "not_present": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05e7\u05d9\u05d9\u05dd", + "not_running": "{entity_name} \u05d0\u05d9\u05e0\u05d5 \u05e4\u05d5\u05e2\u05dc \u05e2\u05d5\u05d3", "not_unsafe": "{entity_name} \u05d4\u05e4\u05da \u05dc\u05d1\u05d8\u05d5\u05d7", "occupied": "{entity_name} \u05e0\u05ea\u05e4\u05e1", "opened": "{entity_name} \u05e0\u05e4\u05ea\u05d7", @@ -84,6 +91,7 @@ "powered": "{entity_name} \u05de\u05d5\u05e4\u05e2\u05dc", "present": "{entity_name} \u05e0\u05d5\u05db\u05d7", "problem": "{entity_name} \u05d4\u05d7\u05dc\u05d4 \u05dc\u05d6\u05d4\u05d5\u05ea \u05d1\u05e2\u05d9\u05d4", + "running": "{entity_name} \u05d4\u05d7\u05dc \u05dc\u05e4\u05e2\u05d5\u05dc", "smoke": "{entity_name} \u05d4\u05d7\u05dc \u05dc\u05d6\u05d4\u05d5\u05ea \u05e2\u05e9\u05df", "sound": "{entity_name} \u05d4\u05d7\u05dc \u05dc\u05d6\u05d4\u05d5\u05ea \u05e6\u05dc\u05d9\u05dc", "turned_off": "{entity_name} \u05db\u05d5\u05d1\u05d4", @@ -93,6 +101,19 @@ "vibration": "{entity_name} \u05d4\u05d7\u05dc \u05dc\u05d6\u05d4\u05d5\u05ea \u05e8\u05d8\u05d8" } }, + "device_class": { + "cold": "\u05e7\u05d5\u05e8", + "gas": "\u05d2\u05d6", + "heat": "\u05d7\u05d5\u05dd", + "moisture": "\u05dc\u05d7\u05d5\u05ea", + "motion": "\u05ea\u05e0\u05d5\u05e2\u05d4", + "occupancy": "\u05ea\u05e4\u05d5\u05e1\u05d4", + "power": "\u05db\u05d7", + "problem": "\u05d1\u05e2\u05d9\u05d4", + "smoke": "\u05e2\u05e9\u05df", + "sound": "\u05e7\u05d5\u05dc", + "vibration": "\u05e8\u05d8\u05d8" + }, "state": { "_": { "off": "\u05db\u05d1\u05d5\u05d9", @@ -170,6 +191,10 @@ "off": "\u05ea\u05e7\u05d9\u05df", "on": "\u05d1\u05e2\u05d9\u05d4" }, + "running": { + "off": "\u05dc\u05d0 \u05e4\u05d5\u05e2\u05dc", + "on": "\u05e4\u05d5\u05e2\u05dc" + }, "safety": { "off": "\u05d1\u05d8\u05d5\u05d7", "on": "\u05dc\u05d0 \u05d1\u05d8\u05d5\u05d7" diff --git a/homeassistant/components/binary_sensor/translations/hu.json b/homeassistant/components/binary_sensor/translations/hu.json index 9c95cc67d93..90fdcce7575 100644 --- a/homeassistant/components/binary_sensor/translations/hu.json +++ b/homeassistant/components/binary_sensor/translations/hu.json @@ -101,6 +101,19 @@ "vibration": "{entity_name} rezg\u00e9st \u00e9rz\u00e9kel" } }, + "device_class": { + "cold": "h\u0171t\u00e9s", + "gas": "g\u00e1z", + "heat": "f\u0171t\u00e9s", + "moisture": "nedvess\u00e9g", + "motion": "mozg\u00e1s", + "occupancy": "foglalts\u00e1g", + "power": "teljes\u00edtm\u00e9ny", + "problem": "probl\u00e9ma", + "smoke": "f\u00fcst", + "sound": "hang", + "vibration": "rezg\u00e9s" + }, "state": { "_": { "off": "Ki", @@ -119,7 +132,7 @@ "on": "Hideg" }, "connectivity": { - "off": "Lekapcsol\u00f3dva", + "off": "Lev\u00e1lasztva", "on": "Kapcsol\u00f3dva" }, "door": { diff --git a/homeassistant/components/binary_sensor/translations/id.json b/homeassistant/components/binary_sensor/translations/id.json index 54dcb66dd7a..b9688d494db 100644 --- a/homeassistant/components/binary_sensor/translations/id.json +++ b/homeassistant/components/binary_sensor/translations/id.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} tidak mendeteksi masalah", "is_no_smoke": "{entity_name} tidak mendeteksi asap", "is_no_sound": "{entity_name} tidak mendeteksi suara", + "is_no_update": "{entity_name} sudah yang terbaru", "is_no_vibration": "{entity_name} tidak mendeteksi getaran", "is_not_bat_low": "Baterai {entity_name} normal", "is_not_cold": "{entity_name} tidak dingin", @@ -30,6 +31,8 @@ "is_not_plugged_in": "{entity_name} dicabut", "is_not_powered": "{entity_name} tidak ditenagai", "is_not_present": "{entity_name} tidak ada", + "is_not_running": "{entity_name} tidak berjalan", + "is_not_tampered": "{entity_name} tidak mendeteksi gangguan", "is_not_unsafe": "{entity_name} aman", "is_occupied": "{entity_name} ditempati", "is_off": "{entity_name} mati", @@ -39,9 +42,12 @@ "is_powered": "{entity_name} ditenagai", "is_present": "{entity_name} ada", "is_problem": "{entity_name} mendeteksi masalah", + "is_running": "{entity_name} sedang berjalan", "is_smoke": "{entity_name} mendeteksi asap", "is_sound": "{entity_name} mendeteksi suara", + "is_tampered": "{entity_name} mendeteksi gangguan", "is_unsafe": "{entity_name} tidak aman", + "is_update": "{entity_name} memiliki pembaruan yang tersedia", "is_vibration": "{entity_name} mendeteksi getaran" }, "trigger_type": { @@ -50,6 +56,8 @@ "connected": "{entity_name} terhubung", "gas": "{entity_name} mulai mendeteksi gas", "hot": "{entity_name} menjadi panas", + "is_not_tampered": "{entity_name} berhenti mendeteksi gangguan", + "is_tampered": "{entity_name} mulai mendeteksi gangguan", "light": "{entity_name} mulai mendeteksi cahaya", "locked": "{entity_name} terkunci", "moist": "{entity_name} menjadi lembab", @@ -61,6 +69,7 @@ "no_problem": "{entity_name} berhenti mendeteksi masalah", "no_smoke": "{entity_name} berhenti mendeteksi asap", "no_sound": "{entity_name} berhenti mendeteksi suara", + "no_update": "{entity_name} menjadi yang terbaru", "no_vibration": "{entity_name} berhenti mendeteksi getaran", "not_bat_low": "Baterai {entity_name} normal", "not_cold": "{entity_name} menjadi tidak dingin", @@ -74,6 +83,7 @@ "not_plugged_in": "{entity_name} dicabut", "not_powered": "{entity_name} tidak ditenagai", "not_present": "{entity_name} tidak ada", + "not_running": "{entity_name} tidak lagi berjalan", "not_unsafe": "{entity_name} menjadi aman", "occupied": "{entity_name} menjadi ditempati", "opened": "{entity_name} terbuka", @@ -81,14 +91,29 @@ "powered": "{entity_name} ditenagai", "present": "{entity_name} ada", "problem": "{entity_name} mulai mendeteksi masalah", + "running": "{entity_name} mulai berjalan", "smoke": "{entity_name} mulai mendeteksi asap", "sound": "{entity_name} mulai mendeteksi suara", "turned_off": "{entity_name} dimatikan", "turned_on": "{entity_name} dinyalakan", "unsafe": "{entity_name} menjadi tidak aman", + "update": "{entity_name} mendapat pembaruan yang tersedia", "vibration": "{entity_name} mulai mendeteksi getaran" } }, + "device_class": { + "cold": "dingin", + "gas": "gas", + "heat": "panas", + "moisture": "kelembaban", + "motion": "gerakan", + "occupancy": "okupansi", + "power": "daya", + "problem": "masalah", + "smoke": "asap", + "sound": "suara", + "vibration": "vibrasi" + }, "state": { "_": { "off": "Mati", @@ -166,6 +191,10 @@ "off": "Oke", "on": "Bermasalah" }, + "running": { + "off": "Tidak berjalan", + "on": "Berjalan" + }, "safety": { "off": "Aman", "on": "Tidak aman" @@ -179,6 +208,7 @@ "on": "Terdeteksi" }, "update": { + "off": "Diperbarui", "on": "Pembaruan tersedia" }, "vibration": { diff --git a/homeassistant/components/binary_sensor/translations/it.json b/homeassistant/components/binary_sensor/translations/it.json index ef16af64af7..f0de143b244 100644 --- a/homeassistant/components/binary_sensor/translations/it.json +++ b/homeassistant/components/binary_sensor/translations/it.json @@ -31,6 +31,7 @@ "is_not_plugged_in": "{entity_name} \u00e8 collegato", "is_not_powered": "{entity_name} non \u00e8 alimentato", "is_not_present": "{entity_name} non \u00e8 presente", + "is_not_running": "{entity_name} non \u00e8 in funzionamento", "is_not_tampered": "{entity_name} non rileva manomissioni", "is_not_unsafe": "{entity_name} \u00e8 sicuro", "is_occupied": "{entity_name} \u00e8 occupato", @@ -41,6 +42,7 @@ "is_powered": "{entity_name} \u00e8 alimentato", "is_present": "{entity_name} \u00e8 presente", "is_problem": "{entity_name} sta rilevando un problema", + "is_running": "{entity_name} \u00e8 in funzionamento", "is_smoke": "{entity_name} sta rilevando il fumo", "is_sound": "{entity_name} sta rilevando il suono", "is_tampered": "{entity_name} rileva manomissioni", @@ -81,6 +83,7 @@ "not_plugged_in": "{entity_name} \u00e8 scollegato", "not_powered": "{entity_name} non \u00e8 alimentato", "not_present": "{entity_name} non \u00e8 presente", + "not_running": "{entity_name} non \u00e8 pi\u00f9 in funzione", "not_unsafe": "{entity_name} \u00e8 diventato sicuro", "occupied": "{entity_name} \u00e8 diventato occupato", "opened": "{entity_name} \u00e8 aperto", @@ -88,6 +91,7 @@ "powered": "{entity_name} \u00e8 alimentato", "present": "{entity_name} \u00e8 presente", "problem": "{entity_name} ha iniziato a rilevare un problema", + "running": "{entity_name} ha iniziato a funzionare", "smoke": "{entity_name} ha iniziato la rilevazione di fumo", "sound": "{entity_name} ha iniziato il rilevamento del suono", "turned_off": "{entity_name} disattivato", @@ -97,6 +101,19 @@ "vibration": "{entity_name} iniziato a rilevare le vibrazioni" } }, + "device_class": { + "cold": "freddo", + "gas": "gas", + "heat": "caldo", + "moisture": "umidit\u00e0", + "motion": "movimento", + "occupancy": "occupazione", + "power": "potenza", + "problem": "problema", + "smoke": "fumo", + "sound": "suono", + "vibration": "vibrazione" + }, "state": { "_": { "off": "Spento", @@ -174,6 +191,10 @@ "off": "OK", "on": "Problema" }, + "running": { + "off": "Non in esecuzione", + "on": "In esecuzione" + }, "safety": { "off": "Sicuro", "on": "Non Sicuro" diff --git a/homeassistant/components/binary_sensor/translations/ja.json b/homeassistant/components/binary_sensor/translations/ja.json index 54280a5334a..979d2cf966a 100644 --- a/homeassistant/components/binary_sensor/translations/ja.json +++ b/homeassistant/components/binary_sensor/translations/ja.json @@ -1,4 +1,119 @@ { + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} \u96fb\u6c60\u6b8b\u91cf\u304c\u5c11\u306a\u304f\u306a\u3063\u3066\u3044\u307e\u3059", + "is_cold": "{entity_name} \u51b7\u3048\u3066\u3044\u308b", + "is_connected": "{entity_name} \u304c\u63a5\u7d9a\u3055\u308c\u3066\u3044\u307e\u3059", + "is_gas": "{entity_name} \u304c\u30ac\u30b9\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u3059", + "is_hot": "{entity_name} \u71b1\u3044", + "is_light": "{entity_name} \u304c\u5149\u3092\u691c\u77e5\u3057\u3066\u3044\u307e\u3059", + "is_locked": "{entity_name} \u306f\u30ed\u30c3\u30af\u3055\u308c\u3066\u3044\u307e\u3059", + "is_moist": "{entity_name} \u306f\u6e7f\u3063\u3066\u3044\u307e\u3059", + "is_motion": "{entity_name} \u306f\u3001\u52d5\u304d\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u3059", + "is_moving": "{entity_name} \u304c\u79fb\u52d5\u4e2d\u3067\u3059", + "is_no_gas": "{entity_name} \u306f\u3001\u30ac\u30b9\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u305b\u3093", + "is_no_light": "{entity_name} \u306f\u3001\u5149\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u305b\u3093", + "is_no_motion": "{entity_name} \u306f\u3001\u52d5\u304d\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u305b\u3093", + "is_no_problem": "{entity_name} \u306f\u3001\u554f\u984c\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u305b\u3093", + "is_no_smoke": "{entity_name} \u306f\u3001\u7159\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u305b\u3093", + "is_no_sound": "{entity_name} \u306f\u3001\u97f3\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u305b\u3093", + "is_no_update": "{entity_name} \u306f\u6700\u65b0\u3067\u3059", + "is_no_vibration": "{entity_name} \u306f\u632f\u52d5\u3092\u611f\u77e5\u3057\u3066\u3044\u307e\u305b\u3093", + "is_not_bat_low": "{entity_name} \u30d0\u30c3\u30c6\u30ea\u30fc\u306f\u6b63\u5e38\u3067\u3059", + "is_not_cold": "{entity_name} \u51b7\u3048\u3066\u3044\u307e\u305b\u3093", + "is_not_connected": "{entity_name} \u304c\u5207\u65ad\u3055\u308c\u307e\u3057\u305f", + "is_not_hot": "{entity_name} \u306f\u71b1\u304f\u3042\u308a\u307e\u305b\u3093", + "is_not_locked": "{entity_name} \u306e\u30ed\u30c3\u30af\u306f\u89e3\u9664\u3055\u308c\u3066\u3044\u307e\u3059", + "is_not_moist": "{entity_name} \u306f\u4e7e\u71e5\u3057\u3066\u3044\u307e\u3059", + "is_not_moving": "{entity_name} \u306f\u52d5\u3044\u3066\u3044\u307e\u305b\u3093", + "is_not_occupied": "{entity_name} \u306f\u5360\u6709\u3055\u308c\u3066\u3044\u307e\u305b\u3093", + "is_not_open": "{entity_name} \u306f\u9589\u3058\u3066\u3044\u307e\u3059", + "is_not_plugged_in": "{entity_name} \u30d7\u30e9\u30b0\u304c\u629c\u304b\u308c\u3066\u3044\u307e\u3059", + "is_not_powered": "{entity_name} \u306f\u96fb\u529b\u304c\u4f9b\u7d66\u3055\u308c\u3066\u3044\u307e\u305b\u3093", + "is_not_present": "{entity_name} \u304c\u5b58\u5728\u3057\u307e\u305b\u3093", + "is_not_running": "{entity_name} \u306f\u5b9f\u884c\u3055\u308c\u3066\u3044\u307e\u305b\u3093", + "is_not_tampered": "{entity_name} \u306f\u6539\u7ac4(tampering)\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u305b\u3093", + "is_not_unsafe": "{entity_name} \u306f\u5b89\u5168\u3067\u3059", + "is_occupied": "{entity_name} \u306f\u5360\u6709\u3055\u308c\u3066\u3044\u307e\u3059", + "is_off": "{entity_name} \u306f\u30aa\u30d5\u3067\u3059", + "is_on": "{entity_name} \u304c\u30aa\u30f3\u3067\u3059", + "is_open": "{entity_name} \u304c\u958b\u3044\u3066\u3044\u307e\u3059", + "is_plugged_in": "{entity_name} \u304c\u63a5\u7d9a\u3055\u308c\u3066\u3044\u307e\u3059", + "is_powered": "{entity_name} \u306e\u96fb\u6e90\u304c\u5165\u3063\u3066\u3044\u307e\u3059", + "is_present": "{entity_name} \u304c\u5b58\u5728\u3057\u307e\u3059", + "is_problem": "{entity_name} \u304c\u554f\u984c\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u3059", + "is_running": "{entity_name} \u304c\u5b9f\u884c\u3055\u308c\u3066\u3044\u307e\u3059", + "is_smoke": "{entity_name} \u304c\u7159\u3092\u691c\u77e5\u3057\u3066\u3044\u307e\u3059", + "is_sound": "{entity_name} \u304c\u97f3\u3092\u691c\u77e5\u3057\u3066\u3044\u307e\u3059", + "is_tampered": "{entity_name} \u304c\u6539\u7ac4(tampering)\u3092\u691c\u51fa\u3057\u3066\u3044\u307e\u3059", + "is_unsafe": "{entity_name} \u306f\u5b89\u5168\u3067\u306f\u3042\u308a\u307e\u305b\u3093", + "is_update": "{entity_name} \u306b\u5229\u7528\u53ef\u80fd\u306a\u30a2\u30c3\u30d7\u30c7\u30fc\u30c8\u304c\u3042\u308a\u307e\u3059", + "is_vibration": "{entity_name} \u304c\u632f\u52d5\u3092\u611f\u77e5\u3057\u3066\u3044\u307e\u3059" + }, + "trigger_type": { + "bat_low": "{entity_name} \u96fb\u6c60\u6b8b\u91cf\u304c\u5c11\u306a\u304f\u306a\u3063\u3066\u3044\u307e\u3059", + "cold": "{entity_name} \u51b7\u3048\u3066\u3044\u307e\u3059", + "connected": "{entity_name} \u63a5\u7d9a\u3055\u308c\u3066\u3044\u307e\u3059", + "gas": "{entity_name} \u304c\u30ac\u30b9\u306e\u691c\u51fa\u3092\u958b\u59cb\u3057\u307e\u3057\u305f", + "hot": "{entity_name} \u6e29\u307e\u3063\u3066\u3044\u307e\u3059", + "is_not_tampered": "{entity_name} \u304c\u6539\u7ac4(tampering)\u306e\u691c\u51fa\u3092\u505c\u6b62\u3057\u307e\u3057\u305f", + "is_tampered": "{entity_name} \u304c\u6539\u7ac4(tampering)\u306e\u691c\u51fa\u3092\u958b\u59cb\u3057\u307e\u3057\u305f", + "light": "{entity_name} \u306f\u3001\u5149\u306e\u691c\u51fa\u3092\u958b\u59cb\u3057\u307e\u3057\u305f", + "locked": "{entity_name} \u306f\u30ed\u30c3\u30af\u3055\u308c\u3066\u3044\u307e\u3059", + "moist": "{entity_name} \u304c\u6e7f\u3063\u305f", + "motion": "{entity_name} \u306f\u3001\u52d5\u304d\u3092\u691c\u51fa\u3057\u59cb\u3081\u307e\u3057\u305f", + "moving": "{entity_name} \u306f\u3001\u79fb\u52d5\u3092\u958b\u59cb\u3057\u307e\u3057\u305f", + "no_gas": "{entity_name} \u306f\u3001\u30ac\u30b9\u306e\u691c\u51fa\u3092\u505c\u6b62\u3057\u307e\u3057\u305f", + "no_light": "{entity_name} \u306f\u3001\u5149\u306e\u691c\u51fa\u3092\u505c\u6b62\u3057\u307e\u3057\u305f", + "no_motion": "{entity_name} \u306f\u3001\u52d5\u304d\u306e\u691c\u51fa\u3092\u505c\u6b62\u3057\u307e\u3057\u305f", + "no_problem": "{entity_name} \u306f\u3001\u554f\u984c\u306e\u691c\u51fa\u3092\u505c\u6b62\u3057\u307e\u3057\u305f", + "no_smoke": "{entity_name} \u306f\u3001\u7159\u306e\u691c\u51fa\u3092\u505c\u6b62\u3057\u307e\u3057\u305f", + "no_sound": "{entity_name} \u306f\u3001\u97f3\u306e\u691c\u51fa\u3092\u505c\u6b62\u3057\u307e\u3057\u305f", + "no_update": "{entity_name} \u304c\u6700\u65b0\u306b\u306a\u308a\u307e\u3057\u305f", + "no_vibration": "{entity_name} \u304c\u632f\u52d5\u3092\u611f\u77e5\u3057\u306a\u304f\u306a\u3063\u305f", + "not_bat_low": "{entity_name} \u30d0\u30c3\u30c6\u30ea\u30fc\u6b63\u5e38", + "not_cold": "{entity_name} \u306f\u51b7\u3048\u3066\u3044\u307e\u305b\u3093", + "not_connected": "{entity_name} \u304c\u5207\u65ad\u3055\u308c\u307e\u3057\u305f", + "not_hot": "{entity_name} \u6e29\u307e\u3063\u3066\u3044\u307e\u305b\u3093", + "not_locked": "{entity_name} \u306e\u30ed\u30c3\u30af\u304c\u89e3\u9664\u3055\u308c\u307e\u3057\u305f", + "not_moist": "{entity_name} \u306f\u4e7e\u3044\u3066\u3044\u307e\u305b\u3093", + "not_moving": "{entity_name} \u304c\u52d5\u304d\u3092\u505c\u6b62\u3057\u307e\u3057\u305f", + "not_occupied": "{entity_name} \u304c\u5360\u6709\u3055\u308c\u306a\u304f\u306a\u308a\u307e\u3057\u305f", + "not_opened": "{entity_name} \u30af\u30ed\u30fc\u30ba\u30c9", + "not_plugged_in": "{entity_name} \u306e\u30d7\u30e9\u30b0\u304c\u629c\u304b\u308c\u307e\u3057\u305f", + "not_powered": "{entity_name} \u306f\u96fb\u6e90\u304c\u5165\u3063\u3066\u3044\u307e\u305b\u3093", + "not_present": "{entity_name} \u304c\u5b58\u5728\u3057\u307e\u305b\u3093", + "not_running": "{entity_name} \u306f\u3082\u3046\u5b9f\u884c\u3055\u308c\u3066\u3044\u306a\u3044", + "not_unsafe": "{entity_name} \u304c\u5b89\u5168\u306b\u306a\u308a\u307e\u3057\u305f", + "occupied": "{entity_name} \u304c\u5360\u6709\u3055\u308c\u307e\u3057\u305f", + "opened": "{entity_name} \u304c\u958b\u304b\u308c\u307e\u3057\u305f", + "plugged_in": "{entity_name} \u304c\u63a5\u7d9a\u3055\u308c\u3066\u3044\u307e\u3059", + "powered": "{entity_name} \u96fb\u6e90", + "present": "{entity_name} \u304c\u5b58\u5728", + "problem": "{entity_name} \u304c\u554f\u984c\u306e\u691c\u51fa\u3092\u958b\u59cb\u3057\u307e\u3057\u305f", + "running": "{entity_name} \u306e\u5b9f\u884c\u3092\u958b\u59cb", + "smoke": "{entity_name} \u304c\u7159\u306e\u691c\u51fa\u3092\u958b\u59cb\u3057\u307e\u3057\u305f", + "sound": "{entity_name} \u304c\u97f3\u306e\u691c\u51fa\u3092\u958b\u59cb\u3057\u307e\u3057\u305f", + "turned_off": "{entity_name} \u30aa\u30d5\u306b\u306a\u308a\u307e\u3057\u305f", + "turned_on": "{entity_name} \u30aa\u30f3\u306b\u306a\u3063\u3066\u3044\u307e\u3059", + "unsafe": "{entity_name} \u306f\u5b89\u5168\u3067\u306f\u306a\u304f\u306a\u308a\u307e\u3057\u305f", + "update": "{entity_name} \u306f\u3001\u5229\u7528\u53ef\u80fd\u306a\u30a2\u30c3\u30d7\u30c7\u30fc\u30c8\u3092\u53d6\u5f97\u3057\u307e\u3057\u305f\u3002", + "vibration": "{entity_name} \u304c\u632f\u52d5\u3092\u611f\u77e5\u3057\u59cb\u3081\u307e\u3057\u305f" + } + }, + "device_class": { + "cold": "\u51b7\u305f\u3044", + "gas": "\u30ac\u30b9", + "heat": "\u71b1", + "moisture": "\u6e7f\u6c17", + "motion": "\u30e2\u30fc\u30b7\u30e7\u30f3", + "occupancy": "\u5360\u6709", + "power": "\u30d1\u30ef\u30fc", + "problem": "\u554f\u984c", + "smoke": "\u7159", + "sound": "\u97f3", + "vibration": "\u632f\u52d5" + }, "state": { "_": { "off": "\u30aa\u30d5", @@ -8,6 +123,10 @@ "off": "\u901a\u5e38", "on": "\u4f4e" }, + "battery_charging": { + "off": "\u5145\u96fb\u3057\u3066\u3044\u306a\u3044", + "on": "\u5145\u96fb" + }, "cold": { "off": "\u901a\u5e38", "on": "\u4f4e\u6e29" @@ -17,24 +136,28 @@ "on": "\u63a5\u7d9a\u6e08" }, "door": { - "off": "\u9589", - "on": "\u958b" + "off": "\u30af\u30ed\u30fc\u30ba\u30c9", + "on": "\u30aa\u30fc\u30d7\u30f3" }, "garage_door": { - "off": "\u9589", - "on": "\u958b" + "off": "\u30af\u30ed\u30fc\u30ba\u30c9", + "on": "\u30aa\u30fc\u30d7\u30f3" }, "gas": { - "off": "\u672a\u691c\u51fa", + "off": "\u30af\u30ea\u30a2", "on": "\u691c\u51fa" }, "heat": { "off": "\u6b63\u5e38", "on": "\u9ad8\u6e29" }, + "light": { + "off": "\u30e9\u30a4\u30c8\u306a\u3057", + "on": "\u30e9\u30a4\u30c8\u3092\u691c\u51fa" + }, "lock": { - "off": "\u30ed\u30c3\u30af\u3055\u308c\u307e\u3057\u305f", - "on": "\u30ed\u30c3\u30af\u3055\u308c\u3066\u3044\u307e\u305b\u3093" + "off": "\u65bd\u9320\u4e2d", + "on": "\u30ed\u30c3\u30af\u89e3\u9664" }, "moisture": { "off": "\u30c9\u30e9\u30a4", @@ -44,40 +167,57 @@ "off": "\u672a\u691c\u51fa", "on": "\u691c\u51fa" }, + "moving": { + "off": "\u52d5\u3044\u3066\u3044\u306a\u3044", + "on": "\u52d5\u3044\u3066\u3044\u308b" + }, "occupancy": { "off": "\u672a\u691c\u51fa", "on": "\u691c\u51fa" }, "opening": { - "off": "\u9589\u9396", - "on": "\u958b\u653e" + "off": "\u30af\u30ed\u30fc\u30ba\u30c9", + "on": "\u30aa\u30fc\u30d7\u30f3" + }, + "plug": { + "off": "\u30a2\u30f3\u30d7\u30e9\u30b0\u30c9", + "on": "\u30d7\u30e9\u30b0\u30a4\u30f3" }, "presence": { "off": "\u5916\u51fa", "on": "\u5728\u5b85" }, "problem": { - "off": "OK" + "off": "OK", + "on": "\u554f\u984c" + }, + "running": { + "off": "\u30e9\u30f3\u30cb\u30f3\u30b0\u3067\u306f\u306a\u3044", + "on": "\u30e9\u30f3\u30cb\u30f3\u30b0" }, "safety": { "off": "\u5b89\u5168", "on": "\u5371\u967a" }, "smoke": { - "off": "\u672a\u691c\u51fa", + "off": "\u30af\u30ea\u30a2", "on": "\u691c\u51fa" }, "sound": { "off": "\u672a\u691c\u51fa", "on": "\u691c\u51fa" }, + "update": { + "off": "\u6700\u65b0", + "on": "\u66f4\u65b0\u53ef\u80fd" + }, "vibration": { - "off": "\u672a\u691c\u51fa", + "off": "\u30af\u30ea\u30a2", "on": "\u691c\u51fa" }, "window": { - "off": "\u9589\u9396", - "on": "\u958b\u653e" + "off": "\u30af\u30ed\u30fc\u30ba\u30c9", + "on": "\u30aa\u30fc\u30d7\u30f3" } }, "title": "\u30d0\u30a4\u30ca\u30ea\u30bb\u30f3\u30b5\u30fc" diff --git a/homeassistant/components/binary_sensor/translations/nl.json b/homeassistant/components/binary_sensor/translations/nl.json index 1abf0b86bca..f3d8a263187 100644 --- a/homeassistant/components/binary_sensor/translations/nl.json +++ b/homeassistant/components/binary_sensor/translations/nl.json @@ -31,6 +31,8 @@ "is_not_plugged_in": "{entity_name} is niet aangesloten", "is_not_powered": "{entity_name} is niet van stroom voorzien...", "is_not_present": "{entity_name} is niet aanwezig", + "is_not_running": "{entity_name} is niet langer actief", + "is_not_tampered": "{entity_name} detecteert geen sabotage", "is_not_unsafe": "{entity_name} is veilig", "is_occupied": "{entity_name} bezet is", "is_off": "{entity_name} is uitgeschakeld", @@ -40,8 +42,10 @@ "is_powered": "{entity_name} is van stroom voorzien....", "is_present": "{entity_name} is aanwezig", "is_problem": "{entity_name} detecteert een probleem", + "is_running": "{entity_name} is actief", "is_smoke": "{entity_name} detecteert rook", "is_sound": "{entity_name} detecteert geluid", + "is_tampered": "{entity_name} detecteert sabotage", "is_unsafe": "{entity_name} is onveilig", "is_update": "{entity_name} heeft een update beschikbaar", "is_vibration": "{entity_name} detecteert trillingen" @@ -52,6 +56,7 @@ "connected": "{entity_name} verbonden", "gas": "{entity_name} begon gas te detecteren", "hot": "{entity_name} werd heet", + "is_not_tampered": "{entity_name} gestopt met het detecteren van sabotage", "is_tampered": "{entity_name} begonnen met het detecteren van sabotage", "light": "{entity_name} begon licht te detecteren", "locked": "{entity_name} vergrendeld", @@ -78,6 +83,7 @@ "not_plugged_in": "{entity_name} niet verbonden", "not_powered": "{entity_name} niet ingeschakeld", "not_present": "{entity_name} is niet aanwezig", + "not_running": "{entity_name} is niet langer actief", "not_unsafe": "{entity_name} werd veilig", "occupied": "{entity_name} werd bezet", "opened": "{entity_name} geopend", @@ -85,6 +91,7 @@ "powered": "{entity_name} heeft vermogen", "present": "{entity_name} aanwezig", "problem": "{entity_name} begonnen met het detecteren van een probleem", + "running": "{entity_name} is actief geworden", "smoke": "{entity_name} begon rook te detecteren", "sound": "{entity_name} begon geluid te detecteren", "turned_off": "{entity_name} uitgeschakeld", @@ -94,6 +101,19 @@ "vibration": "{entity_name} begon trillingen te detecteren" } }, + "device_class": { + "cold": "koud", + "gas": "gas", + "heat": "warmte", + "moisture": "vochtigheid", + "motion": "beweging", + "occupancy": "bezetting", + "power": "power", + "problem": "probleem", + "smoke": "rook", + "sound": "geluid", + "vibration": "trilling" + }, "state": { "_": { "off": "Uit", @@ -171,6 +191,10 @@ "off": "OK", "on": "Probleem" }, + "running": { + "off": "Niet actief", + "on": "Actief" + }, "safety": { "off": "Veilig", "on": "Onveilig" diff --git a/homeassistant/components/binary_sensor/translations/no.json b/homeassistant/components/binary_sensor/translations/no.json index 7dd6243edf8..da2fea944c4 100644 --- a/homeassistant/components/binary_sensor/translations/no.json +++ b/homeassistant/components/binary_sensor/translations/no.json @@ -31,6 +31,7 @@ "is_not_plugged_in": "{entity_name} er koblet fra", "is_not_powered": "{entity_name} er spenningsl\u00f8s", "is_not_present": "{entity_name} er ikke tilstede", + "is_not_running": "{entity_name} kj\u00f8rer ikke", "is_not_tampered": "{entity_name} oppdager ikke manipulering", "is_not_unsafe": "{entity_name} er trygg", "is_occupied": "{entity_name} er opptatt", @@ -41,6 +42,7 @@ "is_powered": "{entity_name} er spenningssatt", "is_present": "{entity_name} er tilstede", "is_problem": "{entity_name} registrerer et problem", + "is_running": "{entity_name} kj\u00f8rer", "is_smoke": "{entity_name} registrerer r\u00f8yk", "is_sound": "{entity_name} registrerer lyd", "is_tampered": "{entity_name} oppdager manipulering", @@ -81,6 +83,7 @@ "not_plugged_in": "{entity_name} koblet fra", "not_powered": "{entity_name} spenningsl\u00f8s", "not_present": "{entity_name} ikke til stede", + "not_running": "{entity_name} kj\u00f8rer ikke lenger", "not_unsafe": "{entity_name} ble trygg", "occupied": "{entity_name} ble opptatt", "opened": "{entity_name} \u00e5pnet", @@ -88,6 +91,7 @@ "powered": "{entity_name} spenningssatt", "present": "{entity_name} tilstede", "problem": "{entity_name} begynte \u00e5 registrere et problem", + "running": "{entity_name} begynte \u00e5 kj\u00f8re", "smoke": "{entity_name} begynte \u00e5 registrere r\u00f8yk", "sound": "{entity_name} begynte \u00e5 registrere lyd", "turned_off": "{entity_name} sl\u00e5tt av", @@ -97,6 +101,19 @@ "vibration": "{entity_name} begynte \u00e5 oppdage vibrasjon" } }, + "device_class": { + "cold": "kald", + "gas": "Gass", + "heat": "varme", + "moisture": "fuktighet", + "motion": "bevegelse", + "occupancy": "Bruk", + "power": "kraft", + "problem": "problem", + "smoke": "r\u00f8yk", + "sound": "lyd", + "vibration": "vibrasjon" + }, "state": { "_": { "off": "Av", @@ -174,6 +191,10 @@ "off": "", "on": "" }, + "running": { + "off": "Kj\u00f8rer ikke", + "on": "Kj\u00f8rer" + }, "safety": { "off": "Sikker", "on": "Usikker" diff --git a/homeassistant/components/binary_sensor/translations/pl.json b/homeassistant/components/binary_sensor/translations/pl.json index 648b48a178f..f0267fd248b 100644 --- a/homeassistant/components/binary_sensor/translations/pl.json +++ b/homeassistant/components/binary_sensor/translations/pl.json @@ -101,6 +101,19 @@ "vibration": "sensor {entity_name} wykryje wibracje" } }, + "device_class": { + "cold": "zimno", + "gas": "gaz", + "heat": "gor\u0105co", + "moisture": "wilgotno\u015b\u0107", + "motion": "ruch", + "occupancy": "obecno\u015b\u0107", + "power": "zasilanie", + "problem": "problem", + "smoke": "dym", + "sound": "d\u017awi\u0119k", + "vibration": "wibracja" + }, "state": { "_": { "off": "wy\u0142.", diff --git a/homeassistant/components/binary_sensor/translations/pt-BR.json b/homeassistant/components/binary_sensor/translations/pt-BR.json index 385d8620d76..711ac35f9f6 100644 --- a/homeassistant/components/binary_sensor/translations/pt-BR.json +++ b/homeassistant/components/binary_sensor/translations/pt-BR.json @@ -9,6 +9,19 @@ "no_motion": "{entity_name} parou de detectar movimento" } }, + "device_class": { + "cold": "frio", + "gas": "g\u00e1s", + "heat": "calor", + "moisture": "umidade", + "motion": "movimento", + "occupancy": "presen\u00e7a", + "power": "energia", + "problem": "problema", + "smoke": "fuma\u00e7a", + "sound": "som", + "vibration": "vibra\u00e7\u00e3o" + }, "state": { "_": { "off": "Desligado", diff --git a/homeassistant/components/binary_sensor/translations/ru.json b/homeassistant/components/binary_sensor/translations/ru.json index 09a9da61e20..bb8ec9fdadb 100644 --- a/homeassistant/components/binary_sensor/translations/ru.json +++ b/homeassistant/components/binary_sensor/translations/ru.json @@ -101,6 +101,19 @@ "vibration": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e" } }, + "device_class": { + "cold": "\u043e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0435", + "gas": "\u0433\u0430\u0437", + "heat": "\u043d\u0430\u0433\u0440\u0435\u0432", + "moisture": "\u0432\u043b\u0430\u0433\u0430", + "motion": "\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "occupancy": "\u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "power": "\u043c\u043e\u0449\u043d\u043e\u0441\u0442\u044c", + "problem": "\u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0430", + "smoke": "\u0434\u044b\u043c", + "sound": "\u0437\u0432\u0443\u043a", + "vibration": "\u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044f" + }, "state": { "_": { "off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e", diff --git a/homeassistant/components/binary_sensor/translations/sl.json b/homeassistant/components/binary_sensor/translations/sl.json index 02c4eedeba9..cc34982ad0a 100644 --- a/homeassistant/components/binary_sensor/translations/sl.json +++ b/homeassistant/components/binary_sensor/translations/sl.json @@ -30,6 +30,7 @@ "is_not_plugged_in": "{entity_name} je odklopljen", "is_not_powered": "{entity_name} ni napajan", "is_not_present": "{entity_name} ni prisoten", + "is_not_tampered": "{entity_name} ne zaznava nedovoljenih posegov", "is_not_unsafe": "{entity_name} je varen", "is_occupied": "{entity_name} je zaseden", "is_off": "{entity_name} je izklopljen", @@ -41,6 +42,7 @@ "is_problem": "{entity_name} zaznava te\u017eavo", "is_smoke": "{entity_name} zaznava dim", "is_sound": "{entity_name} zaznava zvok", + "is_tampered": "{entity_name} zaznava nedovoljeno poseganje", "is_unsafe": "{entity_name} ni varen", "is_vibration": "{entity_name} zaznava vibracije" }, @@ -50,6 +52,8 @@ "connected": "{entity_name} povezan", "gas": "{entity_name} za\u010del zaznavati plin", "hot": "{entity_name} je postal vro\u010d", + "is_not_tampered": "{entity_name} je prenehal zaznavati nedovoljena dejanja", + "is_tampered": "{entity_name} je za\u010del zaznavati nedovoljeno poseganje", "light": "{entity_name} za\u010del zaznavati svetlobo", "locked": "{entity_name} zaklenjen", "moist": "{entity_name} postal vla\u017een", @@ -89,6 +93,19 @@ "vibration": "{entity_name} je za\u010del odkrivat vibracije" } }, + "device_class": { + "cold": "hladno", + "gas": "plin", + "heat": "toplota", + "moisture": "vlaga", + "motion": "gibanje", + "occupancy": "zasedenost", + "power": "mo\u010d", + "problem": "te\u017eava", + "smoke": "dim", + "sound": "zvok", + "vibration": "vibracija" + }, "state": { "_": { "off": "Izklju\u010den", @@ -164,6 +181,10 @@ "off": "OK", "on": "Te\u017eava" }, + "running": { + "off": "Ni v teku", + "on": "V teku" + }, "safety": { "off": "Varno", "on": "Nevarno" diff --git a/homeassistant/components/binary_sensor/translations/th.json b/homeassistant/components/binary_sensor/translations/th.json index b8f41eb2b73..30c0a5fbe2e 100644 --- a/homeassistant/components/binary_sensor/translations/th.json +++ b/homeassistant/components/binary_sensor/translations/th.json @@ -1,4 +1,13 @@ { + "device_class": { + "cold": "\u0e40\u0e22\u0e47\u0e19", + "gas": "\u0e41\u0e01\u0e4a\u0e2a", + "heat": "\u0e04\u0e27\u0e32\u0e21\u0e23\u0e49\u0e2d\u0e19", + "problem": "\u0e1b\u0e31\u0e0d\u0e2b\u0e32", + "smoke": "\u0e04\u0e27\u0e31\u0e19", + "sound": "\u0e40\u0e2a\u0e35\u0e22\u0e07", + "vibration": "\u0e01\u0e32\u0e23\u0e2a\u0e31\u0e48\u0e19" + }, "state": { "_": { "off": "\u0e1b\u0e34\u0e14", diff --git a/homeassistant/components/binary_sensor/translations/tr.json b/homeassistant/components/binary_sensor/translations/tr.json index daf44cc967b..72022dd9d39 100644 --- a/homeassistant/components/binary_sensor/translations/tr.json +++ b/homeassistant/components/binary_sensor/translations/tr.json @@ -1,10 +1,119 @@ { "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} pili zay\u0131f", + "is_cold": "{entity_name} so\u011fuk", + "is_connected": "{entity_name} ba\u011fl\u0131", + "is_gas": "{entity_name} gaz alg\u0131l\u0131yor", + "is_hot": "{entity_name} s\u0131cak", + "is_light": "{entity_name} \u0131\u015f\u0131k alg\u0131l\u0131yor", + "is_locked": "{entity_name} kilitli", + "is_moist": "{entity_name} nemli", + "is_motion": "{entity_name} hareket alg\u0131l\u0131yor", + "is_moving": "{entity_name} ta\u015f\u0131n\u0131yor", + "is_no_gas": "{entity_name} gaz alg\u0131lam\u0131yor", + "is_no_light": "{entity_name} \u0131\u015f\u0131\u011f\u0131 alg\u0131lam\u0131yor", + "is_no_motion": "{entity_name} hareketi alg\u0131lam\u0131yor", + "is_no_problem": "{entity_name} sorun alg\u0131lam\u0131yor", + "is_no_smoke": "{entity_name} duman alg\u0131lam\u0131yor", + "is_no_sound": "{entity_name} sesi alg\u0131lam\u0131yor", + "is_no_update": "{entity_name} g\u00fcncel", + "is_no_vibration": "{entity_name} titre\u015fim alg\u0131lam\u0131yor", + "is_not_bat_low": "{entity_name} pili normal", + "is_not_cold": "{entity_name} so\u011fuk de\u011fil", + "is_not_connected": "{entity_name} ba\u011flant\u0131s\u0131 kesildi", + "is_not_hot": "{entity_name} s\u0131cak de\u011fil", + "is_not_locked": "{entity_name} kilidi a\u00e7\u0131ld\u0131", + "is_not_moist": "{entity_name} kuru", + "is_not_moving": "{entity_name} hareket etmiyor", + "is_not_occupied": "{entity_name} me\u015fgul de\u011fil", + "is_not_open": "{entity_name} kapat\u0131ld\u0131", + "is_not_plugged_in": "{entity_name} fi\u015fi \u00e7ekildi", + "is_not_powered": "{entity_name} desteklenmiyor", + "is_not_present": "{entity_name} mevcut de\u011fil", + "is_not_running": "{entity_name} \u00e7al\u0131\u015fm\u0131yor", + "is_not_tampered": "{entity_name} , kurcalamay\u0131 alg\u0131lam\u0131yor", + "is_not_unsafe": "{entity_name} g\u00fcvenli", + "is_occupied": "{entity_name} dolu", + "is_off": "{entity_name} kapal\u0131", + "is_on": "{entity_name} a\u00e7\u0131k", + "is_open": "{entity_name} a\u00e7\u0131k", + "is_plugged_in": "{entity_name} tak\u0131l\u0131", + "is_powered": "{entity_name} destekleniyor", + "is_present": "{entity_name} mevcut", + "is_problem": "{entity_name} sorun alg\u0131l\u0131yor", + "is_running": "{entity_name} \u00e7al\u0131\u015f\u0131yor", + "is_smoke": "{entity_name} duman alg\u0131l\u0131yor", + "is_sound": "{entity_name} sesi alg\u0131l\u0131yor", + "is_tampered": "{entity_name} , kurcalama alg\u0131l\u0131yor", + "is_unsafe": "{entity_name} g\u00fcvenli de\u011fil", + "is_update": "{entity_name} i\u00e7in bir g\u00fcncelleme mevcut", + "is_vibration": "{entity_name} titre\u015fim alg\u0131l\u0131yor" + }, "trigger_type": { + "bat_low": "{entity_name} pil seviyesi d\u00fc\u015f\u00fck", + "cold": "{entity_name} so\u011fudu", + "connected": "{entity_name} ba\u011fland\u0131", + "gas": "{entity_name} gaz alg\u0131lamaya ba\u015flad\u0131", + "hot": "{entity_name} \u0131s\u0131nd\u0131", + "is_not_tampered": "{entity_name} kurcalamay\u0131 alg\u0131lamay\u0131 durdurdu", + "is_tampered": "{entity_name} , kurcalamay\u0131 alg\u0131lamaya ba\u015flad\u0131", + "light": "{entity_name} \u0131\u015f\u0131\u011f\u0131 alg\u0131lamaya ba\u015flad\u0131", + "locked": "{entity_name} kilitlendi", "moist": "{entity_name} nemli oldu", - "not_opened": "{entity_name} kapat\u0131ld\u0131" + "motion": "{entity_name} hareket alg\u0131lamaya ba\u015flad\u0131", + "moving": "{entity_name} ta\u015f\u0131nmaya ba\u015flad\u0131", + "no_gas": "{entity_name} gaz alg\u0131lamay\u0131 durdurdu", + "no_light": "{entity_name} \u0131\u015f\u0131\u011f\u0131 alg\u0131lamay\u0131 durdurdu", + "no_motion": "{entity_name} hareket alg\u0131lamay\u0131 durdurdu", + "no_problem": "{entity_name} sorunu alg\u0131lamay\u0131 durdurdu", + "no_smoke": "{entity_name} duman alg\u0131lamay\u0131 durdurdu", + "no_sound": "{entity_name} ses alg\u0131lamay\u0131 durdurdu", + "no_update": "{entity_name} g\u00fcncellendi", + "no_vibration": "{entity_name} titre\u015fimi alg\u0131lamay\u0131 durdurdu", + "not_bat_low": "{entity_name} pil normal", + "not_cold": "{entity_name} so\u011fuk olmad\u0131", + "not_connected": "{entity_name} ba\u011flant\u0131s\u0131 kesildi", + "not_hot": "{entity_name} s\u0131cak olmad\u0131", + "not_locked": "{entity_name} kilidi a\u00e7\u0131ld\u0131", + "not_moist": "{entity_name} kuru hale geldi", + "not_moving": "{entity_name} hareket etmeyi durdurdu", + "not_occupied": "{entity_name} dolu de\u011fil", + "not_opened": "{entity_name} kapat\u0131ld\u0131", + "not_plugged_in": "{entity_name} fi\u015fi \u00e7ekildi", + "not_powered": "{entity_name} desteklenmiyor", + "not_present": "{entity_name} mevcut de\u011fil", + "not_running": "{entity_name} art\u0131k \u00e7al\u0131\u015fm\u0131yor", + "not_unsafe": "{entity_name} g\u00fcvenli hale geldi", + "occupied": "{entity_name} i\u015fgal edildi", + "opened": "{entity_name} a\u00e7\u0131ld\u0131", + "plugged_in": "{entity_name} tak\u0131l\u0131", + "powered": "{entity_name} destekleniyor", + "present": "{entity_name} mevcut", + "problem": "{entity_name} sorun alg\u0131lamaya ba\u015flad\u0131", + "running": "{entity_name} \u00e7al\u0131\u015fmaya ba\u015flad\u0131", + "smoke": "{entity_name} duman alg\u0131lamaya ba\u015flad\u0131", + "sound": "{entity_name} sesi alg\u0131lamaya ba\u015flad\u0131", + "turned_off": "{entity_name} kapat\u0131ld\u0131", + "turned_on": "{entity_name} a\u00e7\u0131ld\u0131", + "unsafe": "{entity_name} g\u00fcvensiz hale geldi", + "update": "{entity_name} bir g\u00fcncelleme ald\u0131", + "vibration": "{entity_name} , titre\u015fimi alg\u0131lamaya ba\u015flad\u0131" } }, + "device_class": { + "cold": "so\u011fuk", + "gas": "gaz", + "heat": "s\u0131cakl\u0131k", + "moisture": "nem", + "motion": "hareket", + "occupancy": "doluluk", + "power": "g\u00fc\u00e7", + "problem": "sorun", + "smoke": "duman", + "sound": "ses", + "vibration": "titre\u015fim" + }, "state": { "_": { "off": "Kapal\u0131", @@ -24,14 +133,14 @@ }, "connectivity": { "off": "Ba\u011flant\u0131 kesildi", - "on": "Ba\u011fl\u0131" + "on": "Ba\u011fland\u0131" }, "door": { - "off": "Kapal\u0131", + "off": "Kapand\u0131", "on": "A\u00e7\u0131k" }, "garage_door": { - "off": "Kapal\u0131", + "off": "Kapand\u0131", "on": "A\u00e7\u0131k" }, "gas": { @@ -47,8 +156,8 @@ "on": "I\u015f\u0131k alg\u0131land\u0131" }, "lock": { - "off": "Kilit kapal\u0131", - "on": "Kilit a\u00e7\u0131k" + "off": "Kilitli", + "on": "Kilitli de\u011fil" }, "moisture": { "off": "Kuru", @@ -67,7 +176,7 @@ "on": "Alg\u0131land\u0131" }, "opening": { - "off": "Kapal\u0131", + "off": "Kapand\u0131", "on": "A\u00e7\u0131k" }, "plug": { @@ -75,13 +184,17 @@ "on": "Tak\u0131l\u0131" }, "presence": { - "off": "D\u0131\u015farda", + "off": "D\u0131\u015far\u0131da", "on": "Evde" }, "problem": { "off": "Tamam", "on": "Sorun" }, + "running": { + "off": "\u00c7al\u0131\u015fm\u0131yor", + "on": "\u00c7al\u0131\u015f" + }, "safety": { "off": "G\u00fcvenli", "on": "G\u00fcvensiz" @@ -94,12 +207,16 @@ "off": "Temiz", "on": "Alg\u0131land\u0131" }, + "update": { + "off": "G\u00fcncel", + "on": "G\u00fcncelle\u015ftirme kullan\u0131labilir" + }, "vibration": { "off": "Temiz", "on": "Alg\u0131land\u0131" }, "window": { - "off": "Kapal\u0131", + "off": "Kapand\u0131", "on": "A\u00e7\u0131k" } }, diff --git a/homeassistant/components/binary_sensor/translations/zh-Hant.json b/homeassistant/components/binary_sensor/translations/zh-Hant.json index 5f27ce7319a..d9705225361 100644 --- a/homeassistant/components/binary_sensor/translations/zh-Hant.json +++ b/homeassistant/components/binary_sensor/translations/zh-Hant.json @@ -101,6 +101,19 @@ "vibration": "{entity_name}\u5df2\u5075\u6e2c\u5230\u9707\u52d5" } }, + "device_class": { + "cold": "\u51b7", + "gas": "\u6c23\u9ad4", + "heat": "\u71b1", + "moisture": "\u6fd5\u6c23", + "motion": "\u52d5\u4f5c", + "occupancy": "\u4f54\u7a7a", + "power": "\u96fb\u529b", + "problem": "\u7570\u5e38", + "smoke": "\u7159\u9727", + "sound": "\u8072\u97f3", + "vibration": "\u9707\u52d5" + }, "state": { "_": { "off": "\u95dc\u9589", diff --git a/homeassistant/components/blebox/__init__.py b/homeassistant/components/blebox/__init__.py index 681fff4a9bc..b6a0045940d 100644 --- a/homeassistant/components/blebox/__init__.py +++ b/homeassistant/components/blebox/__init__.py @@ -6,7 +6,7 @@ from blebox_uniapi.products import Products from blebox_uniapi.session import ApiHost from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -16,7 +16,14 @@ from .const import DEFAULT_SETUP_TIMEOUT, DOMAIN, PRODUCT _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["cover", "sensor", "switch", "air_quality", "light", "climate"] +PLATFORMS = [ + Platform.COVER, + Platform.SENSOR, + Platform.SWITCH, + Platform.AIR_QUALITY, + Platform.LIGHT, + Platform.CLIMATE, +] PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/blebox/translations/bg.json b/homeassistant/components/blebox/translations/bg.json index 11108007b21..7f4a2894507 100644 --- a/homeassistant/components/blebox/translations/bg.json +++ b/homeassistant/components/blebox/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" }, "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", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "flow_title": "{name} ({host})", diff --git a/homeassistant/components/blebox/translations/ja.json b/homeassistant/components/blebox/translations/ja.json new file mode 100644 index 00000000000..87e840f043e --- /dev/null +++ b/homeassistant/components/blebox/translations/ja.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "address_already_configured": "{address} \u306b\u306f\u3059\u3067\u306bBleBox\u30c7\u30d0\u30a4\u30b9\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002", + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc", + "unsupported_version": "BleBox device\u306e\u30d5\u30a1\u30fc\u30e0\u30a6\u30a7\u30a2\u304c\u53e4\u304f\u306a\u3063\u3066\u3044\u307e\u3059\u3002\u6700\u521d\u306b\u30a2\u30c3\u30d7\u30b0\u30ec\u30fc\u30c9\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "host": "IP\u30a2\u30c9\u30ec\u30b9", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "BleBox\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3001Home Assistant\u3068\u9023\u643a\u3059\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002", + "title": "BleBox\u30c7\u30d0\u30a4\u30b9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/blebox/translations/ru.json b/homeassistant/components/blebox/translations/ru.json index b230c4e9974..4b3528ec4fe 100644 --- a/homeassistant/components/blebox/translations/ru.json +++ b/homeassistant/components/blebox/translations/ru.json @@ -16,7 +16,7 @@ "host": "IP-\u0430\u0434\u0440\u0435\u0441", "port": "\u041f\u043e\u0440\u0442" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 BleBox.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 BleBox.", "title": "BleBox" } } diff --git a/homeassistant/components/blebox/translations/tr.json b/homeassistant/components/blebox/translations/tr.json index 6acd2cf7d43..ab4fe91a097 100644 --- a/homeassistant/components/blebox/translations/tr.json +++ b/homeassistant/components/blebox/translations/tr.json @@ -9,12 +9,15 @@ "unknown": "Beklenmeyen hata", "unsupported_version": "BleBox cihaz\u0131n\u0131n g\u00fcncel olmayan bellenimi var. L\u00fctfen \u00f6nce y\u00fckseltin." }, + "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "\u0130p Adresi", + "host": "IP Adresi", "port": "Port" - } + }, + "description": "BleBox'\u0131n\u0131z\u0131 Home Assistant ile entegre olacak \u015fekilde ayarlay\u0131n.", + "title": "BleBox cihaz\u0131n\u0131z\u0131 kurun" } } } diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index c93adbec46b..8986782031f 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -1,4 +1,6 @@ """Constants for Blink.""" +from homeassistant.const import Platform + DOMAIN = "blink" DEVICE_ID = "Home Assistant" @@ -23,4 +25,9 @@ SERVICE_TRIGGER = "trigger_camera" SERVICE_SAVE_VIDEO = "save_video" SERVICE_SEND_PIN = "send_pin" -PLATFORMS = ("alarm_control_panel", "binary_sensor", "camera", "sensor") +PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.CAMERA, + Platform.SENSOR, +] diff --git a/homeassistant/components/blink/translations/bg.json b/homeassistant/components/blink/translations/bg.json index 2ac8a444100..32c84eeb1dc 100644 --- a/homeassistant/components/blink/translations/bg.json +++ b/homeassistant/components/blink/translations/bg.json @@ -1,7 +1,36 @@ { "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\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_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": { + "2fa": { + "data": { + "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" + }, + "user": { + "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" + } + } + } + }, + "options": { + "step": { + "simple_options": { + "data": { + "scan_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043d\u0430 \u0441\u043a\u0430\u043d\u0438\u0440\u0430\u043d\u0435 (\u0441\u0435\u043a\u0443\u043d\u0434\u0438)" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/blink/translations/ca.json b/homeassistant/components/blink/translations/ca.json index bd5079d2a16..695db588b0d 100644 --- a/homeassistant/components/blink/translations/ca.json +++ b/homeassistant/components/blink/translations/ca.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", - "invalid_access_token": "Token d'acc\u00e9s no v\u00e0lid", + "invalid_access_token": "Token d'acc\u00e9s inv\u00e0lid", "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", "unknown": "Error inesperat" }, diff --git a/homeassistant/components/blink/translations/ja.json b/homeassistant/components/blink/translations/ja.json new file mode 100644 index 00000000000..40724c01d42 --- /dev/null +++ b/homeassistant/components/blink/translations/ja.json @@ -0,0 +1,40 @@ +{ + "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", + "invalid_access_token": "\u7121\u52b9\u306a\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "2fa": { + "data": { + "2fa": "2\u8981\u7d20\u30b3\u30fc\u30c9" + }, + "description": "E\u30e1\u30fc\u30eb\u3067\u9001\u3089\u308c\u3066\u304d\u305fPIN\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "2\u8981\u7d20\u8a8d\u8a3c" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Blink\u30a2\u30ab\u30a6\u30f3\u30c8\u3067\u30b5\u30a4\u30f3\u30a4\u30f3" + } + } + }, + "options": { + "step": { + "simple_options": { + "data": { + "scan_interval": "\u30b9\u30ad\u30e3\u30f3\u30a4\u30f3\u30bf\u30fc\u30d0\u30eb(\u79d2)" + }, + "description": "Blink\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u8a2d\u5b9a", + "title": "Blink \u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/blink/translations/tr.json b/homeassistant/components/blink/translations/tr.json index cef806cb309..1a7444cb644 100644 --- a/homeassistant/components/blink/translations/tr.json +++ b/homeassistant/components/blink/translations/tr.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", - "invalid_access_token": "Ge\u00e7ersiz eri\u015fim belirteci", + "invalid_access_token": "Ge\u00e7ersiz eri\u015fim anahtar\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "unknown": "Beklenmeyen hata" }, @@ -14,13 +14,26 @@ "data": { "2fa": "\u0130ki ad\u0131ml\u0131 kimlik do\u011frulama kodu" }, - "description": "E-postan\u0131za g\u00f6nderilen PIN kodunu girin" + "description": "E-postan\u0131za g\u00f6nderilen PIN kodunu girin", + "title": "\u0130ki fakt\u00f6rl\u00fc kimlik do\u011frulama" }, "user": { "data": { "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "Blink hesab\u0131yla oturum a\u00e7\u0131n" + } + } + }, + "options": { + "step": { + "simple_options": { + "data": { + "scan_interval": "Tarama Aral\u0131\u011f\u0131 (saniye)" + }, + "description": "Blink entegrasyonunu yap\u0131land\u0131r\u0131n", + "title": "Blink Se\u00e7enekleri" } } } diff --git a/homeassistant/components/bloomsky/binary_sensor.py b/homeassistant/components/bloomsky/binary_sensor.py index 858c39c4db9..2ef4586f537 100644 --- a/homeassistant/components/bloomsky/binary_sensor.py +++ b/homeassistant/components/bloomsky/binary_sensor.py @@ -39,7 +39,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BloomSkySensor(BinarySensorEntity): """Representation of a single binary sensor in a BloomSky device.""" - def __init__(self, bs, device, sensor_name): + def __init__(self, bs, device, sensor_name): # pylint: disable=invalid-name """Initialize a BloomSky binary sensor.""" self._bloomsky = bs self._device_id = device["DeviceID"] diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py index 288a1767c7e..9a14145d1d8 100644 --- a/homeassistant/components/bloomsky/sensor.py +++ b/homeassistant/components/bloomsky/sensor.py @@ -79,7 +79,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BloomSkySensor(SensorEntity): """Representation of a single sensor in a BloomSky device.""" - def __init__(self, bs, device, sensor_name): + def __init__(self, bs, device, sensor_name): # pylint: disable=invalid-name """Initialize a BloomSky sensor.""" self._bloomsky = bs self._device_id = device["DeviceID"] diff --git a/homeassistant/components/blueprint/importer.py b/homeassistant/components/blueprint/importer.py index 99dffb114e1..de39741d8ed 100644 --- a/homeassistant/components/blueprint/importer.py +++ b/homeassistant/components/blueprint/importer.py @@ -59,9 +59,7 @@ def _get_github_import_url(url: str) -> str: if url.startswith("https://raw.githubusercontent.com/"): return url - match = GITHUB_FILE_PATTERN.match(url) - - if match is None: + if (match := GITHUB_FILE_PATTERN.match(url)) is None: raise UnsupportedUrl("Not a GitHub file url") repo, path = match.groups() @@ -74,8 +72,7 @@ def _get_community_post_import_url(url: str) -> str: Async friendly. """ - match = COMMUNITY_TOPIC_PATTERN.match(url) - if match is None: + if (match := COMMUNITY_TOPIC_PATTERN.match(url)) is None: raise UnsupportedUrl("Not a topic url") _topic, post = match.groups() diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index 648ff2a1809..bfefff36601 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -3,6 +3,6 @@ "name": "Bluesound", "documentation": "https://www.home-assistant.io/integrations/bluesound", "requirements": ["xmltodict==0.12.0"], - "codeowners": [], + "codeowners": ["@thrawnarn"], "iot_class": "local_polling" } diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 6c90a511a05..c91a2dedca3 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -358,7 +358,7 @@ class BluesoundPlayer(MediaPlayerEntity): try: websession = async_get_clientsession(self._hass) - with async_timeout.timeout(10): + async with async_timeout.timeout(10): response = await websession.get(url) if response.status == HTTPStatus.OK: @@ -400,7 +400,7 @@ class BluesoundPlayer(MediaPlayerEntity): try: - with async_timeout.timeout(125): + async with async_timeout.timeout(125): response = await self._polling_session.get( url, headers={CONNECTION: KEEP_ALIVE} ) diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index c7592c5db34..e681cac8223 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -18,6 +18,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_REGION, CONF_USERNAME, + Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady @@ -34,7 +35,6 @@ from .const import ( CONF_ACCOUNT, CONF_ALLOWED_REGIONS, CONF_READ_ONLY, - CONF_USE_LOCATION, DATA_ENTRIES, DATA_HASS_CONFIG, ) @@ -64,10 +64,15 @@ SERVICE_SCHEMA = vol.Schema( DEFAULT_OPTIONS = { CONF_READ_ONLY: False, - CONF_USE_LOCATION: False, } -PLATFORMS = ["binary_sensor", "device_tracker", "lock", "notify", "sensor"] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.DEVICE_TRACKER, + Platform.LOCK, + Platform.NOTIFY, + Platform.SENSOR, +] UPDATE_INTERVAL = 5 # in minutes SERVICE_UPDATE_STATE = "update_state" @@ -150,7 +155,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await _async_update_all() hass.config_entries.async_setup_platforms( - entry, [platform for platform in PLATFORMS if platform != NOTIFY_DOMAIN] + entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] ) # set up notify platform, no entry support for notify platform yet, @@ -171,7 +176,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( - entry, [platform for platform in PLATFORMS if platform != NOTIFY_DOMAIN] + entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] ) # Only remove services if it is the last account and not read only @@ -208,13 +213,10 @@ def setup_account( password: str = entry.data[CONF_PASSWORD] region: str = entry.data[CONF_REGION] read_only: bool = entry.options[CONF_READ_ONLY] - use_location: bool = entry.options[CONF_USE_LOCATION] _LOGGER.debug("Adding new account %s", name) - pos = ( - (hass.config.latitude, hass.config.longitude) if use_location else (None, None) - ) + pos = (hass.config.latitude, hass.config.longitude) cd_account = BMWConnectedDriveAccount( username, password, region, name, read_only, *pos ) @@ -251,6 +253,13 @@ def setup_account( function_call = getattr(vehicle.remote_services, function_name) function_call() + if call.service in [ + "find_vehicle", + "activate_air_conditioning", + "deactivate_air_conditioning", + ]: + cd_account.update() + if not read_only: # register the remote services for service in _SERVICE_MAP: @@ -347,9 +356,9 @@ class BMWConnectedDriveBaseEntity(Entity): } self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, vehicle.vin)}, - manufacturer=vehicle.attributes.get("brand"), + manufacturer=vehicle.brand.name, model=vehicle.name, - name=f'{vehicle.attributes.get("brand")} {vehicle.name}', + name=f"{vehicle.brand.name} {vehicle.name}", ) def update_callback(self) -> None: diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index 7ed23f72389..37c0271a034 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -6,11 +6,18 @@ from dataclasses import dataclass import logging from typing import Any, cast -from bimmer_connected.state import ChargingState, LockState, VehicleState from bimmer_connected.vehicle import ConnectedDriveVehicle -from bimmer_connected.vehicle_status import ConditionBasedServiceReport +from bimmer_connected.vehicle_status import ( + ChargingState, + ConditionBasedServiceReport, + LockState, + VehicleStatus, +) from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_LIGHT, + DEVICE_CLASS_LOCK, DEVICE_CLASS_OPENING, DEVICE_CLASS_PLUG, DEVICE_CLASS_PROBLEM, @@ -18,7 +25,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import LENGTH_KILOMETERS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_system import UnitSystem @@ -28,13 +34,13 @@ from . import ( BMWConnectedDriveAccount, BMWConnectedDriveBaseEntity, ) -from .const import CONF_ACCOUNT, DATA_ENTRIES +from .const import CONF_ACCOUNT, DATA_ENTRIES, UNIT_MAP _LOGGER = logging.getLogger(__name__) def _are_doors_closed( - vehicle_state: VehicleState, extra_attributes: dict[str, Any], *args: Any + vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any ) -> bool: # device class opening: On means open, Off means closed _LOGGER.debug("Status of lid: %s", vehicle_state.all_lids_closed) @@ -44,7 +50,7 @@ def _are_doors_closed( def _are_windows_closed( - vehicle_state: VehicleState, extra_attributes: dict[str, Any], *args: Any + vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any ) -> bool: # device class opening: On means open, Off means closed for window in vehicle_state.windows: @@ -53,7 +59,7 @@ def _are_windows_closed( def _are_doors_locked( - vehicle_state: VehicleState, extra_attributes: dict[str, Any], *args: Any + vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any ) -> bool: # device class lock: On means unlocked, Off means locked # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED @@ -63,7 +69,7 @@ def _are_doors_locked( def _are_parking_lights_on( - vehicle_state: VehicleState, extra_attributes: dict[str, Any], *args: Any + vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any ) -> bool: # device class light: On means light detected, Off means no light extra_attributes["lights_parking"] = vehicle_state.parking_lights.value @@ -71,7 +77,7 @@ def _are_parking_lights_on( def _are_problems_detected( - vehicle_state: VehicleState, + vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], unit_system: UnitSystem, ) -> bool: @@ -82,7 +88,7 @@ def _are_problems_detected( def _check_control_messages( - vehicle_state: VehicleState, extra_attributes: dict[str, Any], *args: Any + vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any ) -> bool: # device class problem: On means problem detected, Off means no problem check_control_messages = vehicle_state.check_control_messages @@ -96,7 +102,7 @@ def _check_control_messages( def _is_vehicle_charging( - vehicle_state: VehicleState, extra_attributes: dict[str, Any], *args: Any + vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any ) -> bool: # device class power: On means power detected, Off means no power extra_attributes["charging_status"] = vehicle_state.charging_status.value @@ -107,7 +113,7 @@ def _is_vehicle_charging( def _is_vehicle_plugged_in( - vehicle_state: VehicleState, extra_attributes: dict[str, Any], *args: Any + vehicle_state: VehicleStatus, extra_attributes: dict[str, Any], *args: Any ) -> bool: # device class plug: On means device is plugged in, # Off means device is unplugged @@ -124,7 +130,12 @@ def _format_cbs_report( if report.due_date is not None: result[f"{service_type} date"] = report.due_date.strftime("%Y-%m-%d") if report.due_distance is not None: - distance = round(unit_system.length(report.due_distance, LENGTH_KILOMETERS)) + distance = round( + unit_system.length( + report.due_distance[0], + UNIT_MAP.get(report.due_distance[1], report.due_distance[1]), + ) + ) result[f"{service_type} distance"] = f"{distance} {unit_system.length_unit}" return result @@ -133,7 +144,7 @@ def _format_cbs_report( class BMWRequiredKeysMixin: """Mixin for required keys.""" - value_fn: Callable[[VehicleState, dict[str, Any], UnitSystem], bool] + value_fn: Callable[[VehicleStatus, dict[str, Any], UnitSystem], bool] @dataclass @@ -161,14 +172,14 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( BMWBinarySensorEntityDescription( key="door_lock_state", name="Door lock state", - device_class="lock", + device_class=DEVICE_CLASS_LOCK, icon="mdi:car-key", value_fn=_are_doors_locked, ), BMWBinarySensorEntityDescription( key="lights_parking", name="Parking lights", - device_class="light", + device_class=DEVICE_CLASS_LIGHT, icon="mdi:car-parking-lights", value_fn=_are_parking_lights_on, ), @@ -190,7 +201,7 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( BMWBinarySensorEntityDescription( key="charging_status", name="Charging status", - device_class="power", + device_class=DEVICE_CLASS_BATTERY_CHARGING, icon="mdi:ev-station", value_fn=_is_vehicle_charging, ), @@ -245,7 +256,8 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, BinarySensorEntity): def update(self) -> None: """Read new state data from the library.""" - vehicle_state = self._vehicle.state + _LOGGER.debug("Updating binary sensors of %s", self._vehicle.name) + vehicle_state = self._vehicle.status result = self._attrs.copy() self._attr_is_on = self.entity_description.value_fn( diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index 838c991edb3..3b07830c077 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from . import DOMAIN -from .const import CONF_ALLOWED_REGIONS, CONF_READ_ONLY, CONF_USE_LOCATION +from .const import CONF_ALLOWED_REGIONS, CONF_READ_ONLY DATA_SCHEMA = vol.Schema( { @@ -115,10 +115,6 @@ class BMWConnectedDriveOptionsFlow(config_entries.OptionsFlow): CONF_READ_ONLY, default=self.config_entry.options.get(CONF_READ_ONLY, False), ): bool, - vol.Optional( - CONF_USE_LOCATION, - default=self.config_entry.options.get(CONF_USE_LOCATION, False), - ): bool, } ), ) diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py index 7af24496838..83609d239c1 100644 --- a/homeassistant/components/bmw_connected_drive/const.py +++ b/homeassistant/components/bmw_connected_drive/const.py @@ -1,4 +1,11 @@ """Const file for the BMW Connected Drive integration.""" +from homeassistant.const import ( + LENGTH_KILOMETERS, + LENGTH_MILES, + VOLUME_GALLONS, + VOLUME_LITERS, +) + ATTRIBUTION = "Data provided by BMW Connected Drive" CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"] @@ -9,3 +16,10 @@ CONF_ACCOUNT = "account" DATA_HASS_CONFIG = "hass_config" DATA_ENTRIES = "entries" + +UNIT_MAP = { + "KILOMETERS": LENGTH_KILOMETERS, + "MILES": LENGTH_MILES, + "LITERS": VOLUME_LITERS, + "GALLONS": VOLUME_GALLONS, +} diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py index 4d7b6094968..0ba2d5012a1 100644 --- a/homeassistant/components/bmw_connected_drive/device_tracker.py +++ b/homeassistant/components/bmw_connected_drive/device_tracker.py @@ -35,7 +35,7 @@ async def async_setup_entry( for vehicle in account.account.vehicles: entities.append(BMWDeviceTracker(account, vehicle)) - if not vehicle.state.is_vehicle_tracking_enabled: + if not vehicle.is_vehicle_tracking_enabled: _LOGGER.info( "Tracking is (currently) disabled for vehicle %s (%s), defaulting to unknown", vehicle.name, @@ -59,7 +59,7 @@ class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity): super().__init__(account, vehicle) self._attr_unique_id = vehicle.vin - self._location = pos if (pos := vehicle.state.gps_position) else None + self._location = pos if (pos := vehicle.status.gps_position) else None self._attr_name = vehicle.name @property @@ -79,9 +79,10 @@ class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity): def update(self) -> None: """Update state of the decvice tracker.""" + _LOGGER.debug("Updating device tracker of %s", self._vehicle.name) self._attr_extra_state_attributes = self._attrs self._location = ( - self._vehicle.state.gps_position - if self._vehicle.state.is_vehicle_tracking_enabled + self._vehicle.status.gps_position + if self._vehicle.is_vehicle_tracking_enabled else None ) diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index 62e42476812..71539019f82 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -2,8 +2,8 @@ import logging from typing import Any -from bimmer_connected.state import LockState from bimmer_connected.vehicle import ConnectedDriveVehicle +from bimmer_connected.vehicle_status import LockState from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry @@ -78,8 +78,10 @@ class BMWLock(BMWConnectedDriveBaseEntity, LockEntity): def update(self) -> None: """Update state of the lock.""" - _LOGGER.debug("%s: updating data for %s", self._vehicle.name, self._attribute) - vehicle_state = self._vehicle.state + _LOGGER.debug( + "Updating lock data for '%s' of %s", self._attribute, self._vehicle.name + ) + vehicle_state = self._vehicle.status if not self.door_lock_state_available: self._attr_is_locked = None else: diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 110f8295c8a..fc641548aff 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.7.21"], + "requirements": ["bimmer_connected==0.8.5"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/homeassistant/components/bmw_connected_drive/notify.py b/homeassistant/components/bmw_connected_drive/notify.py index 4f4645bf78a..2db25aaa592 100644 --- a/homeassistant/components/bmw_connected_drive/notify.py +++ b/homeassistant/components/bmw_connected_drive/notify.py @@ -9,8 +9,6 @@ from bimmer_connected.vehicle import ConnectedDriveVehicle from homeassistant.components.notify import ( ATTR_DATA, ATTR_TARGET, - ATTR_TITLE, - ATTR_TITLE_DEFAULT, BaseNotificationService, ) from homeassistant.const import ATTR_LATITUDE, ATTR_LOCATION, ATTR_LONGITUDE, ATTR_NAME @@ -63,7 +61,6 @@ class BMWNotificationService(BaseNotificationService): _LOGGER.debug("Sending message to %s", vehicle.name) # Extract params from data dict - title: str = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = kwargs.get(ATTR_DATA) # Check if message is a POI @@ -84,6 +81,4 @@ class BMWNotificationService(BaseNotificationService): vehicle.remote_services.trigger_send_poi(location_dict) else: - vehicle.remote_services.trigger_send_message( - {ATTR_TEXT: message, ATTR_SUBJECT: title} - ) + raise ValueError(f"'data.{ATTR_LOCATION}' is required.") diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 4f0dc7904fc..3eda7ccd5b0 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -1,34 +1,28 @@ """Support for reading vehicle status from BMW connected drive portal.""" from __future__ import annotations -from copy import copy +from collections.abc import Callable from dataclasses import dataclass import logging +from typing import cast -from bimmer_connected.const import SERVICE_ALL_TRIPS, SERVICE_LAST_TRIP, SERVICE_STATUS -from bimmer_connected.state import ChargingState from bimmer_connected.vehicle import ConnectedDriveVehicle from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_UNIT_SYSTEM_IMPERIAL, - DEVICE_CLASS_TIMESTAMP, - ENERGY_KILO_WATT_HOUR, - ENERGY_WATT_HOUR, + DEVICE_CLASS_BATTERY, LENGTH_KILOMETERS, LENGTH_MILES, - MASS_KILOGRAMS, PERCENTAGE, TIME_HOURS, - TIME_MINUTES, VOLUME_GALLONS, VOLUME_LITERS, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.icon import icon_for_battery_level -import homeassistant.util.dt as dt_util +from homeassistant.helpers.typing import StateType from homeassistant.util.unit_system import UnitSystem from . import ( @@ -36,7 +30,7 @@ from . import ( BMWConnectedDriveAccount, BMWConnectedDriveBaseEntity, ) -from .const import CONF_ACCOUNT, DATA_ENTRIES +from .const import CONF_ACCOUNT, DATA_ENTRIES, UNIT_MAP _LOGGER = logging.getLogger(__name__) @@ -47,6 +41,7 @@ class BMWSensorEntityDescription(SensorEntityDescription): unit_metric: str | None = None unit_imperial: str | None = None + value: Callable = lambda x, y: x SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { @@ -59,58 +54,14 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { ), "charging_status": BMWSensorEntityDescription( key="charging_status", - icon="mdi:battery-charging", + icon="mdi:ev-station", + value=lambda x, y: x.value, ), - # No icon as this is dealt with directly as a special case in icon() "charging_level_hv": BMWSensorEntityDescription( key="charging_level_hv", unit_metric=PERCENTAGE, unit_imperial=PERCENTAGE, - ), - # LastTrip attributes - "date_utc": BMWSensorEntityDescription( - key="date_utc", - device_class=DEVICE_CLASS_TIMESTAMP, - ), - "duration": BMWSensorEntityDescription( - key="duration", - icon="mdi:timer-outline", - unit_metric=TIME_MINUTES, - unit_imperial=TIME_MINUTES, - ), - "electric_distance_ratio": BMWSensorEntityDescription( - key="electric_distance_ratio", - icon="mdi:percent-outline", - unit_metric=PERCENTAGE, - unit_imperial=PERCENTAGE, - entity_registry_enabled_default=False, - ), - # AllTrips attributes - "battery_size_max": BMWSensorEntityDescription( - key="battery_size_max", - icon="mdi:battery-charging-high", - unit_metric=ENERGY_WATT_HOUR, - unit_imperial=ENERGY_WATT_HOUR, - entity_registry_enabled_default=False, - ), - "reset_date_utc": BMWSensorEntityDescription( - key="reset_date_utc", - device_class=DEVICE_CLASS_TIMESTAMP, - entity_registry_enabled_default=False, - ), - "saved_co2": BMWSensorEntityDescription( - key="saved_co2", - icon="mdi:tree-outline", - unit_metric=MASS_KILOGRAMS, - unit_imperial=MASS_KILOGRAMS, - entity_registry_enabled_default=False, - ), - "saved_co2_green_energy": BMWSensorEntityDescription( - key="saved_co2_green_energy", - icon="mdi:tree-outline", - unit_metric=MASS_KILOGRAMS, - unit_imperial=MASS_KILOGRAMS, - entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_BATTERY, ), # --- Specific --- "mileage": BMWSensorEntityDescription( @@ -118,254 +69,61 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { icon="mdi:speedometer", unit_metric=LENGTH_KILOMETERS, unit_imperial=LENGTH_MILES, + value=lambda x, hass: round( + hass.config.units.length(x[0], UNIT_MAP.get(x[1], x[1])) + ), ), "remaining_range_total": BMWSensorEntityDescription( key="remaining_range_total", icon="mdi:map-marker-distance", unit_metric=LENGTH_KILOMETERS, unit_imperial=LENGTH_MILES, + value=lambda x, hass: round( + hass.config.units.length(x[0], UNIT_MAP.get(x[1], x[1])) + ), ), "remaining_range_electric": BMWSensorEntityDescription( key="remaining_range_electric", icon="mdi:map-marker-distance", unit_metric=LENGTH_KILOMETERS, unit_imperial=LENGTH_MILES, + value=lambda x, hass: round( + hass.config.units.length(x[0], UNIT_MAP.get(x[1], x[1])) + ), ), "remaining_range_fuel": BMWSensorEntityDescription( key="remaining_range_fuel", icon="mdi:map-marker-distance", unit_metric=LENGTH_KILOMETERS, unit_imperial=LENGTH_MILES, - ), - "max_range_electric": BMWSensorEntityDescription( - key="max_range_electric", - icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, + value=lambda x, hass: round( + hass.config.units.length(x[0], UNIT_MAP.get(x[1], x[1])) + ), ), "remaining_fuel": BMWSensorEntityDescription( key="remaining_fuel", icon="mdi:gas-station", unit_metric=VOLUME_LITERS, unit_imperial=VOLUME_GALLONS, + value=lambda x, hass: round( + hass.config.units.volume(x[0], UNIT_MAP.get(x[1], x[1])) + ), ), - # LastTrip attributes - "average_combined_consumption": BMWSensorEntityDescription( - key="average_combined_consumption", - icon="mdi:flash", - unit_metric=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - unit_imperial=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - ), - "average_electric_consumption": BMWSensorEntityDescription( - key="average_electric_consumption", - icon="mdi:power-plug-outline", - unit_metric=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - unit_imperial=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - ), - "average_recuperation": BMWSensorEntityDescription( - key="average_recuperation", - icon="mdi:recycle-variant", - unit_metric=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - unit_imperial=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - ), - "electric_distance": BMWSensorEntityDescription( - key="electric_distance", - icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, - ), - "saved_fuel": BMWSensorEntityDescription( - key="saved_fuel", - icon="mdi:fuel", - unit_metric=VOLUME_LITERS, - unit_imperial=VOLUME_GALLONS, - entity_registry_enabled_default=False, - ), - "total_distance": BMWSensorEntityDescription( - key="total_distance", - icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, - ), - # AllTrips attributes - "average_combined_consumption_community_average": BMWSensorEntityDescription( - key="average_combined_consumption_community_average", - icon="mdi:flash", - unit_metric=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - unit_imperial=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - entity_registry_enabled_default=False, - ), - "average_combined_consumption_community_high": BMWSensorEntityDescription( - key="average_combined_consumption_community_high", - icon="mdi:flash", - unit_metric=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - unit_imperial=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - entity_registry_enabled_default=False, - ), - "average_combined_consumption_community_low": BMWSensorEntityDescription( - key="average_combined_consumption_community_low", - icon="mdi:flash", - unit_metric=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - unit_imperial=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - entity_registry_enabled_default=False, - ), - "average_combined_consumption_user_average": BMWSensorEntityDescription( - key="average_combined_consumption_user_average", - icon="mdi:flash", - unit_metric=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - unit_imperial=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - ), - "average_electric_consumption_community_average": BMWSensorEntityDescription( - key="average_electric_consumption_community_average", - icon="mdi:power-plug-outline", - unit_metric=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - unit_imperial=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - entity_registry_enabled_default=False, - ), - "average_electric_consumption_community_high": BMWSensorEntityDescription( - key="average_electric_consumption_community_high", - icon="mdi:power-plug-outline", - unit_metric=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - unit_imperial=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - entity_registry_enabled_default=False, - ), - "average_electric_consumption_community_low": BMWSensorEntityDescription( - key="average_electric_consumption_community_low", - icon="mdi:power-plug-outline", - unit_metric=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - unit_imperial=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - entity_registry_enabled_default=False, - ), - "average_electric_consumption_user_average": BMWSensorEntityDescription( - key="average_electric_consumption_user_average", - icon="mdi:power-plug-outline", - unit_metric=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - unit_imperial=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - ), - "average_recuperation_community_average": BMWSensorEntityDescription( - key="average_recuperation_community_average", - icon="mdi:recycle-variant", - unit_metric=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - unit_imperial=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - entity_registry_enabled_default=False, - ), - "average_recuperation_community_high": BMWSensorEntityDescription( - key="average_recuperation_community_high", - icon="mdi:recycle-variant", - unit_metric=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - unit_imperial=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - entity_registry_enabled_default=False, - ), - "average_recuperation_community_low": BMWSensorEntityDescription( - key="average_recuperation_community_low", - icon="mdi:recycle-variant", - unit_metric=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - unit_imperial=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - entity_registry_enabled_default=False, - ), - "average_recuperation_user_average": BMWSensorEntityDescription( - key="average_recuperation_user_average", - icon="mdi:recycle-variant", - unit_metric=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", - unit_imperial=f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", - ), - "chargecycle_range_community_average": BMWSensorEntityDescription( - key="chargecycle_range_community_average", - icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, - entity_registry_enabled_default=False, - ), - "chargecycle_range_community_high": BMWSensorEntityDescription( - key="chargecycle_range_community_high", - icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, - entity_registry_enabled_default=False, - ), - "chargecycle_range_community_low": BMWSensorEntityDescription( - key="chargecycle_range_community_low", - icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, - entity_registry_enabled_default=False, - ), - "chargecycle_range_user_average": BMWSensorEntityDescription( - key="chargecycle_range_user_average", - icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, - ), - "chargecycle_range_user_current_charge_cycle": BMWSensorEntityDescription( - key="chargecycle_range_user_current_charge_cycle", - icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, - ), - "chargecycle_range_user_high": BMWSensorEntityDescription( - key="chargecycle_range_user_high", - icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, - ), - "total_electric_distance_community_average": BMWSensorEntityDescription( - key="total_electric_distance_community_average", - icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, - entity_registry_enabled_default=False, - ), - "total_electric_distance_community_high": BMWSensorEntityDescription( - key="total_electric_distance_community_high", - icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, - entity_registry_enabled_default=False, - ), - "total_electric_distance_community_low": BMWSensorEntityDescription( - key="total_electric_distance_community_low", - icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, - entity_registry_enabled_default=False, - ), - "total_electric_distance_user_average": BMWSensorEntityDescription( - key="total_electric_distance_user_average", - icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, - entity_registry_enabled_default=False, - ), - "total_electric_distance_user_total": BMWSensorEntityDescription( - key="total_electric_distance_user_total", - icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, - entity_registry_enabled_default=False, - ), - "total_saved_fuel": BMWSensorEntityDescription( - key="total_saved_fuel", - icon="mdi:fuel", - unit_metric=VOLUME_LITERS, - unit_imperial=VOLUME_GALLONS, - entity_registry_enabled_default=False, + "fuel_percent": BMWSensorEntityDescription( + key="fuel_percent", + icon="mdi:gas-station", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, ), } -DEFAULT_BMW_DESCRIPTION = BMWSensorEntityDescription( - key="", - entity_registry_enabled_default=True, -) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the BMW ConnectedDrive sensors from config entry.""" - # pylint: disable=too-many-nested-blocks unit_system = hass.config.units account: BMWConnectedDriveAccount = hass.data[BMW_DOMAIN][DATA_ENTRIES][ config_entry.entry_id @@ -373,118 +131,13 @@ async def async_setup_entry( entities: list[BMWConnectedDriveSensor] = [] for vehicle in account.account.vehicles: - for service in vehicle.available_state_services: - if service == SERVICE_STATUS: - entities.extend( - [ - BMWConnectedDriveSensor( - account, vehicle, description, unit_system - ) - for attribute_name in vehicle.drive_train_attributes - if attribute_name in vehicle.available_attributes - and (description := SENSOR_TYPES.get(attribute_name)) - ] - ) - if service == SERVICE_LAST_TRIP: - entities.extend( - [ - # mypy issues will be fixed in next release - # https://github.com/python/mypy/issues/9096 - BMWConnectedDriveSensor( - account, - vehicle, - description, # type: ignore[arg-type] - unit_system, - service, - ) - for attribute_name in vehicle.state.last_trip.available_attributes - if attribute_name != "date" - and (description := SENSOR_TYPES.get(attribute_name)) # type: ignore[no-redef] - ] - ) - if "date" in vehicle.state.last_trip.available_attributes: - entities.append( - BMWConnectedDriveSensor( - account, - vehicle, - SENSOR_TYPES["date_utc"], - unit_system, - service, - ) - ) - if service == SERVICE_ALL_TRIPS: - for attribute_name in vehicle.state.all_trips.available_attributes: - if attribute_name == "reset_date": - entities.append( - BMWConnectedDriveSensor( - account, - vehicle, - SENSOR_TYPES["reset_date_utc"], - unit_system, - service, - ) - ) - elif attribute_name in ( - "average_combined_consumption", - "average_electric_consumption", - "average_recuperation", - "chargecycle_range", - "total_electric_distance", - ): - entities.extend( - [ - BMWConnectedDriveSensor( - account, - vehicle, - SENSOR_TYPES[f"{attribute_name}_{attr}"], - unit_system, - service, - ) - for attr in ( - "community_average", - "community_high", - "community_low", - "user_average", - ) - ] - ) - if attribute_name == "chargecycle_range": - entities.extend( - BMWConnectedDriveSensor( - account, - vehicle, - SENSOR_TYPES[f"{attribute_name}_{attr}"], - unit_system, - service, - ) - for attr in ("user_current_charge_cycle", "user_high") - ) - elif attribute_name == "total_electric_distance": - entities.extend( - [ - BMWConnectedDriveSensor( - account, - vehicle, - SENSOR_TYPES[f"{attribute_name}_{attr}"], - unit_system, - service, - ) - for attr in ("user_total",) - ] - ) - else: - if (description := SENSOR_TYPES.get(attribute_name)) is None: - description = copy(DEFAULT_BMW_DESCRIPTION) - description.key = attribute_name - entities.append( - BMWConnectedDriveSensor( - account, - vehicle, - description, - unit_system, - service, - ) - ) + entities.extend( + [ + BMWConnectedDriveSensor(account, vehicle, description, unit_system) + for attribute_name in vehicle.available_attributes + if (description := SENSOR_TYPES.get(attribute_name)) + ] + ) async_add_entities(entities, True) @@ -500,87 +153,21 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity): vehicle: ConnectedDriveVehicle, description: BMWSensorEntityDescription, unit_system: UnitSystem, - service: str | None = None, ) -> None: """Initialize BMW vehicle sensor.""" super().__init__(account, vehicle) self.entity_description = description - self._service = service - if service: - self._attr_name = f"{vehicle.name} {service.lower()}_{description.key}" - self._attr_unique_id = f"{vehicle.vin}-{service.lower()}-{description.key}" - else: - self._attr_name = f"{vehicle.name} {description.key}" - self._attr_unique_id = f"{vehicle.vin}-{description.key}" + self._attr_name = f"{vehicle.name} {description.key}" + self._attr_unique_id = f"{vehicle.vin}-{description.key}" if unit_system.name == CONF_UNIT_SYSTEM_IMPERIAL: self._attr_native_unit_of_measurement = description.unit_imperial else: self._attr_native_unit_of_measurement = description.unit_metric - def update(self) -> None: - """Read new state data from the library.""" - _LOGGER.debug("Updating %s", self._vehicle.name) - vehicle_state = self._vehicle.state - sensor_key = self.entity_description.key - if sensor_key == "charging_status": - self._attr_native_value = getattr(vehicle_state, sensor_key).value - elif self.unit_of_measurement == VOLUME_GALLONS: - value = getattr(vehicle_state, sensor_key) - value_converted = self.hass.config.units.volume(value, VOLUME_LITERS) - self._attr_native_value = round(value_converted) - elif self.unit_of_measurement == LENGTH_MILES: - value = getattr(vehicle_state, sensor_key) - value_converted = self.hass.config.units.length(value, LENGTH_KILOMETERS) - self._attr_native_value = round(value_converted) - elif self._service is None: - self._attr_native_value = getattr(vehicle_state, sensor_key) - elif self._service == SERVICE_LAST_TRIP: - vehicle_last_trip = self._vehicle.state.last_trip - if sensor_key == "date_utc": - date_str = getattr(vehicle_last_trip, "date") - if parsed_date := dt_util.parse_datetime(date_str): - self._attr_native_value = parsed_date.isoformat() - else: - _LOGGER.debug( - "Could not parse date string for 'date_utc' sensor: %s", - date_str, - ) - self._attr_native_value = None - else: - self._attr_native_value = getattr(vehicle_last_trip, sensor_key) - elif self._service == SERVICE_ALL_TRIPS: - vehicle_all_trips = self._vehicle.state.all_trips - for attribute in ( - "average_combined_consumption", - "average_electric_consumption", - "average_recuperation", - "chargecycle_range", - "total_electric_distance", - ): - if sensor_key.startswith(f"{attribute}_"): - attr = getattr(vehicle_all_trips, attribute) - sub_attr = sensor_key.replace(f"{attribute}_", "") - self._attr_native_value = getattr(attr, sub_attr) - return - if sensor_key == "reset_date_utc": - date_str = getattr(vehicle_all_trips, "reset_date") - if parsed_date := dt_util.parse_datetime(date_str): - self._attr_native_value = parsed_date.isoformat() - else: - _LOGGER.debug( - "Could not parse date string for 'reset_date_utc' sensor: %s", - date_str, - ) - self._attr_native_value = None - else: - self._attr_native_value = getattr(vehicle_all_trips, sensor_key) - - vehicle_state = self._vehicle.state - charging_state = vehicle_state.charging_status in {ChargingState.CHARGING} - - if sensor_key == "charging_level_hv": - self._attr_icon = icon_for_battery_level( - battery_level=vehicle_state.charging_level_hv, charging=charging_state - ) + @property + def native_value(self) -> StateType: + """Return the state.""" + state = getattr(self._vehicle.status, self.entity_description.key) + return cast(StateType, self.entity_description.value(state, self.hass)) diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index c0c45b814a4..3e93cccb8c6 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -21,8 +21,7 @@ "step": { "account_options": { "data": { - "read_only": "Read-only (only sensors and notify, no execution of services, no lock)", - "use_location": "Use Home Assistant location for car location polls (required for non i3/i8 vehicles produced before 7/2014)" + "read_only": "Read-only (only sensors and notify, no execution of services, no lock)" } } } diff --git a/homeassistant/components/bmw_connected_drive/translations/ja.json b/homeassistant/components/bmw_connected_drive/translations/ja.json new file mode 100644 index 00000000000..5e33b26f423 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/translations/ja.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "region": "ConnectedDrive\u30ea\u30fc\u30b8\u30e7\u30f3", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + }, + "options": { + "step": { + "account_options": { + "data": { + "read_only": "\u30ea\u30fc\u30c9\u30aa\u30f3\u30ea\u30fc(\u30bb\u30f3\u30b5\u30fc\u3068\u901a\u77e5\u306e\u307f\u3001\u30b5\u30fc\u30d3\u30b9\u306e\u5b9f\u884c\u306f\u4e0d\u53ef\u3001\u30ed\u30c3\u30af\u4e0d\u53ef)", + "use_location": "Home Assistant\u306e\u5834\u6240\u3092\u3001\u8eca\u306e\u4f4d\u7f6e\u3068\u3057\u3066\u30dd\u30fc\u30ea\u30f3\u30b0\u306b\u4f7f\u7528\u3059\u308b((2014\u5e747\u67087\u65e5\u4ee5\u524d\u306b\u751f\u7523\u3055\u308c\u305f\u3001i3/i8\u4ee5\u5916\u306e\u8eca\u4e21\u3067\u306f\u5fc5\u9808)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bmw_connected_drive/translations/sv.json b/homeassistant/components/bmw_connected_drive/translations/sv.json new file mode 100644 index 00000000000..93cd828041e --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/translations/sv.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Konto har redan konfigurerats" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta.", + "invalid_auth": "Ogiltig autentisering" + }, + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "region": "ConnectedDrive Region", + "username": "Anv\u00e4ndarnamn" + } + } + } + }, + "options": { + "step": { + "account_options": { + "data": { + "read_only": "Endast l\u00e4sbar (endast sensorer och meddelanden, ingen k\u00f6rning av tj\u00e4nster, ingen l\u00e5sning)", + "use_location": "Anv\u00e4nd plats f\u00f6r Home Assistant som plats f\u00f6r bilen (kr\u00e4vs f\u00f6r icke i3/i8-fordon tillverkade f\u00f6re 7/2014)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bmw_connected_drive/translations/tr.json b/homeassistant/components/bmw_connected_drive/translations/tr.json index 153aa4126b0..d38f950392b 100644 --- a/homeassistant/components/bmw_connected_drive/translations/tr.json +++ b/homeassistant/components/bmw_connected_drive/translations/tr.json @@ -11,9 +11,20 @@ "user": { "data": { "password": "Parola", + "region": "ConnectedDrive B\u00f6lgesi", "username": "Kullan\u0131c\u0131 Ad\u0131" } } } + }, + "options": { + "step": { + "account_options": { + "data": { + "read_only": "Salt okunur (yaln\u0131zca sens\u00f6rler ve bildirim, hizmetlerin y\u00fcr\u00fct\u00fclmesi yok, kilit yok)", + "use_location": "Araba konumu anketleri i\u00e7in Home Assistant konumunu kullan\u0131n (7/2014 tarihinden \u00f6nce \u00fcretilmi\u015f i3/i8 olmayan ara\u00e7lar i\u00e7in gereklidir)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 20b6b0a2ea5..95fa740f24c 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -8,7 +8,12 @@ from aiohttp import ClientError, ClientResponseError, ClientTimeout from bond_api import Bond, BPUPSubscriptions, start_bpup from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_HOST, + EVENT_HOMEASSISTANT_STOP, + Platform, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -18,7 +23,12 @@ from homeassistant.helpers.entity import SLOW_UPDATE_WARNING from .const import BPUP_SUBS, BRIDGE_MAKE, DOMAIN, HUB from .utils import BondHub -PLATFORMS = ["cover", "fan", "light", "switch"] +PLATFORMS = [ + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.SWITCH, +] _API_TIMEOUT = SLOW_UPDATE_WARNING - 1 _LOGGER = logging.getLogger(__name__) @@ -36,7 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: timeout=ClientTimeout(total=_API_TIMEOUT), session=async_get_clientsession(hass), ) - hub = BondHub(bond) + hub = BondHub(bond, host) try: await hub.setup() except ClientResponseError as ex: @@ -69,7 +79,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: assert hub.bond_id is not None hub_name = hub.name or hub.bond_id - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry_id, identifiers={(DOMAIN, hub.bond_id)}, @@ -78,6 +88,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model=hub.target, sw_version=hub.fw_ver, suggested_area=hub.location, + configuration_url=f"http://{host}", ) _async_remove_old_device_identifiers(config_entry_id, device_registry, hub) diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 6f70d37e0a1..d3a7b4adf72 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -10,12 +10,12 @@ from bond_api import Bond import voluptuous as vol from homeassistant import config_entries, exceptions +from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import DiscoveryInfoType from .const import DOMAIN from .utils import BondHub @@ -47,7 +47,7 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> tuple[st data[CONF_HOST], data[CONF_ACCESS_TOKEN], session=async_get_clientsession(hass) ) try: - hub = BondHub(bond) + hub = BondHub(bond, data[CONF_HOST]) await hub.setup(max_devices=1) except ClientConnectionError as error: raise InputValidationError("cannot_connect") from error @@ -87,15 +87,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self._discovered[CONF_ACCESS_TOKEN] = token - _, hub_name = await _validate_input(self.hass, self._discovered) + try: + _, hub_name = await _validate_input(self.hass, self._discovered) + except InputValidationError: + return self._discovered[CONF_NAME] = hub_name async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle a flow initialized by zeroconf discovery.""" - name: str = discovery_info[CONF_NAME] - host: str = discovery_info[CONF_HOST] + name: str = discovery_info.name + host: str = discovery_info.host bond_id = name.partition(".")[0] await self.async_set_unique_id(bond_id) for entry in self._async_current_entries(): diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py index 3a2777b09e8..2c5f0fe66c3 100644 --- a/homeassistant/components/bond/cover.py +++ b/homeassistant/components/bond/cover.py @@ -5,7 +5,16 @@ from typing import Any from bond_api import Action, BPUPSubscriptions, DeviceType -from homeassistant.components.cover import DEVICE_CLASS_SHADE, CoverEntity +from homeassistant.components.cover import ( + DEVICE_CLASS_SHADE, + SUPPORT_CLOSE, + SUPPORT_CLOSE_TILT, + SUPPORT_OPEN, + SUPPORT_OPEN_TILT, + SUPPORT_STOP, + SUPPORT_STOP_TILT, + CoverEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity @@ -45,6 +54,21 @@ class BondCover(BondEntity, CoverEntity): ) -> None: """Create HA entity representing Bond cover.""" super().__init__(hub, device, bpup_subs) + supported_features = 0 + if self._device.supports_open(): + supported_features |= SUPPORT_OPEN + if self._device.supports_close(): + supported_features |= SUPPORT_CLOSE + if self._device.supports_tilt_open(): + supported_features |= SUPPORT_OPEN_TILT + if self._device.supports_tilt_close(): + supported_features |= SUPPORT_CLOSE_TILT + if self._device.supports_hold(): + if self._device.supports_open() or self._device.supports_close(): + supported_features |= SUPPORT_STOP + if self._device.supports_tilt_open() or self._device.supports_tilt_close(): + supported_features |= SUPPORT_STOP_TILT + self._attr_supported_features = supported_features def _apply_state(self, state: dict) -> None: cover_open = state.get("open") @@ -63,3 +87,15 @@ class BondCover(BondEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Hold cover.""" await self._hub.bond.action(self._device.device_id, Action.hold()) + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + await self._hub.bond.action(self._device.device_id, Action.tilt_open()) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + await self._hub.bond.action(self._device.device_id, Action.tilt_close()) + + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self._hub.bond.action(self._device.device_id, Action.hold()) diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 5f37de4fa19..342e407ff48 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -65,6 +65,7 @@ class BondEntity(Entity): manufacturer=self._hub.make, # type ignore: tuple items should not be Optional identifiers={(DOMAIN, self._hub.bond_id, self._device.device_id)}, # type: ignore[arg-type] + configuration_url=f"http://{self._hub.host}", ) if self.name is not None: device_info[ATTR_NAME] = self.name diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index dd4699ad006..255f848c167 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -180,8 +180,7 @@ class BondLight(BondBaseLight, BondEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - brightness = kwargs.get(ATTR_BRIGHTNESS) - if brightness: + if brightness := kwargs.get(ATTR_BRIGHTNESS): await self._hub.bond.action( self._device.device_id, Action.set_brightness(round((brightness * 100) / 255)), diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index a8395b68d60..cf5255e84a4 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -3,7 +3,7 @@ "name": "Bond", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bond", - "requirements": ["bond-api==0.1.14"], + "requirements": ["bond-api==0.1.15"], "zeroconf": ["_bond._tcp.local."], "codeowners": ["@bdraco", "@prystupa", "@joshs85"], "quality_scale": "platinum", diff --git a/homeassistant/components/bond/translations/bg.json b/homeassistant/components/bond/translations/bg.json new file mode 100644 index 00000000000..7f67a133aa8 --- /dev/null +++ b/homeassistant/components/bond/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" + }, + "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": "{name} ({host})", + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/ja.json b/homeassistant/components/bond/translations/ja.json new file mode 100644 index 00000000000..c5bf98f6740 --- /dev/null +++ b/homeassistant/components/bond/translations/ja.json @@ -0,0 +1,28 @@ +{ + "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "old_firmware": "Bond device\u3067\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u306a\u3044\u53e4\u3044\u30d5\u30a1\u30fc\u30e0\u30a6\u30a7\u30a2 - \u7d9a\u884c\u3059\u308b\u524d\u306b\u30a2\u30c3\u30d7\u30b0\u30ec\u30fc\u30c9\u3057\u3066\u304f\u3060\u3055\u3044", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name} ({host})", + "step": { + "confirm": { + "data": { + "access_token": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3" + }, + "description": "{name} \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "access_token": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", + "host": "\u30db\u30b9\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/tr.json b/homeassistant/components/bond/translations/tr.json index 3488480a218..e00bdd6370c 100644 --- a/homeassistant/components/bond/translations/tr.json +++ b/homeassistant/components/bond/translations/tr.json @@ -6,18 +6,21 @@ "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "old_firmware": "Bond cihaz\u0131nda desteklenmeyen eski s\u00fcr\u00fcm - l\u00fctfen devam etmeden \u00f6nce y\u00fckseltin", "unknown": "Beklenmeyen hata" }, + "flow_title": "{name} ({host})", "step": { "confirm": { "data": { - "access_token": "Eri\u015fim Belirteci" - } + "access_token": "Eri\u015fim Anahtar\u0131" + }, + "description": "{name} kurmak istiyor musunuz?" }, "user": { "data": { - "access_token": "Eri\u015fim Belirteci", - "host": "Ana Bilgisayar" + "access_token": "Eri\u015fim Anahtar\u0131", + "host": "Ana bilgisayar" } } } diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index 4f3de1bf1f0..b9dbe07ea50 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -82,6 +82,26 @@ class BondDevice: """Return True if this device supports any of the direction related commands.""" return self._has_any_action({Action.SET_DIRECTION}) + def supports_open(self) -> bool: + """Return True if this device supports opening.""" + return self._has_any_action({Action.OPEN}) + + def supports_close(self) -> bool: + """Return True if this device supports closing.""" + return self._has_any_action({Action.CLOSE}) + + def supports_tilt_open(self) -> bool: + """Return True if this device supports tilt opening.""" + return self._has_any_action({Action.TILT_OPEN}) + + def supports_tilt_close(self) -> bool: + """Return True if this device supports tilt closing.""" + return self._has_any_action({Action.TILT_CLOSE}) + + def supports_hold(self) -> bool: + """Return True if this device supports hold aka stop.""" + return self._has_any_action({Action.HOLD}) + def supports_light(self) -> bool: """Return True if this device supports any of the light related commands.""" return self._has_any_action({Action.TURN_LIGHT_ON, Action.TURN_LIGHT_OFF}) @@ -104,9 +124,10 @@ class BondDevice: class BondHub: """Hub device representing Bond Bridge.""" - def __init__(self, bond: Bond) -> None: + def __init__(self, bond: Bond, host: str) -> None: """Initialize Bond Hub.""" self.bond: Bond = bond + self.host = host self._bridge: dict[str, Any] = {} self._version: dict[str, Any] = {} self._devices: list[BondDevice] = [] diff --git a/homeassistant/components/bosch_shc/__init__.py b/homeassistant/components/bosch_shc/__init__.py index f68a2b68467..afcf2571c31 100644 --- a/homeassistant/components/bosch_shc/__init__.py +++ b/homeassistant/components/bosch_shc/__init__.py @@ -6,7 +6,7 @@ from boschshcpy.exceptions import SHCAuthenticationError, SHCConnectionError from homeassistant.components.zeroconf import async_get_instance from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -19,7 +19,7 @@ from .const import ( DOMAIN, ) -PLATFORMS = ["binary_sensor", "sensor"] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bosch_shc/config_flow.py b/homeassistant/components/bosch_shc/config_flow.py index 416dc6cf304..6ad1a374a5a 100644 --- a/homeassistant/components/bosch_shc/config_flow.py +++ b/homeassistant/components/bosch_shc/config_flow.py @@ -12,8 +12,9 @@ from boschshcpy.exceptions import ( import voluptuous as vol from homeassistant import config_entries, core -from homeassistant.components.zeroconf import async_get_instance +from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult from .const import ( CONF_HOSTNAME, @@ -40,7 +41,7 @@ def write_tls_asset(hass: core.HomeAssistant, filename: str, asset: bytes) -> No file_handle.write(asset.decode("utf-8")) -def create_credentials_and_validate(hass, host, user_input, zeroconf): +def create_credentials_and_validate(hass, host, user_input, zeroconf_instance): """Create and store credentials and validate session.""" helper = SHCRegisterClient(host, user_input[CONF_PASSWORD]) result = helper.register(host, "HomeAssistant") @@ -54,21 +55,21 @@ def create_credentials_and_validate(hass, host, user_input, zeroconf): hass.config.path(DOMAIN, CONF_SHC_CERT), hass.config.path(DOMAIN, CONF_SHC_KEY), True, - zeroconf, + zeroconf_instance, ) session.authenticate() return result -def get_info_from_host(hass, host, zeroconf): +def get_info_from_host(hass, host, zeroconf_instance): """Get information from host.""" session = SHCSession( host, "", "", True, - zeroconf, + zeroconf_instance, ) information = session.mdns_info() return {"title": information.name, "unique_id": information.unique_id} @@ -123,14 +124,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the credentials step.""" errors = {} if user_input is not None: - zeroconf = await async_get_instance(self.hass) + zeroconf_instance = await zeroconf.async_get_instance(self.hass) try: result = await self.hass.async_add_executor_job( create_credentials_and_validate, self.hass, self.host, user_input, - zeroconf, + zeroconf_instance, ) except SHCAuthenticationError: errors["base"] = "invalid_auth" @@ -181,28 +182,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="credentials", data_schema=schema, errors=errors ) - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle zeroconf discovery.""" - if not discovery_info.get("name", "").startswith("Bosch SHC"): + if not discovery_info.name.startswith("Bosch SHC"): return self.async_abort(reason="not_bosch_shc") try: - hosts = ( - discovery_info["host"] - if isinstance(discovery_info["host"], list) - else [discovery_info["host"]] - ) - for host in hosts: - if host.startswith("169."): # skip link local address - continue - self.info = await self._get_info(host) - self.host = host - if self.host is None: - return self.async_abort(reason="cannot_connect") + self.info = await self._get_info(discovery_info.host) except SHCConnectionError: return self.async_abort(reason="cannot_connect") + self.host = discovery_info.host - local_name = discovery_info["hostname"][:-1] + local_name = discovery_info.hostname[:-1] node_name = local_name[: -len(".local")] await self.async_set_unique_id(self.info["unique_id"]) @@ -227,11 +220,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _get_info(self, host): """Get additional information.""" - zeroconf = await async_get_instance(self.hass) + zeroconf_instance = await zeroconf.async_get_instance(self.hass) return await self.hass.async_add_executor_job( get_info_from_host, self.hass, host, - zeroconf, + zeroconf_instance, ) diff --git a/homeassistant/components/bosch_shc/cover.py b/homeassistant/components/bosch_shc/cover.py new file mode 100644 index 00000000000..91543e586bf --- /dev/null +++ b/homeassistant/components/bosch_shc/cover.py @@ -0,0 +1,86 @@ +"""Platform for cover integration.""" +from boschshcpy import SHCSession, SHCShutterControl + +from homeassistant.components.cover import ( + ATTR_POSITION, + DEVICE_CLASS_SHUTTER, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + SUPPORT_STOP, + CoverEntity, +) + +from .const import DATA_SESSION, DOMAIN +from .entity import SHCEntity + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the SHC cover platform.""" + + entities = [] + session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION] + + for cover in session.device_helper.shutter_controls: + entities.append( + ShutterControlCover( + device=cover, + parent_id=session.information.unique_id, + entry_id=config_entry.entry_id, + ) + ) + + if entities: + async_add_entities(entities) + + +class ShutterControlCover(SHCEntity, CoverEntity): + """Representation of a SHC shutter control device.""" + + _attr_device_class = DEVICE_CLASS_SHUTTER + _attr_supported_features = ( + SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP | SUPPORT_SET_POSITION + ) + + @property + def current_cover_position(self): + """Return the current cover position.""" + return round(self._device.level * 100.0) + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self._device.stop() + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + return self.current_cover_position == 0 + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return ( + self._device.operation_state + == SHCShutterControl.ShutterControlService.State.OPENING + ) + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return ( + self._device.operation_state + == SHCShutterControl.ShutterControlService.State.CLOSING + ) + + def open_cover(self, **kwargs): + """Open the cover.""" + self._device.level = 1.0 + + def close_cover(self, **kwargs): + """Close cover.""" + self._device.level = 0.0 + + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + self._device.level = position / 100.0 diff --git a/homeassistant/components/bosch_shc/translations/bg.json b/homeassistant/components/bosch_shc/translations/bg.json index 80f917a9793..759dd6b21fb 100644 --- a/homeassistant/components/bosch_shc/translations/bg.json +++ b/homeassistant/components/bosch_shc/translations/bg.json @@ -1,3 +1,25 @@ { + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "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": "Bosch SHC: {name}", + "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": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + }, "title": "Bosch SHC" } \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/id.json b/homeassistant/components/bosch_shc/translations/id.json index c2167eb0f20..723c6bdabbe 100644 --- a/homeassistant/components/bosch_shc/translations/id.json +++ b/homeassistant/components/bosch_shc/translations/id.json @@ -7,13 +7,32 @@ "error": { "cannot_connect": "Gagal terhubung", "invalid_auth": "Autentikasi tidak valid", + "pairing_failed": "Pemasangan gagal; periksa apakah Bosch Smart Home Controller dalam mode pemasangan (LED berkedip) dan kata sandi Anda benar.", + "session_error": "Kesalahan sesi: API mengembalikan hasil Non-OK.", "unknown": "Kesalahan yang tidak diharapkan" }, "flow_title": "Bosch SHC: {name}", "step": { + "confirm_discovery": { + "description": "Tekan tombol sisi depan Bosch Smart Home Controller hingga LED mulai berkedip.\nSiap melanjutkan penyiapan {model} @ {host} dengan Home Assistant?" + }, + "credentials": { + "data": { + "password": "Kata Sandi dari Smart Home Controller" + } + }, "reauth_confirm": { + "description": "Integrasi bosch_shc perlu mengautentikasi ulang akun Anda", "title": "Autentikasi Ulang Integrasi" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Siapkan Bosch Smart Home Controller Anda untuk memungkinkan pemantauan dan kontrol dengan Home Assistant.", + "title": "Parameter autentikasi SHC" } } - } + }, + "title": "Bosch SHC" } \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/ja.json b/homeassistant/components/bosch_shc/translations/ja.json new file mode 100644 index 00000000000..fb466338238 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/ja.json @@ -0,0 +1,38 @@ +{ + "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" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "pairing_failed": "\u30da\u30a2\u30ea\u30f3\u30b0\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002Bosch Smart Home Controller\u304c\u30da\u30a2\u30ea\u30f3\u30b0\u30e2\u30fc\u30c9\u306b\u306a\u3063\u3066\u3044\u308b(LED\u304c\u70b9\u6ec5)\u3053\u3068\u3068\u3001\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u6b63\u3057\u3044\u304b\u3069\u3046\u304b\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "session_error": "\u30bb\u30c3\u30b7\u30e7\u30f3\u30a8\u30e9\u30fc: API\u304c\u3001OK\u4ee5\u5916\u306e\u7d50\u679c\u3092\u8fd4\u3057\u307e\u3059\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "LED\u304c\u70b9\u6ec5\u3057\u59cb\u3081\u308b\u307e\u3067\u3001Bosch Smart Home Controller\u306e\u5168\u9762\u306b\u3042\u308b\u30dc\u30bf\u30f3\u3092\u62bc\u3057\u3066\u304f\u3060\u3055\u3044\u3002\nHome Assistant\u3067\u3001{model} @ {host} \u3092\u8a2d\u5b9a\u3059\u308b\u6e96\u5099\u306f\u3067\u304d\u307e\u3057\u305f\u304b\uff1f" + }, + "credentials": { + "data": { + "password": "Smart Home Controller\u306e\u30d1\u30b9\u30ef\u30fc\u30c9" + } + }, + "reauth_confirm": { + "description": "bosch_shc\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u306f\u3001\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u518d\u8a8d\u8a3c\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "Bosch Smart Home Controller\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3001Home Assistant\u3067\u76e3\u8996\u304a\u3088\u3073\u5236\u5fa1\u3067\u304d\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002", + "title": "SHC\u8a8d\u8a3c\u30d1\u30e9\u30e1\u30fc\u30bf\u30fc" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/ru.json b/homeassistant/components/bosch_shc/translations/ru.json index ebbfd46812c..498003b2501 100644 --- a/homeassistant/components/bosch_shc/translations/ru.json +++ b/homeassistant/components/bosch_shc/translations/ru.json @@ -29,7 +29,7 @@ "data": { "host": "\u0425\u043e\u0441\u0442" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Bosch Smart Home Controller.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Bosch Smart Home Controller.", "title": "\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 SHC" } } diff --git a/homeassistant/components/bosch_shc/translations/tr.json b/homeassistant/components/bosch_shc/translations/tr.json new file mode 100644 index 00000000000..b974ef63ebf --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/tr.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "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", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "pairing_failed": "E\u015fle\u015ftirme ba\u015far\u0131s\u0131z; l\u00fctfen Bosch Ak\u0131ll\u0131 Ev Kumandas\u0131n\u0131n e\u015fle\u015ftirme modunda (LED yan\u0131p s\u00f6n\u00fcyor) ve \u015fifrenizin do\u011fru oldu\u011funu kontrol edin.", + "session_error": "Oturum hatas\u0131: API, Tamam Olmayan sonucu d\u00f6nd\u00fcr\u00fcr.", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "L\u00fctfen LED yan\u0131p s\u00f6nmeye ba\u015flayana kadar Bosch Ak\u0131ll\u0131 Ev Denetleyicisinin \u00f6n taraf\u0131ndaki d\u00fc\u011fmeye bas\u0131n.\n Home Assistant ile {model} @ {host} kurulumuna devam etmeye haz\u0131r m\u0131s\u0131n\u0131z?" + }, + "credentials": { + "data": { + "password": "Ak\u0131ll\u0131 Ev Denetleyicisinin \u015eifresi" + } + }, + "reauth_confirm": { + "description": "bosch_shc entegrasyonunun hesab\u0131n\u0131z\u0131 yeniden do\u011frulamas\u0131 gerekiyor", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, + "user": { + "data": { + "host": "Ana bilgisayar" + }, + "description": "Ev Asistan\u0131 ile izleme ve kontrole izin vermek i\u00e7in Bosch Ak\u0131ll\u0131 Ev Denetleyicinizi kurun.", + "title": "SHC kimlik do\u011frulama parametreleri" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/__init__.py b/homeassistant/components/braviatv/__init__.py index 744c856a143..38dbc4f0ebc 100644 --- a/homeassistant/components/braviatv/__init__.py +++ b/homeassistant/components/braviatv/__init__.py @@ -10,10 +10,8 @@ from typing import Final from bravia_tv import BraviaRC from bravia_tv.braviarc import NoIPControl -from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN -from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -22,7 +20,7 @@ from .const import CLIENTID_PREFIX, CONF_IGNORED_SOURCES, DOMAIN, NICKNAME _LOGGER = logging.getLogger(__name__) -PLATFORMS: Final[list[str]] = [MEDIA_PLAYER_DOMAIN, REMOTE_DOMAIN] +PLATFORMS: Final[list[Platform]] = [Platform.MEDIA_PLAYER, Platform.REMOTE] SCAN_INTERVAL: Final = timedelta(seconds=10) diff --git a/homeassistant/components/braviatv/translations/ja.json b/homeassistant/components/braviatv/translations/ja.json new file mode 100644 index 00000000000..835e67e7b9f --- /dev/null +++ b/homeassistant/components/braviatv/translations/ja.json @@ -0,0 +1,39 @@ +{ + "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" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "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" + }, + "description": "\u30bd\u30cb\u30fc\u88fd\u306e\u30c6\u30ec\u30d3 \u30d6\u30e9\u30d3\u30a2\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": "Sony Bravia TV\u3092\u8a8d\u8a3c\u3059\u308b" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "Sony Bravia TV\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002\u8a2d\u5b9a\u306b\u95a2\u3059\u308b\u554f\u984c\u304c\u767a\u751f\u3057\u305f\u5834\u5408\u306f\u3001\u6b21\u306e https://www.home-assistant.io/integrations/braviatv \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044\n\n\u304d\u3061\u3093\u3068\u30c6\u30ec\u30d3\u306e\u96fb\u6e90\u304c\u5165\u3063\u3066\u3044\u308b\u3053\u3068\u3082\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Sony Bravia\u30c6\u30ec\u30d3" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "\u7121\u8996\u3055\u308c\u305f\u30bd\u30fc\u30b9\u306e\u30ea\u30b9\u30c8" + }, + "title": "Sony Bravia \u30c6\u30ec\u30d3\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/tr.json b/homeassistant/components/braviatv/translations/tr.json index 0853c8028fc..6d0f82e29a4 100644 --- a/homeassistant/components/braviatv/translations/tr.json +++ b/homeassistant/components/braviatv/translations/tr.json @@ -1,20 +1,27 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "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." }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi", "unsupported_model": "TV modeliniz desteklenmiyor." }, "step": { "authorize": { + "data": { + "pin": "PIN Kodu" + }, + "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", "title": "Sony Bravia TV'yi yetkilendirin" }, "user": { "data": { "host": "Ana Bilgisayar" }, + "description": "Sony Bravia TV entegrasyonunu ayarlay\u0131n. Yap\u0131land\u0131rmayla ilgili sorunlar\u0131n\u0131z varsa \u015fu adrese gidin: https://www.home-assistant.io/integrations/braviatv \n\n TV'nizin a\u00e7\u0131k oldu\u011fundan emin olun.", "title": "Sony Bravia TV" } } @@ -22,6 +29,9 @@ "options": { "step": { "user": { + "data": { + "ignored_sources": "Yok say\u0131lan kaynaklar\u0131n listesi" + }, "title": "Sony Bravia TV i\u00e7in se\u00e7enekler" } } diff --git a/homeassistant/components/broadlink/config_flow.py b/homeassistant/components/broadlink/config_flow.py index 884a6a9d102..8a32ba02ee8 100644 --- a/homeassistant/components/broadlink/config_flow.py +++ b/homeassistant/components/broadlink/config_flow.py @@ -13,12 +13,11 @@ from broadlink.exceptions import ( import voluptuous as vol from homeassistant import config_entries, data_entry_flow -from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS -from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_TIMEOUT, CONF_TYPE from homeassistant.helpers import config_validation as cv -from .const import DEFAULT_PORT, DEFAULT_TIMEOUT, DOMAIN, DOMAINS_AND_TYPES +from .const import DEFAULT_PORT, DEFAULT_TIMEOUT, DEVICE_TYPES, DOMAIN from .helpers import format_mac _LOGGER = logging.getLogger(__name__) @@ -35,8 +34,7 @@ class BroadlinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_set_device(self, device, raise_on_progress=True): """Define a device for the config flow.""" - supported_types = set.union(*DOMAINS_AND_TYPES.values()) - if device.type not in supported_types: + if device.type not in DEVICE_TYPES: _LOGGER.error( "Unsupported device: %s. If it worked before, please open " "an issue at https://github.com/home-assistant/core/issues", @@ -55,10 +53,12 @@ class BroadlinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): "host": device.host[0], } - async def async_step_dhcp(self, discovery_info): + async def async_step_dhcp( + self, discovery_info: dhcp.DhcpServiceInfo + ) -> data_entry_flow.FlowResult: """Handle dhcp discovery.""" - host = discovery_info[IP_ADDRESS] - unique_id = discovery_info[MAC_ADDRESS].lower().replace(":", "") + host = discovery_info.ip + unique_id = discovery_info.macaddress.lower().replace(":", "") await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) @@ -73,8 +73,7 @@ class BroadlinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="cannot_connect") return self.async_abort(reason="unknown") - supported_types = set.union(*DOMAINS_AND_TYPES.values()) - if device.type not in supported_types: + if device.type not in DEVICE_TYPES: return self.async_abort(reason="not_supported") await self.async_set_device(device) @@ -110,7 +109,7 @@ class BroadlinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): else: device.timeout = timeout - if self.source != SOURCE_REAUTH: + if self.source != config_entries.SOURCE_REAUTH: await self.async_set_device(device) self._abort_if_unique_id_configured( updates={CONF_HOST: device.host[0], CONF_TIMEOUT: timeout} diff --git a/homeassistant/components/broadlink/const.py b/homeassistant/components/broadlink/const.py index f40fd7785a1..3f7744ecbb4 100644 --- a/homeassistant/components/broadlink/const.py +++ b/homeassistant/components/broadlink/const.py @@ -1,14 +1,11 @@ -"""Constants for the Broadlink integration.""" -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +"""Constants.""" +from homeassistant.const import Platform DOMAIN = "broadlink" DOMAINS_AND_TYPES = { - REMOTE_DOMAIN: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"}, - SENSOR_DOMAIN: { + Platform.REMOTE: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"}, + Platform.SENSOR: { "A1", "RM4MINI", "RM4PRO", @@ -18,7 +15,7 @@ DOMAINS_AND_TYPES = { "SP4", "SP4B", }, - SWITCH_DOMAIN: { + Platform.SWITCH: { "BG1", "MP1", "RM4MINI", @@ -34,8 +31,9 @@ DOMAINS_AND_TYPES = { "SP4", "SP4B", }, - LIGHT_DOMAIN: {"LB1"}, + Platform.LIGHT: {"LB1"}, } +DEVICE_TYPES = set.union(*DOMAINS_AND_TYPES.values()) DEFAULT_PORT = 80 DEFAULT_TIMEOUT = 5 diff --git a/homeassistant/components/broadlink/device.py b/homeassistant/components/broadlink/device.py index 2686b3dd9ed..951be9b26bb 100644 --- a/homeassistant/components/broadlink/device.py +++ b/homeassistant/components/broadlink/device.py @@ -23,9 +23,9 @@ from .updater import get_update_manager _LOGGER = logging.getLogger(__name__) -def get_domains(dev_type): +def get_domains(device_type): """Return the domains available for a device type.""" - return {d for d, t in DOMAINS_AND_TYPES.items() if dev_type in t} + return {d for d, t in DOMAINS_AND_TYPES.items() if device_type in t} class BroadlinkDevice: @@ -56,19 +56,26 @@ class BroadlinkDevice: """Return the mac address of the device.""" return self.config.data[CONF_MAC] + @property + def available(self): + """Return True if the device is available.""" + if self.update_manager is None: # pragma: no cover + return False + return self.update_manager.available + @staticmethod async def async_update(hass, entry): """Update the device and related entities. Triggered when the device is renamed on the frontend. """ - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device({(DOMAIN, entry.unique_id)}) device_registry.async_update_device(device_entry.id, name=entry.title) await hass.config_entries.async_reload(entry.entry_id) - def _auth_fetch_firmware(self): - """Auth and fetch firmware.""" + def _get_firmware_version(self): + """Get firmware version.""" self.api.auth() with suppress(BroadlinkException, OSError): return self.api.get_fwversion() @@ -89,7 +96,7 @@ class BroadlinkDevice: try: self.fw_version = await self.hass.async_add_executor_job( - self._auth_fetch_firmware + self._get_firmware_version ) except AuthenticationError: diff --git a/homeassistant/components/broadlink/entity.py b/homeassistant/components/broadlink/entity.py index 080cd5bab71..2c7a05a7e70 100644 --- a/homeassistant/components/broadlink/entity.py +++ b/homeassistant/components/broadlink/entity.py @@ -47,17 +47,19 @@ class BroadlinkEntity(Entity): @property def available(self): - """Return True if the remote is available.""" - return self._device.update_manager.available + """Return True if the entity is available.""" + return self._device.available @property def device_info(self) -> DeviceInfo: """Return device info.""" + device = self._device + return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self._device.mac_address)}, - identifiers={(DOMAIN, self._device.unique_id)}, - manufacturer=self._device.api.manufacturer, - model=self._device.api.model, - name=self._device.name, - sw_version=self._device.fw_version, + connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)}, + identifiers={(DOMAIN, device.unique_id)}, + manufacturer=device.api.manufacturer, + model=device.api.model, + name=device.name, + sw_version=device.fw_version, ) diff --git a/homeassistant/components/broadlink/helpers.py b/homeassistant/components/broadlink/helpers.py index 6d81b98d5d1..bec61ba5bbd 100644 --- a/homeassistant/components/broadlink/helpers.py +++ b/homeassistant/components/broadlink/helpers.py @@ -3,7 +3,7 @@ from base64 import b64decode from homeassistant import config_entries from homeassistant.const import CONF_HOST -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/broadlink/light.py b/homeassistant/components/broadlink/light.py index 4fc8f4c2120..698401f3e2e 100644 --- a/homeassistant/components/broadlink/light.py +++ b/homeassistant/components/broadlink/light.py @@ -28,9 +28,10 @@ BROADLINK_COLOR_MODE_SCENES = 2 async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Broadlink light.""" device = hass.data[DOMAIN].devices[config_entry.entry_id] + lights = [] if device.api.type == "LB1": - lights = [BroadlinkLight(device)] + lights.append(BroadlinkLight(device)) async_add_entities(lights) @@ -123,14 +124,13 @@ class BroadlinkLight(BroadlinkEntity, LightEntity): async def _async_set_state(self, state): """Set the state of the light.""" + device = self._device + try: - state = await self._device.async_request( - self._device.api.set_state, **state - ) + state = await device.async_request(device.api.set_state, **state) except (BroadlinkException, OSError) as err: _LOGGER.error("Failed to set state: %s", err) - return False + return self._update_state(state) self.async_write_ha_state() - return True diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index a0c5c4130e5..5a939b68bb4 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -34,10 +34,10 @@ from homeassistant.components.remote import ( ) from homeassistant.const import CONF_HOST, STATE_OFF from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store -from homeassistant.util.dt import utcnow +from homeassistant.util import dt from .const import DOMAIN from .entity import BroadlinkEntity @@ -213,10 +213,11 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): kwargs[ATTR_COMMAND] = command kwargs = SERVICE_SEND_SCHEMA(kwargs) commands = kwargs[ATTR_COMMAND] - device = kwargs.get(ATTR_DEVICE) + subdevice = kwargs.get(ATTR_DEVICE) repeat = kwargs[ATTR_NUM_REPEATS] delay = kwargs[ATTR_DELAY_SECS] service = f"{RM_DOMAIN}.{SERVICE_SEND_COMMAND}" + device = self._device if not self._attr_is_on: _LOGGER.warning( @@ -228,13 +229,13 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): await self._async_load_storage() try: - code_list = self._extract_codes(commands, device) + code_list = self._extract_codes(commands, subdevice) except ValueError as err: _LOGGER.error("Failed to call %s: %s", service, err) raise rf_flags = {0xB2, 0xD7} - if not hasattr(self._device.api, "sweep_frequency") and any( + if not hasattr(device.api, "sweep_frequency") and any( c[0] in rf_flags for codes in code_list for c in codes ): err_msg = f"{self.entity_id} doesn't support sending RF commands" @@ -247,18 +248,18 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): await asyncio.sleep(delay) if len(codes) > 1: - code = codes[self._flags[device]] + code = codes[self._flags[subdevice]] else: code = codes[0] try: - await self._device.async_request(self._device.api.send_data, code) + await device.async_request(device.api.send_data, code) except (BroadlinkException, OSError) as err: _LOGGER.error("Error during %s: %s", service, err) break if len(codes) > 1: - self._flags[device] ^= 1 + self._flags[subdevice] ^= 1 at_least_one_sent = True if at_least_one_sent: @@ -269,9 +270,10 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): kwargs = SERVICE_LEARN_SCHEMA(kwargs) commands = kwargs[ATTR_COMMAND] command_type = kwargs[ATTR_COMMAND_TYPE] - device = kwargs[ATTR_DEVICE] + subdevice = kwargs[ATTR_DEVICE] toggle = kwargs[ATTR_ALTERNATIVE] service = f"{RM_DOMAIN}.{SERVICE_LEARN_COMMAND}" + device = self._device if not self._attr_is_on: _LOGGER.warning( @@ -286,7 +288,7 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): if command_type == COMMAND_TYPE_IR: learn_command = self._async_learn_ir_command - elif hasattr(self._device.api, "sweep_frequency"): + elif hasattr(device.api, "sweep_frequency"): learn_command = self._async_learn_rf_command else: @@ -310,7 +312,7 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): _LOGGER.error("Failed to learn '%s': %s", command, err) continue - self._codes.setdefault(device, {}).update({command: code}) + self._codes.setdefault(subdevice, {}).update({command: code}) should_store = True if should_store: @@ -318,8 +320,10 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): async def _async_learn_ir_command(self, command): """Learn an infrared command.""" + device = self._device + try: - await self._device.async_request(self._device.api.enter_learning) + await device.async_request(device.api.enter_learning) except (BroadlinkException, OSError) as err: _LOGGER.debug("Failed to enter learning mode: %s", err) @@ -332,11 +336,11 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): ) try: - start_time = utcnow() - while (utcnow() - start_time) < LEARNING_TIMEOUT: + start_time = dt.utcnow() + while (dt.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) try: - code = await self._device.async_request(self._device.api.check_data) + code = await device.async_request(device.api.check_data) except (ReadError, StorageError): continue return b64encode(code).decode("utf8") @@ -353,8 +357,10 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): async def _async_learn_rf_command(self, command): """Learn a radiofrequency command.""" + device = self._device + try: - await self._device.async_request(self._device.api.sweep_frequency) + await device.async_request(device.api.sweep_frequency) except (BroadlinkException, OSError) as err: _LOGGER.debug("Failed to sweep frequency: %s", err) @@ -367,18 +373,14 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): ) try: - start_time = utcnow() - while (utcnow() - start_time) < LEARNING_TIMEOUT: + start_time = dt.utcnow() + while (dt.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) - found = await self._device.async_request( - self._device.api.check_frequency - ) + found = await device.async_request(device.api.check_frequency) if found: break else: - await self._device.async_request( - self._device.api.cancel_sweep_frequency - ) + await device.async_request(device.api.cancel_sweep_frequency) raise TimeoutError( "No radiofrequency found within " f"{LEARNING_TIMEOUT.total_seconds()} seconds" @@ -392,7 +394,7 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): await asyncio.sleep(1) try: - await self._device.async_request(self._device.api.find_rf_packet) + await device.async_request(device.api.find_rf_packet) except (BroadlinkException, OSError) as err: _LOGGER.debug("Failed to enter learning mode: %s", err) @@ -405,11 +407,11 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): ) try: - start_time = utcnow() - while (utcnow() - start_time) < LEARNING_TIMEOUT: + start_time = dt.utcnow() + while (dt.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) try: - code = await self._device.async_request(self._device.api.check_data) + code = await device.async_request(device.api.check_data) except (ReadError, StorageError): continue return b64encode(code).decode("utf8") @@ -428,7 +430,7 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): """Delete a list of commands from a remote.""" kwargs = SERVICE_DELETE_SCHEMA(kwargs) commands = kwargs[ATTR_COMMAND] - device = kwargs[ATTR_DEVICE] + subdevice = kwargs[ATTR_DEVICE] service = f"{RM_DOMAIN}.{SERVICE_DELETE_COMMAND}" if not self._attr_is_on: @@ -443,9 +445,9 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): await self._async_load_storage() try: - codes = self._codes[device] + codes = self._codes[subdevice] except KeyError as err: - err_msg = f"Device not found: {repr(device)}" + err_msg = f"Device not found: {repr(subdevice)}" _LOGGER.error("Failed to call %s. %s", service, err_msg) raise ValueError(err_msg) from err @@ -470,8 +472,8 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): # Clean up if not codes: - del self._codes[device] - if self._flags.pop(device, None) is not None: + del self._codes[subdevice] + if self._flags.pop(subdevice, None) is not None: self._flag_storage.async_delay_save(self._get_flags, FLAG_SAVE_DELAY) self._code_storage.async_delay_save(self._get_codes, CODE_SAVE_DELAY) diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index 5ed1e424f53..d8fc9632321 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -1,6 +1,5 @@ """Support for Broadlink switches.""" from abc import ABC, abstractmethod -from functools import partial import logging from broadlink.exceptions import BroadlinkException @@ -23,11 +22,12 @@ from homeassistant.const import ( CONF_TIMEOUT, CONF_TYPE, STATE_ON, + Platform, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity -from .const import DOMAIN, SWITCH_DOMAIN +from .const import DOMAIN from .entity import BroadlinkEntity from .helpers import data_packet, import_device, mac_address @@ -91,7 +91,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) if switches: - platform_data = hass.data[DOMAIN].platforms.setdefault(SWITCH_DOMAIN, {}) + platform_data = hass.data[DOMAIN].platforms.setdefault(Platform.SWITCH, {}) platform_data.setdefault(mac_addr, []).extend(switches) else: @@ -108,25 +108,26 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Broadlink switch.""" device = hass.data[DOMAIN].devices[config_entry.entry_id] + switches = [] if device.api.type in {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"}: - platform_data = hass.data[DOMAIN].platforms.get(SWITCH_DOMAIN, {}) + platform_data = hass.data[DOMAIN].platforms.get(Platform.SWITCH, {}) user_defined_switches = platform_data.get(device.api.mac, {}) - switches = [ + switches.extend( BroadlinkRMSwitch(device, config) for config in user_defined_switches - ] + ) elif device.api.type == "SP1": - switches = [BroadlinkSP1Switch(device)] + switches.append(BroadlinkSP1Switch(device)) elif device.api.type in {"SP2", "SP2S", "SP3", "SP3S", "SP4", "SP4B"}: - switches = [BroadlinkSP2Switch(device)] + switches.append(BroadlinkSP2Switch(device)) elif device.api.type == "BG1": - switches = [BroadlinkBG1Slot(device, slot) for slot in range(1, 3)] + switches.extend(BroadlinkBG1Slot(device, slot) for slot in range(1, 3)) elif device.api.type == "MP1": - switches = [BroadlinkMP1Slot(device, slot) for slot in range(1, 5)] + switches.extend(BroadlinkMP1Slot(device, slot) for slot in range(1, 5)) async_add_entities(switches) @@ -179,11 +180,13 @@ class BroadlinkRMSwitch(BroadlinkSwitch): async def _async_send_packet(self, packet): """Send a packet to the device.""" + device = self._device + if packet is None: return True try: - await self._device.async_request(self._device.api.send_data, packet) + await device.async_request(device.api.send_data, packet) except (BroadlinkException, OSError) as err: _LOGGER.error("Failed to send packet: %s", err) return False @@ -200,8 +203,10 @@ class BroadlinkSP1Switch(BroadlinkSwitch): async def _async_send_packet(self, packet): """Send a packet to the device.""" + device = self._device + try: - await self._device.async_request(self._device.api.set_power, packet) + await device.async_request(device.api.set_power, packet) except (BroadlinkException, OSError) as err: _LOGGER.error("Failed to send packet: %s", err) return False @@ -242,10 +247,10 @@ class BroadlinkMP1Slot(BroadlinkSwitch): async def _async_send_packet(self, packet): """Send a packet to the device.""" + device = self._device + try: - await self._device.async_request( - self._device.api.set_power, self._slot, packet - ) + await device.async_request(device.api.set_power, self._slot, packet) except (BroadlinkException, OSError) as err: _LOGGER.error("Failed to send packet: %s", err) return False @@ -273,9 +278,11 @@ class BroadlinkBG1Slot(BroadlinkSwitch): async def _async_send_packet(self, packet): """Send a packet to the device.""" - set_state = partial(self._device.api.set_state, **{f"pwr{self._slot}": packet}) + device = self._device + state = {f"pwr{self._slot}": packet} + try: - await self._device.async_request(set_state) + await device.async_request(device.api.set_state, **state) except (BroadlinkException, OSError) as err: _LOGGER.error("Failed to send packet: %s", err) return False diff --git a/homeassistant/components/broadlink/translations/bg.json b/homeassistant/components/broadlink/translations/bg.json index 7534f7228f9..8c5b5347516 100644 --- a/homeassistant/components/broadlink/translations/bg.json +++ b/homeassistant/components/broadlink/translations/bg.json @@ -1,11 +1,34 @@ { "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\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", - "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" + "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", + "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", + "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" + "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} ({model} at {host})", + "step": { + "finish": { + "data": { + "name": "\u0418\u043c\u0435" + }, + "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0438\u043c\u0435 \u0437\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e" + }, + "unlock": { + "data": { + "unlock": "\u0414\u0430, \u043d\u0430\u043f\u0440\u0430\u0432\u0435\u0442\u0435 \u0433\u043e." + } + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/broadlink/translations/ja.json b/homeassistant/components/broadlink/translations/ja.json new file mode 100644 index 00000000000..37c08070d18 --- /dev/null +++ b/homeassistant/components/broadlink/translations/ja.json @@ -0,0 +1,47 @@ +{ + "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", + "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9", + "not_supported": "\u30c7\u30d0\u30a4\u30b9\u306f\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u305b\u3093", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name} ({model} at {host})", + "step": { + "auth": { + "title": "\u30c7\u30d0\u30a4\u30b9\u3078\u306e\u8a8d\u8a3c" + }, + "finish": { + "data": { + "name": "\u540d\u524d" + }, + "title": "\u30c7\u30d0\u30a4\u30b9\u306e\u540d\u524d\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "reset": { + "description": "{name} ({model} \u306e {host}) \u306f\u30ed\u30c3\u30af\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u8a8d\u8a3c\u3057\u3066\u8a2d\u5b9a\u3092\u5b8c\u4e86\u3059\u308b\u306b\u306f\u3001\u30c7\u30d0\u30a4\u30b9\u306e\u30ed\u30c3\u30af\u3092\u89e3\u9664\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\u624b\u9806:\n1. Broadlink\u30a2\u30d7\u30ea\u3092\u958b\u304d\u307e\u3059\u3002\n2. \u30c7\u30d0\u30a4\u30b9\u3092\u30af\u30ea\u30c3\u30af\u3057\u307e\u3059\u3002\n3. \u53f3\u4e0a\u306e`...`\u3092\u30af\u30ea\u30c3\u30af\u3057\u307e\u3059\u3002\n4. \u30da\u30fc\u30b8\u306e\u4e00\u756a\u4e0b\u307e\u3067\u30b9\u30af\u30ed\u30fc\u30eb\u3057\u307e\u3059\u3002\n5. \u30ed\u30c3\u30af\u3092\u7121\u52b9\u306b\u3057\u307e\u3059\u3002", + "title": "\u30c7\u30d0\u30a4\u30b9\u306e\u30ed\u30c3\u30af\u3092\u89e3\u9664" + }, + "unlock": { + "data": { + "unlock": "\u306f\u3044\u3001\u3084\u308a\u307e\u3059\u3002" + }, + "description": "{name} ({model} \u306e {host}) \u304c\u30ed\u30c3\u30af\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u3053\u308c\u306b\u3088\u308a\u3001Home Assistant\u3067\u306e\u8a8d\u8a3c\u554f\u984c\u306b\u3064\u306a\u304c\u308b\u53ef\u80fd\u6027\u304c\u3042\u308a\u307e\u3059\u3002\u30ed\u30c3\u30af\u3092\u89e3\u9664\u3057\u307e\u3059\u304b\uff1f", + "title": "\u30c7\u30d0\u30a4\u30b9\u306e\u30ed\u30c3\u30af\u3092\u89e3\u9664(\u30aa\u30d7\u30b7\u30e7\u30f3)" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "timeout": "\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8" + }, + "title": "\u30c7\u30d0\u30a4\u30b9\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/broadlink/translations/tr.json b/homeassistant/components/broadlink/translations/tr.json index d37a3203476..b2f79ddaac0 100644 --- a/homeassistant/components/broadlink/translations/tr.json +++ b/homeassistant/components/broadlink/translations/tr.json @@ -4,32 +4,40 @@ "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", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi", "not_supported": "Cihaz desteklenmiyor", "unknown": "Beklenmeyen hata" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi", "unknown": "Beklenmeyen hata" }, + "flow_title": "{name} ({model} at {host})", "step": { "auth": { "title": "Cihaza kimlik do\u011frulama" }, "finish": { + "data": { + "name": "Ad" + }, "title": "Cihaz i\u00e7in bir isim se\u00e7in" }, "reset": { + "description": "{name} ( {model} at {host} ) kilitli. Yap\u0131land\u0131rmay\u0131 do\u011frulamak ve tamamlamak i\u00e7in cihaz\u0131n kilidini a\u00e7man\u0131z gerekir. Talimatlar:\n 1. Broadlink uygulamas\u0131n\u0131 a\u00e7\u0131n.\n 2. Cihaza t\u0131klay\u0131n.\n 3. Sa\u011f \u00fcstteki `...` se\u00e7ene\u011fine t\u0131klay\u0131n.\n 4. Sayfan\u0131n en alt\u0131na gidin.\n 5. Kilidi devre d\u0131\u015f\u0131 b\u0131rak\u0131n.", "title": "Cihaz\u0131n kilidini a\u00e7\u0131n" }, "unlock": { "data": { "unlock": "Evet, yap." }, + "description": "{name} ( {model} at {host} ) kilitli. Bu, Home Assistant'ta kimlik do\u011frulama sorunlar\u0131na yol a\u00e7abilir. Kilidini a\u00e7mak ister misin?", "title": "Cihaz\u0131n kilidini a\u00e7\u0131n (iste\u011fe ba\u011fl\u0131)" }, "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "timeout": "Zaman a\u015f\u0131m\u0131" }, "title": "Cihaza ba\u011flan\u0131n" diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index 45053c74f03..ce715e991b0 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -8,14 +8,14 @@ from brother import Brother, DictToObj, SnmpError, UnsupportedModel import pysnmp.hlapi.asyncio as SnmpEngine from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_TYPE +from homeassistant.const import CONF_HOST, CONF_TYPE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DATA_CONFIG_ENTRY, DOMAIN, SNMP from .utils import get_snmp_engine -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py index 20e5938884f..39a196aa6cb 100644 --- a/homeassistant/components/brother/config_flow.py +++ b/homeassistant/components/brother/config_flow.py @@ -9,9 +9,9 @@ from brother import Brother, SnmpError, UnsupportedModel import voluptuous as vol from homeassistant import config_entries, exceptions +from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.typing import DiscoveryInfoType from .const import DOMAIN, PRINTER_TYPES from .utils import get_snmp_engine @@ -80,17 +80,17 @@ class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" # Hostname is format: brother.local. - self.host = discovery_info["hostname"].rstrip(".") + self.host = discovery_info.hostname.rstrip(".") # Do not probe the device if the host is already configured self._async_abort_entries_match({CONF_HOST: self.host}) snmp_engine = get_snmp_engine(self.hass) - model = discovery_info.get("properties", {}).get("product") + model = discovery_info.properties.get("product") try: self.brother = Brother(self.host, snmp_engine=snmp_engine, model=model) diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py index a91d84103e1..21f535ec1e4 100644 --- a/homeassistant/components/brother/const.py +++ b/homeassistant/components/brother/const.py @@ -3,272 +3,10 @@ from __future__ import annotations from typing import Final -from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, - SensorEntityDescription, -) -from homeassistant.const import ( - DEVICE_CLASS_TIMESTAMP, - ENTITY_CATEGORY_DIAGNOSTIC, - PERCENTAGE, -) - -ATTR_BELT_UNIT_REMAINING_LIFE: Final = "belt_unit_remaining_life" -ATTR_BLACK_DRUM_COUNTER: Final = "black_drum_counter" -ATTR_BLACK_DRUM_REMAINING_LIFE: Final = "black_drum_remaining_life" -ATTR_BLACK_DRUM_REMAINING_PAGES: Final = "black_drum_remaining_pages" -ATTR_BLACK_INK_REMAINING: Final = "black_ink_remaining" -ATTR_BLACK_TONER_REMAINING: Final = "black_toner_remaining" -ATTR_BW_COUNTER: Final = "b/w_counter" -ATTR_COLOR_COUNTER: Final = "color_counter" -ATTR_COUNTER: Final = "counter" -ATTR_CYAN_DRUM_COUNTER: Final = "cyan_drum_counter" -ATTR_CYAN_DRUM_REMAINING_LIFE: Final = "cyan_drum_remaining_life" -ATTR_CYAN_DRUM_REMAINING_PAGES: Final = "cyan_drum_remaining_pages" -ATTR_CYAN_INK_REMAINING: Final = "cyan_ink_remaining" -ATTR_CYAN_TONER_REMAINING: Final = "cyan_toner_remaining" -ATTR_DRUM_COUNTER: Final = "drum_counter" -ATTR_DRUM_REMAINING_LIFE: Final = "drum_remaining_life" -ATTR_DRUM_REMAINING_PAGES: Final = "drum_remaining_pages" -ATTR_DUPLEX_COUNTER: Final = "duplex_unit_pages_counter" -ATTR_FUSER_REMAINING_LIFE: Final = "fuser_remaining_life" -ATTR_LASER_REMAINING_LIFE: Final = "laser_remaining_life" -ATTR_MAGENTA_DRUM_COUNTER: Final = "magenta_drum_counter" -ATTR_MAGENTA_DRUM_REMAINING_LIFE: Final = "magenta_drum_remaining_life" -ATTR_MAGENTA_DRUM_REMAINING_PAGES: Final = "magenta_drum_remaining_pages" -ATTR_MAGENTA_INK_REMAINING: Final = "magenta_ink_remaining" -ATTR_MAGENTA_TONER_REMAINING: Final = "magenta_toner_remaining" -ATTR_MANUFACTURER: Final = "Brother" -ATTR_PAGE_COUNTER: Final = "page_counter" -ATTR_PF_KIT_1_REMAINING_LIFE: Final = "pf_kit_1_remaining_life" -ATTR_PF_KIT_MP_REMAINING_LIFE: Final = "pf_kit_mp_remaining_life" -ATTR_REMAINING_PAGES: Final = "remaining_pages" -ATTR_STATUS: Final = "status" -ATTR_UPTIME: Final = "uptime" -ATTR_YELLOW_DRUM_COUNTER: Final = "yellow_drum_counter" -ATTR_YELLOW_DRUM_REMAINING_LIFE: Final = "yellow_drum_remaining_life" -ATTR_YELLOW_DRUM_REMAINING_PAGES: Final = "yellow_drum_remaining_pages" -ATTR_YELLOW_INK_REMAINING: Final = "yellow_ink_remaining" -ATTR_YELLOW_TONER_REMAINING: Final = "yellow_toner_remaining" - DATA_CONFIG_ENTRY: Final = "config_entry" DOMAIN: Final = "brother" -UNIT_PAGES: Final = "p" - PRINTER_TYPES: Final = ["laser", "ink"] SNMP: Final = "snmp" - -ATTRS_MAP: Final[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, - ), -} - -SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( - SensorEntityDescription( - key=ATTR_STATUS, - icon="mdi:printer", - name=ATTR_STATUS.title(), - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_PAGE_COUNTER, - icon="mdi:file-document-outline", - name=ATTR_PAGE_COUNTER.replace("_", " ").title(), - native_unit_of_measurement=UNIT_PAGES, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_BW_COUNTER, - icon="mdi:file-document-outline", - name=ATTR_BW_COUNTER.replace("_", " ").title(), - native_unit_of_measurement=UNIT_PAGES, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_COLOR_COUNTER, - icon="mdi:file-document-outline", - name=ATTR_COLOR_COUNTER.replace("_", " ").title(), - native_unit_of_measurement=UNIT_PAGES, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_DUPLEX_COUNTER, - icon="mdi:file-document-outline", - name=ATTR_DUPLEX_COUNTER.replace("_", " ").title(), - native_unit_of_measurement=UNIT_PAGES, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_DRUM_REMAINING_LIFE, - icon="mdi:chart-donut", - name=ATTR_DRUM_REMAINING_LIFE.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_BLACK_DRUM_REMAINING_LIFE, - icon="mdi:chart-donut", - name=ATTR_BLACK_DRUM_REMAINING_LIFE.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_CYAN_DRUM_REMAINING_LIFE, - icon="mdi:chart-donut", - name=ATTR_CYAN_DRUM_REMAINING_LIFE.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_MAGENTA_DRUM_REMAINING_LIFE, - icon="mdi:chart-donut", - name=ATTR_MAGENTA_DRUM_REMAINING_LIFE.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_YELLOW_DRUM_REMAINING_LIFE, - icon="mdi:chart-donut", - name=ATTR_YELLOW_DRUM_REMAINING_LIFE.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_BELT_UNIT_REMAINING_LIFE, - icon="mdi:current-ac", - name=ATTR_BELT_UNIT_REMAINING_LIFE.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_FUSER_REMAINING_LIFE, - icon="mdi:water-outline", - name=ATTR_FUSER_REMAINING_LIFE.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_LASER_REMAINING_LIFE, - icon="mdi:spotlight-beam", - name=ATTR_LASER_REMAINING_LIFE.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_PF_KIT_1_REMAINING_LIFE, - icon="mdi:printer-3d", - name=ATTR_PF_KIT_1_REMAINING_LIFE.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_PF_KIT_MP_REMAINING_LIFE, - icon="mdi:printer-3d", - name=ATTR_PF_KIT_MP_REMAINING_LIFE.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_BLACK_TONER_REMAINING, - icon="mdi:printer-3d-nozzle", - name=ATTR_BLACK_TONER_REMAINING.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_CYAN_TONER_REMAINING, - icon="mdi:printer-3d-nozzle", - name=ATTR_CYAN_TONER_REMAINING.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_MAGENTA_TONER_REMAINING, - icon="mdi:printer-3d-nozzle", - name=ATTR_MAGENTA_TONER_REMAINING.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_YELLOW_TONER_REMAINING, - icon="mdi:printer-3d-nozzle", - name=ATTR_YELLOW_TONER_REMAINING.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_BLACK_INK_REMAINING, - icon="mdi:printer-3d-nozzle", - name=ATTR_BLACK_INK_REMAINING.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_CYAN_INK_REMAINING, - icon="mdi:printer-3d-nozzle", - name=ATTR_CYAN_INK_REMAINING.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_MAGENTA_INK_REMAINING, - icon="mdi:printer-3d-nozzle", - name=ATTR_MAGENTA_INK_REMAINING.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_YELLOW_INK_REMAINING, - icon="mdi:printer-3d-nozzle", - name=ATTR_YELLOW_INK_REMAINING.replace("_", " ").title(), - native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - SensorEntityDescription( - key=ATTR_UPTIME, - name=ATTR_UPTIME.title(), - entity_registry_enabled_default=False, - device_class=DEVICE_CLASS_TIMESTAMP, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), -) diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 00cb91b2860..be46bc33b6b 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -1,11 +1,22 @@ """Support for the Brother service.""" from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime from typing import Any, cast -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import ( + CONF_HOST, + DEVICE_CLASS_TIMESTAMP, + ENTITY_CATEGORY_DIAGNOSTIC, + PERCENTAGE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -13,16 +24,67 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import BrotherDataUpdateCoordinator -from .const import ( - ATTR_COUNTER, - ATTR_MANUFACTURER, - ATTR_REMAINING_PAGES, - ATTR_UPTIME, - ATTRS_MAP, - DATA_CONFIG_ENTRY, - DOMAIN, - SENSOR_TYPES, -) +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 = "b/w_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, + ), +} async def async_setup_entry( @@ -44,7 +106,9 @@ async def async_setup_entry( for description in SENSOR_TYPES: if description.key in coordinator.data: - sensors.append(BrotherPrinterSensor(coordinator, description, device_info)) + sensors.append( + description.entity_class(coordinator, description, device_info) + ) async_add_entities(sensors, False) @@ -54,7 +118,7 @@ class BrotherPrinterSensor(CoordinatorEntity, SensorEntity): def __init__( self, coordinator: BrotherDataUpdateCoordinator, - description: SensorEntityDescription, + description: BrotherSensorEntityDescription, device_info: DeviceInfo, ) -> None: """Initialize.""" @@ -66,13 +130,8 @@ class BrotherPrinterSensor(CoordinatorEntity, SensorEntity): self.entity_description = description @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state.""" - if self.entity_description.key == ATTR_UPTIME: - return cast( - StateType, - getattr(self.coordinator.data, self.entity_description.key).isoformat(), - ) return cast( StateType, getattr(self.coordinator.data, self.entity_description.key) ) @@ -89,3 +148,215 @@ class BrotherPrinterSensor(CoordinatorEntity, SensorEntity): ) self._attrs[ATTR_COUNTER] = getattr(self.coordinator.data, drum_counter) return self._attrs + + +class BrotherPrinterUptimeSensor(BrotherPrinterSensor): + """Define an Brother Printer Uptime sensor.""" + + @property + def native_value(self) -> datetime: + """Return the state.""" + return cast( + datetime, getattr(self.coordinator.data, self.entity_description.key) + ) + + +@dataclass +class BrotherSensorEntityDescription(SensorEntityDescription): + """A class that describes sensor entities.""" + + entity_class: type[BrotherPrinterSensor] = BrotherPrinterSensor + + +SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( + BrotherSensorEntityDescription( + key=ATTR_STATUS, + icon="mdi:printer", + name=ATTR_STATUS.title(), + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_PAGE_COUNTER, + icon="mdi:file-document-outline", + name=ATTR_PAGE_COUNTER.replace("_", " ").title(), + native_unit_of_measurement=UNIT_PAGES, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_BW_COUNTER, + icon="mdi:file-document-outline", + name=ATTR_BW_COUNTER.replace("_", " ").title(), + native_unit_of_measurement=UNIT_PAGES, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_COLOR_COUNTER, + icon="mdi:file-document-outline", + name=ATTR_COLOR_COUNTER.replace("_", " ").title(), + native_unit_of_measurement=UNIT_PAGES, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_DUPLEX_COUNTER, + icon="mdi:file-document-outline", + name=ATTR_DUPLEX_COUNTER.replace("_", " ").title(), + native_unit_of_measurement=UNIT_PAGES, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_DRUM_REMAINING_LIFE, + icon="mdi:chart-donut", + name=ATTR_DRUM_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_BLACK_DRUM_REMAINING_LIFE, + icon="mdi:chart-donut", + name=ATTR_BLACK_DRUM_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_CYAN_DRUM_REMAINING_LIFE, + icon="mdi:chart-donut", + name=ATTR_CYAN_DRUM_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_MAGENTA_DRUM_REMAINING_LIFE, + icon="mdi:chart-donut", + name=ATTR_MAGENTA_DRUM_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_YELLOW_DRUM_REMAINING_LIFE, + icon="mdi:chart-donut", + name=ATTR_YELLOW_DRUM_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_BELT_UNIT_REMAINING_LIFE, + icon="mdi:current-ac", + name=ATTR_BELT_UNIT_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_FUSER_REMAINING_LIFE, + icon="mdi:water-outline", + name=ATTR_FUSER_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_LASER_REMAINING_LIFE, + icon="mdi:spotlight-beam", + name=ATTR_LASER_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_PF_KIT_1_REMAINING_LIFE, + icon="mdi:printer-3d", + name=ATTR_PF_KIT_1_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_PF_KIT_MP_REMAINING_LIFE, + icon="mdi:printer-3d", + name=ATTR_PF_KIT_MP_REMAINING_LIFE.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_BLACK_TONER_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_BLACK_TONER_REMAINING.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_CYAN_TONER_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_CYAN_TONER_REMAINING.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_MAGENTA_TONER_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_MAGENTA_TONER_REMAINING.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_YELLOW_TONER_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_YELLOW_TONER_REMAINING.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_BLACK_INK_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_BLACK_INK_REMAINING.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_CYAN_INK_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_CYAN_INK_REMAINING.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_MAGENTA_INK_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_MAGENTA_INK_REMAINING.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_YELLOW_INK_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_YELLOW_INK_REMAINING.replace("_", " ").title(), + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + BrotherSensorEntityDescription( + key=ATTR_UPTIME, + name=ATTR_UPTIME.title(), + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TIMESTAMP, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_class=BrotherPrinterUptimeSensor, + ), +) diff --git a/homeassistant/components/brother/translations/bg.json b/homeassistant/components/brother/translations/bg.json index afb3272e575..a2b013df69e 100644 --- a/homeassistant/components/brother/translations/bg.json +++ b/homeassistant/components/brother/translations/bg.json @@ -1,5 +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" + }, "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/brother/translations/ja.json b/homeassistant/components/brother/translations/ja.json new file mode 100644 index 00000000000..c77ad218ea9 --- /dev/null +++ b/homeassistant/components/brother/translations/ja.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "unsupported_model": "\u3053\u306e\u30d7\u30ea\u30f3\u30bf\u30fc\u30e2\u30c7\u30eb\u306f\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "snmp_error": "SNMP\u30b5\u30fc\u30d0\u30fc\u304c\u30aa\u30d5\u306b\u306a\u3063\u3066\u3044\u308b\u304b\u3001\u30d7\u30ea\u30f3\u30bf\u30fc\u304c\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002", + "wrong_host": "\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9\u304c\u7121\u52b9\u3067\u3059\u3002" + }, + "flow_title": "{model} {serial_number}", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "type": "\u30d7\u30ea\u30f3\u30bf\u30fc\u306e\u7a2e\u985e" + }, + "description": "\u30d6\u30e9\u30b6\u30fc\u793e\u88fd\u30d7\u30ea\u30f3\u30bf\u30fc\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u3002\u8a2d\u5b9a\u306b\u554f\u984c\u304c\u3042\u308b\u5834\u5408\u306f\u3001https://www.home-assistant.io/integrations/brother \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "zeroconf_confirm": { + "data": { + "type": "\u30d7\u30ea\u30f3\u30bf\u30fc\u306e\u7a2e\u985e" + }, + "description": "Brother\u793e\u306e\u30d7\u30ea\u30f3\u30bf\u30fc {model} \u3067\u3001\u30b7\u30ea\u30a2\u30eb\u756a\u53f7\u304c `{serial_number}` \u3092Home Assistant\u306b\u8ffd\u52a0\u3057\u307e\u3059\u304b\uff1f", + "title": "\u30d6\u30e9\u30b6\u30fc\u30d7\u30ea\u30f3\u30bf\u30fc\u3092\u767a\u898b" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/tr.json b/homeassistant/components/brother/translations/tr.json index cd91a485252..a883e719c0d 100644 --- a/homeassistant/components/brother/translations/tr.json +++ b/homeassistant/components/brother/translations/tr.json @@ -5,17 +5,24 @@ "unsupported_model": "Bu yaz\u0131c\u0131 modeli desteklenmiyor." }, "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "snmp_error": "SNMP sunucusu kapal\u0131 veya yaz\u0131c\u0131 desteklenmiyor.", + "wrong_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi." }, "flow_title": "Brother Yaz\u0131c\u0131: {model} {serial_number}", "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "type": "Yaz\u0131c\u0131n\u0131n t\u00fcr\u00fc" - } + }, + "description": "Brother yaz\u0131c\u0131 entegrasyonunu ayarlay\u0131n. Yap\u0131land\u0131rmayla ilgili sorunlar\u0131n\u0131z varsa \u015fu adrese gidin: https://www.home-assistant.io/integrations/brother" }, "zeroconf_confirm": { + "data": { + "type": "Yaz\u0131c\u0131n\u0131n t\u00fcr\u00fc" + }, + "description": "Seri numaras\u0131 ` {serial_number} ` olan Brother Yaz\u0131c\u0131 {model} }'i Home Assistant'a eklemek ister misiniz?", "title": "Ke\u015ffedilen Brother Yaz\u0131c\u0131" } } diff --git a/homeassistant/components/brunt/__init__.py b/homeassistant/components/brunt/__init__.py index f89d57cdec1..37c9fd73632 100644 --- a/homeassistant/components/brunt/__init__.py +++ b/homeassistant/components/brunt/__init__.py @@ -1 +1,78 @@ """The brunt component.""" +from __future__ import annotations + +import logging + +from aiohttp.client_exceptions import ClientResponseError, ServerDisconnectedError +import async_timeout +from brunt import BruntClientAsync + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DATA_BAPI, DATA_COOR, DOMAIN, PLATFORMS, REGULAR_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Brunt using config flow.""" + session = async_get_clientsession(hass) + bapi = BruntClientAsync( + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + session=session, + ) + try: + await bapi.async_login() + except ServerDisconnectedError as exc: + raise ConfigEntryNotReady("Brunt not ready to connect.") from exc + except ClientResponseError as exc: + raise ConfigEntryAuthFailed( + f"Brunt could not connect with username: {entry.data[CONF_USERNAME]}." + ) from exc + + async def async_update_data(): + """Fetch data from the Brunt endpoint for all Things. + + Error 403 is the API response for any kind of authentication error (failed password or email) + Error 401 is the API response for things that are not part of the account, could happen when a device is deleted from the account. + """ + try: + async with async_timeout.timeout(10): + things = await bapi.async_get_things(force=True) + return {thing.SERIAL: thing for thing in things} + except ServerDisconnectedError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + except ClientResponseError as err: + if err.status == 403: + raise ConfigEntryAuthFailed() from err + if err.status == 401: + _LOGGER.warning("Device not found, will reload Brunt integration") + await hass.config_entries.async_reload(entry.entry_id) + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="brunt", + update_method=async_update_data, + update_interval=REGULAR_INTERVAL, + ) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {DATA_BAPI: bapi, DATA_COOR: coordinator} + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/brunt/config_flow.py b/homeassistant/components/brunt/config_flow.py new file mode 100644 index 00000000000..636a9affddd --- /dev/null +++ b/homeassistant/components/brunt/config_flow.py @@ -0,0 +1,119 @@ +"""Config flow for brunt integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from aiohttp import ClientResponseError +from aiohttp.client_exceptions import ServerDisconnectedError +from brunt import BruntClientAsync +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) +REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) + + +async def validate_input(user_input: dict[str, Any]) -> dict[str, str] | None: + """Login to the brunt api and return errors if any.""" + errors = None + bapi = BruntClientAsync( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ) + try: + await bapi.async_login() + except ClientResponseError as exc: + if exc.status == 403: + _LOGGER.warning("Brunt Credentials are incorrect") + errors = {"base": "invalid_auth"} + else: + _LOGGER.exception("Unknown error when trying to login to Brunt: %s", exc) + errors = {"base": "unknown"} + except ServerDisconnectedError: + _LOGGER.warning("Cannot connect to Brunt") + errors = {"base": "cannot_connect"} + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Unknown error when trying to login to Brunt: %s", exc) + errors = {"base": "unknown"} + finally: + await bapi.async_close() + return errors + + +class BruntConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Brunt.""" + + VERSION = 1 + + _reauth_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) + + errors = await validate_input(user_input) + if errors is not None: + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=user_input, + ) + + async def async_step_reauth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + assert self._reauth_entry + username = self._reauth_entry.data[CONF_USERNAME] + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, + description_placeholders={"username": username}, + ) + user_input[CONF_USERNAME] = username + errors = await validate_input(user_input) + if errors is not None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, + errors=errors, + description_placeholders={"username": username}, + ) + + 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") + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import config from configuration.yaml.""" + await self.async_set_unique_id(import_config[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + return await self.async_step_user(import_config) diff --git a/homeassistant/components/brunt/const.py b/homeassistant/components/brunt/const.py new file mode 100644 index 00000000000..cc85ac9a415 --- /dev/null +++ b/homeassistant/components/brunt/const.py @@ -0,0 +1,19 @@ +"""Constants for Brunt.""" +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "brunt" +ATTR_REQUEST_POSITION = "request_position" +NOTIFICATION_ID = "brunt_notification" +NOTIFICATION_TITLE = "Brunt Cover Setup" +ATTRIBUTION = "Based on an unofficial Brunt SDK." +PLATFORMS = [Platform.COVER] +DATA_BAPI = "bapi" +DATA_COOR = "coordinator" + +CLOSED_POSITION = 0 +OPEN_POSITION = 100 + +REGULAR_INTERVAL = timedelta(seconds=20) +FAST_INTERVAL = timedelta(seconds=5) diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index 650ce9c05c6..cc0ecd0feab 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -1,84 +1,134 @@ """Support for Brunt Blind Engine covers.""" from __future__ import annotations +from collections.abc import MutableMapping import logging +from typing import Any -from brunt import BruntAPI -import voluptuous as vol +from aiohttp.client_exceptions import ClientResponseError +from brunt import BruntClientAsync, Thing from homeassistant.components.cover import ( ATTR_POSITION, - DEVICE_CLASS_WINDOW, - PLATFORM_SCHEMA, + DEVICE_CLASS_SHADE, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION, CoverEntity, ) -from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME -import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +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, + DataUpdateCoordinator, +) + +from .const import ( + ATTR_REQUEST_POSITION, + ATTRIBUTION, + CLOSED_POSITION, + DATA_BAPI, + DATA_COOR, + DOMAIN, + FAST_INTERVAL, + OPEN_POSITION, + REGULAR_INTERVAL, +) _LOGGER = logging.getLogger(__name__) COVER_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION -ATTR_REQUEST_POSITION = "request_position" -NOTIFICATION_ID = "brunt_notification" -NOTIFICATION_TITLE = "Brunt Cover Setup" -ATTRIBUTION = "Based on an unofficial Brunt SDK." -CLOSED_POSITION = 0 -OPEN_POSITION = 100 - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the brunt platform.""" - - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - - bapi = BruntAPI(username=username, password=password) - try: - if not (things := bapi.getThings()["things"]): - _LOGGER.error("No things present in account") - else: - add_entities( - [ - BruntDevice(bapi, thing["NAME"], thing["thingUri"]) - for thing in things - ], - True, - ) - except (TypeError, KeyError, NameError, ValueError) as ex: - _LOGGER.error("%s", ex) - hass.components.persistent_notification.create( - "Error: {ex}
You will need to restart hass after fixing.", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Component setup, run import config flow for each entry in config.""" + _LOGGER.warning( + "Loading brunt via platform config is deprecated; The configuration has been migrated to a config entry and can be safely removed from configuration.yaml" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config ) + ) -class BruntDevice(CoverEntity): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the brunt platform.""" + bapi: BruntClientAsync = hass.data[DOMAIN][entry.entry_id][DATA_BAPI] + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][DATA_COOR] + + async_add_entities( + BruntDevice(coordinator, serial, thing, bapi, entry.entry_id) + for serial, thing in coordinator.data.items() + ) + + +class BruntDevice(CoordinatorEntity, CoverEntity): """ Representation of a Brunt cover device. Contains the common logic for all Brunt devices. """ - _attr_device_class = DEVICE_CLASS_WINDOW - _attr_supported_features = COVER_FEATURES - - def __init__(self, bapi, name, thing_uri): + def __init__( + self, + coordinator: DataUpdateCoordinator, + serial: str, + thing: Thing, + bapi: BruntClientAsync, + entry_id: str, + ) -> None: """Init the Brunt device.""" + super().__init__(coordinator) + self._attr_unique_id = serial self._bapi = bapi - self._attr_name = name - self._thing_uri = thing_uri + self._thing = thing + self._entry_id = entry_id - self._state = {} + self._remove_update_listener = None + + self._attr_name = self._thing.NAME + self._attr_device_class = DEVICE_CLASS_SHADE + self._attr_supported_features = COVER_FEATURES + self._attr_attribution = ATTRIBUTION + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + name=self._attr_name, + via_device=(DOMAIN, self._entry_id), + manufacturer="Brunt", + sw_version=self._thing.FW_VERSION, + model=self._thing.MODEL, + ) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_add_listener(self._brunt_update_listener) + ) + + @property + def current_cover_position(self) -> int | None: + """ + Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + pos = self.coordinator.data[self.unique_id].currentPosition + return int(pos) if pos is not None else None @property def request_cover_position(self) -> int | None: @@ -89,8 +139,8 @@ class BruntDevice(CoverEntity): to Brunt, at times there is a diff of 1 to current None is unknown, 0 is closed, 100 is fully open. """ - pos = self._state.get("requestPosition") - return int(pos) if pos else None + pos = self.coordinator.data[self.unique_id].requestPosition + return int(pos) if pos is not None else None @property def move_state(self) -> int | None: @@ -99,37 +149,64 @@ class BruntDevice(CoverEntity): None is unknown, 0 when stopped, 1 when opening, 2 when closing """ - mov = self._state.get("moveState") - return int(mov) if mov else None + mov = self.coordinator.data[self.unique_id].moveState + return int(mov) if mov is not None else None - def update(self): - """Poll the current state of the device.""" - try: - self._state = self._bapi.getState(thingUri=self._thing_uri).get("thing") - self._attr_available = True - except (TypeError, KeyError, NameError, ValueError) as ex: - _LOGGER.error("%s", ex) - self._attr_available = False - self._attr_is_opening = self.move_state == 1 - self._attr_is_closing = self.move_state == 2 - pos = self._state.get("currentPosition") - self._attr_current_cover_position = int(pos) if pos else None - self._attr_is_closed = self.current_cover_position == CLOSED_POSITION - self._attr_extra_state_attributes = { - ATTR_ATTRIBUTION: ATTRIBUTION, + @property + def is_opening(self) -> bool: + """Return if the cover is opening or not.""" + return self.move_state == 1 + + @property + def is_closing(self) -> bool: + """Return if the cover is closing or not.""" + return self.move_state == 2 + + @property + def extra_state_attributes(self) -> MutableMapping[str, Any]: + """Return the detailed device state attributes.""" + return { ATTR_REQUEST_POSITION: self.request_cover_position, } - def open_cover(self, **kwargs): + @property + def is_closed(self) -> bool: + """Return true if cover is closed, else False.""" + return self.current_cover_position == CLOSED_POSITION + + async def async_open_cover(self, **kwargs: Any) -> None: """Set the cover to the open position.""" - self._bapi.changeRequestPosition(OPEN_POSITION, thingUri=self._thing_uri) + await self._async_update_cover(OPEN_POSITION) - def close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Set the cover to the closed position.""" - self._bapi.changeRequestPosition(CLOSED_POSITION, thingUri=self._thing_uri) + await self._async_update_cover(CLOSED_POSITION) - def set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Set the cover to a specific position.""" - self._bapi.changeRequestPosition( - kwargs[ATTR_POSITION], thingUri=self._thing_uri - ) + await self._async_update_cover(int(kwargs[ATTR_POSITION])) + + async def _async_update_cover(self, position: int) -> None: + """Set the cover to the new position and wait for the update to be reflected.""" + try: + await self._bapi.async_change_request_position( + position, thingUri=self._thing.thingUri + ) + except ClientResponseError as exc: + raise HomeAssistantError( + f"Unable to reposition {self._thing.NAME}" + ) from exc + self.coordinator.update_interval = FAST_INTERVAL + await self.coordinator.async_request_refresh() + + @callback + def _brunt_update_listener(self) -> None: + """Update the update interval after each refresh.""" + if ( + self.request_cover_position + == self._bapi.last_requested_positions[self._thing.thingUri] + and self.move_state == 0 + ): + self.coordinator.update_interval = REGULAR_INTERVAL + else: + self.coordinator.update_interval = FAST_INTERVAL diff --git a/homeassistant/components/brunt/manifest.json b/homeassistant/components/brunt/manifest.json index ba7d1ba117d..976b017ca09 100644 --- a/homeassistant/components/brunt/manifest.json +++ b/homeassistant/components/brunt/manifest.json @@ -1,8 +1,9 @@ { "domain": "brunt", "name": "Brunt Blind Engine", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/brunt", - "requirements": ["brunt==0.1.3"], + "requirements": ["brunt==1.0.0"], "codeowners": ["@eavanvalkenburg"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/brunt/strings.json b/homeassistant/components/brunt/strings.json new file mode 100644 index 00000000000..37b2f95bc08 --- /dev/null +++ b/homeassistant/components/brunt/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "title": "Setup your Brunt integration", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Please reenter the password for: {username}", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } + } \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/bg.json b/homeassistant/components/brunt/translations/bg.json new file mode 100644 index 00000000000..71737c4fb26 --- /dev/null +++ b/homeassistant/components/brunt/translations/bg.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + }, + "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u043f\u0430\u0440\u043e\u043b\u0430\u0442\u0430 \u0437\u0430: {username}", + "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": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/ca.json b/homeassistant/components/brunt/translations/ca.json new file mode 100644 index 00000000000..9a1c657af7c --- /dev/null +++ b/homeassistant/components/brunt/translations/ca.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Contrasenya" + }, + "description": "Torna a introduir la contrasenya de: {username}", + "title": "Reautenticaci\u00f3 de la integraci\u00f3" + }, + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "title": "Configuraci\u00f3 de la integraci\u00f3 Brunt" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/de.json b/homeassistant/components/brunt/translations/de.json new file mode 100644 index 00000000000..efbc9b437e1 --- /dev/null +++ b/homeassistant/components/brunt/translations/de.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Passwort" + }, + "description": "Bitte gib das Passwort f\u00fcr {username} erneut ein:", + "title": "Integration erneut authentifizieren" + }, + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "title": "Richte deine Brunt-Integration ein" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/en.json b/homeassistant/components/brunt/translations/en.json new file mode 100644 index 00000000000..82fdda36822 --- /dev/null +++ b/homeassistant/components/brunt/translations/en.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Please reenter the password for: {username}", + "title": "Reauthenticate Integration" + }, + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "title": "Setup your Brunt integration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/et.json b/homeassistant/components/brunt/translations/et.json new file mode 100644 index 00000000000..0d283b8a664 --- /dev/null +++ b/homeassistant/components/brunt/translations/et.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba seadistatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Salas\u00f5na" + }, + "description": "Sisesta salas\u00f5na uuesti: {username}", + "title": "Taastuvasta sidumine" + }, + "user": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "title": "Seadista oma Brunti sidumine" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/he.json b/homeassistant/components/brunt/translations/he.json new file mode 100644 index 00000000000..d6636c6f865 --- /dev/null +++ b/homeassistant/components/brunt/translations/he.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "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", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "reauth_confirm": { + "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", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/hu.json b/homeassistant/components/brunt/translations/hu.json new file mode 100644 index 00000000000..3abb5cbf297 --- /dev/null +++ b/homeassistant/components/brunt/translations/hu.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3" + }, + "description": "K\u00e9rem, adja meg \u00fajra a jelsz\u00f3t: {felhaszn\u00e1l\u00f3n\u00e9v}", + "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" + }, + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "title": "A Brunt integr\u00e1ci\u00f3 be\u00e1ll\u00edt\u00e1sa" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/id.json b/homeassistant/components/brunt/translations/id.json new file mode 100644 index 00000000000..21b4d381ed5 --- /dev/null +++ b/homeassistant/components/brunt/translations/id.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Kata Sandi" + }, + "description": "Masukkan kembali kata sandi untuk: {username}", + "title": "Autentikasi Ulang Integrasi" + }, + "user": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + }, + "title": "Siapkan integrasi Brunt Anda" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/ja.json b/homeassistant/components/brunt/translations/ja.json new file mode 100644 index 00000000000..a0c477443b8 --- /dev/null +++ b/homeassistant/components/brunt/translations/ja.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u518d\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044: {username}", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Brunt\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/lt.json b/homeassistant/components/brunt/translations/lt.json new file mode 100644 index 00000000000..98e6719deb2 --- /dev/null +++ b/homeassistant/components/brunt/translations/lt.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "password": "Slapta\u017eodis" + } + }, + "user": { + "data": { + "password": "Slapta\u017eodis", + "username": "Slapyvardis" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/nl.json b/homeassistant/components/brunt/translations/nl.json new file mode 100644 index 00000000000..86a7df6a585 --- /dev/null +++ b/homeassistant/components/brunt/translations/nl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Wachtwoord" + }, + "description": "Voer het wachtwoord opnieuw in voor: {username}", + "title": "Verifieer de integratie opnieuw" + }, + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "title": "Stel uw Brunt-integratie in" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/no.json b/homeassistant/components/brunt/translations/no.json new file mode 100644 index 00000000000..b77151ac92a --- /dev/null +++ b/homeassistant/components/brunt/translations/no.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Passord" + }, + "description": "Skriv inn passordet p\u00e5 nytt for: {username}", + "title": "Godkjenne integrering p\u00e5 nytt" + }, + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "title": "Konfigurer Brunt-integrasjonen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/pl.json b/homeassistant/components/brunt/translations/pl.json new file mode 100644 index 00000000000..61c5f95269a --- /dev/null +++ b/homeassistant/components/brunt/translations/pl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Has\u0142o" + }, + "description": "Wprowad\u017a ponownie has\u0142o dla: {username}:", + "title": "Ponownie uwierzytelnij integracj\u0119" + }, + "user": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "title": "Konfiguracja integracji Brunt" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/ru.json b/homeassistant/components/brunt/translations/ru.json new file mode 100644 index 00000000000..1adcd8906d3 --- /dev/null +++ b/homeassistant/components/brunt/translations/ru.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\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.", + "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.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \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": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "title": "Brunt" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/sl.json b/homeassistant/components/brunt/translations/sl.json new file mode 100644 index 00000000000..2a39d333e2f --- /dev/null +++ b/homeassistant/components/brunt/translations/sl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Ra\u010dun \u017ee nastavljen" + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Geslo" + }, + "description": "Ponovno vnesite geslo za: {username}" + }, + "user": { + "data": { + "password": "Geslo", + "username": "Uporabni\u0161ko ime" + }, + "title": "Nastavite Brunt integracijo" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/tr.json b/homeassistant/components/brunt/translations/tr.json new file mode 100644 index 00000000000..95875b3de67 --- /dev/null +++ b/homeassistant/components/brunt/translations/tr.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Parola" + }, + "description": "L\u00fctfen \u015fifreyi tekrar girin: {username}", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, + "user": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "title": "Brunt entegrasyonunuzu kurun" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/zh-Hant.json b/homeassistant/components/brunt/translations/zh-Hant.json new file mode 100644 index 00000000000..960fea4967b --- /dev/null +++ b/homeassistant/components/brunt/translations/zh-Hant.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u78bc" + }, + "description": "\u8acb\u91cd\u65b0\u8f38\u5165\u5bc6\u78bc\uff1a{username}", + "title": "\u91cd\u65b0\u8a8d\u8b49\u6574\u5408" + }, + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "title": "\u8a2d\u5b9a Brunt \u6574\u5408" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 6c6c8a18336..7ada5b01e46 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -3,9 +3,14 @@ from datetime import timedelta from bsblan import BSBLan, BSBLanConnectionError -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -14,7 +19,7 @@ from .const import CONF_PASSKEY, DATA_BSBLAN_CLIENT, DOMAIN SCAN_INTERVAL = timedelta(seconds=30) -PLATFORMS = [CLIMATE_DOMAIN] +PLATFORMS = [Platform.CLIMATE] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/bsblan/translations/ja.json b/homeassistant/components/bsblan/translations/ja.json new file mode 100644 index 00000000000..3aa85a17cf8 --- /dev/null +++ b/homeassistant/components/bsblan/translations/ja.json @@ -0,0 +1,24 @@ +{ + "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" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "passkey": "\u30d1\u30b9\u30ad\u30fc\u6587\u5b57\u5217", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "BSB-Lan\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3001Home Assistant\u3068\u9023\u643a\u3059\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002", + "title": "BSB-Lan device\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bsblan/translations/ru.json b/homeassistant/components/bsblan/translations/ru.json index e52a249a493..27297418983 100644 --- a/homeassistant/components/bsblan/translations/ru.json +++ b/homeassistant/components/bsblan/translations/ru.json @@ -16,7 +16,7 @@ "port": "\u041f\u043e\u0440\u0442", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 BSB-Lan.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 BSB-Lan.", "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" } } diff --git a/homeassistant/components/bsblan/translations/tr.json b/homeassistant/components/bsblan/translations/tr.json index 803b5102a07..623dab14446 100644 --- a/homeassistant/components/bsblan/translations/tr.json +++ b/homeassistant/components/bsblan/translations/tr.json @@ -6,14 +6,18 @@ "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, + "flow_title": "{name}", "step": { "user": { "data": { - "host": "Ana Bilgisayar", - "password": "\u015eifre", + "host": "Ana bilgisayar", + "passkey": "Ge\u00e7i\u015f anahtar\u0131 dizesi", + "password": "Parola", "port": "Port", - "username": "Kullan\u0131c\u0131 ad\u0131" - } + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "BSB-Lan cihaz\u0131n\u0131z\u0131 Home Assistant ile entegre olacak \u015fekilde ayarlay\u0131n.", + "title": "BSB-Lan cihaz\u0131na ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/bt_smarthub/device_tracker.py b/homeassistant/components/bt_smarthub/device_tracker.py index ef4e89bd4bb..a4c8717e444 100644 --- a/homeassistant/components/bt_smarthub/device_tracker.py +++ b/homeassistant/components/bt_smarthub/device_tracker.py @@ -59,8 +59,7 @@ class BTSmartHubScanner(DeviceScanner): self.success_init = False # Test the router is accessible - data = self.get_bt_smarthub_data() - if data: + if self.get_bt_smarthub_data(): self.success_init = True else: _LOGGER.info("Failed to connect to %s", self.smarthub.router_ip) @@ -85,8 +84,7 @@ class BTSmartHubScanner(DeviceScanner): return _LOGGER.info("Scanning") - data = self.get_bt_smarthub_data() - if not data: + if not (data := self.get_bt_smarthub_data()): _LOGGER.warning("Error scanning devices") return self.last_results = data diff --git a/homeassistant/components/buienradar/__init__.py b/homeassistant/components/buienradar/__init__.py index d7ec47d2bf8..64b94cfbaa8 100644 --- a/homeassistant/components/buienradar/__init__.py +++ b/homeassistant/components/buienradar/__init__.py @@ -2,11 +2,12 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import DOMAIN -PLATFORMS = ["camera", "sensor", "weather"] +PLATFORMS = [Platform.CAMERA, Platform.SENSOR, Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/buienradar/manifest.json b/homeassistant/components/buienradar/manifest.json index d7759aa9b8d..f88bfb83ddf 100644 --- a/homeassistant/components/buienradar/manifest.json +++ b/homeassistant/components/buienradar/manifest.json @@ -3,7 +3,7 @@ "name": "Buienradar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/buienradar", - "requirements": ["buienradar==1.0.4"], + "requirements": ["buienradar==1.0.5"], "codeowners": ["@mjj4791", "@ties", "@Robbie1221"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/buienradar/translations/id.json b/homeassistant/components/buienradar/translations/id.json index a4331fced9f..46eb80123cc 100644 --- a/homeassistant/components/buienradar/translations/id.json +++ b/homeassistant/components/buienradar/translations/id.json @@ -14,5 +14,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "country_code": "Kode negara negara untuk menampilkan gambar kamera.", + "delta": "Interval waktu pembaruan gambar kamera dalam detik", + "timeframe": "Waktu mendatang dalam menit untuk mendapatkan prakiraan curah hujan" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/buienradar/translations/ja.json b/homeassistant/components/buienradar/translations/ja.json new file mode 100644 index 00000000000..f21946e9d8c --- /dev/null +++ b/homeassistant/components/buienradar/translations/ja.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "step": { + "user": { + "data": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "country_code": "\u30ab\u30e1\u30e9\u753b\u50cf\u3092\u8868\u793a\u3059\u308b\u56fd\u306e\u56fd\u30b3\u30fc\u30c9\u3002", + "delta": "\u30ab\u30e1\u30e9\u753b\u50cf\u306e\u66f4\u65b0\u9593\u9694(\u79d2)", + "timeframe": "\u5206\u9593\u306e\u964d\u6c34\u91cf\u4e88\u5831\u3092\u5148\u8aad\u307f\u3059\u308b" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/buienradar/translations/tr.json b/homeassistant/components/buienradar/translations/tr.json new file mode 100644 index 00000000000..db8c7be0ef6 --- /dev/null +++ b/homeassistant/components/buienradar/translations/tr.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "step": { + "user": { + "data": { + "latitude": "Enlem", + "longitude": "Boylam" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "country_code": "Kamera g\u00f6r\u00fcnt\u00fclerinin g\u00f6r\u00fcnt\u00fclenece\u011fi \u00fclkenin \u00fclke kodu.", + "delta": "Kamera g\u00f6r\u00fcnt\u00fcs\u00fc g\u00fcncellemeleri aras\u0131ndaki saniye cinsinden zaman aral\u0131\u011f\u0131", + "timeframe": "Ya\u011f\u0131\u015f tahmini i\u00e7in dakikalar" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index 63c585f8c2f..3686e2bd3c9 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -88,7 +88,7 @@ class BrData: resp = None try: websession = async_get_clientsession(self.hass) - with async_timeout.timeout(10): + async with async_timeout.timeout(10): resp = await websession.get(url) result[STATUS_CODE] = resp.status diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py new file mode 100644 index 00000000000..2e9a8c05163 --- /dev/null +++ b/homeassistant/components/button/__init__.py @@ -0,0 +1,128 @@ +"""Component to pressing a button as platforms.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging +from typing import final + +import voluptuous as vol + +from homeassistant.backports.enum import StrEnum +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util + +from .const import DOMAIN, SERVICE_PRESS + +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + +_LOGGER = logging.getLogger(__name__) + + +class ButtonDeviceClass(StrEnum): + """Device class for buttons.""" + + RESTART = "restart" + UPDATE = "update" + + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(ButtonDeviceClass)) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Button entities.""" + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_PRESS, + {}, + "_async_press_action", + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class ButtonEntityDescription(EntityDescription): + """A class that describes button entities.""" + + device_class: ButtonDeviceClass | None = None + + +class ButtonEntity(RestoreEntity): + """Representation of a Button entity.""" + + entity_description: ButtonEntityDescription + _attr_should_poll = False + _attr_device_class: ButtonDeviceClass | None + _attr_state: None = None + __last_pressed: datetime | None = None + + @property + def device_class(self) -> ButtonDeviceClass | None: + """Return the class of this entity.""" + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class + return None + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if self.__last_pressed is None: + return None + return self.__last_pressed.isoformat() + + @final + async def _async_press_action(self) -> None: + """Press the button (from e.g., service call). + + Should not be overridden, handle setting last press timestamp. + """ + self.__last_pressed = dt_util.utcnow() + self.async_write_ha_state() + await self.async_press() + + async def async_added_to_hass(self) -> None: + """Call when the button is added to hass.""" + state = await self.async_get_last_state() + if state is not None and state.state is not None: + self.__last_pressed = dt_util.parse_datetime(state.state) + + def press(self) -> None: + """Press the button.""" + raise NotImplementedError() + + async def async_press(self) -> None: + """Press the button.""" + await self.hass.async_add_executor_job(self.press) diff --git a/homeassistant/components/button/const.py b/homeassistant/components/button/const.py new file mode 100644 index 00000000000..273314b2e97 --- /dev/null +++ b/homeassistant/components/button/const.py @@ -0,0 +1,4 @@ +"""Provides the constants needed for the component.""" + +DOMAIN = "button" +SERVICE_PRESS = "press" diff --git a/homeassistant/components/button/device_action.py b/homeassistant/components/button/device_action.py new file mode 100644 index 00000000000..2dffd9c600f --- /dev/null +++ b/homeassistant/components/button/device_action.py @@ -0,0 +1,58 @@ +"""Provides device actions for Button.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, SERVICE_PRESS + +ACTION_TYPES = {"press"} + +ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), + vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + } +) + + +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: + """List device actions for button devices.""" + registry = entity_registry.async_get(hass) + return [ + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "press", + } + for entry in entity_registry.async_entries_for_device(registry, device_id) + if entry.domain == DOMAIN + ] + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Context | None +) -> None: + """Execute a device action.""" + await hass.services.async_call( + DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: config[CONF_ENTITY_ID], + }, + blocking=True, + context=context, + ) diff --git a/homeassistant/components/button/device_trigger.py b/homeassistant/components/button/device_trigger.py new file mode 100644 index 00000000000..6d4692234f7 --- /dev/null +++ b/homeassistant/components/button/device_trigger.py @@ -0,0 +1,73 @@ +"""Provides device triggers for Button.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.homeassistant.triggers.state import ( + async_attach_trigger as async_attach_state_trigger, + async_validate_trigger_config as async_validate_state_trigger_config, +) +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN + +TRIGGER_TYPES = {"pressed"} + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + } +) + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: + """List device triggers for button devices.""" + registry = entity_registry.async_get(hass) + return [ + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "pressed", + } + for entry in entity_registry.async_entries_for_device(registry, device_id) + if entry.domain == DOMAIN + ] + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: AutomationTriggerInfo, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + state_config = { + CONF_PLATFORM: "state", + CONF_ENTITY_ID: config[CONF_ENTITY_ID], + } + + state_config = await async_validate_state_trigger_config(hass, state_config) + return await async_attach_state_trigger( + hass, state_config, action, automation_info, platform_type="device" + ) diff --git a/homeassistant/components/button/manifest.json b/homeassistant/components/button/manifest.json new file mode 100644 index 00000000000..beeaca487a6 --- /dev/null +++ b/homeassistant/components/button/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "button", + "name": "Button", + "documentation": "https://www.home-assistant.io/integrations/button", + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/button/services.yaml b/homeassistant/components/button/services.yaml new file mode 100644 index 00000000000..245368f9d5b --- /dev/null +++ b/homeassistant/components/button/services.yaml @@ -0,0 +1,6 @@ +press: + name: Press + description: Press the button entity. + target: + entity: + domain: button diff --git a/homeassistant/components/button/strings.json b/homeassistant/components/button/strings.json new file mode 100644 index 00000000000..ca774c57d77 --- /dev/null +++ b/homeassistant/components/button/strings.json @@ -0,0 +1,11 @@ +{ + "title": "Button", + "device_automation": { + "trigger_type": { + "pressed": "{entity_name} has been pressed" + }, + "action_type": { + "press": "Press {entity_name} button" + } + } +} diff --git a/homeassistant/components/button/translations/af.json b/homeassistant/components/button/translations/af.json new file mode 100644 index 00000000000..7c1e00817ee --- /dev/null +++ b/homeassistant/components/button/translations/af.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Nyomd meg a(z) [entity_name] gombot" + }, + "trigger_type": { + "pressed": "[entity_name] megnyomva" + } + }, + "title": "Gomb" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/bg.json b/homeassistant/components/button/translations/bg.json new file mode 100644 index 00000000000..85b46c01967 --- /dev/null +++ b/homeassistant/components/button/translations/bg.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "\u041d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0431\u0443\u0442\u043e\u043d\u0430 {entity_name}" + }, + "trigger_type": { + "pressed": "{entity_name} \u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442" + } + }, + "title": "\u0411\u0443\u0442\u043e\u043d" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/ca.json b/homeassistant/components/button/translations/ca.json new file mode 100644 index 00000000000..376570dd7be --- /dev/null +++ b/homeassistant/components/button/translations/ca.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Prem el bot\u00f3 {entity_name}" + }, + "trigger_type": { + "pressed": "S'ha premut {entity_name}" + } + }, + "title": "Bot\u00f3" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/de.json b/homeassistant/components/button/translations/de.json new file mode 100644 index 00000000000..26d651ea1f0 --- /dev/null +++ b/homeassistant/components/button/translations/de.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Dr\u00fccke die {entity_name} Taste" + }, + "trigger_type": { + "pressed": "{entity_name} wurde gedr\u00fcckt" + } + }, + "title": "Taste" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/en.json b/homeassistant/components/button/translations/en.json new file mode 100644 index 00000000000..8b19cf25774 --- /dev/null +++ b/homeassistant/components/button/translations/en.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Press {entity_name} button" + }, + "trigger_type": { + "pressed": "{entity_name} has been pressed" + } + }, + "title": "Button" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/et.json b/homeassistant/components/button/translations/et.json new file mode 100644 index 00000000000..b5d5cec992b --- /dev/null +++ b/homeassistant/components/button/translations/et.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Vajuta nuppu {entity_name}" + }, + "trigger_type": { + "pressed": "Vajutati nuppu {entity_name}" + } + }, + "title": "Nupp" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/he.json b/homeassistant/components/button/translations/he.json new file mode 100644 index 00000000000..ea695684d60 --- /dev/null +++ b/homeassistant/components/button/translations/he.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "\u05dc\u05d7\u05e5 \u05e2\u05dc \u05dc\u05d7\u05e6\u05df {entity_name}" + }, + "trigger_type": { + "pressed": "{entity_name} \u05e0\u05dc\u05d7\u05e5" + } + }, + "title": "\u05dc\u05d7\u05e6\u05df" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/hu.json b/homeassistant/components/button/translations/hu.json new file mode 100644 index 00000000000..07d1b33e401 --- /dev/null +++ b/homeassistant/components/button/translations/hu.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "{entity_name} megnyom\u00e1sa" + }, + "trigger_type": { + "pressed": "{entity_name} megnyomva" + } + }, + "title": "Gomb" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/id.json b/homeassistant/components/button/translations/id.json new file mode 100644 index 00000000000..bb51d4003da --- /dev/null +++ b/homeassistant/components/button/translations/id.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Tekan tombol {entity_name}" + }, + "trigger_type": { + "pressed": "{entity_name} telah ditekan" + } + }, + "title": "Tombol" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/it.json b/homeassistant/components/button/translations/it.json new file mode 100644 index 00000000000..9cfc538a5ce --- /dev/null +++ b/homeassistant/components/button/translations/it.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Premi il pulsante {entity_name}" + }, + "trigger_type": { + "pressed": "{entity_name} \u00e8 stato premuto" + } + }, + "title": "Pulsante" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/ja.json b/homeassistant/components/button/translations/ja.json new file mode 100644 index 00000000000..9d2f1615dc1 --- /dev/null +++ b/homeassistant/components/button/translations/ja.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "{entity_name} \u30dc\u30bf\u30f3\u3092\u62bc\u3059" + }, + "trigger_type": { + "pressed": "{entity_name} \u304c\u62bc\u3055\u308c\u307e\u3057\u305f" + } + }, + "title": "\u30dc\u30bf\u30f3" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/lt.json b/homeassistant/components/button/translations/lt.json new file mode 100644 index 00000000000..14c1935abdc --- /dev/null +++ b/homeassistant/components/button/translations/lt.json @@ -0,0 +1,3 @@ +{ + "title": "Mygtukas" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/nl.json b/homeassistant/components/button/translations/nl.json new file mode 100644 index 00000000000..31fe69d11f5 --- /dev/null +++ b/homeassistant/components/button/translations/nl.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Druk op de knop {entity_name}" + }, + "trigger_type": { + "pressed": "{entity_name} is ingedrukt" + } + }, + "title": "Knop" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/no.json b/homeassistant/components/button/translations/no.json new file mode 100644 index 00000000000..94daca72747 --- /dev/null +++ b/homeassistant/components/button/translations/no.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Trykk p\u00e5 {entity_name} -knappen" + }, + "trigger_type": { + "pressed": "{entity_name} har blitt trykket" + } + }, + "title": "Knapp" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/pl.json b/homeassistant/components/button/translations/pl.json new file mode 100644 index 00000000000..e5af8b8c29b --- /dev/null +++ b/homeassistant/components/button/translations/pl.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "naci\u015bnij przycisk {entity_name}" + }, + "trigger_type": { + "pressed": "{entity_name} zosta\u0142 naci\u015bni\u0119ty" + } + }, + "title": "Przycisk" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/pt-BR.json b/homeassistant/components/button/translations/pt-BR.json new file mode 100644 index 00000000000..840bc6ddcc2 --- /dev/null +++ b/homeassistant/components/button/translations/pt-BR.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "Pressione o bot\u00e3o {entity_name}" + }, + "trigger_type": { + "pressed": "{entity_name} foi pressionado" + } + }, + "title": "Bot\u00e3o" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/ru.json b/homeassistant/components/button/translations/ru.json new file mode 100644 index 00000000000..187f3846442 --- /dev/null +++ b/homeassistant/components/button/translations/ru.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "\u041d\u0430\u0436\u0430\u0442\u044c \u043a\u043d\u043e\u043f\u043a\u0443 {entity_name}" + }, + "trigger_type": { + "pressed": "\u041d\u0430\u0436\u0430\u0442\u0430 \u043a\u043d\u043e\u043f\u043a\u0430 {entity_name}" + } + }, + "title": "\u041a\u043d\u043e\u043f\u043a\u0430" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/sl.json b/homeassistant/components/button/translations/sl.json new file mode 100644 index 00000000000..84e3a3ff12b --- /dev/null +++ b/homeassistant/components/button/translations/sl.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "trigger_type": { + "pressed": "{entity_name} je pritisnjena" + } + }, + "title": "Gumb" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/th.json b/homeassistant/components/button/translations/th.json new file mode 100644 index 00000000000..fbcccf87d30 --- /dev/null +++ b/homeassistant/components/button/translations/th.json @@ -0,0 +1,3 @@ +{ + "title": "\u0e1b\u0e38\u0e48\u0e21" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/tr.json b/homeassistant/components/button/translations/tr.json new file mode 100644 index 00000000000..a02a9f5e75b --- /dev/null +++ b/homeassistant/components/button/translations/tr.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "{entity_name} d\u00fc\u011fmesine bas\u0131n" + }, + "trigger_type": { + "pressed": "{entity_name} tu\u015funa bas\u0131ld\u0131" + } + }, + "title": "D\u00fc\u011fme" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/zh-Hans.json b/homeassistant/components/button/translations/zh-Hans.json new file mode 100644 index 00000000000..88c70556aa1 --- /dev/null +++ b/homeassistant/components/button/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "\u6309\u4e0b {entity_name} \u6309\u94ae" + }, + "trigger_type": { + "pressed": "{entity_name} \u88ab\u6309\u4e0b" + } + }, + "title": "\u6309\u94ae" +} \ No newline at end of file diff --git a/homeassistant/components/button/translations/zh-Hant.json b/homeassistant/components/button/translations/zh-Hant.json new file mode 100644 index 00000000000..e91b342dd44 --- /dev/null +++ b/homeassistant/components/button/translations/zh-Hant.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "press": "\u6309\u4e0b {entity_name} \u6309\u9215" + }, + "trigger_type": { + "pressed": "{entity_name} \u5df2\u6309\u4e0b" + } + }, + "title": "\u6309\u9215" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/ca.json b/homeassistant/components/calendar/translations/ca.json index 63cffd7063f..f1b3279a4cb 100644 --- a/homeassistant/components/calendar/translations/ca.json +++ b/homeassistant/components/calendar/translations/ca.json @@ -1,8 +1,8 @@ { "state": { "_": { - "off": "off", - "on": "on" + "off": "OFF", + "on": "ON" } }, "title": "Calendari" diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 5a3d730e7d3..fd05d3c2095 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -275,9 +275,7 @@ def _get_camera_from_entity_id(hass: HomeAssistant, entity_id: str) -> Camera: if (component := hass.data.get(DOMAIN)) is None: raise HomeAssistantError("Camera integration not set up") - camera = component.get_entity(entity_id) - - if camera is None: + if (camera := component.get_entity(entity_id)) is None: raise HomeAssistantError("Camera not found") if not camera.is_on: @@ -371,9 +369,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class Camera(Entity): """The base class for camera entities.""" + # Entity Properties + _attr_brand: str | None = None + _attr_frame_interval: float = MIN_STREAM_INTERVAL + _attr_frontend_stream_type: str | None + _attr_is_on: bool = True + _attr_is_recording: bool = False + _attr_is_streaming: bool = False + _attr_model: str | None = None + _attr_motion_detection_enabled: bool = False + _attr_should_poll: bool = False # No need to poll cameras + _attr_state: None = None # State is determined by is_on + _attr_supported_features: int = 0 + def __init__(self) -> None: """Initialize a camera.""" - self.is_streaming: bool = False self.stream: Stream | None = None self.stream_options: dict[str, str] = {} self.content_type: str = DEFAULT_CONTENT_TYPE @@ -381,45 +391,47 @@ class Camera(Entity): self._warned_old_signature = False self.async_update_token() - @property - def should_poll(self) -> bool: - """No need to poll cameras.""" - return False - @property def entity_picture(self) -> str: """Return a link to the camera feed as entity picture.""" + if self._attr_entity_picture is not None: + return self._attr_entity_picture return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1]) @property def supported_features(self) -> int: """Flag supported features.""" - return 0 + return self._attr_supported_features @property def is_recording(self) -> bool: """Return true if the device is recording.""" - return False + return self._attr_is_recording + + @property + def is_streaming(self) -> bool: + """Return true if the device is streaming.""" + return self._attr_is_streaming @property def brand(self) -> str | None: """Return the camera brand.""" - return None + return self._attr_brand @property def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" - return False + return self._attr_motion_detection_enabled @property def model(self) -> str | None: """Return the camera model.""" - return None + return self._attr_model @property def frame_interval(self) -> float: """Return the interval between frames of the mjpeg stream.""" - return MIN_STREAM_INTERVAL + return self._attr_frame_interval @property def frontend_stream_type(self) -> str | None: @@ -429,6 +441,8 @@ class Camera(Entity): frontend which camera attributes and player to use. The default type is to use HLS, and components can override to change the type. """ + if hasattr(self, "_attr_frontend_stream_type"): + return self._attr_frontend_stream_type if not self.supported_features & SUPPORT_STREAM: return None return STREAM_TYPE_HLS @@ -510,6 +524,7 @@ class Camera(Entity): return await self.handle_async_still_stream(request, self.frame_interval) @property + @final def state(self) -> str: """Return the camera state.""" if self.is_recording: @@ -521,7 +536,7 @@ class Camera(Entity): @property def is_on(self) -> bool: """Return true if on.""" - return True + return self._attr_is_on def turn_off(self) -> None: """Turn off camera.""" @@ -572,8 +587,6 @@ class Camera(Entity): if self.frontend_stream_type: attrs["frontend_stream_type"] = self.frontend_stream_type - # Remove after home-assistant/frontend#10298 is merged into nightly - attrs["stream_type"] = self.frontend_stream_type return attrs @@ -596,9 +609,7 @@ class CameraView(HomeAssistantView): async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse: """Start a GET request.""" - camera = self.component.get_entity(entity_id) - - if camera is None: + if (camera := self.component.get_entity(entity_id)) is None: raise web.HTTPNotFound() camera = cast(Camera, camera) diff --git a/homeassistant/components/camera/manifest.json b/homeassistant/components/camera/manifest.json index a8a834b60b3..6f1b8fdcb35 100644 --- a/homeassistant/components/camera/manifest.json +++ b/homeassistant/components/camera/manifest.json @@ -3,7 +3,7 @@ "name": "Camera", "documentation": "https://www.home-assistant.io/integrations/camera", "dependencies": ["http"], - "requirements": ["PyTurboJPEG==1.6.1"], + "requirements": ["PyTurboJPEG==1.6.3"], "after_dependencies": ["media_player"], "codeowners": [], "quality_scale": "internal" diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py index 36f3d60d0db..53a149ff7d8 100644 --- a/homeassistant/components/camera/prefs.py +++ b/homeassistant/components/camera/prefs.py @@ -40,9 +40,7 @@ class CameraPreferences: async def async_initialize(self) -> None: """Finish initializing the preferences.""" - prefs = await self._store.async_load() - - if prefs is None: + if (prefs := await self._store.async_load()) is None: prefs = {} self._prefs = prefs diff --git a/homeassistant/components/camera/translations/ja.json b/homeassistant/components/camera/translations/ja.json index 4ab2b8ed3b6..9a893c41643 100644 --- a/homeassistant/components/camera/translations/ja.json +++ b/homeassistant/components/camera/translations/ja.json @@ -1,7 +1,9 @@ { "state": { "_": { - "idle": "\u30a2\u30a4\u30c9\u30eb" + "idle": "\u30a2\u30a4\u30c9\u30eb", + "recording": "\u30ec\u30b3\u30fc\u30c7\u30a3\u30f3\u30b0", + "streaming": "\u30b9\u30c8\u30ea\u30fc\u30df\u30f3\u30b0" } }, "title": "\u30ab\u30e1\u30e9" diff --git a/homeassistant/components/canary/__init__.py b/homeassistant/components/canary/__init__.py index b276fc4ed34..9b020a2f09d 100644 --- a/homeassistant/components/canary/__init__.py +++ b/homeassistant/components/canary/__init__.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.components.camera.const import DOMAIN as CAMERA_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv @@ -49,7 +49,11 @@ CONFIG_SCHEMA: Final = vol.Schema( extra=vol.ALLOW_EXTRA, ) -PLATFORMS: Final[list[str]] = ["alarm_control_panel", "camera", "sensor"] +PLATFORMS: Final[list[Platform]] = [ + Platform.ALARM_CONTROL_PANEL, + Platform.CAMERA, + Platform.SENSOR, +] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/canary/translations/ja.json b/homeassistant/components/canary/translations/ja.json new file mode 100644 index 00000000000..9f9903b86e4 --- /dev/null +++ b/homeassistant/components/canary/translations/ja.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Canary\u306b\u63a5\u7d9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ffmpeg_arguments": "ffmpeg\u306b\u6e21\u3055\u308c\u308b\u30ab\u30e1\u30e9\u7528\u306e\u5f15\u6570", + "timeout": "\u30ea\u30af\u30a8\u30b9\u30c8\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8(\u79d2)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/canary/translations/tr.json b/homeassistant/components/canary/translations/tr.json index 6d18629b067..2e5b05f58c7 100644 --- a/homeassistant/components/canary/translations/tr.json +++ b/homeassistant/components/canary/translations/tr.json @@ -7,11 +7,23 @@ "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, + "flow_title": "{name}", "step": { "user": { "data": { "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "title": "Canary'ya ba\u011flan" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ffmpeg_arguments": "Kameralar i\u00e7in ffmpeg'e ge\u00e7irilen arg\u00fcmanlar", + "timeout": "\u0130stek Zaman A\u015f\u0131m\u0131 (saniye)" } } } diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index bb316f2b511..aaf8d5b9c6c 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -2,6 +2,8 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, CONF_UUID, DOMAIN @@ -49,7 +51,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_config() - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle a flow initialized by zeroconf discovery.""" if self._async_in_progress() or self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py index 03ffdfbd15c..06db70b830a 100644 --- a/homeassistant/components/cast/const.py +++ b/homeassistant/components/cast/const.py @@ -1,7 +1,6 @@ """Consts for Cast integration.""" DOMAIN = "cast" -DEFAULT_PORT = 8009 # Stores a threading.Lock that is held by the internal pychromecast discovery. INTERNAL_DISCOVERY_RUNNING_KEY = "cast_discovery_running" diff --git a/homeassistant/components/cast/discovery.py b/homeassistant/components/cast/discovery.py index a5ac4c02047..e76302fefbc 100644 --- a/homeassistant/components/cast/discovery.py +++ b/homeassistant/components/cast/discovery.py @@ -11,7 +11,6 @@ from homeassistant.helpers.dispatcher import dispatcher_send from .const import ( CAST_BROWSER_KEY, CONF_KNOWN_HOSTS, - DEFAULT_PORT, INTERNAL_DISCOVERY_RUNNING_KEY, SIGNAL_CAST_DISCOVERED, SIGNAL_CAST_REMOVED, @@ -21,15 +20,13 @@ from .helpers import ChromecastInfo, ChromeCastZeroconf _LOGGER = logging.getLogger(__name__) -def discover_chromecast(hass: HomeAssistant, device_info): +def discover_chromecast( + hass: HomeAssistant, cast_info: pychromecast.models.CastInfo +) -> None: """Discover a Chromecast.""" info = ChromecastInfo( - services=device_info.services, - uuid=device_info.uuid, - model_name=device_info.model_name, - friendly_name=device_info.friendly_name, - is_audio_group=device_info.port != DEFAULT_PORT, + cast_info=cast_info, ) if info.uuid is None: @@ -74,10 +71,7 @@ def setup_internal_discovery(hass: HomeAssistant, config_entry) -> None: _remove_chromecast( hass, ChromecastInfo( - services=cast_info.services, - uuid=cast_info.uuid, - model_name=cast_info.model_name, - friendly_name=cast_info.friendly_name, + cast_info=cast_info, ), ) diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index 6d021d020c4..ba7380bcaa2 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -5,7 +5,8 @@ from typing import Optional import attr from pychromecast import dial -from pychromecast.const import CAST_MANUFACTURERS +from pychromecast.const import CAST_TYPE_GROUP +from pychromecast.models import CastInfo @attr.s(slots=True, frozen=True) @@ -15,90 +16,49 @@ class ChromecastInfo: This also has the same attributes as the mDNS fields by zeroconf. """ - services: set | None = attr.ib() - uuid: str | None = attr.ib( - converter=attr.converters.optional(str), default=None - ) # always convert UUID to string if not None - _manufacturer = attr.ib(type=Optional[str], default=None) - model_name: str = attr.ib(default="") - friendly_name: str | None = attr.ib(default=None) - is_audio_group = attr.ib(type=Optional[bool], default=False) + cast_info: CastInfo = attr.ib() is_dynamic_group = attr.ib(type=Optional[bool], default=None) @property - def is_information_complete(self) -> bool: - """Return if all information is filled out.""" - want_dynamic_group = self.is_audio_group - have_dynamic_group = self.is_dynamic_group is not None - have_all_except_dynamic_group = all( - attr.astuple( - self, - filter=attr.filters.exclude( - attr.fields(ChromecastInfo).is_dynamic_group - ), - ) - ) - return have_all_except_dynamic_group and ( - not want_dynamic_group or have_dynamic_group - ) + def friendly_name(self) -> str: + """Return the UUID.""" + return self.cast_info.friendly_name @property - def manufacturer(self) -> str | None: - """Return the manufacturer.""" - if self._manufacturer: - return self._manufacturer - if not self.model_name: - return None - return CAST_MANUFACTURERS.get(self.model_name.lower(), "Google Inc.") + def is_audio_group(self) -> bool: + """Return if the cast is an audio group.""" + return self.cast_info.cast_type == CAST_TYPE_GROUP + + @property + def uuid(self) -> bool: + """Return the UUID.""" + return self.cast_info.uuid def fill_out_missing_chromecast_info(self) -> ChromecastInfo: """Return a new ChromecastInfo object with missing attributes filled in. Uses blocking HTTP / HTTPS. """ - if self.is_information_complete: + if not self.is_audio_group or self.is_dynamic_group is not None: # We have all information, no need to check HTTP API. return self # Fill out missing group information via HTTP API. - if self.is_audio_group: - is_dynamic_group = False - http_group_status = None - if self.uuid: - http_group_status = dial.get_multizone_status( - None, - services=self.services, - zconf=ChromeCastZeroconf.get_zeroconf(), - ) - if http_group_status is not None: - is_dynamic_group = any( - str(g.uuid) == self.uuid - for g in http_group_status.dynamic_groups - ) - - return ChromecastInfo( - services=self.services, - uuid=self.uuid, - friendly_name=self.friendly_name, - model_name=self.model_name, - is_audio_group=True, - is_dynamic_group=is_dynamic_group, + is_dynamic_group = False + http_group_status = None + http_group_status = dial.get_multizone_status( + None, + services=self.cast_info.services, + zconf=ChromeCastZeroconf.get_zeroconf(), + ) + if http_group_status is not None: + is_dynamic_group = any( + g.uuid == self.cast_info.uuid for g in http_group_status.dynamic_groups ) - # Fill out some missing information (friendly_name, uuid) via HTTP dial. - http_device_status = dial.get_device_status( - None, services=self.services, zconf=ChromeCastZeroconf.get_zeroconf() - ) - if http_device_status is None: - # HTTP dial didn't give us any new information. - return self - return ChromecastInfo( - services=self.services, - uuid=(self.uuid or http_device_status.uuid), - friendly_name=(self.friendly_name or http_device_status.friendly_name), - manufacturer=(self.manufacturer or http_device_status.manufacturer), - model_name=(self.model_name or http_device_status.model_name), + cast_info=self.cast_info, + is_dynamic_group=is_dynamic_group, ) diff --git a/homeassistant/components/cast/home_assistant_cast.py b/homeassistant/components/cast/home_assistant_cast.py index fb2d790d03d..e4b72f0bf40 100644 --- a/homeassistant/components/cast/home_assistant_cast.py +++ b/homeassistant/components/cast/home_assistant_cast.py @@ -28,7 +28,7 @@ async def async_setup_ha_cast( if user is None: user = await hass.auth.async_create_system_user( - "Home Assistant Cast", [auth.GROUP_ID_ADMIN] + "Home Assistant Cast", group_ids=[auth.GROUP_ID_ADMIN] ) hass.config_entries.async_update_entry( entry, data={**entry.data, "user_id": user.id} @@ -80,7 +80,5 @@ async def async_remove_user( """Remove Home Assistant Cast user.""" user_id: str | None = entry.data.get("user_id") - if user_id is not None: - user = await hass.auth.async_get_user(user_id) - if user: - await hass.auth.async_remove_user(user) + if user_id is not None and (user := await hass.auth.async_get_user(user_id)): + await hass.auth.async_remove_user(user) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index e74f0840a6c..3f3c31b8d3d 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -3,7 +3,7 @@ "name": "Google Cast", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cast", - "requirements": ["pychromecast==9.3.1"], + "requirements": ["pychromecast==10.1.1"], "after_dependencies": [ "cloud", "http", diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index eca7c69f5d2..46c25501f3a 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -46,6 +46,8 @@ from homeassistant.components.media_player.const import ( from homeassistant.components.plex.const import PLEX_URI_SCHEME from homeassistant.components.plex.services import lookup_plex_media from homeassistant.const import ( + CAST_APP_ID_HOMEASSISTANT_LOVELACE, + CAST_APP_ID_HOMEASSISTANT_MEDIA, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, @@ -77,15 +79,7 @@ _LOGGER = logging.getLogger(__name__) CAST_SPLASH = "https://www.home-assistant.io/images/cast/splash.png" -SUPPORT_CAST = ( - SUPPORT_PAUSE - | SUPPORT_PLAY - | SUPPORT_PLAY_MEDIA - | SUPPORT_STOP - | SUPPORT_TURN_OFF - | SUPPORT_TURN_ON -) - +SUPPORT_CAST = SUPPORT_PLAY_MEDIA | SUPPORT_TURN_OFF ENTITY_SCHEMA = vol.All( vol.Schema( @@ -140,7 +134,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Handle discovery of a new chromecast.""" # If wanted_uuids is set, we're only accepting specific cast devices identified # by UUID - if wanted_uuids is not None and discover.uuid not in wanted_uuids: + if wanted_uuids is not None and str(discover.uuid) not in wanted_uuids: # UUID not matching, ignore. return @@ -168,7 +162,6 @@ class CastDevice(MediaPlayerEntity): """Initialize the cast device.""" self._cast_info = cast_info - self.services = cast_info.services self._chromecast: pychromecast.Chromecast | None = None self.cast_status = None self.media_status = None @@ -182,13 +175,13 @@ class CastDevice(MediaPlayerEntity): self._add_remove_handler = None self._cast_view_remove_handler = None - self._attr_unique_id = cast_info.uuid + self._attr_unique_id = str(cast_info.uuid) self._attr_name = cast_info.friendly_name - if cast_info.model_name != "Google Cast Group": + if cast_info.cast_info.model_name != "Google Cast Group": self._attr_device_info = DeviceInfo( identifiers={(CAST_DOMAIN, str(cast_info.uuid).replace("-", ""))}, - manufacturer=str(cast_info.manufacturer), - model=cast_info.model_name, + manufacturer=str(cast_info.cast_info.manufacturer), + model=cast_info.cast_info.model_name, name=str(cast_info.friendly_name), ) @@ -230,20 +223,14 @@ class CastDevice(MediaPlayerEntity): "[%s %s] Connecting to cast device by service %s", self.entity_id, self._cast_info.friendly_name, - self.services, + self._cast_info.cast_info.services, ) chromecast = await self.hass.async_add_executor_job( pychromecast.get_chromecast_from_cast_info, - pychromecast.discovery.CastInfo( - self.services, - self._cast_info.uuid, - self._cast_info.model_name, - self._cast_info.friendly_name, - None, - None, - ), + self._cast_info.cast_info, ChromeCastZeroconf.get_zeroconf(), ) + chromecast.media_controller.app_id = CAST_APP_ID_HOMEASSISTANT_MEDIA self._chromecast = chromecast if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data: @@ -566,14 +553,18 @@ class CastDevice(MediaPlayerEntity): @property def state(self): """Return the state of the player.""" - if (media_status := self._media_status()[0]) is None: - return None - if media_status.player_is_playing: + # The lovelace app loops media to prevent timing out, don't show that + if self.app_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE: + return STATE_PLAYING + if (media_status := self._media_status()[0]) is not None: + if media_status.player_is_playing: + return STATE_PLAYING + if media_status.player_is_paused: + return STATE_PAUSED + if media_status.player_is_idle: + return STATE_IDLE + if self.app_id is not None and self.app_id != pychromecast.IDLE_APP_ID: return STATE_PLAYING - if media_status.player_is_paused: - return STATE_PAUSED - if media_status.player_is_idle: - return STATE_IDLE if self._chromecast is not None and self._chromecast.is_idle: return STATE_OFF return None @@ -581,12 +572,18 @@ class CastDevice(MediaPlayerEntity): @property def media_content_id(self): """Content ID of current playing media.""" + # The lovelace app loops media to prevent timing out, don't show that + if self.app_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE: + return None media_status = self._media_status()[0] return media_status.content_id if media_status else None @property def media_content_type(self): """Content type of current playing media.""" + # The lovelace app loops media to prevent timing out, don't show that + if self.app_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE: + return None if (media_status := self._media_status()[0]) is None: return None if media_status.media_is_tvshow: @@ -600,6 +597,9 @@ class CastDevice(MediaPlayerEntity): @property def media_duration(self): """Duration of current playing media in seconds.""" + # The lovelace app loops media to prevent timing out, don't show that + if self.app_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE: + return None media_status = self._media_status()[0] return media_status.duration if media_status else None @@ -677,13 +677,20 @@ class CastDevice(MediaPlayerEntity): support = SUPPORT_CAST media_status = self._media_status()[0] + if ( + self._chromecast + and self._chromecast.cast_type == pychromecast.const.CAST_TYPE_CHROMECAST + ): + support |= SUPPORT_TURN_ON + if ( self.cast_status and self.cast_status.volume_control_type != VOLUME_CONTROL_TYPE_FIXED ): support |= SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET - if media_status: + if media_status and self.app_id != CAST_APP_ID_HOMEASSISTANT_LOVELACE: + support |= SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_STOP if media_status.supports_queue_next: support |= SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK if media_status.supports_seek: @@ -697,6 +704,9 @@ class CastDevice(MediaPlayerEntity): @property def media_position(self): """Position of current playing media in seconds.""" + # The lovelace app loops media to prevent timing out, don't show that + if self.app_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE: + return None media_status = self._media_status()[0] if media_status is None or not ( media_status.player_is_playing @@ -712,6 +722,8 @@ class CastDevice(MediaPlayerEntity): Returns value from homeassistant.util.dt.utcnow(). """ + if self.app_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE: + return None media_status_recevied = self._media_status()[1] return media_status_recevied @@ -754,7 +766,6 @@ class DynamicCastGroup: self.hass = hass self._cast_info = cast_info - self.services = cast_info.services self._chromecast: pychromecast.Chromecast | None = None self.mz_mgr = None self._status_listener: CastStatusListener | None = None @@ -802,20 +813,14 @@ class DynamicCastGroup: "[%s %s] Connecting to cast device by service %s", "Dynamic group", self._cast_info.friendly_name, - self.services, + self._cast_info.cast_info.services, ) chromecast = await self.hass.async_add_executor_job( pychromecast.get_chromecast_from_cast_info, - pychromecast.discovery.CastInfo( - self.services, - self._cast_info.uuid, - self._cast_info.model_name, - self._cast_info.friendly_name, - None, - None, - ), + self._cast_info.cast_info, ChromeCastZeroconf.get_zeroconf(), ) + chromecast.media_controller.app_id = CAST_APP_ID_HOMEASSISTANT_MEDIA self._chromecast = chromecast if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data: @@ -864,7 +869,7 @@ class DynamicCastGroup: # Removed is not our device. return - if not discover.services: + if not discover.cast_info.services: # Clean up the dynamic group _LOGGER.debug("Clean up dynamic group: %s", discover) await self.async_tear_down() diff --git a/homeassistant/components/cast/translations/id.json b/homeassistant/components/cast/translations/id.json index b0a54f52897..e81b25a4844 100644 --- a/homeassistant/components/cast/translations/id.json +++ b/homeassistant/components/cast/translations/id.json @@ -25,12 +25,18 @@ }, "step": { "advanced_options": { + "data": { + "ignore_cec": "Abaikan CEC", + "uuid": "UUID yang diizinkan" + }, + "description": "UUID yang Diizinkan - Daftar UUID perangkat Cast yang dipisahkan koma untuk ditambahkan ke Home Assistant. Gunakan hanya jika Anda tidak ingin menambahkan semua perangkat cast yang tersedia.\nAbaikan CEC - Daftar Chromecast yang dipisahkan koma yang harus mengabaikan data CEC untuk menentukan input aktif. Daftar ini akan diteruskan ke pychromecast.IGNORE_CEC.", "title": "Konfigurasi Google Cast tingkat lanjut" }, "basic_options": { "data": { "known_hosts": "Host yang dikenal" }, + "description": "Host yang Dikenal - Daftar nama host atau alamat IP perangkat cast, dipisahkan dengan tanda koma, gunakan jika penemuan mDNS tidak berfungsi.", "title": "Konfigurasi Google Cast" } } diff --git a/homeassistant/components/cast/translations/ja.json b/homeassistant/components/cast/translations/ja.json index b669a6e65b8..626ef56cba1 100644 --- a/homeassistant/components/cast/translations/ja.json +++ b/homeassistant/components/cast/translations/ja.json @@ -1,8 +1,43 @@ { "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "invalid_known_hosts": "\u65e2\u77e5\u306e\u30db\u30b9\u30c8\u306f\u3001\u30b3\u30f3\u30de\u3067\u533a\u5207\u3089\u308c\u305f\u30db\u30b9\u30c8\u306e\u30ea\u30b9\u30c8\u3067\u306a\u3051\u308c\u3070\u306a\u308a\u307e\u305b\u3093\u3002" + }, "step": { + "config": { + "data": { + "known_hosts": "\u65e2\u77e5\u306e\u30db\u30b9\u30c8" + }, + "description": "\u65e2\u77e5\u306e\u30db\u30b9\u30c8 - Cast\u30c7\u30d0\u30a4\u30b9\u306e\u30db\u30b9\u30c8\u540d\u3001\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9\u306e\u30b3\u30f3\u30de\u533a\u5207\u308a\u30ea\u30b9\u30c8\u3002mDNS\u691c\u51fa\u304c\u6a5f\u80fd\u3057\u3066\u3044\u306a\u3044\u5834\u5408\u306b\u4f7f\u7528\u3057\u307e\u3059\u3002", + "title": "Google Cast\u306e\u8a2d\u5b9a" + }, "confirm": { - "description": "Google Cast\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + } + } + }, + "options": { + "error": { + "invalid_known_hosts": "\u65e2\u77e5\u306e\u30db\u30b9\u30c8\u306f\u3001\u30b3\u30f3\u30de\u3067\u533a\u5207\u3089\u308c\u305f\u30db\u30b9\u30c8\u306e\u30ea\u30b9\u30c8\u3067\u306a\u3051\u308c\u3070\u306a\u308a\u307e\u305b\u3093\u3002" + }, + "step": { + "advanced_options": { + "data": { + "ignore_cec": "CEC\u3092\u7121\u8996\u3059\u308b", + "uuid": "\u8a31\u53ef\u3055\u308c\u305fUUID" + }, + "description": "Allowed(\u8a31\u53ef) UUIDs - Home Assistant\u306b\u8ffd\u52a0\u3059\u308b\u30ad\u30e3\u30b9\u30c8\u30c7\u30d0\u30a4\u30b9\u306eUUID\u3092\u30b3\u30f3\u30de\u533a\u5207\u306e\u30ea\u30b9\u30c8\u3002\u4f7f\u7528\u53ef\u80fd\u306a\u3059\u3079\u3066\u306e\u30ad\u30e3\u30b9\u30c8\u30c7\u30d0\u30a4\u30b9\u3092\u8ffd\u52a0\u3057\u305f\u304f\u306a\u3044\u5834\u5408\u306b\u306e\u307f\u4f7f\u7528\u3057\u3066\u304f\u3060\u3055\u3044\u3002Ignore(\u7121\u8996) CEC - \u30a2\u30af\u30c6\u30a3\u30d6\u306a\u5165\u529b\u3092\u6c7a\u5b9a\u3059\u308b\u305f\u3081\u306b\u3001CEC\u30c7\u30fc\u30bf\u3092\u7121\u8996\u3057\u305f\u3044\u30af\u30ed\u30fc\u30e0\u30ad\u30e3\u30b9\u30c8\u306e\u30b3\u30f3\u30de\u533a\u5207\u306e\u30ea\u30b9\u30c8\u3002\u3053\u308c\u306f\u3001pychromecast.IGNORE_CEC \u306b\u6e21\u3055\u308c\u307e\u3059\u3002", + "title": "Google Cast\u306e\u9ad8\u5ea6\u306a\u8a2d\u5b9a" + }, + "basic_options": { + "data": { + "known_hosts": "\u65e2\u77e5\u306e\u30db\u30b9\u30c8" + }, + "description": "\u65e2\u77e5\u306e\u30db\u30b9\u30c8 - Cast\u30c7\u30d0\u30a4\u30b9\u306e\u30db\u30b9\u30c8\u540d\u3001\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9\u306e\u30b3\u30f3\u30de\u533a\u5207\u308a\u30ea\u30b9\u30c8\u3002mDNS\u691c\u51fa\u304c\u6a5f\u80fd\u3057\u3066\u3044\u306a\u3044\u5834\u5408\u306b\u4f7f\u7528\u3057\u307e\u3059\u3002", + "title": "Google Cast\u306e\u8a2d\u5b9a" } } } diff --git a/homeassistant/components/cast/translations/tr.json b/homeassistant/components/cast/translations/tr.json index 8de4663957e..3a2609c302a 100644 --- a/homeassistant/components/cast/translations/tr.json +++ b/homeassistant/components/cast/translations/tr.json @@ -3,10 +3,42 @@ "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, + "error": { + "invalid_known_hosts": "Bilinen ana bilgisayarlar, virg\u00fclle ayr\u0131lm\u0131\u015f bir ana bilgisayar listesi olmal\u0131d\u0131r." + }, "step": { + "config": { + "data": { + "known_hosts": "Bilinen ana bilgisayarlar" + }, + "description": "Bilinen Ana Bilgisayarlar - Yay\u0131n cihazlar\u0131n\u0131n ana bilgisayar adlar\u0131n\u0131n veya IP adreslerinin virg\u00fclle ayr\u0131lm\u0131\u015f listesi, mDNS ke\u015ffi \u00e7al\u0131\u015fm\u0131yorsa kullan\u0131n.", + "title": "Google Cast yap\u0131land\u0131rmas\u0131" + }, "confirm": { "description": "Kuruluma ba\u015flamak ister misiniz?" } } + }, + "options": { + "error": { + "invalid_known_hosts": "Bilinen ana bilgisayarlar, virg\u00fclle ayr\u0131lm\u0131\u015f bir ana bilgisayar listesi olmal\u0131d\u0131r." + }, + "step": { + "advanced_options": { + "data": { + "ignore_cec": "CEC'yi yoksay", + "uuid": "\u0130zin verilen UUID'ler" + }, + "description": "\u0130zin Verilen UUID'ler - Home Asistan\u0131'na eklenecek Cast cihazlar\u0131n\u0131n UUID'lerinin virg\u00fclle ayr\u0131lm\u0131\u015f listesi. Yaln\u0131zca mevcut t\u00fcm yay\u0131n cihazlar\u0131n\u0131 eklemek istemiyorsan\u0131z kullan\u0131n.\n CEC'yi Yoksay - Etkin giri\u015fi belirlemek i\u00e7in CEC verilerini yoksaymas\u0131 gereken virg\u00fclle ayr\u0131lm\u0131\u015f bir Chromecast listesi. Bu, pychromecast.IGNORE_CEC'e iletilecektir.", + "title": "Geli\u015fmi\u015f Google Cast yap\u0131land\u0131rmas\u0131" + }, + "basic_options": { + "data": { + "known_hosts": "Bilinen ana bilgisayarlar" + }, + "description": "Bilinen Ana Bilgisayarlar - Yay\u0131n cihazlar\u0131n\u0131n ana bilgisayar adlar\u0131n\u0131n veya IP adreslerinin virg\u00fclle ayr\u0131lm\u0131\u015f listesi, mDNS ke\u015ffi \u00e7al\u0131\u015fm\u0131yorsa kullan\u0131n.", + "title": "Google Cast yap\u0131land\u0131rmas\u0131" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index 61c7a0758c7..2980d4ca5d4 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -6,7 +6,12 @@ import logging from typing import Optional from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STARTED +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + EVENT_HOMEASSISTANT_STARTED, + Platform, +) from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(hours=12) -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index 7b6445a2f35..0aa67993180 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -1,5 +1,7 @@ """Counter for the days until an HTTPS (TLS) certificate will expire.""" -from datetime import timedelta +from __future__ import annotations + +from datetime import datetime, timedelta import voluptuous as vol @@ -85,8 +87,8 @@ class SSLCertificateTimestamp(CertExpiryEntity, SensorEntity): self._attr_unique_id = f"{coordinator.host}:{coordinator.port}-timestamp" @property - def native_value(self): + def native_value(self) -> datetime | None: """Return the state of the sensor.""" if self.coordinator.data: - return self.coordinator.data.isoformat() + return self.coordinator.data return None diff --git a/homeassistant/components/cert_expiry/translations/ca.json b/homeassistant/components/cert_expiry/translations/ca.json index 42da690550b..1a9d3b109a5 100644 --- a/homeassistant/components/cert_expiry/translations/ca.json +++ b/homeassistant/components/cert_expiry/translations/ca.json @@ -20,5 +20,5 @@ } } }, - "title": "Caducitat del certificat" + "title": "Caducitat de certificat" } \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/ja.json b/homeassistant/components/cert_expiry/translations/ja.json new file mode 100644 index 00000000000..5b3aa8dbe61 --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/ja.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "import_failed": "\u30b3\u30f3\u30d5\u30a3\u30b0\u304b\u3089\u306e\u30a4\u30f3\u30dd\u30fc\u30c8\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "error": { + "connection_refused": "\u30db\u30b9\u30c8\u306b\u63a5\u7d9a\u3059\u308b\u3068\u304d\u306b\u63a5\u7d9a\u304c\u62d2\u5426\u3055\u308c\u307e\u3057\u305f", + "connection_timeout": "\u3053\u306e\u30db\u30b9\u30c8\u306b\u63a5\u7d9a\u3059\u308b\u3068\u304d\u306b\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8", + "resolve_failed": "\u3053\u306e\u30db\u30b9\u30c8\u306f\u89e3\u6c7a\u3067\u304d\u307e\u305b\u3093" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u8a3c\u660e\u66f8\u306e\u540d\u524d", + "port": "\u30dd\u30fc\u30c8" + }, + "title": "\u30c6\u30b9\u30c8\u3059\u308b\u8a3c\u660e\u66f8\u3092\u5b9a\u7fa9\u3059\u308b" + } + } + }, + "title": "\u8a3c\u660e\u66f8\u306e\u6709\u52b9\u671f\u9650" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/tr.json b/homeassistant/components/cert_expiry/translations/tr.json index 6c05bef3a65..0f8b7c45a6f 100644 --- a/homeassistant/components/cert_expiry/translations/tr.json +++ b/homeassistant/components/cert_expiry/translations/tr.json @@ -1,15 +1,24 @@ { "config": { "abort": { - "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "import_failed": "Yap\u0131land\u0131rmadan i\u00e7e aktarma ba\u015far\u0131s\u0131z oldu" + }, + "error": { + "connection_refused": "Ana bilgisayara ba\u011flan\u0131rken ba\u011flant\u0131 reddedildi", + "connection_timeout": "Bu ana bilgisayara ba\u011flan\u0131rken zaman a\u015f\u0131m\u0131", + "resolve_failed": "Bu ana bilgisayar \u00e7\u00f6z\u00fcmlenemedi" }, "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", + "name": "Sertifikan\u0131n ad\u0131", "port": "Port" - } + }, + "title": "Test edilecek sertifikay\u0131 tan\u0131mlay\u0131n" } } - } + }, + "title": "Sertifikan\u0131n Sona Erme Tarihi" } \ No newline at end of file diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py index b30e9dae1f3..9861f657ff6 100644 --- a/homeassistant/components/cisco_ios/device_tracker.py +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -65,9 +65,7 @@ class CiscoDeviceScanner(DeviceScanner): Returns boolean if scanning successful. """ - string_result = self._get_arp_data() - - if string_result: + if string_result := self._get_arp_data(): self.last_results = [] last_results = [] diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index fd0c96c6fbe..937e2582fbb 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -135,7 +135,7 @@ async def async_citybikes_request(hass, uri, schema): try: session = async_get_clientsession(hass) - with async_timeout.timeout(REQUEST_TIMEOUT): + async with async_timeout.timeout(REQUEST_TIMEOUT): req = await session.get(DEFAULT_ENDPOINT.format(uri=uri)) json_response = await req.json() diff --git a/homeassistant/components/climacell/__init__.py b/homeassistant/components/climacell/__init__.py index c6a40b839f2..e3edc778955 100644 --- a/homeassistant/components/climacell/__init__.py +++ b/homeassistant/components/climacell/__init__.py @@ -15,8 +15,6 @@ from pyclimacell.exceptions import ( UnknownException, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, @@ -24,9 +22,11 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, + Platform, ) from homeassistant.core import HomeAssistant 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 ( CoordinatorEntity, @@ -75,7 +75,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = [SENSOR_DOMAIN, WEATHER_DOMAIN] +PLATFORMS = [Platform.SENSOR, Platform.WEATHER] def _set_update_interval(hass: HomeAssistant, current_entry: ConfigEntry) -> timedelta: @@ -112,7 +112,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up ClimaCell API from a config entry.""" hass.data.setdefault(DOMAIN, {}) - params = {} + params: dict[str, Any] = {} # If config entry options not set up, set them up if not entry.options: params["options"] = { @@ -206,7 +206,7 @@ class ClimaCellDataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" - data = {FORECASTS: {}} + data: dict[str, Any] = {FORECASTS: {}} try: if self._api_version == 3: data[CURRENT] = await self._api.realtime( @@ -358,7 +358,7 @@ class ClimaCellEntity(CoordinatorEntity): def device_info(self) -> DeviceInfo: """Return device registry information.""" return DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self._config_entry.data[CONF_API_KEY])}, manufacturer="ClimaCell", name="ClimaCell", diff --git a/homeassistant/components/climacell/sensor.py b/homeassistant/components/climacell/sensor.py index f934449fdb0..597e1095f89 100644 --- a/homeassistant/components/climacell/sensor.py +++ b/homeassistant/components/climacell/sensor.py @@ -28,6 +28,8 @@ async def async_setup_entry( ) -> None: """Set up a config entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] + api_class: type[BaseClimaCellSensorEntity] + sensor_types: tuple[ClimaCellSensorEntityDescription, ...] if (api_version := config_entry.data[CONF_API_VERSION]) == 3: api_class = ClimaCellV3SensorEntity @@ -81,6 +83,7 @@ class BaseClimaCellSensorEntity(ClimaCellEntity, SensorEntity): state = self._state if ( state is not None + and not isinstance(state, str) and self.entity_description.unit_imperial is not None and self.entity_description.metric_conversion != 1.0 and self.entity_description.is_metric_check is not None @@ -95,7 +98,8 @@ class BaseClimaCellSensorEntity(ClimaCellEntity, SensorEntity): return round(state * conversion, 4) if self.entity_description.value_map is not None and state is not None: - return self.entity_description.value_map(state).name.lower() + # mypy bug: "Literal[IntEnum.value]" not callable + return self.entity_description.value_map(state).name.lower() # type: ignore[misc] return state diff --git a/homeassistant/components/climacell/translations/bg.json b/homeassistant/components/climacell/translations/bg.json index 6b1e4d3cba2..af84485310d 100644 --- a/homeassistant/components/climacell/translations/bg.json +++ b/homeassistant/components/climacell/translations/bg.json @@ -1,11 +1,13 @@ { "config": { "error": { + "invalid_api_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { "user": { "data": { + "api_key": "API \u043a\u043b\u044e\u0447", "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/climacell/translations/ja.json b/homeassistant/components/climacell/translations/ja.json new file mode 100644 index 00000000000..5114f8e9881 --- /dev/null +++ b/homeassistant/components/climacell/translations/ja.json @@ -0,0 +1,34 @@ +{ + "config": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_api_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc", + "rate_limited": "\u73fe\u5728\u30ec\u30fc\u30c8\u304c\u5236\u9650\u3055\u308c\u3066\u3044\u307e\u3059\u306e\u3067\u3001\u5f8c\u3067\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "api_version": "API\u30d0\u30fc\u30b8\u30e7\u30f3", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "name": "\u540d\u524d" + }, + "description": "\u7def\u5ea6\u3068\u7d4c\u5ea6\u304c\u6307\u5b9a\u3055\u308c\u3066\u3044\u306a\u3044\u5834\u5408\u306f\u3001Home Assistant\u8a2d\u5b9a\u306e\u65e2\u5b9a\u5024\u304c\u4f7f\u7528\u3055\u308c\u307e\u3059\u3002\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u306f\u4e88\u6e2c\u30bf\u30a4\u30d7\u3054\u3068\u306b\u4f5c\u6210\u3055\u308c\u307e\u3059\u304c\u3001\u30c7\u30d5\u30a9\u30eb\u30c8\u3067\u306f\u9078\u629e\u3057\u305f\u3082\u306e\u3060\u3051\u304c\u6709\u52b9\u306b\u306a\u308a\u307e\u3059\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "timestep": "\u6700\u5c0f: NowCast Forecasts\u306e\u9593" + }, + "description": "`nowcast` forecast(\u4e88\u6e2c) \u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u3092\u6709\u52b9\u306b\u3059\u308b\u3053\u3068\u3092\u9078\u629e\u3057\u305f\u5834\u5408\u3001\u5404\u4e88\u6e2c\u9593\u306e\u5206\u6570\u3092\u8a2d\u5b9a\u3059\u308b\u3053\u3068\u304c\u3067\u304d\u307e\u3059\u3002\u63d0\u4f9b\u3055\u308c\u308bforecast(\u4e88\u6e2c)\u306e\u6570\u306f\u3001forecast(\u4e88\u6e2c)\u306e\u9593\u306b\u9078\u629e\u3057\u305f\u5206\u6570\u306b\u4f9d\u5b58\u3057\u307e\u3059\u3002", + "title": "ClimaCell\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u66f4\u65b0\u3057\u307e\u3059" + } + } + }, + "title": "ClimaCell" +} \ No newline at end of file diff --git a/homeassistant/components/climacell/translations/no.json b/homeassistant/components/climacell/translations/no.json index 7a821bcccbb..b9994be7a86 100644 --- a/homeassistant/components/climacell/translations/no.json +++ b/homeassistant/components/climacell/translations/no.json @@ -15,7 +15,7 @@ "longitude": "Lengdegrad", "name": "Navn" }, - "description": "Hvis Breddegrad og Lengdegrad ikke er oppgitt, vil standardverdiene i Home Assistant-konfigurasjonen bli brukt. Det blir opprettet en enhet for hver prognosetype, men bare de du velger blir aktivert som standard." + "description": "Hvis Breddegrad og Lengdegrad ikke er oppgitt, vil standardverdiene i Home Assistant-konfigurasjonen bli brukt. Det blir opprettet en entitet for hver prognosetype, men bare de du velger blir aktivert som standard." } } }, @@ -25,7 +25,7 @@ "data": { "timestep": "Min. mellom NowCast prognoser" }, - "description": "Hvis du velger \u00e5 aktivere \u00abnowcast\u00bb -varselenheten, kan du konfigurere antall minutter mellom hver prognose. Antall angitte prognoser avhenger av antall minutter som er valgt mellom prognosene.", + "description": "Hvis du velger \u00e5 aktivere \u00abnowcast\u00bb -varselentiteten, kan du konfigurere antall minutter mellom hver prognose. Antall angitte prognoser avhenger av antall minutter som er valgt mellom prognosene.", "title": "Oppdater ClimaCell Alternativer" } } diff --git a/homeassistant/components/climacell/translations/tr.json b/homeassistant/components/climacell/translations/tr.json new file mode 100644 index 00000000000..3481e0d61b1 --- /dev/null +++ b/homeassistant/components/climacell/translations/tr.json @@ -0,0 +1,34 @@ +{ + "config": { + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_api_key": "Ge\u00e7ersiz API anahtar\u0131", + "rate_limited": "\u015eu anda oran s\u0131n\u0131rl\u0131, l\u00fctfen daha sonra tekrar deneyin.", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "api_key": "API Anahtar\u0131", + "api_version": "API S\u00fcr\u00fcm\u00fc", + "latitude": "Enlem", + "longitude": "Boylam", + "name": "Ad" + }, + "description": "Enlem ve Boylam sa\u011flanmazsa, Home Assistant yap\u0131land\u0131rmas\u0131ndaki varsay\u0131lan de\u011ferler kullan\u0131l\u0131r. Her tahmin t\u00fcr\u00fc i\u00e7in bir varl\u0131k olu\u015fturulacak, ancak varsay\u0131lan olarak yaln\u0131zca se\u00e7tikleriniz etkinle\u015ftirilecektir." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "timestep": "Min. NowCast Tahminleri Aras\u0131nda" + }, + "description": "'Nowcast' tahmin varl\u0131\u011f\u0131n\u0131 etkinle\u015ftirmeyi se\u00e7erseniz, her tahmin aras\u0131ndaki dakika say\u0131s\u0131n\u0131 yap\u0131land\u0131rabilirsiniz. Sa\u011flanan tahmin say\u0131s\u0131, tahminler aras\u0131nda se\u00e7ilen dakika say\u0131s\u0131na ba\u011fl\u0131d\u0131r.", + "title": "ClimaCell Se\u00e7eneklerini G\u00fcncelle" + } + } + }, + "title": "ClimaCell" +} \ No newline at end of file diff --git a/homeassistant/components/climacell/weather.py b/homeassistant/components/climacell/weather.py index cb0783c6bee..eafb47aac99 100644 --- a/homeassistant/components/climacell/weather.py +++ b/homeassistant/components/climacell/weather.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import abstractmethod from collections.abc import Mapping from datetime import datetime -from typing import Any +from typing import Any, cast from pyclimacell.const import ( CURRENT, @@ -37,6 +37,8 @@ from homeassistant.const import ( LENGTH_MILES, PRESSURE_HPA, PRESSURE_INHG, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant @@ -45,6 +47,7 @@ from homeassistant.helpers.sun import is_up from homeassistant.util import dt as dt_util from homeassistant.util.distance import convert as distance_convert from homeassistant.util.pressure import convert as pressure_convert +from homeassistant.util.speed import convert as speed_convert from . import ClimaCellDataUpdateCoordinator, ClimaCellEntity from .const import ( @@ -133,7 +136,7 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): @staticmethod @abstractmethod def _translate_condition( - condition: int | None, sun_is_up: bool = True + condition: str | int | None, sun_is_up: bool = True ) -> str | None: """Translate ClimaCell condition into an HA condition.""" @@ -141,7 +144,7 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): self, forecast_dt: datetime, use_datetime: bool, - condition: str, + condition: int | str, precipitation: float | None, precipitation_probability: float | None, temp: float | None, @@ -166,7 +169,10 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): ) if wind_speed: wind_speed = round( - distance_convert(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS), 4 + speed_convert( + wind_speed, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR + ), + 4, ) data = { @@ -188,7 +194,10 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): wind_gust = self.wind_gust if wind_gust and self.hass.config.units.is_metric: wind_gust = round( - distance_convert(self.wind_gust, LENGTH_MILES, LENGTH_KILOMETERS), 4 + speed_convert( + self.wind_gust, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR + ), + 4, ) cloud_cover = self.cloud_cover return { @@ -236,7 +245,10 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): """Return the wind speed.""" if self.hass.config.units.is_metric and self._wind_speed: return round( - distance_convert(self._wind_speed, LENGTH_MILES, LENGTH_KILOMETERS), 4 + speed_convert( + self._wind_speed, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR + ), + 4, ) return self._wind_speed @@ -262,7 +274,7 @@ class ClimaCellWeatherEntity(BaseClimaCellWeatherEntity): @staticmethod def _translate_condition( - condition: int | None, sun_is_up: bool = True + condition: int | str | None, sun_is_up: bool = True ) -> str | None: """Translate ClimaCell condition into an HA condition.""" if condition is None: @@ -365,12 +377,13 @@ class ClimaCellWeatherEntity(BaseClimaCellWeatherEntity): precipitation_probability = values.get(CC_ATTR_PRECIPITATION_PROBABILITY) temp = values.get(CC_ATTR_TEMPERATURE_HIGH) - temp_low = values.get(CC_ATTR_TEMPERATURE_LOW) + temp_low = None wind_direction = values.get(CC_ATTR_WIND_DIRECTION) wind_speed = values.get(CC_ATTR_WIND_SPEED) if self.forecast_type == DAILY: use_datetime = False + temp_low = values.get(CC_ATTR_TEMPERATURE_LOW) if precipitation: precipitation = precipitation * 24 elif self.forecast_type == NOWCAST: @@ -409,11 +422,12 @@ class ClimaCellV3WeatherEntity(BaseClimaCellWeatherEntity): @staticmethod def _translate_condition( - condition: str | None, sun_is_up: bool = True + condition: int | str | None, sun_is_up: bool = True ) -> str | None: """Translate ClimaCell condition into an HA condition.""" if not condition: return None + condition = cast(str, condition) if "clear" in condition.lower(): if sun_is_up: return CLEAR_CONDITIONS["day"] diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index 97bb4515f14..f3e01b5a387 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -71,12 +71,9 @@ async def async_get_conditions( @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" - if config_validation: - config = CONDITION_SCHEMA(config) - if config[CONF_TYPE] == "is_hvac_mode": attribute = const.ATTR_HVAC_MODE else: diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py index 05212e6ab99..6bd6f4c3e02 100644 --- a/homeassistant/components/climate/device_trigger.py +++ b/homeassistant/components/climate/device_trigger.py @@ -131,7 +131,9 @@ async def async_attach_trigger( } if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] - state_config = state_trigger.TRIGGER_SCHEMA(state_config) + state_config = await state_trigger.async_validate_trigger_config( + hass, state_config + ) return await state_trigger.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) @@ -157,7 +159,9 @@ async def async_attach_trigger( if CONF_FOR in config: numeric_state_config[CONF_FOR] = config[CONF_FOR] - numeric_state_config = numeric_state_trigger.TRIGGER_SCHEMA(numeric_state_config) + numeric_state_config = await numeric_state_trigger.async_validate_trigger_config( + hass, numeric_state_config + ) return await numeric_state_trigger.async_attach_trigger( hass, numeric_state_config, action, automation_info, platform_type="device" ) diff --git a/homeassistant/components/climate/translations/ca.json b/homeassistant/components/climate/translations/ca.json index 89720be754e..3eb99744751 100644 --- a/homeassistant/components/climate/translations/ca.json +++ b/homeassistant/components/climate/translations/ca.json @@ -22,7 +22,7 @@ "fan_only": "Nom\u00e9s ventilador", "heat": "Escalfa", "heat_cool": "Escalfa/Refreda", - "off": "off" + "off": "OFF" } }, "title": "Climatitzaci\u00f3" diff --git a/homeassistant/components/climate/translations/ja.json b/homeassistant/components/climate/translations/ja.json index 2d660b8dd54..0c89bac48d8 100644 --- a/homeassistant/components/climate/translations/ja.json +++ b/homeassistant/components/climate/translations/ja.json @@ -1,4 +1,19 @@ { + "device_automation": { + "action_type": { + "set_hvac_mode": "{entity_name} \u306eHVAC\u30e2\u30fc\u30c9\u3092\u5909\u66f4", + "set_preset_mode": "{entity_name} \u306e\u30d7\u30ea\u30bb\u30c3\u30c8\u3092\u5909\u66f4" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} \u306f\u7279\u5b9a\u306eHVAC\u30e2\u30fc\u30c9\u306b\u30bb\u30c3\u30c8\u3055\u308c\u3066\u3044\u307e\u3059", + "is_preset_mode": "{entity_name} \u306f\u7279\u5b9a\u306e\u30d7\u30ea\u30bb\u30c3\u30c8\u30e2\u30fc\u30c9\u306b\u30bb\u30c3\u30c8\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} \u6e2c\u5b9a\u6e7f\u5ea6\u304c\u5909\u5316\u3057\u307e\u3057\u305f", + "current_temperature_changed": "{entity_name} \u6e2c\u5b9a\u6e29\u5ea6\u304c\u5909\u5316\u3057\u307e\u3057\u305f", + "hvac_mode_changed": "{entity_name} HVAC\u30e2\u30fc\u30c9\u304c\u5909\u5316\u3057\u307e\u3057\u305f" + } + }, "state": { "_": { "auto": "\u30aa\u30fc\u30c8", @@ -6,7 +21,9 @@ "dry": "\u30c9\u30e9\u30a4", "fan_only": "\u30d5\u30a1\u30f3\u306e\u307f", "heat": "\u6696\u623f", + "heat_cool": "\u6696/\u51b7", "off": "\u30aa\u30d5" } - } + }, + "title": "\u6c17\u5019" } \ No newline at end of file diff --git a/homeassistant/components/climate/translations/tr.json b/homeassistant/components/climate/translations/tr.json index 201fec4c4b6..3e175e6f598 100644 --- a/homeassistant/components/climate/translations/tr.json +++ b/homeassistant/components/climate/translations/tr.json @@ -3,6 +3,15 @@ "action_type": { "set_hvac_mode": "{entity_name} \u00fczerinde HVAC modunu de\u011fi\u015ftir", "set_preset_mode": "{entity_name} \u00fczerindeki \u00f6n ayar\u0131 de\u011fi\u015ftir" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} , belirli bir HVAC moduna ayarland\u0131", + "is_preset_mode": "{entity_name} , belirli bir \u00f6n ayar moduna ayarland\u0131" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} \u00f6l\u00e7\u00fclen nem de\u011fi\u015fti", + "current_temperature_changed": "{entity_name} \u00f6l\u00e7\u00fclen s\u0131cakl\u0131k de\u011fi\u015fti", + "hvac_mode_changed": "{entity_name} HVAC modu de\u011fi\u015fti" } }, "state": { @@ -16,5 +25,5 @@ "off": "Kapal\u0131" } }, - "title": "\u0130klim" + "title": "\u0130klimlendirme" } \ No newline at end of file diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 41bab5e0bd4..0d1bdf66c12 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -16,11 +16,7 @@ from homeassistant.components.alexa import ( errors as alexa_errors, state_report as alexa_state_report, ) -from homeassistant.const import ( - CLOUD_NEVER_EXPOSED_ENTITIES, - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, -) +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, ENTITY_CATEGORIES from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.helpers import entity_registry as er, start from homeassistant.helpers.event import async_call_later @@ -135,10 +131,7 @@ class AlexaConfig(alexa_config.AbstractConfig): entity_registry = er.async_get(self.hass) if registry_entry := entity_registry.async_get(entity_id): - auxiliary_entity = registry_entry.entity_category in ( - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, - ) + auxiliary_entity = registry_entry.entity_category in ENTITY_CATEGORIES else: auxiliary_entity = False @@ -320,7 +313,7 @@ class AlexaConfig(alexa_config.AbstractConfig): ) try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED) return True diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 5a10e1d1e5c..5f3abe521af 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -42,12 +42,13 @@ class CloudClient(Interface): self._websession = websession self.google_user_config = google_user_config self.alexa_user_config = alexa_user_config - self._alexa_config = None - self._google_config = None + self._alexa_config: alexa_config.AlexaConfig | None = None + self._google_config: google_config.CloudGoogleConfig | None = None @property def base_path(self) -> Path: """Return path to base dir.""" + assert self._hass.config.config_dir is not None return Path(self._hass.config.config_dir) @property @@ -56,7 +57,7 @@ class CloudClient(Interface): return self._prefs @property - def loop(self) -> asyncio.BaseEventLoop: + def loop(self) -> asyncio.AbstractEventLoop: """Return client loop.""" return self._hass.loop @@ -66,7 +67,7 @@ class CloudClient(Interface): return self._websession @property - def aiohttp_runner(self) -> aiohttp.web.AppRunner: + def aiohttp_runner(self) -> aiohttp.web.AppRunner | None: """Return client webinterface aiohttp application.""" return self._hass.http.runner diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index d0417e0d38d..f24f172be36 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -63,13 +63,5 @@ MODE_PROD = "production" DISPATCHER_REMOTE_UPDATE = "cloud_remote_update" -class InvalidTrustedNetworks(Exception): - """Raised when invalid trusted networks config.""" - - -class InvalidTrustedProxies(Exception): - """Raised when invalid trusted proxies config.""" - - class RequireRelink(Exception): """The skill needs to be relinked.""" diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index f3f5a64bbd6..9ecd76302b7 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -8,11 +8,7 @@ from hass_nabucasa.google_report_state import ErrorResponse from homeassistant.components.google_assistant.const import DOMAIN as GOOGLE_DOMAIN from homeassistant.components.google_assistant.helpers import AbstractConfig -from homeassistant.const import ( - CLOUD_NEVER_EXPOSED_ENTITIES, - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, -) +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, ENTITY_CATEGORIES from homeassistant.core import CoreState, split_entity_id from homeassistant.helpers import entity_registry as er, start from homeassistant.setup import async_setup_component @@ -133,10 +129,7 @@ class CloudGoogleConfig(AbstractConfig): entity_registry = er.async_get(self.hass) if registry_entry := entity_registry.async_get(entity_id): - auxiliary_entity = registry_entry.entity_category in ( - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, - ) + auxiliary_entity = registry_entry.entity_category in ENTITY_CATEGORIES else: auxiliary_entity = False diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 5bfebec40a3..cd682057266 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -33,8 +33,6 @@ from .const import ( PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_TTS_DEFAULT_VOICE, REQUEST_TIMEOUT, - InvalidTrustedNetworks, - InvalidTrustedProxies, RequireRelink, ) @@ -42,14 +40,6 @@ _LOGGER = logging.getLogger(__name__) _CLOUD_ERRORS = { - InvalidTrustedNetworks: ( - HTTPStatus.INTERNAL_SERVER_ERROR, - "Remote UI not compatible with 127.0.0.1/::1 as a trusted network.", - ), - InvalidTrustedProxies: ( - HTTPStatus.INTERNAL_SERVER_ERROR, - "Remote UI not compatible with 127.0.0.1/::1 as trusted proxies.", - ), asyncio.TimeoutError: ( HTTPStatus.BAD_GATEWAY, "Unable to reach the Home Assistant cloud.", @@ -204,7 +194,7 @@ class CloudLogoutView(HomeAssistantView): hass = request.app["hass"] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT): + async with async_timeout.timeout(REQUEST_TIMEOUT): await cloud.logout() return self.json_message("ok") @@ -230,7 +220,7 @@ class CloudRegisterView(HomeAssistantView): hass = request.app["hass"] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT): + async with async_timeout.timeout(REQUEST_TIMEOUT): await cloud.auth.async_register(data["email"], data["password"]) return self.json_message("ok") @@ -249,7 +239,7 @@ class CloudResendConfirmView(HomeAssistantView): hass = request.app["hass"] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT): + async with async_timeout.timeout(REQUEST_TIMEOUT): await cloud.auth.async_resend_email_confirm(data["email"]) return self.json_message("ok") @@ -268,14 +258,14 @@ class CloudForgotPasswordView(HomeAssistantView): hass = request.app["hass"] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT): + async with async_timeout.timeout(REQUEST_TIMEOUT): await cloud.auth.async_forgot_password(data["email"]) return self.json_message("ok") -@websocket_api.async_response @websocket_api.websocket_command({vol.Required("type"): "cloud/status"}) +@websocket_api.async_response async def websocket_cloud_status(hass, connection, msg): """Handle request for account info. @@ -308,13 +298,13 @@ def _require_cloud_login(handler): @_require_cloud_login -@websocket_api.async_response @websocket_api.websocket_command({vol.Required("type"): "cloud/subscription"}) +@websocket_api.async_response async def websocket_subscription(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] try: - with async_timeout.timeout(REQUEST_TIMEOUT): + async with async_timeout.timeout(REQUEST_TIMEOUT): data = await cloud_api.async_subscription_info(cloud) except aiohttp.ClientError: connection.send_error( @@ -325,7 +315,6 @@ async def websocket_subscription(hass, connection, msg): @_require_cloud_login -@websocket_api.async_response @websocket_api.websocket_command( { vol.Required("type"): "cloud/update_prefs", @@ -341,6 +330,7 @@ async def websocket_subscription(hass, connection, msg): ), } ) +@websocket_api.async_response async def websocket_update_prefs(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] @@ -353,7 +343,7 @@ async def websocket_update_prefs(hass, connection, msg): if changes.get(PREF_ALEXA_REPORT_STATE): alexa_config = await cloud.client.get_alexa_config() try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): await alexa_config.async_get_access_token() except asyncio.TimeoutError: connection.send_error( @@ -375,14 +365,14 @@ async def websocket_update_prefs(hass, connection, msg): @_require_cloud_login -@websocket_api.async_response -@_ws_handle_cloud_errors @websocket_api.websocket_command( { vol.Required("type"): "cloud/cloudhook/create", vol.Required("webhook_id"): str, } ) +@websocket_api.async_response +@_ws_handle_cloud_errors async def websocket_hook_create(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] @@ -391,14 +381,14 @@ async def websocket_hook_create(hass, connection, msg): @_require_cloud_login -@websocket_api.async_response -@_ws_handle_cloud_errors @websocket_api.websocket_command( { vol.Required("type"): "cloud/cloudhook/delete", vol.Required("webhook_id"): str, } ) +@websocket_api.async_response +@_ws_handle_cloud_errors async def websocket_hook_delete(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] @@ -440,9 +430,9 @@ async def _account_data(cloud): @websocket_api.require_admin @_require_cloud_login +@websocket_api.websocket_command({"type": "cloud/remote/connect"}) @websocket_api.async_response @_ws_handle_cloud_errors -@websocket_api.websocket_command({"type": "cloud/remote/connect"}) async def websocket_remote_connect(hass, connection, msg): """Handle request for connect remote.""" cloud = hass.data[DOMAIN] @@ -452,9 +442,9 @@ async def websocket_remote_connect(hass, connection, msg): @websocket_api.require_admin @_require_cloud_login +@websocket_api.websocket_command({"type": "cloud/remote/disconnect"}) @websocket_api.async_response @_ws_handle_cloud_errors -@websocket_api.websocket_command({"type": "cloud/remote/disconnect"}) async def websocket_remote_disconnect(hass, connection, msg): """Handle request for disconnect remote.""" cloud = hass.data[DOMAIN] @@ -464,9 +454,9 @@ async def websocket_remote_disconnect(hass, connection, msg): @websocket_api.require_admin @_require_cloud_login +@websocket_api.websocket_command({"type": "cloud/google_assistant/entities"}) @websocket_api.async_response @_ws_handle_cloud_errors -@websocket_api.websocket_command({"type": "cloud/google_assistant/entities"}) async def google_assistant_list(hass, connection, msg): """List all google assistant entities.""" cloud = hass.data[DOMAIN] @@ -489,8 +479,6 @@ async def google_assistant_list(hass, connection, msg): @websocket_api.require_admin @_require_cloud_login -@websocket_api.async_response -@_ws_handle_cloud_errors @websocket_api.websocket_command( { "type": "cloud/google_assistant/entities/update", @@ -501,6 +489,8 @@ async def google_assistant_list(hass, connection, msg): vol.Optional("disable_2fa"): bool, } ) +@websocket_api.async_response +@_ws_handle_cloud_errors async def google_assistant_update(hass, connection, msg): """Update google assistant config.""" cloud = hass.data[DOMAIN] @@ -517,9 +507,9 @@ async def google_assistant_update(hass, connection, msg): @websocket_api.require_admin @_require_cloud_login +@websocket_api.websocket_command({"type": "cloud/alexa/entities"}) @websocket_api.async_response @_ws_handle_cloud_errors -@websocket_api.websocket_command({"type": "cloud/alexa/entities"}) async def alexa_list(hass, connection, msg): """List all alexa entities.""" cloud = hass.data[DOMAIN] @@ -542,8 +532,6 @@ async def alexa_list(hass, connection, msg): @websocket_api.require_admin @_require_cloud_login -@websocket_api.async_response -@_ws_handle_cloud_errors @websocket_api.websocket_command( { "type": "cloud/alexa/entities/update", @@ -551,6 +539,8 @@ async def alexa_list(hass, connection, msg): vol.Optional("should_expose"): vol.Any(None, bool), } ) +@websocket_api.async_response +@_ws_handle_cloud_errors async def alexa_update(hass, connection, msg): """Update alexa entity config.""" cloud = hass.data[DOMAIN] @@ -567,14 +557,14 @@ async def alexa_update(hass, connection, msg): @websocket_api.require_admin @_require_cloud_login -@websocket_api.async_response @websocket_api.websocket_command({"type": "cloud/alexa/sync"}) +@websocket_api.async_response async def alexa_sync(hass, connection, msg): """Sync with Alexa.""" cloud = hass.data[DOMAIN] alexa_config = await cloud.client.get_alexa_config() - with async_timeout.timeout(10): + async with async_timeout.timeout(10): try: success = await alexa_config.async_sync_entities() except alexa_errors.NoTokenAvailable: @@ -591,13 +581,13 @@ async def alexa_sync(hass, connection, msg): connection.send_error(msg["id"], ws_const.ERR_UNKNOWN_ERROR, "Unknown error") -@websocket_api.async_response @websocket_api.websocket_command({"type": "cloud/thingtalk/convert", "query": str}) +@websocket_api.async_response async def thingtalk_convert(hass, connection, msg): """Convert a query.""" cloud = hass.data[DOMAIN] - with async_timeout.timeout(10): + async with async_timeout.timeout(10): try: connection.send_result( msg["id"], await thingtalk.async_convert(cloud, msg["query"]) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index a4c81bcc64f..11d4ebbb175 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,8 +1,6 @@ """Preference management for cloud.""" from __future__ import annotations -from ipaddress import ip_address - from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User from homeassistant.core import callback @@ -34,8 +32,6 @@ from .const import ( PREF_SHOULD_EXPOSE, PREF_TTS_DEFAULT_VOICE, PREF_USERNAME, - InvalidTrustedNetworks, - InvalidTrustedProxies, ) STORAGE_KEY = DOMAIN @@ -54,9 +50,7 @@ class CloudPreferences: async def async_initialize(self): """Finish initializing the preferences.""" - prefs = await self._store.async_load() - - if prefs is None: + if (prefs := await self._store.async_load()) is None: prefs = self._empty_config("") self._prefs = prefs @@ -112,14 +106,6 @@ class CloudPreferences: if value is not UNDEFINED: prefs[key] = value - if remote_enabled is True and self._has_local_trusted_network: - prefs[PREF_ENABLE_REMOTE] = False - raise InvalidTrustedNetworks - - if remote_enabled is True and self._has_local_trusted_proxies: - prefs[PREF_ENABLE_REMOTE] = False - raise InvalidTrustedProxies - await self._save_prefs(prefs) async def async_update_google_entity_config( @@ -219,9 +205,6 @@ class CloudPreferences: if not self._prefs.get(PREF_ENABLE_REMOTE, False): return False - if self._has_local_trusted_network or self._has_local_trusted_proxies: - return False - return True @property @@ -298,8 +281,9 @@ class CloudPreferences: return user.id user = await self._hass.auth.async_create_system_user( - "Home Assistant Cloud", [GROUP_ID_ADMIN] + "Home Assistant Cloud", group_ids=[GROUP_ID_ADMIN], local_only=True ) + assert user is not None await self.async_update(cloud_user=user.id) return user.id @@ -312,38 +296,6 @@ class CloudPreferences: # an image was restored without restoring the cloud prefs. return await self._hass.auth.async_get_user(user_id) - @property - def _has_local_trusted_network(self) -> bool: - """Return if we allow localhost to bypass auth.""" - local4 = ip_address("127.0.0.1") - local6 = ip_address("::1") - - for prv in self._hass.auth.auth_providers: - if prv.type != "trusted_networks": - continue - - for network in prv.trusted_networks: - if local4 in network or local6 in network: - return True - - return False - - @property - def _has_local_trusted_proxies(self) -> bool: - """Return if we allow localhost to be a proxy and use its data.""" - if not hasattr(self._hass, "http"): - return False - - local4 = ip_address("127.0.0.1") - local6 = ip_address("::1") - - if any( - local4 in nwk or local6 in nwk for nwk in self._hass.http.trusted_proxies - ): - return True - - return False - async def _save_prefs(self, prefs): """Save preferences to disk.""" self._prefs = prefs diff --git a/homeassistant/components/cloud/translations/id.json b/homeassistant/components/cloud/translations/id.json index 1cff542796c..a8f6d7b4b67 100644 --- a/homeassistant/components/cloud/translations/id.json +++ b/homeassistant/components/cloud/translations/id.json @@ -10,6 +10,7 @@ "relayer_connected": "Relayer Terhubung", "remote_connected": "Terhubung Jarak Jauh", "remote_enabled": "Kontrol Jarak Jauh Diaktifkan", + "remote_server": "Server Daring", "subscription_expiration": "Masa Kedaluwarsa Langganan" } } diff --git a/homeassistant/components/cloud/translations/ja.json b/homeassistant/components/cloud/translations/ja.json new file mode 100644 index 00000000000..d9c6674fa6b --- /dev/null +++ b/homeassistant/components/cloud/translations/ja.json @@ -0,0 +1,17 @@ +{ + "system_health": { + "info": { + "alexa_enabled": "Alexa\u6709\u52b9", + "can_reach_cert_server": "\u8a3c\u660e\u66f8\u30b5\u30fc\u30d0\u30fc\u306b\u5230\u9054\u30a2\u30af\u30bb\u30b9\u3059\u308b", + "can_reach_cloud": "Home AssistantCloud\u306b\u30a2\u30af\u30bb\u30b9\u3059\u308b", + "can_reach_cloud_auth": "\u8a8d\u8a3c\u30b5\u30fc\u30d0\u30fc\u306b\u5230\u9054", + "google_enabled": "Google\u6709\u52b9", + "logged_in": "\u30ed\u30b0\u30a4\u30f3\u6e08", + "relayer_connected": "\u63a5\u7d9a\u3055\u308c\u305f\u518d\u30ec\u30a4\u30e4\u30fc", + "remote_connected": "\u30ea\u30e2\u30fc\u30c8\u63a5\u7d9a", + "remote_enabled": "\u30ea\u30e2\u30fc\u30c8\u6709\u52b9", + "remote_server": "\u30ea\u30e2\u30fc\u30c8\u30b5\u30fc\u30d0\u30fc", + "subscription_expiration": "\u30b5\u30d6\u30b9\u30af\u30ea\u30d7\u30b7\u30e7\u30f3\u306e\u6709\u52b9\u671f\u9650" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloud/translations/tr.json b/homeassistant/components/cloud/translations/tr.json index b9b82f2c08c..5f3edb5d3f1 100644 --- a/homeassistant/components/cloud/translations/tr.json +++ b/homeassistant/components/cloud/translations/tr.json @@ -2,7 +2,9 @@ "system_health": { "info": { "alexa_enabled": "Alexa Etkin", + "can_reach_cert_server": "Sertifika Sunucusuna Ula\u015f\u0131n", "can_reach_cloud": "Home Assistant Cloud'a ula\u015f\u0131n", + "can_reach_cloud_auth": "Kimlik Do\u011frulama Sunucusuna Ula\u015f\u0131n", "google_enabled": "Google Etkin", "logged_in": "Giri\u015f Yapt\u0131", "relayer_connected": "Yeniden Katman ba\u011fl\u0131", diff --git a/homeassistant/components/cloud/utils.py b/homeassistant/components/cloud/utils.py index 715a8119af9..5908a0ac816 100644 --- a/homeassistant/components/cloud/utils.py +++ b/homeassistant/components/cloud/utils.py @@ -9,13 +9,17 @@ from aiohttp import payload, web def aiohttp_serialize_response(response: web.Response) -> dict[str, Any]: """Serialize an aiohttp response to a dictionary.""" if (body := response.body) is None: - pass + body_decoded = None elif isinstance(body, payload.StringPayload): # pylint: disable=protected-access - body = body._value.decode(body.encoding) + body_decoded = body._value.decode(body.encoding) elif isinstance(body, bytes): - body = body.decode(response.charset or "utf-8") + body_decoded = body.decode(response.charset or "utf-8") else: raise ValueError("Unknown payload encoding") - return {"status": response.status, "body": body, "headers": dict(response.headers)} + return { + "status": response.status, + "body": body_decoded, + "headers": dict(response.headers), + } diff --git a/homeassistant/components/cloudflare/manifest.json b/homeassistant/components/cloudflare/manifest.json index c831dbeb34d..ebb9e4b5f62 100644 --- a/homeassistant/components/cloudflare/manifest.json +++ b/homeassistant/components/cloudflare/manifest.json @@ -2,7 +2,7 @@ "domain": "cloudflare", "name": "Cloudflare", "documentation": "https://www.home-assistant.io/integrations/cloudflare", - "requirements": ["pycfdns==1.2.1"], + "requirements": ["pycfdns==1.2.2"], "codeowners": ["@ludeeus", "@ctalkington"], "config_flow": true, "iot_class": "cloud_push" diff --git a/homeassistant/components/cloudflare/translations/id.json b/homeassistant/components/cloudflare/translations/id.json index 73f0455273c..b3f49244658 100644 --- a/homeassistant/components/cloudflare/translations/id.json +++ b/homeassistant/components/cloudflare/translations/id.json @@ -14,7 +14,8 @@ "step": { "reauth_confirm": { "data": { - "api_token": "Token API" + "api_token": "Token API", + "description": "Autentikasi ulang dengan akun Cloudflare Anda." } }, "records": { diff --git a/homeassistant/components/cloudflare/translations/ja.json b/homeassistant/components/cloudflare/translations/ja.json new file mode 100644 index 00000000000..81fdf638148 --- /dev/null +++ b/homeassistant/components/cloudflare/translations/ja.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "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\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_zone": "\u7121\u52b9\u306a\u30be\u30fc\u30f3" + }, + "flow_title": "{name}", + "step": { + "reauth_confirm": { + "data": { + "api_token": "API\u30c8\u30fc\u30af\u30f3", + "description": "Cloudflare\u30a2\u30ab\u30a6\u30f3\u30c8\u3067\u518d\u8a8d\u8a3c\u3057\u307e\u3059\u3002" + } + }, + "records": { + "data": { + "records": "\u30ec\u30b3\u30fc\u30c9" + }, + "title": "\u30a2\u30c3\u30d7\u30c7\u30fc\u30c8\u3059\u308b\u30ec\u30b3\u30fc\u30c9\u3092\u9078\u629e" + }, + "user": { + "data": { + "api_token": "API\u30c8\u30fc\u30af\u30f3" + }, + "description": "\u3053\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306b\u306f\u3001\u30a2\u30ab\u30a6\u30f3\u30c8\u5185\u306e\u3059\u3079\u3066\u306e\u30be\u30fc\u30f3\u306b\u5bfe\u3059\u308b\u3001 Zone:Zone:Read \u304a\u3088\u3073\u3001Zone:DNS:Edit\u306e\u6a29\u9650\u3067\u4f5c\u6210\u3055\u308c\u305fAPI\u30c8\u30fc\u30af\u30f3\u304c\u5fc5\u8981\u3067\u3059\u3002", + "title": "Cloudflare\u306b\u63a5\u7d9a" + }, + "zone": { + "data": { + "zone": "\u30be\u30fc\u30f3" + }, + "title": "\u66f4\u65b0\u3059\u308b\u30be\u30fc\u30f3\u3092\u9078\u629e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloudflare/translations/tr.json b/homeassistant/components/cloudflare/translations/tr.json index 5d1180961f6..1f0c68d7f90 100644 --- a/homeassistant/components/cloudflare/translations/tr.json +++ b/homeassistant/components/cloudflare/translations/tr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "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.", "unknown": "Beklenmeyen hata" }, @@ -9,8 +10,14 @@ "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "invalid_zone": "Ge\u00e7ersiz b\u00f6lge" }, - "flow_title": "Cloudflare: {name}", + "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "API Anahtar\u0131", + "description": "Cloudflare hesab\u0131n\u0131zla yeniden kimlik do\u011frulamas\u0131 yap\u0131n." + } + }, "records": { "data": { "records": "Kay\u0131tlar" @@ -19,8 +26,9 @@ }, "user": { "data": { - "api_token": "API Belirteci" + "api_token": "API Anahtar\u0131" }, + "description": "Bu entegrasyon, hesab\u0131n\u0131zdaki t\u00fcm b\u00f6lgeler i\u00e7in Zone:Zone:Read ve Zone:DNS:Edit izinleriyle olu\u015fturulmu\u015f bir API Simgesi gerektirir.", "title": "Cloudflare'ye ba\u011flan\u0131n" }, "zone": { diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index 26f41ac2e67..56be5bca57b 100644 --- a/homeassistant/components/co2signal/__init__.py +++ b/homeassistant/components/co2signal/__init__.py @@ -8,7 +8,7 @@ from typing import TypedDict, cast import CO2Signal 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, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -16,7 +16,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_COUNTRY_CODE, DOMAIN from .util import get_extra_name -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -134,9 +134,6 @@ def get_data(hass: HomeAssistant, config: dict) -> CO2SignalResponse: _LOGGER.exception("Unexpected exception") raise UnknownError from err - except Exception as err: - _LOGGER.exception("Unexpected exception") - raise UnknownError from err else: if "error" in data: diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index e7f94e4d603..fb4e48c66e8 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -10,7 +10,7 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CON from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from . import APIRatelimitExceeded, CO2Error, InvalidAuth, UnknownError, get_data +from . import APIRatelimitExceeded, CO2Error, InvalidAuth, get_data from .const import CONF_COUNTRY_CODE, DOMAIN from .util import get_extra_name @@ -172,7 +172,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except APIRatelimitExceeded: errors["base"] = "api_ratelimit" - except UnknownError: + except Exception: # pylint: disable=broad-except errors["base"] = "unknown" else: return self.async_create_entry( diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index b7a36623a3c..53a02afb560 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -21,6 +21,7 @@ from homeassistant.const import ( PERCENTAGE, ) from homeassistant.helpers import config_validation as cv, update_coordinator +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.typing import StateType @@ -104,7 +105,7 @@ class CO2Sensor(update_coordinator.CoordinatorEntity[CO2SignalResponse], SensorE } self._attr_device_info = DeviceInfo( configuration_url="https://www.electricitymap.org/", - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, coordinator.entry_id)}, manufacturer="Tmrow.com", name="CO2 signal", diff --git a/homeassistant/components/co2signal/translations/id.json b/homeassistant/components/co2signal/translations/id.json index 76e72a93fd5..e323b25db2d 100644 --- a/homeassistant/components/co2signal/translations/id.json +++ b/homeassistant/components/co2signal/translations/id.json @@ -2,9 +2,11 @@ "config": { "abort": { "already_configured": "Perangkat sudah dikonfigurasi", + "api_ratelimit": "Batas Tingkat API terlampaui", "unknown": "Kesalahan yang tidak diharapkan" }, "error": { + "api_ratelimit": "Batas Tingkat API terlampaui", "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, @@ -22,8 +24,10 @@ }, "user": { "data": { - "api_key": "Token Akses" - } + "api_key": "Token Akses", + "location": "Dapatkan data untuk" + }, + "description": "Kunjungi https://co2signal.com/ untuk meminta token." } } } diff --git a/homeassistant/components/co2signal/translations/ja.json b/homeassistant/components/co2signal/translations/ja.json new file mode 100644 index 00000000000..cd3f422022d --- /dev/null +++ b/homeassistant/components/co2signal/translations/ja.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "api_ratelimit": "API\u30ec\u30fc\u30c8\u5236\u9650\u3092\u8d85\u3048\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "api_ratelimit": "API\u30ec\u30fc\u30c8\u5236\u9650\u3092\u8d85\u3048\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "coordinates": { + "data": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6" + } + }, + "country": { + "data": { + "country_code": "\u56fd\u5225\u30b3\u30fc\u30c9" + } + }, + "user": { + "data": { + "api_key": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", + "location": "\uff5e\u306e\u30c7\u30fc\u30bf\u3092\u53d6\u5f97" + }, + "description": "\u30c8\u30fc\u30af\u30f3\u3092\u30ea\u30af\u30a8\u30b9\u30c8\u3059\u308b\u306b\u306f\u3001https://co2signal.com/ \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/tr.json b/homeassistant/components/co2signal/translations/tr.json new file mode 100644 index 00000000000..038d8e85ff5 --- /dev/null +++ b/homeassistant/components/co2signal/translations/tr.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "api_ratelimit": "API Ratelimit a\u015f\u0131ld\u0131", + "unknown": "Beklenmeyen hata" + }, + "error": { + "api_ratelimit": "API Ratelimit a\u015f\u0131ld\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Enlem", + "longitude": "Boylam" + } + }, + "country": { + "data": { + "country_code": "\u00dclke kodu" + } + }, + "user": { + "data": { + "api_key": "Eri\u015fim Anahtar\u0131", + "location": "Veri alma" + }, + "description": "Bir anahtar istemek i\u00e7in https://co2signal.com/ adresini ziyaret edin." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index 111064924af..238ff1db87d 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -9,7 +9,7 @@ from coinbase.wallet.error import AuthenticationError import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN +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 @@ -28,7 +28,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py index 65c2636cd82..a01db46b095 100644 --- a/homeassistant/components/coinbase/const.py +++ b/homeassistant/components/coinbase/const.py @@ -36,6 +36,7 @@ WALLETS = { "AOA": "AOA", "ARS": "ARS", "ATOM": "ATOM", + "AUCTION": "AUCTION", "AUD": "AUD", "AWG": "AWG", "AZN": "AZN", @@ -284,6 +285,7 @@ RATES = { "AOA": "AOA", "ARS": "ARS", "ATOM": "ATOM", + "AUCTION": "AUCTION", "AUD": "AUD", "AWG": "AWG", "AZN": "AZN", diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index b4ef4bb8e35..011dd63b151 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -1,8 +1,9 @@ """Support for Coinbase sensors.""" import logging -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from .const import ( @@ -105,9 +106,10 @@ class AccountSensor(SensorEntity): API_ACCOUNT_CURRENCY ] break + self._attr_state_class = SensorStateClass.TOTAL self._attr_device_info = DeviceInfo( configuration_url="https://www.coinbase.com/settings/api", - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self._coinbase_data.user_id)}, manufacturer="Coinbase.com", name=f"Coinbase {self._coinbase_data.user_id[-4:]}", @@ -177,9 +179,10 @@ class ExchangeRateSensor(SensorEntity): 1 / float(self._coinbase_data.exchange_rates[API_RATES][self.currency]), 2 ) self._unit_of_measurement = exchange_base + self._attr_state_class = SensorStateClass.MEASUREMENT self._attr_device_info = DeviceInfo( configuration_url="https://www.coinbase.com/settings/api", - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self._coinbase_data.user_id)}, manufacturer="Coinbase.com", name=f"Coinbase {self._coinbase_data.user_id[-4:]}", diff --git a/homeassistant/components/coinbase/translations/id.json b/homeassistant/components/coinbase/translations/id.json index e0d93019507..0f83a78044b 100644 --- a/homeassistant/components/coinbase/translations/id.json +++ b/homeassistant/components/coinbase/translations/id.json @@ -11,14 +11,31 @@ "step": { "user": { "data": { - "api_key": "Kunci API" - } + "api_key": "Kunci API", + "api_token": "Kode Rahasia API", + "currencies": "Mata Uang Saldo Akun", + "exchange_rates": "Nilai Tukar" + }, + "description": "Silakan masukkan detail kunci API Anda sesuai yang disediakan oleh Coinbase.", + "title": "Detail Kunci API Coinbase" } } }, "options": { "error": { + "currency_unavaliable": "Satu atau beberapa saldo mata uang yang diminta tidak disediakan oleh API Coinbase Anda.", + "exchange_rate_unavaliable": "Satu atau beberapa nilai tukar yang diminta tidak disediakan oleh Coinbase.", "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Saldo dompet untuk dilaporkan.", + "exchange_base": "Mata uang dasar untuk sensor nilai tukar.", + "exchange_rate_currencies": "Nilai tukar untuk dilaporkan." + }, + "description": "Sesuaikan Opsi Coinbase" + } } } } \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/ja.json b/homeassistant/components/coinbase/translations/ja.json new file mode 100644 index 00000000000..55333777aa2 --- /dev/null +++ b/homeassistant/components/coinbase/translations/ja.json @@ -0,0 +1,41 @@ +{ + "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "api_token": "API\u30b7\u30fc\u30af\u30ec\u30c3\u30c8", + "currencies": "\u53e3\u5ea7\u6b8b\u9ad8 \u901a\u8ca8", + "exchange_rates": "\u70ba\u66ff\u30ec\u30fc\u30c8" + }, + "description": "Coinbase\u304b\u3089\u63d0\u4f9b\u3055\u308c\u305fAPI\u30ad\u30fc\u306e\u8a73\u7d30\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Coinbase API\u30ad\u30fc\u306e\u8a73\u7d30" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "\u8981\u6c42\u3055\u308c\u305f\u901a\u8ca8\u6b8b\u9ad8\u306e1\u3064\u4ee5\u4e0a\u304c\u3001Coinbase API\u306b\u3088\u3063\u3066\u63d0\u4f9b\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002", + "exchange_rate_unavaliable": "\u8981\u6c42\u3055\u308c\u305f\u70ba\u66ff\u30ec\u30fc\u30c8\u306e1\u3064\u4ee5\u4e0a\u304cCoinbase\u306b\u3088\u3063\u3066\u63d0\u4f9b\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "\u30a6\u30a9\u30ec\u30c3\u30c8\u306e\u6b8b\u9ad8\u3092\u5831\u544a\u3059\u308b\u3002", + "exchange_base": "\u70ba\u66ff\u30ec\u30fc\u30c8\u30bb\u30f3\u30b5\u30fc\u306e\u57fa\u6e96\u901a\u8ca8\u3002", + "exchange_rate_currencies": "\u30ec\u30dd\u30fc\u30c8\u3059\u3079\u304d\u70ba\u66ff\u30ec\u30fc\u30c8" + }, + "description": "Coinbase\u30aa\u30d7\u30b7\u30e7\u30f3\u306e\u8abf\u6574" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/tr.json b/homeassistant/components/coinbase/translations/tr.json new file mode 100644 index 00000000000..285d2f7bb96 --- /dev/null +++ b/homeassistant/components/coinbase/translations/tr.json @@ -0,0 +1,41 @@ +{ + "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": { + "api_key": "API Anahtar\u0131", + "api_token": "API Gizli Anahtar\u0131", + "currencies": "Hesap Bakiyesi Para Birimleri", + "exchange_rates": "D\u00f6viz Kurlar\u0131" + }, + "description": "L\u00fctfen API anahtar\u0131n\u0131z\u0131n ayr\u0131nt\u0131lar\u0131n\u0131 Coinbase taraf\u0131ndan sa\u011flanan \u015fekilde girin.", + "title": "Coinbase API Anahtar Ayr\u0131nt\u0131lar\u0131" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "\u0130stenen para birimi bakiyelerinden biri veya daha fazlas\u0131 Coinbase API'niz taraf\u0131ndan sa\u011flanm\u0131yor.", + "exchange_rate_unavaliable": "\u0130stenen d\u00f6viz kurlar\u0131ndan biri veya daha fazlas\u0131 Coinbase taraf\u0131ndan sa\u011flanm\u0131yor.", + "unknown": "Beklenmeyen hata" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Rapor edilecek c\u00fczdan bakiyeleri.", + "exchange_base": "D\u00f6viz kuru sens\u00f6rleri i\u00e7in temel para birimi.", + "exchange_rate_currencies": "Raporlanacak d\u00f6viz kurlar\u0131." + }, + "description": "Coinbase Se\u00e7eneklerini Ayarlay\u0131n" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index b0ab5c2aba7..4d8118483b6 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -113,7 +113,7 @@ async def async_setup(hass, hass_config): try: session = aiohttp_client.async_get_clientsession(hass) - with async_timeout.timeout(10): + async with async_timeout.timeout(10): response = await session.get(url) except (asyncio.TimeoutError, aiohttp.ClientError) as err: diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py index fc038adc568..080b31036d9 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -104,7 +104,7 @@ class ComedHourlyPricingSensor(SensorEntity): else: url_string += "?type=currenthouraverage" - with async_timeout.timeout(60): + async with async_timeout.timeout(60): response = await self.websession.get(url_string) # The API responds with MIME type 'text/html' text = await response.text() diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index ae6c1c0c925..fae4cdbcc6b 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_COMMAND_ON, CONF_COMMAND_STATE, CONF_FRIENDLY_NAME, + CONF_ICON_TEMPLATE, CONF_SWITCHES, CONF_VALUE_TEMPLATE, ) @@ -31,6 +32,7 @@ SWITCH_SCHEMA = vol.Schema( vol.Optional(CONF_COMMAND_STATE): cv.string, vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_ICON_TEMPLATE): cv.template, vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, } ) @@ -54,6 +56,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if value_template is not None: value_template.hass = hass + icon_template = device_config.get(CONF_ICON_TEMPLATE) + if icon_template is not None: + icon_template.hass = hass + switches.append( CommandSwitch( hass, @@ -62,6 +68,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): device_config[CONF_COMMAND_ON], device_config[CONF_COMMAND_OFF], device_config.get(CONF_COMMAND_STATE), + icon_template, value_template, device_config[CONF_COMMAND_TIMEOUT], ) @@ -85,6 +92,7 @@ class CommandSwitch(SwitchEntity): command_on, command_off, command_state, + icon_template, value_template, timeout, ): @@ -96,6 +104,7 @@ class CommandSwitch(SwitchEntity): self._command_on = command_on self._command_off = command_off self._command_state = command_state + self._icon_template = icon_template self._value_template = value_template self._timeout = timeout @@ -152,6 +161,10 @@ class CommandSwitch(SwitchEntity): """Update device state.""" if self._command_state: payload = str(self._query_state()) + if self._icon_template: + self._attr_icon = self._icon_template.render_with_possible_json_value( + payload + ) if self._value_template: payload = self._value_template.render_with_possible_json_value(payload) self._state = payload.lower() == "true" diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index e68541973b1..315d1b705df 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -2,7 +2,7 @@ "domain": "compensation", "name": "Compensation", "documentation": "https://www.home-assistant.io/integrations/compensation", - "requirements": ["numpy==1.21.2"], + "requirements": ["numpy==1.21.4"], "codeowners": ["@Petro31"], "iot_class": "calculated" } diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 0815216ec79..ff7b1e4d4cd 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -11,6 +11,7 @@ from homeassistant.const import CONF_ID, EVENT_COMPONENT_LOADED from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import ATTR_COMPONENT +from homeassistant.util.file import write_utf8_file_atomic from homeassistant.util.yaml import dump, load_yaml DOMAIN = "config" @@ -21,7 +22,6 @@ SECTIONS = ( "automation", "config_entries", "core", - "customize", "device_registry", "entity_registry", "group", @@ -226,9 +226,7 @@ class EditIdBasedConfigView(BaseEditConfigView): def _write_value(self, hass, data, config_key, new_value): """Set value.""" - value = self._get_value(hass, data, config_key) - - if value is None: + if (value := self._get_value(hass, data, config_key)) is None: value = {CONF_ID: config_key} data.append(value) @@ -254,6 +252,5 @@ def _write(path, data): """Write YAML helper.""" # Do it before opening file. If dump causes error it will now not # truncate the file. - data = dump(data) - with open(path, "w", encoding="utf-8") as outfile: - outfile.write(data) + contents = dump(data) + write_utf8_file_atomic(path, contents) diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py index 54d992466f9..c46ef78b0b2 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -48,9 +48,7 @@ async def websocket_delete(hass, connection, msg): ) return - user = await hass.auth.async_get_user(msg["user_id"]) - - if not user: + if not (user := await hass.auth.async_get_user(msg["user_id"])): connection.send_message( websocket_api.error_message(msg["id"], "not_found", "User not found") ) @@ -68,11 +66,14 @@ async def websocket_delete(hass, connection, msg): vol.Required("type"): "config/auth/create", vol.Required("name"): str, vol.Optional("group_ids"): [str], + vol.Optional("local_only"): bool, } ) async def websocket_create(hass, connection, msg): """Create a user.""" - user = await hass.auth.async_create_user(msg["name"], msg.get("group_ids")) + user = await hass.auth.async_create_user( + msg["name"], group_ids=msg.get("group_ids"), local_only=msg.get("local_only") + ) connection.send_message( websocket_api.result_message(msg["id"], {"user": _user_info(user)}) @@ -88,13 +89,12 @@ async def websocket_create(hass, connection, msg): vol.Optional("name"): str, vol.Optional("is_active"): bool, vol.Optional("group_ids"): [str], + vol.Optional("local_only"): bool, } ) async def websocket_update(hass, connection, msg): """Update a user.""" - user = await hass.auth.async_get_user(msg.pop("user_id")) - - if not user: + if not (user := await hass.auth.async_get_user(msg.pop("user_id"))): connection.send_message( websocket_api.error_message( msg["id"], websocket_api.const.ERR_NOT_FOUND, "User not found" @@ -150,6 +150,7 @@ def _user_info(user): "name": user.name, "is_owner": user.is_owner, "is_active": user.is_active, + "local_only": user.local_only, "system_generated": user.system_generated, "group_ids": [group.id for group in user.groups], "credentials": [{"type": c.auth_provider_type} for c in user.credentials], diff --git a/homeassistant/components/config/auth_provider_homeassistant.py b/homeassistant/components/config/auth_provider_homeassistant.py index 78175678a58..590ab4bff1a 100644 --- a/homeassistant/components/config/auth_provider_homeassistant.py +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -31,9 +31,8 @@ async def async_setup(hass): async def websocket_create(hass, connection, msg): """Create credentials and attach to a user.""" provider = auth_ha.async_get_provider(hass) - user = await hass.auth.async_get_user(msg["user_id"]) - if user is None: + if (user := await hass.auth.async_get_user(msg["user_id"])) is None: connection.send_error(msg["id"], "not_found", "User not found") return @@ -149,9 +148,7 @@ async def websocket_admin_change_password(hass, connection, msg): if not connection.user.is_owner: raise Unauthorized(context=connection.context(msg)) - user = await hass.auth.async_get_user(msg["user_id"]) - - if user is None: + if (user := await hass.auth.async_get_user(msg["user_id"])) is None: connection.send_error(msg["id"], "user_not_found", "User not found") return diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index cf243137940..61df9dc190d 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -249,8 +249,7 @@ def get_entry( msg_id: int, ) -> config_entries.ConfigEntry | None: """Get entry, send error message if it doesn't exist.""" - entry = hass.config_entries.async_get_entry(entry_id) - if entry is None: + if (entry := hass.config_entries.async_get_entry(entry_id)) is None: send_entry_not_found(connection, msg_id) return entry @@ -367,13 +366,11 @@ async def ignore_config_flow(hass, connection, msg): def entry_json(entry: config_entries.ConfigEntry) -> dict: """Return JSON value of a config entry.""" handler = config_entries.HANDLERS.get(entry.domain) - supports_options = ( - # Guard in case handler is no longer registered (custom component etc) - handler is not None - # pylint: disable=comparison-with-callable - and handler.async_get_options_flow - != config_entries.ConfigFlow.async_get_options_flow + # work out if handler has support for options flow + supports_options = handler is not None and handler.async_supports_options_flow( + entry ) + return { "entry_id": entry.entry_id, "domain": entry.domain, diff --git a/homeassistant/components/config/customize.py b/homeassistant/components/config/customize.py deleted file mode 100644 index 3b1122fc3a5..00000000000 --- a/homeassistant/components/config/customize.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Provide configuration end points for Customize.""" -from homeassistant.components.homeassistant import SERVICE_RELOAD_CORE_CONFIG -from homeassistant.config import DATA_CUSTOMIZE -from homeassistant.core import DOMAIN -import homeassistant.helpers.config_validation as cv - -from . import EditKeyBasedConfigView - -CONFIG_PATH = "customize.yaml" - - -async def async_setup(hass): - """Set up the Customize config API.""" - - async def hook(action, config_key): - """post_write_hook for Config View that reloads groups.""" - await hass.services.async_call(DOMAIN, SERVICE_RELOAD_CORE_CONFIG) - - hass.http.register_view( - CustomizeConfigView( - "customize", "config", CONFIG_PATH, cv.entity_id, dict, post_write_hook=hook - ) - ) - - return True - - -class CustomizeConfigView(EditKeyBasedConfigView): - """Configure a list of entries.""" - - def _get_value(self, hass, data, config_key): - """Get value.""" - customize = hass.data.get(DATA_CUSTOMIZE, {}).get(config_key) or {} - return {"global": customize, "local": data.get(config_key, {})} - - def _write_value(self, hass, data, config_key, new_value): - """Set value.""" - data[config_key] = new_value - - state = hass.states.get(config_key) - state_attributes = dict(state.attributes) - state_attributes.update(new_value) - hass.states.async_set(config_key, state.state, state_attributes) diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 1cc63297352..4d4b8333d70 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -7,7 +7,10 @@ from homeassistant.components.websocket_api.decorators import ( require_admin, ) from homeassistant.core import callback -from homeassistant.helpers.device_registry import DISABLED_USER, async_get_registry +from homeassistant.helpers.device_registry import ( + DeviceEntryDisabler, + async_get_registry, +) WS_TYPE_LIST = "config/device_registry/list" SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( @@ -22,7 +25,8 @@ SCHEMA_WS_UPDATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( vol.Optional("area_id"): vol.Any(str, None), vol.Optional("name_by_user"): vol.Any(str, None), # We only allow setting disabled_by user via API. - vol.Optional("disabled_by"): vol.Any(DISABLED_USER, None), + # No Enum support like this in voluptuous, use .value + vol.Optional("disabled_by"): vol.Any(DeviceEntryDisabler.USER.value, None), } ) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 9b6fc2af82a..d42c5be08fc 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -10,7 +10,10 @@ from homeassistant.components.websocket_api.decorators import ( ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_registry import DISABLED_USER, async_get_registry +from homeassistant.helpers.entity_registry import ( + RegistryEntryDisabler, + async_get_registry, +) async def async_setup(hass): @@ -69,12 +72,18 @@ async def websocket_get_entity(hass, connection, msg): vol.Required("type"): "config/entity_registry/update", vol.Required("entity_id"): cv.entity_id, # If passed in, we update value. Passing None will remove old value. - vol.Optional("name"): vol.Any(str, None), - vol.Optional("icon"): vol.Any(str, None), vol.Optional("area_id"): vol.Any(str, None), + vol.Optional("device_class"): vol.Any(str, None), + vol.Optional("icon"): vol.Any(str, None), + vol.Optional("name"): vol.Any(str, None), vol.Optional("new_entity_id"): str, # We only allow setting disabled_by user via API. - vol.Optional("disabled_by"): vol.Any(DISABLED_USER, None), + vol.Optional("disabled_by"): vol.Any( + None, + vol.All( + vol.Coerce(RegistryEntryDisabler), RegistryEntryDisabler.USER.value + ), + ), } ) async def websocket_update_entity(hass, connection, msg): @@ -92,7 +101,7 @@ async def websocket_update_entity(hass, connection, msg): changes = {} - for key in ("name", "icon", "area_id", "disabled_by"): + for key in ("area_id", "device_class", "disabled_by", "icon", "name"): if key in msg: changes[key] = msg[key] @@ -168,15 +177,15 @@ async def websocket_remove_entity(hass, connection, msg): def _entry_dict(entry): """Convert entry to API format.""" return { + "area_id": entry.area_id, "config_entry_id": entry.config_entry_id, "device_id": entry.device_id, - "area_id": entry.area_id, "disabled_by": entry.disabled_by, - "entity_id": entry.entity_id, - "name": entry.name, - "icon": entry.icon, - "platform": entry.platform, "entity_category": entry.entity_category, + "entity_id": entry.entity_id, + "icon": entry.icon, + "name": entry.name, + "platform": entry.platform, } @@ -184,8 +193,10 @@ def _entry_dict(entry): def _entry_ext_dict(entry): """Convert entry to API format.""" data = _entry_dict(entry) - data["original_name"] = entry.original_name - data["original_icon"] = entry.original_icon - data["unique_id"] = entry.unique_id data["capabilities"] = entry.capabilities + data["device_class"] = entry.device_class + data["original_device_class"] = entry.original_device_class + data["original_icon"] = entry.original_icon + data["original_name"] = entry.original_name + data["unique_id"] = entry.unique_id return data diff --git a/homeassistant/components/configurator/translations/ja.json b/homeassistant/components/configurator/translations/ja.json index 44c6ef349c0..aedc05d5f1c 100644 --- a/homeassistant/components/configurator/translations/ja.json +++ b/homeassistant/components/configurator/translations/ja.json @@ -4,5 +4,6 @@ "configure": "\u8a2d\u5b9a", "configured": "\u8a2d\u5b9a\u6e08\u307f" } - } + }, + "title": "\u30b3\u30f3\u30d5\u30a3\u30ae\u30e5\u30ec\u30fc\u30bf\u30fc" } \ No newline at end of file diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index e57abfa3b73..ee2f51303f2 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_USERNAME, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -41,7 +42,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["light"] +PLATFORMS = [Platform.LIGHT] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -86,7 +87,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _, model, mac_address = controller_unique_id.split("_", 3) entry_data[CONF_DIRECTOR_MODEL] = model.upper() - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, controller_unique_id)}, diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index d13fe31601f..2cf1ca845f7 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -96,9 +96,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: hub = Control4Validator( - user_input["host"], - user_input["username"], - user_input["password"], + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], self.hass, ) try: @@ -123,9 +123,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=controller_unique_id, data={ - CONF_HOST: user_input["host"], - CONF_USERNAME: user_input["username"], - CONF_PASSWORD: user_input["password"], + CONF_HOST: user_input[CONF_HOST], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], CONF_CONTROLLER_UNIQUE_ID: controller_unique_id, }, ) diff --git a/homeassistant/components/control4/translations/bg.json b/homeassistant/components/control4/translations/bg.json new file mode 100644 index 00000000000..cda77281219 --- /dev/null +++ b/homeassistant/components/control4/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" + }, + "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": "IP \u0430\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/ja.json b/homeassistant/components/control4/translations/ja.json new file mode 100644 index 00000000000..ae0824a804f --- /dev/null +++ b/homeassistant/components/control4/translations/ja.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "IP\u30a2\u30c9\u30ec\u30b9", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "Control4\u30a2\u30ab\u30a6\u30f3\u30c8\u306e\u8a73\u7d30\u3068\u3001\u30ed\u30fc\u30ab\u30eb\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u306eIP\u30a2\u30c9\u30ec\u30b9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u9593\u9694\u306e\u79d2\u6570" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/tr.json b/homeassistant/components/control4/translations/tr.json index aed7e564a76..fce14cec68f 100644 --- a/homeassistant/components/control4/translations/tr.json +++ b/homeassistant/components/control4/translations/tr.json @@ -11,9 +11,19 @@ "step": { "user": { "data": { - "host": "\u0130p Adresi", + "host": "IP Adresi", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "L\u00fctfen Control4 hesap ayr\u0131nt\u0131lar\u0131n\u0131z\u0131 ve yerel denetleyicinizin IP adresini girin." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "G\u00fcncellemeler aras\u0131ndaki saniyeler" } } } diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index d957eb8e0b2..405a4f818f4 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -118,9 +118,7 @@ class DefaultAgent(AbstractConversationAgent): for intent_type, matchers in intents.items(): for matcher in matchers: - match = matcher.match(text) - - if not match: + if not (match := matcher.match(text)): continue return await intent.async_handle( diff --git a/homeassistant/components/conversation/translations/ja.json b/homeassistant/components/conversation/translations/ja.json new file mode 100644 index 00000000000..c8dbdcf8f1f --- /dev/null +++ b/homeassistant/components/conversation/translations/ja.json @@ -0,0 +1,3 @@ +{ + "title": "\u4f1a\u8a71" +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index 1bcf20f4d5e..fc0040bf245 100644 --- a/homeassistant/components/coolmaster/__init__.py +++ b/homeassistant/components/coolmaster/__init__.py @@ -4,7 +4,7 @@ import logging from pycoolmasternet_async import CoolMasterNet from homeassistant.components.climate import SCAN_INTERVAL -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -12,7 +12,7 @@ from .const import DATA_COORDINATOR, DATA_INFO, DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["climate"] +PLATFORMS = [Platform.CLIMATE] async def async_setup_entry(hass, entry): diff --git a/homeassistant/components/coolmaster/translations/ja.json b/homeassistant/components/coolmaster/translations/ja.json new file mode 100644 index 00000000000..fd9b5952b9a --- /dev/null +++ b/homeassistant/components/coolmaster/translations/ja.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "no_units": "CoolMasterNet\u306e\u30db\u30b9\u30c8\u306bHVAC\u30e6\u30cb\u30c3\u30c8\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002" + }, + "step": { + "user": { + "data": { + "cool": "\u30af\u30fc\u30eb\u30e2\u30fc\u30c9\u3092\u30b5\u30dd\u30fc\u30c8", + "dry": "\u30c9\u30e9\u30a4\u30e2\u30fc\u30c9\u3092\u30b5\u30dd\u30fc\u30c8", + "fan_only": "\u30d5\u30a1\u30f3\u306e\u307f\u306e\u30e2\u30fc\u30c9\u3092\u30b5\u30dd\u30fc\u30c8", + "heat": "\u30d2\u30fc\u30c8\u30e2\u30fc\u30c9\u3092\u30b5\u30dd\u30fc\u30c8", + "heat_cool": "\u81ea\u52d5\u6696\u623f(\u52a0\u71b1)/\u30af\u30fc\u30eb\u30e2\u30fc\u30c9\u5bfe\u5fdc", + "host": "\u30db\u30b9\u30c8", + "off": "\u30aa\u30d5\u306b\u3067\u304d\u307e\u3059" + }, + "title": "CoolMasterNet\u306e\u63a5\u7d9a\u60c5\u5831\u3092\u8a2d\u5b9a\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/tr.json b/homeassistant/components/coolmaster/translations/tr.json index 4848a34362c..dbca2862bfb 100644 --- a/homeassistant/components/coolmaster/translations/tr.json +++ b/homeassistant/components/coolmaster/translations/tr.json @@ -1,14 +1,21 @@ { "config": { "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "no_units": "CoolMasterNet ana bilgisayar\u0131nda herhangi bir HVAC birimi bulunamad\u0131." }, "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "cool": "So\u011fuk modu destekler", + "dry": "Kuru modu destekler", + "fan_only": "Yaln\u0131zca fan modunu destekler", + "heat": "Is\u0131tma modunu destekler", + "heat_cool": "Otomatik \u0131s\u0131tma/so\u011futma modunu destekler", + "host": "Ana bilgisayar", "off": "Kapat\u0131labilir" - } + }, + "title": "CoolMasterNet ba\u011flant\u0131 ayr\u0131nt\u0131lar\u0131n\u0131z\u0131 ayarlay\u0131n." } } } diff --git a/homeassistant/components/coronavirus/__init__.py b/homeassistant/components/coronavirus/__init__.py index d130e131c8b..27085c88ef2 100644 --- a/homeassistant/components/coronavirus/__init__.py +++ b/homeassistant/components/coronavirus/__init__.py @@ -6,13 +6,14 @@ import async_timeout import coronavirus from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client, entity_registry, update_coordinator from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -66,7 +67,7 @@ async def get_coordinator( return hass.data[DOMAIN] async def async_get_cases(): - with async_timeout.timeout(10): + async with async_timeout.timeout(10): return { case.country: case for case in await coronavirus.get_cases( diff --git a/homeassistant/components/coronavirus/sensor.py b/homeassistant/components/coronavirus/sensor.py index 92fdf232214..14f597299cf 100644 --- a/homeassistant/components/coronavirus/sensor.py +++ b/homeassistant/components/coronavirus/sensor.py @@ -58,8 +58,7 @@ class CoronavirusSensor(CoordinatorEntity, SensorEntity): if self.country == OPTION_WORLDWIDE: sum_cases = 0 for case in self.coordinator.data.values(): - value = getattr(case, self.info_type) - if value is None: + if (value := getattr(case, self.info_type)) is None: continue sum_cases += value diff --git a/homeassistant/components/coronavirus/translations/ja.json b/homeassistant/components/coronavirus/translations/ja.json new file mode 100644 index 00000000000..ef8059fc1b5 --- /dev/null +++ b/homeassistant/components/coronavirus/translations/ja.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "user": { + "data": { + "country": "\u56fd" + }, + "title": "\u76e3\u8996\u3059\u308b\u56fd\u3092\u9078\u629e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/tr.json b/homeassistant/components/coronavirus/translations/tr.json index b608d60f824..118f8997d1f 100644 --- a/homeassistant/components/coronavirus/translations/tr.json +++ b/homeassistant/components/coronavirus/translations/tr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_connect": "Ba\u011flanma hatas\u0131" }, "step": { "user": { diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index 75b2b4902cb..d035d658206 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -240,14 +240,15 @@ class Counter(RestoreEntity): await super().async_added_to_hass() # __init__ will set self._state to self._initial, only override # if needed. - if self._config[CONF_RESTORE]: - state = await self.async_get_last_state() - if state is not None: - self._state = self.compute_next_state(int(state.state)) - self._config[CONF_INITIAL] = state.attributes.get(ATTR_INITIAL) - self._config[CONF_MAXIMUM] = state.attributes.get(ATTR_MAXIMUM) - self._config[CONF_MINIMUM] = state.attributes.get(ATTR_MINIMUM) - self._config[CONF_STEP] = state.attributes.get(ATTR_STEP) + if ( + self._config[CONF_RESTORE] + and (state := await self.async_get_last_state()) is not None + ): + self._state = self.compute_next_state(int(state.state)) + self._config[CONF_INITIAL] = state.attributes.get(ATTR_INITIAL) + self._config[CONF_MAXIMUM] = state.attributes.get(ATTR_MAXIMUM) + self._config[CONF_MINIMUM] = state.attributes.get(ATTR_MINIMUM) + self._config[CONF_STEP] = state.attributes.get(ATTR_STEP) @callback def async_decrement(self) -> None: diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 7a3061d24c8..e654cf1a0d0 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -9,6 +9,7 @@ from typing import Any, final import voluptuous as vol +from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SERVICE_CLOSE_COVER, @@ -44,31 +45,38 @@ SCAN_INTERVAL = timedelta(seconds=15) ENTITY_ID_FORMAT = DOMAIN + ".{}" -# Refer to the cover dev docs for device class descriptions -DEVICE_CLASS_AWNING = "awning" -DEVICE_CLASS_BLIND = "blind" -DEVICE_CLASS_CURTAIN = "curtain" -DEVICE_CLASS_DAMPER = "damper" -DEVICE_CLASS_DOOR = "door" -DEVICE_CLASS_GARAGE = "garage" -DEVICE_CLASS_GATE = "gate" -DEVICE_CLASS_SHADE = "shade" -DEVICE_CLASS_SHUTTER = "shutter" -DEVICE_CLASS_WINDOW = "window" -DEVICE_CLASSES = [ - DEVICE_CLASS_AWNING, - DEVICE_CLASS_BLIND, - DEVICE_CLASS_CURTAIN, - DEVICE_CLASS_DAMPER, - DEVICE_CLASS_DOOR, - DEVICE_CLASS_GARAGE, - DEVICE_CLASS_GATE, - DEVICE_CLASS_SHADE, - DEVICE_CLASS_SHUTTER, - DEVICE_CLASS_WINDOW, -] -DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) +class CoverDeviceClass(StrEnum): + """Device class for cover.""" + + # Refer to the cover dev docs for device class descriptions + AWNING = "awning" + BLIND = "blind" + CURTAIN = "curtain" + DAMPER = "damper" + DOOR = "door" + GARAGE = "garage" + GATE = "gate" + SHADE = "shade" + SHUTTER = "shutter" + WINDOW = "window" + + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(CoverDeviceClass)) + +# DEVICE_CLASS* below are deprecated as of 2021.12 +# use the CoverDeviceClass enum instead. +DEVICE_CLASSES = [cls.value for cls in CoverDeviceClass] +DEVICE_CLASS_AWNING = CoverDeviceClass.AWNING.value +DEVICE_CLASS_BLIND = CoverDeviceClass.BLIND.value +DEVICE_CLASS_CURTAIN = CoverDeviceClass.CURTAIN.value +DEVICE_CLASS_DAMPER = CoverDeviceClass.DAMPER.value +DEVICE_CLASS_DOOR = CoverDeviceClass.DOOR.value +DEVICE_CLASS_GARAGE = CoverDeviceClass.GARAGE.value +DEVICE_CLASS_GATE = CoverDeviceClass.GATE.value +DEVICE_CLASS_SHADE = CoverDeviceClass.SHADE.value +DEVICE_CLASS_SHUTTER = CoverDeviceClass.SHUTTER.value +DEVICE_CLASS_WINDOW = CoverDeviceClass.WINDOW.value SUPPORT_OPEN = 1 SUPPORT_CLOSE = 2 @@ -175,6 +183,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class CoverEntityDescription(EntityDescription): """A class that describes cover entities.""" + device_class: CoverDeviceClass | str | None = None + class CoverEntity(Entity): """Base class for cover entities.""" @@ -182,11 +192,14 @@ class CoverEntity(Entity): entity_description: CoverEntityDescription _attr_current_cover_position: int | None = None _attr_current_cover_tilt_position: int | None = None + _attr_device_class: CoverDeviceClass | str | None _attr_is_closed: bool | None _attr_is_closing: bool | None = None _attr_is_opening: bool | None = None _attr_state: None = None + _cover_is_last_toggle_direction_open = True + @property def current_cover_position(self) -> int | None: """Return current position of cover. @@ -203,13 +216,24 @@ class CoverEntity(Entity): """ return self._attr_current_cover_tilt_position + @property + def device_class(self) -> CoverDeviceClass | str | None: + """Return the class of this entity.""" + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class + return None + @property @final def state(self) -> str | None: """Return the state of the cover.""" if self.is_opening: + self._cover_is_last_toggle_direction_open = True return STATE_OPENING if self.is_closing: + self._cover_is_last_toggle_direction_open = False return STATE_CLOSING if (closed := self.is_closed) is None: @@ -285,17 +309,23 @@ class CoverEntity(Entity): def toggle(self, **kwargs: Any) -> None: """Toggle the entity.""" - if self.is_closed: - self.open_cover(**kwargs) - else: - self.close_cover(**kwargs) + fns = { + "open": self.open_cover, + "close": self.close_cover, + "stop": self.stop_cover, + } + function = self._get_toggle_function(fns) + function(**kwargs) async def async_toggle(self, **kwargs): """Toggle the entity.""" - if self.is_closed: - await self.async_open_cover(**kwargs) - else: - await self.async_close_cover(**kwargs) + fns = { + "open": self.async_open_cover, + "close": self.async_close_cover, + "stop": self.async_stop_cover, + } + function = self._get_toggle_function(fns) + await function(**kwargs) def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" @@ -363,6 +393,17 @@ class CoverEntity(Entity): else: await self.async_close_cover_tilt(**kwargs) + def _get_toggle_function(self, fns): + if SUPPORT_STOP | self.supported_features and ( + self.is_closing or self.is_opening + ): + return fns["stop"] + if self.is_closed: + return fns["open"] + if self._cover_is_last_toggle_direction_open: + return fns["close"] + return fns["open"] + class CoverDevice(CoverEntity): """Representation of a cover (for backwards compatibility).""" diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index c163bd097ae..cca608187a2 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -18,12 +18,7 @@ from homeassistant.const import ( STATE_OPENING, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import ( - condition, - config_validation as cv, - entity_registry, - template, -) +from homeassistant.helpers import condition, config_validation as cv, entity_registry from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -125,12 +120,9 @@ async def async_get_condition_capabilities( @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" - if config_validation: - config = CONDITION_SCHEMA(config) - if config[CONF_TYPE] in STATE_CONDITION_TYPES: if config[CONF_TYPE] == "is_open": state = STATE_OPEN @@ -148,22 +140,19 @@ def async_condition_from_config( return test_is_state if config[CONF_TYPE] == "is_position": - position = "current_position" + position_attr = "current_position" if config[CONF_TYPE] == "is_tilt_position": - position = "current_tilt_position" + position_attr = "current_tilt_position" min_pos = config.get(CONF_ABOVE) max_pos = config.get(CONF_BELOW) - value_template = template.Template( # type: ignore - f"{{{{ state.attributes.{position} }}}}" - ) @callback - def template_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: - """Validate template based if-condition.""" - value_template.hass = hass - + def check_numeric_state( + hass: HomeAssistant, variables: TemplateVarsType = None + ) -> bool: + """Return whether the criteria are met.""" return condition.async_numeric_state( - hass, config[ATTR_ENTITY_ID], max_pos, min_pos, value_template + hass, config[ATTR_ENTITY_ID], max_pos, min_pos, attribute=position_attr ) - return template_if + return check_numeric_state diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index f4a2f4443d1..f960fcdcce6 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -170,7 +170,9 @@ async def async_attach_trigger( } if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] - state_config = state_trigger.TRIGGER_SCHEMA(state_config) + state_config = await state_trigger.async_validate_trigger_config( + hass, state_config + ) return await state_trigger.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) @@ -190,7 +192,9 @@ async def async_attach_trigger( CONF_ABOVE: min_pos, CONF_VALUE_TEMPLATE: value_template, } - numeric_state_config = numeric_state_trigger.TRIGGER_SCHEMA(numeric_state_config) + numeric_state_config = await numeric_state_trigger.async_validate_trigger_config( + hass, numeric_state_config + ) return await numeric_state_trigger.async_attach_trigger( hass, numeric_state_config, action, automation_info, platform_type="device" ) diff --git a/homeassistant/components/cover/translations/hu.json b/homeassistant/components/cover/translations/hu.json index 87bd1c241c6..2155907cae2 100644 --- a/homeassistant/components/cover/translations/hu.json +++ b/homeassistant/components/cover/translations/hu.json @@ -29,10 +29,10 @@ "state": { "_": { "closed": "Z\u00e1rva", - "closing": "Z\u00e1r\u00e1s", + "closing": "Z\u00e1r\u00f3dik", "open": "Nyitva", - "opening": "Nyit\u00e1s", - "stopped": "Meg\u00e1ll\u00edtva" + "opening": "Ny\u00edlik", + "stopped": "Meg\u00e1llt" } }, "title": "Bor\u00edt\u00f3" diff --git a/homeassistant/components/cover/translations/ja.json b/homeassistant/components/cover/translations/ja.json index 859240315bf..2b2f8cdf284 100644 --- a/homeassistant/components/cover/translations/ja.json +++ b/homeassistant/components/cover/translations/ja.json @@ -1,8 +1,39 @@ { + "device_automation": { + "action_type": { + "close": "\u30af\u30ed\u30fc\u30ba {entity_name}", + "close_tilt": "\u30af\u30ed\u30fc\u30ba {entity_name} \u50be\u304d", + "open": "\u30aa\u30fc\u30d7\u30f3 {entity_name}", + "open_tilt": "\u30aa\u30fc\u30d7\u30f3 {entity_name} \u50be\u304d", + "set_position": "{entity_name} \u4f4d\u7f6e\u306e\u8a2d\u5b9a", + "set_tilt_position": "{entity_name} \u50be\u659c\u4f4d\u7f6e\u306e\u8a2d\u5b9a", + "stop": "\u505c\u6b62 {entity_name}" + }, + "condition_type": { + "is_closed": "{entity_name} \u306f\u9589\u3058\u3066\u3044\u307e\u3059", + "is_closing": "{entity_name} \u304c\u7d42\u4e86\u3057\u3066\u3044\u307e\u3059", + "is_open": "{entity_name} \u304c\u958b\u3044\u3066\u3044\u307e\u3059", + "is_opening": "{entity_name} \u304c\u958b\u3044\u3066\u3044\u307e\u3059(is opening)", + "is_position": "\u73fe\u5728\u306e {entity_name} \u4f4d\u7f6e", + "is_tilt_position": "\u73fe\u5728\u306e {entity_name} \u50be\u659c\u4f4d\u7f6e" + }, + "trigger_type": { + "closed": "{entity_name} \u30af\u30ed\u30fc\u30ba\u30c9", + "closing": "{entity_name} \u304c\u7d42\u4e86", + "opened": "{entity_name} \u304c\u958b\u304b\u308c\u307e\u3057\u305f", + "opening": "{entity_name} \u304c\u958b\u304f(Opening)", + "position": "{entity_name} \u4f4d\u7f6e\u306e\u5909\u5316", + "tilt_position": "{entity_name} \u50be\u659c\u4f4d\u7f6e\u306e\u5909\u5316" + } + }, "state": { "_": { - "closed": "\u9589\u9396", - "opening": "\u6249" + "closed": "\u30af\u30ed\u30fc\u30ba\u30c9", + "closing": "\u9589\u3058\u3066\u3044\u307e\u3059", + "open": "\u30aa\u30fc\u30d7\u30f3", + "opening": "\u6249(Opening)", + "stopped": "\u505c\u6b62" } - } + }, + "title": "\u30ab\u30d0\u30fc" } \ No newline at end of file diff --git a/homeassistant/components/cover/translations/tr.json b/homeassistant/components/cover/translations/tr.json index f042233a6d1..7e32eb9e846 100644 --- a/homeassistant/components/cover/translations/tr.json +++ b/homeassistant/components/cover/translations/tr.json @@ -2,12 +2,33 @@ "device_automation": { "action_type": { "close": "{entity_name} kapat", - "open": "{entity_name} a\u00e7\u0131n" + "close_tilt": "{entity_name} e\u011fimini kapat", + "open": "{entity_name} a\u00e7\u0131n", + "open_tilt": "{entity_name} e\u011fimini a\u00e7", + "set_position": "{entity_name} konumunu ayarla", + "set_tilt_position": "{entity_name} e\u011fim konumunu ayarla", + "stop": "{entity_name} durdur" + }, + "condition_type": { + "is_closed": "{entity_name} kapat\u0131ld\u0131", + "is_closing": "{entity_name} kapan\u0131yor", + "is_open": "{entity_name} a\u00e7\u0131k", + "is_opening": "{entity_name} a\u00e7\u0131l\u0131yor", + "is_position": "Ge\u00e7erli {entity_name} konumu:", + "is_tilt_position": "Ge\u00e7erli {entity_name} e\u011fim konumu:" + }, + "trigger_type": { + "closed": "{entity_name} kapat\u0131ld\u0131", + "closing": "{entity_name} kapan\u0131yor", + "opened": "{entity_name} a\u00e7\u0131ld\u0131", + "opening": "{entity_name} a\u00e7\u0131l\u0131yor", + "position": "{entity_name} konum de\u011fi\u015fiklikleri", + "tilt_position": "{entity_name} e\u011fim konumu de\u011fi\u015fiklikleri" } }, "state": { "_": { - "closed": "Kapal\u0131", + "closed": "Kapand\u0131", "closing": "Kapan\u0131yor", "open": "A\u00e7\u0131k", "opening": "A\u00e7\u0131l\u0131yor", diff --git a/homeassistant/components/crownstone/const.py b/homeassistant/components/crownstone/const.py index 21a14b99e86..a362435b9ce 100644 --- a/homeassistant/components/crownstone/const.py +++ b/homeassistant/components/crownstone/const.py @@ -3,9 +3,11 @@ from __future__ import annotations from typing import Final +from homeassistant.const import Platform + # Platforms DOMAIN: Final = "crownstone" -PLATFORMS: Final[list[str]] = ["light"] +PLATFORMS: Final[list[Platform]] = [Platform.LIGHT] # Listeners SSE_LISTENERS: Final = "sse_listeners" diff --git a/homeassistant/components/crownstone/manifest.json b/homeassistant/components/crownstone/manifest.json index 4615d0b0329..758721d5f71 100644 --- a/homeassistant/components/crownstone/manifest.json +++ b/homeassistant/components/crownstone/manifest.json @@ -4,8 +4,8 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/crownstone", "requirements": [ - "crownstone-cloud==1.4.8", - "crownstone-sse==2.0.2", + "crownstone-cloud==1.4.9", + "crownstone-sse==2.0.3", "crownstone-uart==2.1.0", "pyserial==3.5" ], diff --git a/homeassistant/components/crownstone/translations/fr.json b/homeassistant/components/crownstone/translations/fr.json new file mode 100644 index 00000000000..55336a6a87f --- /dev/null +++ b/homeassistant/components/crownstone/translations/fr.json @@ -0,0 +1,21 @@ +{ + "options": { + "step": { + "usb_config": { + "data": { + "usb_path": "Chemin du p\u00e9riph\u00e9rique USB" + } + }, + "usb_config_option": { + "data": { + "usb_path": "Chemin du p\u00e9riph\u00e9rique USB" + } + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Chemin du p\u00e9riph\u00e9rique USB" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/id.json b/homeassistant/components/crownstone/translations/id.json index 5bd28168d9a..aef98346fd2 100644 --- a/homeassistant/components/crownstone/translations/id.json +++ b/homeassistant/components/crownstone/translations/id.json @@ -1,9 +1,12 @@ { "config": { "abort": { - "already_configured": "Akun sudah dikonfigurasi" + "already_configured": "Akun sudah dikonfigurasi", + "usb_setup_complete": "Penyiapan USB Crownstone selesai.", + "usb_setup_unsuccessful": "Penyiapan USB Crownstone tidak berhasil." }, "error": { + "account_not_verified": "Akun tidak diverifikasi. Aktifkan akun Anda melalui email aktivasi dari Crownstone.", "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, @@ -11,27 +14,82 @@ "usb_config": { "data": { "usb_path": "Jalur Perangkat USB" - } + }, + "description": "Pilih port serial dongle USB Crownstone, atau pilih 'Jangan gunakan USB' jika Anda tidak ingin menyiapkan dongle USB.\n\nCari perangkat dengan VID 10C4 dan PID EA60.", + "title": "Konfigurasi dongle USB Crownstone" }, "usb_manual_config": { "data": { "usb_manual_path": "Jalur Perangkat USB" - } + }, + "description": "Masukkan jalur dongle USB Crownstone secara manual.", + "title": "Jalur manual dongle USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Pilih Crownstone Sphere tempat USB berada.", + "title": "Crownstone USB Sphere" }, "user": { "data": { "email": "Email", "password": "Kata Sandi" - } + }, + "title": "Akun Crownstone" } } }, "options": { "step": { + "init": { + "data": { + "usb_sphere_option": "Crownstone Sphere tempat USB berada", + "use_usb_option": "Gunakan dongle USB Crownstone untuk transmisi data lokal" + } + }, + "usb_config": { + "data": { + "usb_path": "Jalur Perangkat USB" + }, + "description": "Pilih port serial dongle USB Crownstone. \n\nCari perangkat dengan VID 10C4 dan PID EA60.", + "title": "Konfigurasi dongle USB Crownstone" + }, + "usb_config_option": { + "data": { + "usb_path": "Jalur Perangkat USB" + }, + "description": "Pilih port serial dongle USB Crownstone. \n\nCari perangkat dengan VID 10C4 dan PID EA60.", + "title": "Konfigurasi dongle USB Crownstone" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "Jalur Perangkat USB" + }, + "description": "Masukkan jalur dongle USB Crownstone secara manual.", + "title": "Jalur manual dongle USB Crownstone" + }, "usb_manual_config_option": { "data": { "usb_manual_path": "Jalur Perangkat USB" - } + }, + "description": "Masukkan jalur dongle USB Crownstone secara manual.", + "title": "Jalur manual dongle USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Pilih Crownstone Sphere tempat USB berada.", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Pilih Crownstone Sphere tempat USB berada.", + "title": "Crownstone USB Sphere" } } } diff --git a/homeassistant/components/crownstone/translations/ja.json b/homeassistant/components/crownstone/translations/ja.json new file mode 100644 index 00000000000..6ab8f858af4 --- /dev/null +++ b/homeassistant/components/crownstone/translations/ja.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "usb_setup_complete": "Crownstone USB\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u304c\u5b8c\u4e86\u3057\u307e\u3057\u305f\u3002", + "usb_setup_unsuccessful": "Crownstone USB\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002" + }, + "error": { + "account_not_verified": "\u30a2\u30ab\u30a6\u30f3\u30c8\u304c\u8a8d\u8a3c\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002Crownstone\u304b\u3089\u306e\u30a2\u30af\u30c6\u30a3\u30d9\u30fc\u30b7\u30e7\u30f3\u30e1\u30fc\u30eb\u3092\u901a\u3057\u3066\u3001\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u30a2\u30af\u30c6\u30a3\u30d9\u30fc\u30c8\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "description": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8\u3092\u9078\u629e\u3059\u308b\u304b\u3001USB\u30c9\u30f3\u30b0\u30eb\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u306a\u3044\u5834\u5408\u306f\u3001\"USB\u3092\u4f7f\u7528\u3057\u306a\u3044\" \u3092\u9078\u629e\u3057\u307e\u3059\u3002 \n\n VID 10C4 \u3067 PID EA60 \u306a\u30c7\u30d0\u30a4\u30b9\u3092\u898b\u3064\u3051\u307e\u3059\u3002", + "title": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u8a2d\u5b9a" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "description": "\u624b\u52d5\u3067\u3001Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u30d1\u30b9\u3092\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u3078\u306e\u624b\u52d5\u30d1\u30b9" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "USB\u306b\u914d\u7f6e\u3055\u308c\u3066\u3044\u308b\u3001CrownstoneSphere\u3092\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "Crownstone USB Sphere" + }, + "user": { + "data": { + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "title": "Crownstone\u30a2\u30ab\u30a6\u30f3\u30c8" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "USB\u306b\u914d\u7f6e\u3055\u308c\u3066\u3044\u308b\u3001CrownstoneSphere", + "use_usb_option": "\u30ed\u30fc\u30ab\u30eb\u30c7\u30fc\u30bf\u306e\u9001\u4fe1\u306b\u3001Crownstone USB\u30c9\u30f3\u30b0\u30eb\u3092\u4f7f\u7528\u3059\u308b" + } + }, + "usb_config": { + "data": { + "usb_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "description": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u30b7\u30ea\u30a2\u30eb \u30dd\u30fc\u30c8\u3092\u9078\u629e\u3057\u307e\u3059\u3002\n\nVID 10C4 \u3067 PID EA60 \u306a\u5024\u306e\u30c7\u30d0\u30a4\u30b9\u3092\u63a2\u3057\u307e\u3059\u3002", + "title": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u8a2d\u5b9a" + }, + "usb_config_option": { + "data": { + "usb_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "description": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u30b7\u30ea\u30a2\u30eb \u30dd\u30fc\u30c8\u3092\u9078\u629e\u3057\u307e\u3059\u3002\n\nVID 10C4 \u3067 PID EA60 \u306a\u5024\u306e\u30c7\u30d0\u30a4\u30b9\u3092\u63a2\u3057\u307e\u3059\u3002", + "title": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u8a2d\u5b9a" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "description": "\u624b\u52d5\u3067\u3001Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u30d1\u30b9\u3092\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u3078\u306e\u624b\u52d5\u30d1\u30b9" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "description": "\u624b\u52d5\u3067\u3001Crownstone USB\u30c9\u30f3\u30b0\u30eb\u306e\u30d1\u30b9\u3092\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "Crownstone USB\u30c9\u30f3\u30b0\u30eb\u3078\u306e\u624b\u52d5\u30d1\u30b9" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "USB\u306b\u914d\u7f6e\u3055\u308c\u3066\u3044\u308b\u3001CrownstoneSphere\u3092\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "Crownstone USB Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "USB\u306b\u914d\u7f6e\u3055\u308c\u3066\u3044\u308b\u3001CrownstoneSphere\u3092\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "Crownstone USB Sphere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/crownstone/translations/pl.json b/homeassistant/components/crownstone/translations/pl.json index c71c27c4601..12ac55d668c 100644 --- a/homeassistant/components/crownstone/translations/pl.json +++ b/homeassistant/components/crownstone/translations/pl.json @@ -1,9 +1,12 @@ { "config": { "abort": { - "already_configured": "Konto jest ju\u017c skonfigurowane" + "already_configured": "Konto jest ju\u017c skonfigurowane", + "usb_setup_complete": "Konfiguracja USB Crownstone zako\u0144czona.", + "usb_setup_unsuccessful": "Konfiguracja USB Crownstone nie powiod\u0142a si\u0119." }, "error": { + "account_not_verified": "Konto niezweryfikowane. Aktywuj swoje konto za pomoc\u0105 e-maila aktywacyjnego od Crownstone.", "invalid_auth": "Niepoprawne uwierzytelnienie", "unknown": "Nieoczekiwany b\u0142\u0105d" }, @@ -11,12 +14,23 @@ "usb_config": { "data": { "usb_path": "\u015acie\u017cka urz\u0105dzenia USB" - } + }, + "description": "Wybierz port szeregowy urz\u0105dzenia USB Crownstone lub wybierz opcj\u0119 \"Nie u\u017cywaj USB\", je\u015bli nie chcesz konfigurowa\u0107 urz\u0105dzenia USB. \n\nPoszukaj urz\u0105dzenia z VID 10C4 i PID EA60.", + "title": "Konfiguracja urz\u0105dzenia USB Crownstone" }, "usb_manual_config": { "data": { "usb_manual_path": "\u015acie\u017cka urz\u0105dzenia USB" - } + }, + "description": "Wprowad\u017a r\u0119cznie \u015bcie\u017ck\u0119 urz\u0105dzenia USB Crownstone.", + "title": "Wpisz r\u0119cznie \u015bcie\u017ck\u0119 urz\u0105dzenia USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Wybierz USB w kt\u00f3rym znajduje si\u0119 Crownstone Sphere.", + "title": "USB Crownstone Sphere" }, "user": { "data": { @@ -29,25 +43,53 @@ }, "options": { "step": { + "init": { + "data": { + "usb_sphere_option": "USB w kt\u00f3rym znajduje si\u0119 Crownstone Sphere.", + "use_usb_option": "U\u017cyj urz\u0105dzenia USB Crownstone do lokalnej transmisji danych" + } + }, "usb_config": { "data": { "usb_path": "\u015acie\u017cka urz\u0105dzenia USB" - } + }, + "description": "Wybierz port szeregowy urz\u0105dzenia USB Crownstone. \n\nPoszukaj urz\u0105dzenia z VID 10C4 i PID EA60.", + "title": "Konfiguracja urz\u0105dzenia USB Crownstone" }, "usb_config_option": { "data": { "usb_path": "\u015acie\u017cka urz\u0105dzenia USB" - } + }, + "description": "Wybierz port szeregowy urz\u0105dzenia USB Crownstone. \n\nPoszukaj urz\u0105dzenia z VID 10C4 i PID EA60.", + "title": "Konfiguracja urz\u0105dzenia USB Crownstone" }, "usb_manual_config": { "data": { "usb_manual_path": "\u015acie\u017cka urz\u0105dzenia USB" - } + }, + "description": "Wprowad\u017a r\u0119cznie \u015bcie\u017ck\u0119 urz\u0105dzenia USB Crownstone.", + "title": "Wpisz r\u0119cznie \u015bcie\u017ck\u0119 urz\u0105dzenia USB Crownstone" }, "usb_manual_config_option": { "data": { "usb_manual_path": "\u015acie\u017cka urz\u0105dzenia USB" - } + }, + "description": "Wprowad\u017a r\u0119cznie \u015bcie\u017ck\u0119 urz\u0105dzenia USB Crownstone.", + "title": "Wpisz r\u0119cznie \u015bcie\u017ck\u0119 urz\u0105dzenia USB Crownstone" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Wybierz USB, w kt\u00f3rym znajduje si\u0119 Crownstone Sphere.", + "title": "USB Crownstone Sphere" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Sphere" + }, + "description": "Wybierz USB w kt\u00f3rym znajduje si\u0119 Crownstone Sphere.", + "title": "USB Crownstone Sphere" } } } diff --git a/homeassistant/components/crownstone/translations/tr.json b/homeassistant/components/crownstone/translations/tr.json new file mode 100644 index 00000000000..1c97cbdf170 --- /dev/null +++ b/homeassistant/components/crownstone/translations/tr.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "usb_setup_complete": "Crownstone USB kurulumu tamamland\u0131.", + "usb_setup_unsuccessful": "Crownstone USB kurulumu ba\u015far\u0131s\u0131z oldu." + }, + "error": { + "account_not_verified": "Hesap do\u011frulanmad\u0131. L\u00fctfen hesab\u0131n\u0131z\u0131 Crownstone'dan gelen aktivasyon e-postas\u0131 arac\u0131l\u0131\u011f\u0131yla etkinle\u015ftirin.", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "usb_config": { + "data": { + "usb_path": "USB Cihaz Yolu" + }, + "description": "Crownstone USB donan\u0131m kilidinin seri ba\u011flant\u0131 noktas\u0131n\u0131 se\u00e7in veya bir USB donan\u0131m kilidi kurmak istemiyorsan\u0131z 'USB kullanma' se\u00e7ene\u011fini se\u00e7in. \n\n VID 10C4 ve PID EA60'a sahip bir cihaz aray\u0131n.", + "title": "Crownstone USB dongle yap\u0131land\u0131rmas\u0131" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB Cihaz Yolu" + }, + "description": "Crownstone USB dongle'\u0131n yolunu manuel olarak girin.", + "title": "Crownstone USB dongle manuel yolu" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Stick" + }, + "description": "USB'nin bulundu\u011fu bir Crownstone Stick se\u00e7in.", + "title": "Crownstone USB Stick" + }, + "user": { + "data": { + "email": "E-posta", + "password": "Parola" + }, + "title": "Crownstone hesab\u0131" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "usb_sphere_option": "USB'nin bulundu\u011fu Crownstone Stick", + "use_usb_option": "Yerel veri iletimi i\u00e7in Crownstone USB dongle kullan\u0131n" + } + }, + "usb_config": { + "data": { + "usb_path": "USB Cihaz Yolu" + }, + "description": "Crownstone USB donan\u0131m kilidinin seri ba\u011flant\u0131 noktas\u0131n\u0131 se\u00e7in. \n\n VID 10C4 ve PID EA60'a sahip bir cihaz aray\u0131n.", + "title": "Crownstone USB dongle yap\u0131land\u0131rmas\u0131" + }, + "usb_config_option": { + "data": { + "usb_path": "USB Cihaz Yolu" + }, + "description": "Crownstone USB donan\u0131m kilidinin seri ba\u011flant\u0131 noktas\u0131n\u0131 se\u00e7in. \n\n VID 10C4 ve PID EA60'a sahip bir cihaz aray\u0131n.", + "title": "Crownstone USB dongle yap\u0131land\u0131rmas\u0131" + }, + "usb_manual_config": { + "data": { + "usb_manual_path": "USB Cihaz Yolu" + }, + "description": "Crownstone USB dongle'\u0131n yolunu manuel olarak girin.", + "title": "Crownstone USB dongle manuel yolu" + }, + "usb_manual_config_option": { + "data": { + "usb_manual_path": "USB Cihaz Yolu" + }, + "description": "Crownstone USB dongle'\u0131n yolunu manuel olarak girin.", + "title": "Crownstone USB dongle manuel yolu" + }, + "usb_sphere_config": { + "data": { + "usb_sphere": "Crownstone Stick" + }, + "description": "USB'nin bulundu\u011fu bir Crownstone Stick se\u00e7in.", + "title": "Crownstone USB Stick" + }, + "usb_sphere_config_option": { + "data": { + "usb_sphere": "Crownstone Stick" + }, + "description": "USB'nin bulundu\u011fu bir Crownstone Stick se\u00e7in.", + "title": "Crownstone USB Stick" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index 185537cc7d0..f588f66761c 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -8,7 +8,7 @@ from async_timeout import timeout from pydaikin.daikin_base import Appliance from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -PLATFORMS = ["climate", "sensor", "switch"] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] CONFIG_SCHEMA = cv.deprecated(DOMAIN) @@ -65,7 +65,7 @@ async def daikin_api_setup(hass, host, key, uuid, password): session = hass.helpers.aiohttp_client.async_get_clientsession() try: - with timeout(TIMEOUT): + async with timeout(TIMEOUT): device = await Appliance.factory( host, session, key=key, uuid=uuid, password=password ) diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index 18447c56d18..0084a89172f 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -10,7 +10,9 @@ from pydaikin.discovery import Discovery import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult from .const import CONF_UUID, DOMAIN, KEY_MAC, TIMEOUT @@ -67,7 +69,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): password = None try: - with timeout(TIMEOUT): + async with timeout(TIMEOUT): device = await Appliance.factory( host, self.hass.helpers.aiohttp_client.async_get_clientsession(), @@ -123,18 +125,20 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): user_input.get(CONF_PASSWORD), ) - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Prepare configuration for a discovered Daikin device.""" _LOGGER.debug("Zeroconf user_input: %s", discovery_info) - devices = Discovery().poll(ip=discovery_info[CONF_HOST]) + devices = Discovery().poll(ip=discovery_info.host) if not devices: _LOGGER.debug( "Could not find MAC-address for %s," " make sure the required UDP ports are open (see integration documentation)", - discovery_info[CONF_HOST], + discovery_info.host, ) return self.async_abort(reason="cannot_connect") await self.async_set_unique_id(next(iter(devices))[KEY_MAC]) self._abort_if_unique_id_configured() - self.host = discovery_info[CONF_HOST] + self.host = discovery_info.host return await self.async_step_user() diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index 62d3e8e1f7e..2ca91aa2780 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -6,7 +6,11 @@ from dataclasses import dataclass from pydaikin.daikin_base import Appliance -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_HUMIDITY, @@ -49,6 +53,7 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( key=ATTR_INSIDE_TEMPERATURE, name="Inside Temperature", device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=TEMP_CELSIUS, value_func=lambda device: device.inside_temperature, ), @@ -56,6 +61,7 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( key=ATTR_OUTSIDE_TEMPERATURE, name="Outside Temperature", device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=TEMP_CELSIUS, value_func=lambda device: device.outside_temperature, ), @@ -63,6 +69,7 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( key=ATTR_HUMIDITY, name="Humidity", device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=PERCENTAGE, value_func=lambda device: device.humidity, ), @@ -70,6 +77,7 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( key=ATTR_TARGET_HUMIDITY, name="Target Humidity", device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, native_unit_of_measurement=PERCENTAGE, value_func=lambda device: device.humidity, ), diff --git a/homeassistant/components/daikin/translations/bg.json b/homeassistant/components/daikin/translations/bg.json index 5a8e7d875f9..c0796234f9a 100644 --- a/homeassistant/components/daikin/translations/bg.json +++ b/homeassistant/components/daikin/translations/bg.json @@ -10,6 +10,7 @@ "step": { "user": { "data": { + "api_key": "API \u043a\u043b\u044e\u0447", "host": "\u0410\u0434\u0440\u0435\u0441", "password": "\u041f\u0430\u0440\u043e\u043b\u0430" }, diff --git a/homeassistant/components/daikin/translations/id.json b/homeassistant/components/daikin/translations/id.json index 8b7cfb5460e..8a35b8e113c 100644 --- a/homeassistant/components/daikin/translations/id.json +++ b/homeassistant/components/daikin/translations/id.json @@ -5,6 +5,7 @@ "cannot_connect": "Gagal terhubung" }, "error": { + "api_password": "Autentikasi tidak valid, gunakan Kunci API atau Kata Sandi.", "cannot_connect": "Gagal terhubung", "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" diff --git a/homeassistant/components/daikin/translations/ja.json b/homeassistant/components/daikin/translations/ja.json new file mode 100644 index 00000000000..6b210a056c9 --- /dev/null +++ b/homeassistant/components/daikin/translations/ja.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\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" + }, + "error": { + "api_password": "\u7121\u52b9\u306a\u8a8d\u8a3c\u3002API\u30ad\u30fc\u307e\u305f\u306f\u3001\u30d1\u30b9\u30ef\u30fc\u30c9\u306e\u3044\u305a\u308c\u304b\u3092\u4f7f\u7528\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u30c0\u30a4\u30ad\u30f3\u88fd\u30a8\u30a2\u30b3\u30f3\u306eIP\u30a2\u30c9\u30ec\u30b9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n\n\u306a\u304a\u3001API\u30ad\u30fc\u3068\u30d1\u30b9\u30ef\u30fc\u30c9\u306f\u3001\u305d\u308c\u305e\u308cBRP072Cxx\u3068SKYFi\u30c7\u30d0\u30a4\u30b9\u3067\u306e\u307f\u4f7f\u7528\u3055\u308c\u307e\u3059\u3002", + "title": "\u30c0\u30a4\u30ad\u30f3\u88fd\u30a8\u30a2\u30b3\u30f3\u306e\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/translations/sl.json b/homeassistant/components/daikin/translations/sl.json index a9f8514146f..40c54ed0206 100644 --- a/homeassistant/components/daikin/translations/sl.json +++ b/homeassistant/components/daikin/translations/sl.json @@ -3,6 +3,9 @@ "abort": { "already_configured": "Naprava je \u017ee konfigurirana" }, + "error": { + "api_password": "Neveljavna avtentikacija, uporabite API klju\u010d ali geslo." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/daikin/translations/tr.json b/homeassistant/components/daikin/translations/tr.json index 4148bf2b9f1..0e548c96f0f 100644 --- a/homeassistant/components/daikin/translations/tr.json +++ b/homeassistant/components/daikin/translations/tr.json @@ -5,6 +5,7 @@ "cannot_connect": "Ba\u011flanma hatas\u0131" }, "error": { + "api_password": "Ge\u00e7ersiz kimlik do\u011frulama , API Anahtar\u0131 veya Parola kullan\u0131n.", "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "unknown": "Beklenmeyen hata" @@ -13,9 +14,11 @@ "user": { "data": { "api_key": "API Anahtar\u0131", - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "password": "Parola" - } + }, + "description": "IP Adresi de\u011ferini girin. \n\n API Anahtar\u0131 ve Parola \u00f6\u011felerinin s\u0131ras\u0131yla BRP072Cxx ve SKYFi cihazlar\u0131 taraf\u0131ndan kullan\u0131ld\u0131\u011f\u0131n\u0131 unutmay\u0131n.", + "title": "Daikin AC'yi yap\u0131land\u0131r\u0131n" } } } diff --git a/homeassistant/components/darksky/sensor.py b/homeassistant/components/darksky/sensor.py index 228370be16a..6d1711b4d0e 100644 --- a/homeassistant/components/darksky/sensor.py +++ b/homeassistant/components/darksky/sensor.py @@ -13,6 +13,7 @@ import voluptuous as vol from homeassistant.components.sensor import ( DEVICE_CLASS_TEMPERATURE, PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, SensorEntity, SensorEntityDescription, ) @@ -181,6 +182,7 @@ SENSOR_TYPES: dict[str, DarkskySensorEntityDescription] = { key="temperature", name="Temperature", device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, si_unit=TEMP_CELSIUS, us_unit=TEMP_FAHRENHEIT, ca_unit=TEMP_CELSIUS, @@ -192,6 +194,7 @@ SENSOR_TYPES: dict[str, DarkskySensorEntityDescription] = { key="apparent_temperature", name="Apparent Temperature", device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, si_unit=TEMP_CELSIUS, us_unit=TEMP_FAHRENHEIT, ca_unit=TEMP_CELSIUS, @@ -203,6 +206,7 @@ SENSOR_TYPES: dict[str, DarkskySensorEntityDescription] = { key="dew_point", name="Dew Point", device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, si_unit=TEMP_CELSIUS, us_unit=TEMP_FAHRENHEIT, ca_unit=TEMP_CELSIUS, @@ -258,6 +262,7 @@ SENSOR_TYPES: dict[str, DarkskySensorEntityDescription] = { key="humidity", name="Humidity", device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, si_unit=PERCENTAGE, us_unit=PERCENTAGE, ca_unit=PERCENTAGE, @@ -754,10 +759,9 @@ class DarkSkySensor(SensorEntity): """ sensor_type = self.entity_description.key lookup_type = convert_to_camel(sensor_type) - state = getattr(data, lookup_type, None) - if state is None: - return state + if (state := getattr(data, lookup_type, None)) is None: + return None if "summary" in sensor_type: self._icon = getattr(data, "icon", "") diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py index 303cfe72b97..8b7e7ed8c04 100644 --- a/homeassistant/components/ddwrt/device_tracker.py +++ b/homeassistant/components/ddwrt/device_tracker.py @@ -67,8 +67,7 @@ class DdWrtDeviceScanner(DeviceScanner): # Test the router is accessible url = f"{self.protocol}://{self.host}/Status_Wireless.live.asp" - data = self.get_ddwrt_data(url) - if not data: + if not self.get_ddwrt_data(url): raise ConnectionError("Cannot connect to DD-Wrt router") def scan_devices(self): @@ -82,9 +81,8 @@ class DdWrtDeviceScanner(DeviceScanner): # If not initialised and not already scanned and not found. if device not in self.mac2name: url = f"{self.protocol}://{self.host}/Status_Lan.live.asp" - data = self.get_ddwrt_data(url) - if not data: + if not (data := self.get_ddwrt_data(url)): return None if not (dhcp_leases := data.get("dhcp_leases")): @@ -115,9 +113,8 @@ class DdWrtDeviceScanner(DeviceScanner): endpoint = "Wireless" if self.wireless_only else "Lan" url = f"{self.protocol}://{self.host}/Status_{endpoint}.live.asp" - data = self.get_ddwrt_data(url) - if not data: + if not (data := self.get_ddwrt_data(url)): return False self.last_results = [] diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index c8da95a006f..041c2f9e31e 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -2,7 +2,7 @@ "domain": "debugpy", "name": "Remote Python Debugger", "documentation": "https://www.home-assistant.io/integrations/debugpy", - "requirements": ["debugpy==1.5.0"], + "requirements": ["debugpy==1.5.1"], "codeowners": ["@frenck"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 47a70a43ae2..f069605d438 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -1,12 +1,18 @@ """Support for deCONZ devices.""" + +from __future__ import annotations + +from typing import cast + +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import callback -from homeassistant.helpers.entity_registry import async_migrate_entries +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.entity_registry as er from .config_flow import get_master_gateway from .const import CONF_GROUP_ID_BASE, CONF_MASTER_GATEWAY, DOMAIN @@ -14,7 +20,7 @@ from .gateway import DeconzGateway from .services import async_setup_services, async_unload_services -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up a deCONZ bridge for a config entry. Load config, group, light and sensor data for server information. @@ -28,7 +34,6 @@ async def async_setup_entry(hass, config_entry): await async_update_master_gateway(hass, config_entry) gateway = DeconzGateway(hass, config_entry) - if not await gateway.async_setup(): return False @@ -46,7 +51,7 @@ async def async_setup_entry(hass, config_entry): return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload deCONZ config entry.""" gateway = hass.data[DOMAIN].pop(config_entry.entry_id) @@ -61,27 +66,36 @@ async def async_unload_entry(hass, config_entry): return await gateway.async_reset() -async def async_update_master_gateway(hass, config_entry): +async def async_update_master_gateway( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: """Update master gateway boolean. Called by setup_entry and unload_entry. Makes sure there is always one master available. """ - master = not get_master_gateway(hass) + try: + master_gateway = get_master_gateway(hass) + master = master_gateway.config_entry == config_entry + except ValueError: + master = True + options = {**config_entry.options, CONF_MASTER_GATEWAY: master} hass.config_entries.async_update_entry(config_entry, options=options) -async def async_update_group_unique_id(hass, config_entry) -> None: +async def async_update_group_unique_id( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: """Update unique ID entities based on deCONZ groups.""" - if not (old_unique_id := config_entry.data.get(CONF_GROUP_ID_BASE)): + if not isinstance(old_unique_id := config_entry.data.get(CONF_GROUP_ID_BASE), str): return - new_unique_id: str = config_entry.unique_id + new_unique_id = cast(str, config_entry.unique_id) @callback - def update_unique_id(entity_entry): + def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: """Update unique ID of entity entry.""" if f"{old_unique_id}-" not in entity_entry.unique_id: return None @@ -91,7 +105,7 @@ async def async_update_group_unique_id(hass, config_entry) -> None: ) } - await async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) data = { CONF_API_KEY: config_entry.data[CONF_API_KEY], CONF_HOST: config_entry.data[CONF_HOST], diff --git a/homeassistant/components/deconz/alarm_control_panel.py b/homeassistant/components/deconz/alarm_control_panel.py index 823c9c67654..e16e4bcc327 100644 --- a/homeassistant/components/deconz/alarm_control_panel.py +++ b/homeassistant/components/deconz/alarm_control_panel.py @@ -1,6 +1,9 @@ """Support for deCONZ alarm control panel devices.""" from __future__ import annotations +from collections.abc import ValuesView + +from pydeconz.alarm_system import AlarmSystem from pydeconz.sensor import ( ANCILLARY_CONTROL_ARMED_AWAY, ANCILLARY_CONTROL_ARMED_NIGHT, @@ -23,6 +26,7 @@ from homeassistant.components.alarm_control_panel import ( SUPPORT_ALARM_ARM_NIGHT, AlarmControlPanelEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, @@ -32,11 +36,12 @@ from homeassistant.const import ( STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry +from .gateway import DeconzGateway, get_gateway_from_config_entry DECONZ_TO_ALARM_STATE = { ANCILLARY_CONTROL_ARMED_AWAY: STATE_ALARM_ARMED_AWAY, @@ -52,14 +57,21 @@ DECONZ_TO_ALARM_STATE = { } -def get_alarm_system_for_unique_id(gateway, unique_id: str): +def get_alarm_system_for_unique_id( + gateway: DeconzGateway, unique_id: str +) -> AlarmSystem | None: """Retrieve alarm system unique ID is registered to.""" for alarm_system in gateway.api.alarmsystems.values(): if unique_id in alarm_system.devices: return alarm_system + return None -async def async_setup_entry(hass, config_entry, async_add_entities) -> None: +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the deCONZ alarm control panel devices. Alarm control panels are based on the same device class as sensors in deCONZ. @@ -68,7 +80,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities) -> None: gateway.entities[DOMAIN] = set() @callback - def async_add_alarm_control_panel(sensors=gateway.api.sensors.values()) -> None: + def async_add_alarm_control_panel( + sensors: list[AncillaryControl] + | ValuesView[AncillaryControl] = gateway.api.sensors.values(), + ) -> None: """Add alarm control panel devices from deCONZ.""" entities = [] @@ -77,10 +92,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities) -> None: if ( isinstance(sensor, AncillaryControl) and sensor.unique_id not in gateway.entities[DOMAIN] - and get_alarm_system_for_unique_id(gateway, sensor.unique_id) + and ( + alarm_system := get_alarm_system_for_unique_id( + gateway, sensor.unique_id + ) + ) + is not None ): - entities.append(DeconzAlarmControlPanel(sensor, gateway)) + entities.append(DeconzAlarmControlPanel(sensor, gateway, alarm_system)) if entities: async_add_entities(entities) @@ -100,16 +120,22 @@ class DeconzAlarmControlPanel(DeconzDevice, AlarmControlPanelEntity): """Representation of a deCONZ alarm control panel.""" TYPE = DOMAIN + _device: AncillaryControl _attr_code_format = FORMAT_NUMBER _attr_supported_features = ( SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_NIGHT ) - def __init__(self, device, gateway) -> None: + def __init__( + self, + device: AncillaryControl, + gateway: DeconzGateway, + alarm_system: AlarmSystem, + ) -> None: """Set up alarm control panel device.""" super().__init__(device, gateway) - self.alarm_system = get_alarm_system_for_unique_id(gateway, device.unique_id) + self.alarm_system = alarm_system @callback def async_update_callback(self) -> None: @@ -124,20 +150,20 @@ class DeconzAlarmControlPanel(DeconzDevice, AlarmControlPanelEntity): @property def state(self) -> str | None: """Return the state of the control panel.""" - return DECONZ_TO_ALARM_STATE.get(self._device.state) + return DECONZ_TO_ALARM_STATE.get(self._device.panel) - async def async_alarm_arm_away(self, code: None = None) -> None: + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self.alarm_system.arm_away(code) - async def async_alarm_arm_home(self, code: None = None) -> None: + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self.alarm_system.arm_stay(code) - async def async_alarm_arm_night(self, code: None = None) -> None: + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" await self.alarm_system.arm_night(code) - async def async_alarm_disarm(self, code: None = None) -> None: + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self.alarm_system.disarm(code) diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index 7f77bbb8809..475c631bdf8 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -1,7 +1,13 @@ """Support for deCONZ binary sensors.""" +from __future__ import annotations + +from collections.abc import ValuesView + from pydeconz.sensor import ( Alarm, CarbonMonoxide, + DeconzBinarySensor as PydeconzBinarySensor, + DeconzSensor as PydeconzSensor, Fire, GenericFlag, OpenClose, @@ -22,13 +28,15 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, ENTITY_CATEGORY_DIAGNOSTIC -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_DARK, ATTR_ON from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry +from .gateway import DeconzGateway, get_gateway_from_config_entry DECONZ_BINARY_SENSORS = ( Alarm, @@ -73,15 +81,22 @@ ENTITY_DESCRIPTIONS = { } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the deCONZ binary sensor.""" gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() @callback - def async_add_sensor(sensors=gateway.api.sensors.values()): + def async_add_sensor( + sensors: list[PydeconzSensor] + | ValuesView[PydeconzSensor] = gateway.api.sensors.values(), + ) -> None: """Add binary sensor from deCONZ.""" - entities = [] + entities: list[DeconzBinarySensor | DeconzTampering] = [] for sensor in sensors: @@ -120,8 +135,9 @@ class DeconzBinarySensor(DeconzDevice, BinarySensorEntity): """Representation of a deCONZ binary sensor.""" TYPE = DOMAIN + _device: PydeconzBinarySensor - def __init__(self, device, gateway): + def __init__(self, device: PydeconzBinarySensor, gateway: DeconzGateway) -> None: """Initialize deCONZ binary sensor.""" super().__init__(device, gateway) @@ -129,21 +145,21 @@ class DeconzBinarySensor(DeconzDevice, BinarySensorEntity): self.entity_description = entity_description @callback - def async_update_callback(self): + def async_update_callback(self) -> None: """Update the sensor's state.""" keys = {"on", "reachable", "state"} if self._device.changed_keys.intersection(keys): super().async_update_callback() @property - def is_on(self): + def is_on(self) -> bool: """Return true if sensor is on.""" - return self._device.state + return self._device.state # type: ignore[no-any-return] @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, bool | float | int | list | None]: """Return the state attributes of the sensor.""" - attr = {} + attr: dict[str, bool | float | int | list | None] = {} if self._device.on is not None: attr[ATTR_ON] = self._device.on @@ -168,11 +184,12 @@ class DeconzTampering(DeconzDevice, BinarySensorEntity): """Representation of a deCONZ tampering sensor.""" TYPE = DOMAIN + _device: PydeconzSensor _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC _attr_device_class = DEVICE_CLASS_TAMPER - def __init__(self, device, gateway): + def __init__(self, device: PydeconzSensor, gateway: DeconzGateway) -> None: """Initialize deCONZ binary sensor.""" super().__init__(device, gateway) @@ -193,4 +210,4 @@ class DeconzTampering(DeconzDevice, BinarySensorEntity): @property def is_on(self) -> bool: """Return the state of the sensor.""" - return self._device.tampered + return self._device.tampered # type: ignore[no-any-return] diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index b9401e6d5a3..85ab4b17a1e 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -1,6 +1,9 @@ """Support for deCONZ climate devices.""" from __future__ import annotations +from collections.abc import ValuesView +from typing import Any + from pydeconz.sensor import ( THERMOSTAT_FAN_MODE_AUTO, THERMOSTAT_FAN_MODE_HIGH, @@ -42,13 +45,15 @@ from homeassistant.components.climate.const import ( SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry +from .gateway import DeconzGateway, get_gateway_from_config_entry DECONZ_FAN_SMART = "smart" @@ -89,7 +94,11 @@ PRESET_MODE_TO_DECONZ = { DECONZ_TO_PRESET_MODE = {value: key for key, value in PRESET_MODE_TO_DECONZ.items()} -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the deCONZ climate devices. Thermostats are based on the same device class as sensors in deCONZ. @@ -98,9 +107,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway.entities[DOMAIN] = set() @callback - def async_add_climate(sensors=gateway.api.sensors.values()): + def async_add_climate( + sensors: list[Thermostat] + | ValuesView[Thermostat] = gateway.api.sensors.values(), + ) -> None: """Add climate devices from deCONZ.""" - entities = [] + entities: list[DeconzThermostat] = [] for sensor in sensors: @@ -131,9 +143,11 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): """Representation of a deCONZ thermostat.""" TYPE = DOMAIN + _device: Thermostat + _attr_temperature_unit = TEMP_CELSIUS - def __init__(self, device, gateway): + def __init__(self, device: Thermostat, gateway: DeconzGateway) -> None: """Set up thermostat device.""" super().__init__(device, gateway) @@ -167,7 +181,7 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): ) @property - def fan_modes(self) -> list: + def fan_modes(self) -> list[str]: """Return the list of available fan operation modes.""" return list(FAN_MODE_TO_DECONZ) @@ -181,7 +195,7 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): # HVAC control @property - def hvac_mode(self): + def hvac_mode(self) -> str: """Return hvac operation ie. heat, cool mode. Need to be one of HVAC_MODE_*. @@ -192,7 +206,7 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): ) @property - def hvac_modes(self) -> list: + def hvac_modes(self) -> list[str]: """Return the list of available hvac operation modes.""" return list(self._hvac_mode_to_deconz) @@ -231,16 +245,20 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): @property def current_temperature(self) -> float: """Return the current temperature.""" - return self._device.temperature + return self._device.temperature # type: ignore[no-any-return] @property - def target_temperature(self) -> float: + def target_temperature(self) -> float | None: """Return the target temperature.""" - if self._device.mode == THERMOSTAT_MODE_COOL: - return self._device.cooling_setpoint - return self._device.heating_setpoint + if self._device.mode == THERMOSTAT_MODE_COOL and self._device.cooling_setpoint: + return self._device.cooling_setpoint # type: ignore[no-any-return] - async def async_set_temperature(self, **kwargs): + if self._device.heating_setpoint: + return self._device.heating_setpoint # type: ignore[no-any-return] + + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if ATTR_TEMPERATURE not in kwargs: raise ValueError(f"Expected attribute {ATTR_TEMPERATURE}") @@ -252,11 +270,11 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): await self._device.set_config(**data) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, bool | int]: """Return the state attributes of the thermostat.""" attr = {} - if self._device.offset: + if self._device.offset is not None: attr[ATTR_OFFSET] = self._device.offset if self._device.valve is not None: diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 3a6c5aecfb5..473cbd72971 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -1,6 +1,10 @@ """Config flow to configure deCONZ component.""" + +from __future__ import annotations + import asyncio from pprint import pformat +from typing import Any, cast from urllib.parse import urlparse import async_timeout @@ -15,8 +19,12 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp +from homeassistant.components.deconz.gateway import DeconzGateway +from homeassistant.components.hassio import HassioServiceInfo +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import ( @@ -36,33 +44,36 @@ CONF_MANUAL_INPUT = "Manually define gateway" @callback -def get_master_gateway(hass): +def get_master_gateway(hass: HomeAssistant) -> DeconzGateway: """Return the gateway which is marked as master.""" for gateway in hass.data[DOMAIN].values(): if gateway.master: - return gateway + return cast(DeconzGateway, gateway) + raise ValueError -class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): +class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a deCONZ config flow.""" VERSION = 1 - _hassio_discovery = None + _hassio_discovery: dict[str, Any] @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Get the options flow for this handler.""" return DeconzOptionsFlowHandler(config_entry) - def __init__(self): + def __init__(self) -> None: """Initialize the deCONZ config flow.""" - self.bridge_id = None - self.bridges = [] - self.deconz_config = {} + self.bridge_id = "" + self.bridges: list[dict[str, int | str]] = [] + self.deconz_config: dict[str, int | str] = {} - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a deCONZ config flow start. Let user choose between discovered bridges and manual configuration. @@ -75,7 +86,7 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): for bridge in self.bridges: if bridge[CONF_HOST] == user_input[CONF_HOST]: - self.bridge_id = bridge[CONF_BRIDGE_ID] + self.bridge_id = cast(str, bridge[CONF_BRIDGE_ID]) self.deconz_config = { CONF_HOST: bridge[CONF_HOST], CONF_PORT: bridge[CONF_PORT], @@ -85,7 +96,7 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): session = aiohttp_client.async_get_clientsession(self.hass) try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): self.bridges = await deconz_discovery(session) except (asyncio.TimeoutError, ResponseError): @@ -108,7 +119,9 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_manual_input() - async def async_step_manual_input(self, user_input=None): + async def async_step_manual_input( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manual configuration.""" if user_input: self.deconz_config = user_input @@ -124,9 +137,11 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ), ) - async def async_step_link(self, user_input=None): + async def async_step_link( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Attempt to link with the deCONZ bridge.""" - errors = {} + errors: dict[str, str] = {} LOGGER.debug( "Preparing linking with deCONZ gateway %s", pformat(self.deconz_config) @@ -141,7 +156,7 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): api_key = await deconz_session.get_api_key() except (ResponseError, RequestError, asyncio.TimeoutError): @@ -153,13 +168,13 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="link", errors=errors) - async def _create_entry(self): + async def _create_entry(self) -> FlowResult: """Create entry for gateway.""" if not self.bridge_id: session = aiohttp_client.async_get_clientsession(self.hass) try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): self.bridge_id = await deconz_get_bridge_id( session, **self.deconz_config ) @@ -178,7 +193,7 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=self.bridge_id, data=self.deconz_config) - async def async_step_reauth(self, config: dict): + async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: """Trigger a reauthentication flow.""" self.context["title_placeholders"] = {CONF_HOST: config[CONF_HOST]} @@ -189,60 +204,63 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_link() - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered deCONZ bridge.""" if ( - discovery_info.get(ssdp.ATTR_UPNP_MANUFACTURER_URL) + discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER_URL) != DECONZ_MANUFACTURERURL ): return self.async_abort(reason="not_deconz_bridge") LOGGER.debug("deCONZ SSDP discovery %s", pformat(discovery_info)) - self.bridge_id = normalize_bridge_id(discovery_info[ssdp.ATTR_UPNP_SERIAL]) - parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) + self.bridge_id = normalize_bridge_id(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) + parsed_url = urlparse(discovery_info.ssdp_location) entry = await self.async_set_unique_id(self.bridge_id) if entry and entry.source == config_entries.SOURCE_HASSIO: return self.async_abort(reason="already_configured") + hostname = cast(str, parsed_url.hostname) + port = cast(int, parsed_url.port) + self._abort_if_unique_id_configured( - updates={CONF_HOST: parsed_url.hostname, CONF_PORT: parsed_url.port} + updates={CONF_HOST: hostname, CONF_PORT: port} ) - self.context["title_placeholders"] = {"host": parsed_url.hostname} + self.context["title_placeholders"] = {"host": hostname} - self.deconz_config = { - CONF_HOST: parsed_url.hostname, - CONF_PORT: parsed_url.port, - } + self.deconz_config = {CONF_HOST: hostname, CONF_PORT: port} return await self.async_step_link() - async def async_step_hassio(self, discovery_info): + async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Prepare configuration for a Hass.io deCONZ bridge. This flow is triggered by the discovery component. """ - LOGGER.debug("deCONZ HASSIO discovery %s", pformat(discovery_info)) + LOGGER.debug("deCONZ HASSIO discovery %s", pformat(discovery_info.config)) - self.bridge_id = normalize_bridge_id(discovery_info[CONF_SERIAL]) + self.bridge_id = normalize_bridge_id(discovery_info.config[CONF_SERIAL]) await self.async_set_unique_id(self.bridge_id) self._abort_if_unique_id_configured( updates={ - CONF_HOST: discovery_info[CONF_HOST], - CONF_PORT: discovery_info[CONF_PORT], - CONF_API_KEY: discovery_info[CONF_API_KEY], + CONF_HOST: discovery_info.config[CONF_HOST], + CONF_PORT: discovery_info.config[CONF_PORT], + CONF_API_KEY: discovery_info.config[CONF_API_KEY], } ) - self._hassio_discovery = discovery_info + self._hassio_discovery = discovery_info.config return await self.async_step_hassio_confirm() - async def async_step_hassio_confirm(self, user_input=None): + async def async_step_hassio_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Confirm a Hass.io discovery.""" + if user_input is not None: self.deconz_config = { CONF_HOST: self._hassio_discovery[CONF_HOST], @@ -258,21 +276,26 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) -class DeconzOptionsFlowHandler(config_entries.OptionsFlow): +class DeconzOptionsFlowHandler(OptionsFlow): """Handle deCONZ options.""" - def __init__(self, config_entry): + gateway: DeconzGateway + + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize deCONZ options flow.""" self.config_entry = config_entry self.options = dict(config_entry.options) - self.gateway = None - 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 deCONZ options.""" self.gateway = get_gateway_from_config_entry(self.hass, self.config_entry) return await self.async_step_deconz_devices() - async def async_step_deconz_devices(self, user_input=None): + async def async_step_deconz_devices( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the deconz devices options.""" if user_input is not None: self.options.update(user_input) diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index 09f0cd15141..ad668934acf 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -1,20 +1,7 @@ """Constants for the deCONZ component.""" import logging -from homeassistant.components.alarm_control_panel import ( - DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, -) -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.components.cover import DOMAIN as COVER_DOMAIN -from homeassistant.components.fan import DOMAIN as FAN_DOMAIN -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN -from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import Platform LOGGER = logging.getLogger(__package__) @@ -34,18 +21,18 @@ CONF_ALLOW_NEW_DEVICES = "allow_new_devices" CONF_MASTER_GATEWAY = "master" PLATFORMS = [ - ALARM_CONTROL_PANEL_DOMAIN, - BINARY_SENSOR_DOMAIN, - CLIMATE_DOMAIN, - COVER_DOMAIN, - FAN_DOMAIN, - LIGHT_DOMAIN, - LOCK_DOMAIN, - NUMBER_DOMAIN, - SCENE_DOMAIN, - SENSOR_DOMAIN, - SIREN_DOMAIN, - SWITCH_DOMAIN, + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.LOCK, + Platform.NUMBER, + Platform.SCENE, + Platform.SENSOR, + Platform.SIREN, + Platform.SWITCH, ] ATTR_DARK = "dark" diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py index 5cf90c4dca1..324452c4aa6 100644 --- a/homeassistant/components/deconz/cover.py +++ b/homeassistant/components/deconz/cover.py @@ -1,5 +1,10 @@ """Support for deCONZ covers.""" +from __future__ import annotations + +from collections.abc import ValuesView +from typing import Any, cast + from pydeconz.light import Cover from homeassistant.components.cover import ( @@ -18,11 +23,13 @@ from homeassistant.components.cover import ( SUPPORT_STOP_TILT, CoverEntity, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry +from .gateway import DeconzGateway, get_gateway_from_config_entry DEVICE_CLASS = { "Level controllable output": DEVICE_CLASS_DAMPER, @@ -31,13 +38,19 @@ DEVICE_CLASS = { } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up covers for deCONZ component.""" gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() @callback - def async_add_cover(lights=gateway.api.lights.values()): + def async_add_cover( + lights: list[Cover] | ValuesView[Cover] = gateway.api.lights.values(), + ) -> None: """Add cover from deCONZ.""" entities = [] @@ -66,8 +79,9 @@ class DeconzCover(DeconzDevice, CoverEntity): """Representation of a deCONZ cover.""" TYPE = DOMAIN + _device: Cover - def __init__(self, device, gateway): + def __init__(self, device: Cover, gateway: DeconzGateway) -> None: """Set up cover device.""" super().__init__(device, gateway) @@ -85,52 +99,52 @@ class DeconzCover(DeconzDevice, CoverEntity): self._attr_device_class = DEVICE_CLASS.get(self._device.type) @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current position of the cover.""" - return 100 - self._device.lift + return 100 - self._device.lift # type: ignore[no-any-return] @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return not self._device.is_open - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - position = 100 - kwargs[ATTR_POSITION] + position = 100 - cast(int, kwargs[ATTR_POSITION]) await self._device.set_position(lift=position) - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" await self._device.open() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" await self._device.close() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop cover.""" await self._device.stop() @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """Return the current tilt position of the cover.""" if self._device.tilt is not None: - return 100 - self._device.tilt + return 100 - self._device.tilt # type: ignore[no-any-return] return None - async def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Tilt the cover to a specific position.""" - position = 100 - kwargs[ATTR_TILT_POSITION] + position = 100 - cast(int, kwargs[ATTR_TILT_POSITION]) await self._device.set_position(tilt=position) - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open cover tilt.""" await self._device.set_position(tilt=0) - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close cover tilt.""" await self._device.set_position(tilt=100) - async def async_stop_cover_tilt(self, **kwargs): + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop cover tilt.""" await self._device.stop() diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index d1abbed0928..ae539ee5d48 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -1,4 +1,7 @@ """Provides device automations for deconz events.""" + +from __future__ import annotations + import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA @@ -14,10 +17,11 @@ from homeassistant.const import ( CONF_TYPE, CONF_UNIQUE_ID, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from . import DOMAIN -from .deconz_event import CONF_DECONZ_EVENT, CONF_GESTURE +from .deconz_event import CONF_DECONZ_EVENT, CONF_GESTURE, DeconzAlarmEvent, DeconzEvent CONF_SUBTYPE = "subtype" @@ -613,16 +617,19 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -def _get_deconz_event_from_device_id(hass, device_id): - """Resolve deconz event from device id.""" +def _get_deconz_event_from_device( + hass: HomeAssistant, + device: dr.DeviceEntry, +) -> DeconzAlarmEvent | DeconzEvent: + """Resolve deconz event from device.""" for gateway in hass.data.get(DOMAIN, {}).values(): - for deconz_event in gateway.events: - - if device_id == deconz_event.device_id: + if device.id == deconz_event.device_id: return deconz_event - return None + raise InvalidDeviceAutomationConfig( + f'No deconz_event tied to device "{device.name}" found' + ) async def async_validate_trigger_config(hass, config): @@ -658,11 +665,7 @@ async def async_attach_trigger(hass, config, action, automation_info): trigger = REMOTES[device.model][trigger] - deconz_event = _get_deconz_event_from_device_id(hass, device.id) - if deconz_event is None: - raise InvalidDeviceAutomationConfig( - f'No deconz_event tied to device "{device.name}" found' - ) + deconz_event = _get_deconz_event_from_device(hass, device) event_id = deconz_event.serial diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index 40862bfcde1..d1ff85f9d65 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -1,6 +1,9 @@ """Support for deCONZ fans.""" from __future__ import annotations +from collections.abc import ValuesView +from typing import Any + from pydeconz.light import ( FAN_SPEED_25_PERCENT, FAN_SPEED_50_PERCENT, @@ -19,15 +22,17 @@ from homeassistant.components.fan import ( SUPPORT_SET_SPEED, FanEntity, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, ) from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry +from .gateway import DeconzGateway, get_gateway_from_config_entry ORDERED_NAMED_FAN_SPEEDS = [ FAN_SPEED_25_PERCENT, @@ -50,13 +55,19 @@ LEGACY_DECONZ_TO_SPEED = { } -async def async_setup_entry(hass, config_entry, async_add_entities) -> None: +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up fans for deCONZ component.""" gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() @callback - def async_add_fan(lights=gateway.api.lights.values()) -> None: + def async_add_fan( + lights: list[Fan] | ValuesView[Fan] = gateway.api.lights.values(), + ) -> None: """Add fan from deCONZ.""" entities = [] @@ -86,8 +97,11 @@ class DeconzFan(DeconzDevice, FanEntity): """Representation of a deCONZ fan.""" TYPE = DOMAIN + _device: Fan - def __init__(self, device, gateway) -> None: + _attr_supported_features = SUPPORT_SET_SPEED + + def __init__(self, device: Fan, gateway: DeconzGateway) -> None: """Set up fan.""" super().__init__(device, gateway) @@ -95,12 +109,10 @@ class DeconzFan(DeconzDevice, FanEntity): if self._device.speed in ORDERED_NAMED_FAN_SPEEDS: self._default_on_speed = self._device.speed - self._attr_supported_features = SUPPORT_SET_SPEED - @property def is_on(self) -> bool: """Return true if fan is on.""" - return self._device.speed != FAN_SPEED_OFF + return self._device.speed != FAN_SPEED_OFF # type: ignore[no-any-return] @property def percentage(self) -> int | None: @@ -153,11 +165,6 @@ class DeconzFan(DeconzDevice, FanEntity): SPEED_MEDIUM, ) - @property - def supported_features(self) -> int: - """Flag supported features.""" - return self._attr_supported_features - @callback def async_update_callback(self) -> None: """Store latest configured speed from the device.""" @@ -183,10 +190,10 @@ class DeconzFan(DeconzDevice, FanEntity): async def async_turn_on( self, - speed: str = None, - percentage: int = None, - preset_mode: str = None, - **kwargs, + speed: str | None = None, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn on fan.""" new_speed = self._default_on_speed @@ -198,6 +205,6 @@ class DeconzFan(DeconzDevice, FanEntity): await self._device.set_speed(new_speed) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off fan.""" await self._device.set_speed(FAN_SPEED_OFF) diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index ddb0d47190c..8742cd087d4 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -152,11 +152,11 @@ class DeconzGateway: # Gateway service configuration_url = f"http://{self.host}:{self.config_entry.data[CONF_PORT]}" if self.config_entry.source == SOURCE_HASSIO: - configuration_url = None + configuration_url = "homeassistant://hassio/ingress/core_deconz" device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, configuration_url=configuration_url, - entry_type="service", + entry_type=dr.DeviceEntryType.SERVICE, identifiers={(DECONZ_DOMAIN, self.api.config.bridge_id)}, manufacturer="Dresden Elektronik", model=self.api.config.model_id, @@ -276,7 +276,7 @@ async def get_gateway( connection_status=async_connection_status_callback, ) try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): await deconz.refresh_state() return deconz diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 6bb4f5c5b00..e287d574633 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -2,6 +2,9 @@ from __future__ import annotations +from collections.abc import ValuesView +from typing import Any + from pydeconz.group import DeconzGroup as Group from pydeconz.light import ( ALERT_LONG, @@ -33,27 +36,35 @@ from homeassistant.components.light import ( SUPPORT_TRANSITION, LightEntity, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.color import color_hs_to_xy from .const import DOMAIN as DECONZ_DOMAIN, POWER_PLUGS from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry +from .gateway import DeconzGateway, get_gateway_from_config_entry DECONZ_GROUP = "is_deconz_group" EFFECT_TO_DECONZ = {EFFECT_COLORLOOP: EFFECT_COLOR_LOOP, "None": EFFECT_NONE} FLASH_TO_DECONZ = {FLASH_SHORT: ALERT_SHORT, FLASH_LONG: ALERT_LONG} -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the deCONZ lights and groups from a config entry.""" gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() @callback - def async_add_light(lights=gateway.api.lights.values()): + def async_add_light( + lights: list[Light] | ValuesView[Light] = gateway.api.lights.values(), + ) -> None: """Add light from deCONZ.""" entities = [] @@ -77,7 +88,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) @callback - def async_add_group(groups=gateway.api.groups.values()): + def async_add_group( + groups: list[Group] | ValuesView[Group] = gateway.api.groups.values(), + ) -> None: """Add group from deCONZ.""" if not gateway.option_allow_deconz_groups: return @@ -113,11 +126,11 @@ class DeconzBaseLight(DeconzDevice, LightEntity): TYPE = DOMAIN - def __init__(self, device, gateway): + def __init__(self, device: Group | Light, gateway: DeconzGateway) -> None: """Set up light.""" super().__init__(device, gateway) - self._attr_supported_color_modes = set() + self._attr_supported_color_modes: set[str] = set() if device.color_temp is not None: self._attr_supported_color_modes.add(COLOR_MODE_COLOR_TEMP) @@ -158,83 +171,83 @@ class DeconzBaseLight(DeconzDevice, LightEntity): return color_mode @property - def brightness(self): + def brightness(self) -> int | None: """Return the brightness of this light between 0..255.""" - return self._device.brightness + return self._device.brightness # type: ignore[no-any-return] @property - def color_temp(self): + def color_temp(self) -> int: """Return the CT color value.""" - return self._device.color_temp + return self._device.color_temp # type: ignore[no-any-return] @property - def hs_color(self) -> tuple: + def hs_color(self) -> tuple[float, float]: """Return the hs color value.""" return (self._device.hue / 65535 * 360, self._device.saturation / 255 * 100) @property - def xy_color(self) -> tuple | None: + def xy_color(self) -> tuple[float, float] | None: """Return the XY color value.""" - return self._device.xy + return self._device.xy # type: ignore[no-any-return] @property - def is_on(self): + def is_on(self) -> bool: """Return true if light is on.""" - return self._device.state + return self._device.state # type: ignore[no-any-return] - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on light.""" - data = {"on": True} + data: dict[str, bool | float | int | str | tuple[float, float]] = {"on": True} - if ATTR_BRIGHTNESS in kwargs: - data["brightness"] = kwargs[ATTR_BRIGHTNESS] + if attr_brightness := kwargs.get(ATTR_BRIGHTNESS): + data["brightness"] = attr_brightness - if ATTR_COLOR_TEMP in kwargs: - data["color_temperature"] = kwargs[ATTR_COLOR_TEMP] + if attr_color_temp := kwargs.get(ATTR_COLOR_TEMP): + data["color_temperature"] = attr_color_temp - if ATTR_HS_COLOR in kwargs: + if attr_hs_color := kwargs.get(ATTR_HS_COLOR): if COLOR_MODE_XY in self._attr_supported_color_modes: - data["xy"] = color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) + data["xy"] = color_hs_to_xy(*attr_hs_color) else: - data["hue"] = int(kwargs[ATTR_HS_COLOR][0] / 360 * 65535) - data["saturation"] = int(kwargs[ATTR_HS_COLOR][1] / 100 * 255) + data["hue"] = int(attr_hs_color[0] / 360 * 65535) + data["saturation"] = int(attr_hs_color[1] / 100 * 255) if ATTR_XY_COLOR in kwargs: data["xy"] = kwargs[ATTR_XY_COLOR] - if ATTR_TRANSITION in kwargs: - data["transition_time"] = int(kwargs[ATTR_TRANSITION] * 10) + if attr_transition := kwargs.get(ATTR_TRANSITION): + data["transition_time"] = int(attr_transition * 10) elif "IKEA" in self._device.manufacturer: data["transition_time"] = 0 - if (alert := FLASH_TO_DECONZ.get(kwargs.get(ATTR_FLASH))) is not None: + if (alert := FLASH_TO_DECONZ.get(kwargs.get(ATTR_FLASH, ""))) is not None: data["alert"] = alert del data["on"] - if (effect := EFFECT_TO_DECONZ.get(kwargs.get(ATTR_EFFECT))) is not None: + if (effect := EFFECT_TO_DECONZ.get(kwargs.get(ATTR_EFFECT, ""))) is not None: data["effect"] = effect await self._device.set_state(**data) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off light.""" if not self._device.state: return - data = {"on": False} + data: dict[str, bool | int | str] = {"on": False} if ATTR_TRANSITION in kwargs: data["brightness"] = 0 data["transition_time"] = int(kwargs[ATTR_TRANSITION] * 10) - if (alert := FLASH_TO_DECONZ.get(kwargs.get(ATTR_FLASH))) is not None: + if (alert := FLASH_TO_DECONZ.get(kwargs.get(ATTR_FLASH, ""))) is not None: data["alert"] = alert del data["on"] await self._device.set_state(**data) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, bool]: """Return the device state attributes.""" return {DECONZ_GROUP: isinstance(self._device, Group)} @@ -242,13 +255,15 @@ class DeconzBaseLight(DeconzDevice, LightEntity): class DeconzLight(DeconzBaseLight): """Representation of a deCONZ light.""" + _device: Light + @property - def max_mireds(self): + def max_mireds(self) -> int: """Return the warmest color_temp that this light supports.""" return self._device.max_color_temp or super().max_mireds @property - def min_mireds(self): + def min_mireds(self) -> int: """Return the coldest color_temp that this light supports.""" return self._device.min_color_temp or super().min_mireds @@ -256,13 +271,15 @@ class DeconzLight(DeconzBaseLight): class DeconzGroup(DeconzBaseLight): """Representation of a deCONZ group.""" - def __init__(self, device, gateway): + _device: Group + + def __init__(self, device: Group, gateway: DeconzGateway) -> None: """Set up group and create an unique id.""" self._unique_id = f"{gateway.bridgeid}-{device.deconz_id}" super().__init__(device, gateway) @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique identifier for this device.""" return self._unique_id @@ -278,7 +295,7 @@ class DeconzGroup(DeconzBaseLight): ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, bool]: """Return the device state attributes.""" attributes = dict(super().extra_state_attributes) attributes["all_on"] = self._device.all_on diff --git a/homeassistant/components/deconz/lock.py b/homeassistant/components/deconz/lock.py index fb344e54176..7bdae3e36ed 100644 --- a/homeassistant/components/deconz/lock.py +++ b/homeassistant/components/deconz/lock.py @@ -1,23 +1,36 @@ """Support for deCONZ locks.""" +from __future__ import annotations + +from collections.abc import ValuesView +from typing import Any + from pydeconz.light import Lock from pydeconz.sensor import DoorLock from homeassistant.components.lock import DOMAIN, LockEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up locks for deCONZ component.""" gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() @callback - def async_add_lock_from_light(lights=gateway.api.lights.values()): + def async_add_lock_from_light( + lights: list[Lock] | ValuesView[Lock] = gateway.api.lights.values(), + ) -> None: """Add lock from deCONZ.""" entities = [] @@ -41,7 +54,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) @callback - def async_add_lock_from_sensor(sensors=gateway.api.sensors.values()): + def async_add_lock_from_sensor( + sensors: list[DoorLock] | ValuesView[DoorLock] = gateway.api.sensors.values(), + ) -> None: """Add lock from deCONZ.""" entities = [] @@ -72,16 +87,17 @@ class DeconzLock(DeconzDevice, LockEntity): """Representation of a deCONZ lock.""" TYPE = DOMAIN + _device: DoorLock | Lock @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if lock is on.""" - return self._device.is_locked + return self._device.is_locked # type: ignore[no-any-return] - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" await self._device.lock() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" await self._device.unlock() diff --git a/homeassistant/components/deconz/logbook.py b/homeassistant/components/deconz/logbook.py index 0d7ad67dda6..1c41feda7da 100644 --- a/homeassistant/components/deconz/logbook.py +++ b/homeassistant/components/deconz/logbook.py @@ -5,15 +5,11 @@ from collections.abc import Callable from homeassistant.const import ATTR_DEVICE_ID, CONF_EVENT from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.event import Event from .const import CONF_GESTURE, DOMAIN as DECONZ_DOMAIN -from .deconz_event import ( - CONF_DECONZ_ALARM_EVENT, - CONF_DECONZ_EVENT, - DeconzAlarmEvent, - DeconzEvent, -) +from .deconz_event import CONF_DECONZ_ALARM_EVENT, CONF_DECONZ_EVENT from .device_trigger import ( CONF_BOTH_BUTTONS, CONF_BOTTOM_BUTTONS, @@ -57,7 +53,7 @@ from .device_trigger import ( CONF_TURN_OFF, CONF_TURN_ON, REMOTES, - _get_deconz_event_from_device_id, + _get_deconz_event_from_device, ) ACTIONS = { @@ -108,9 +104,11 @@ INTERFACES = { } -def _get_device_event_description(modelid: str, event: str) -> tuple: +def _get_device_event_description( + modelid: str, event: int +) -> tuple[str | None, str | None]: """Get device event description.""" - device_event_descriptions: dict = REMOTES[modelid] + device_event_descriptions = REMOTES[modelid] for event_type_tuple, event_dict in device_event_descriptions.items(): if event == event_dict.get(CONF_EVENT): @@ -124,16 +122,16 @@ def _get_device_event_description(modelid: str, event: str) -> tuple: @callback def async_describe_events( hass: HomeAssistant, - async_describe_event: Callable[[str, str, Callable[[Event], dict]], None], + async_describe_event: Callable[[str, str, Callable[[Event], dict[str, str]]], None], ) -> None: """Describe logbook events.""" + device_registry = dr.async_get(hass) @callback - def async_describe_deconz_alarm_event(event: Event) -> dict: + def async_describe_deconz_alarm_event(event: Event) -> dict[str, str]: """Describe deCONZ logbook alarm event.""" - deconz_alarm_event: DeconzAlarmEvent | None = _get_deconz_event_from_device_id( - hass, event.data[ATTR_DEVICE_ID] - ) + device = device_registry.devices[event.data[ATTR_DEVICE_ID]] + deconz_alarm_event = _get_deconz_event_from_device(hass, device) data = event.data[CONF_EVENT] @@ -143,11 +141,10 @@ def async_describe_events( } @callback - def async_describe_deconz_event(event: Event) -> dict: + def async_describe_deconz_event(event: Event) -> dict[str, str]: """Describe deCONZ logbook event.""" - deconz_event: DeconzEvent | None = _get_deconz_event_from_device_id( - hass, event.data[ATTR_DEVICE_ID] - ) + device = device_registry.devices[event.data[ATTR_DEVICE_ID]] + deconz_event = _get_deconz_event_from_device(hass, device) action = None interface = None diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py index 0ac355f7dd1..6c3ca08710c 100644 --- a/homeassistant/components/deconz/number.py +++ b/homeassistant/components/deconz/number.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import ValuesView from dataclasses import dataclass from pydeconz.sensor import PRESENCE_DELAY, Presence @@ -11,25 +12,35 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ENTITY_CATEGORY_CONFIG -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry +from .gateway import DeconzGateway, get_gateway_from_config_entry @dataclass -class DeconzNumberEntityDescription(NumberEntityDescription): +class DeconzNumberEntityDescriptionBase: + """Required values when describing deCONZ number entities.""" + + device_property: str + suffix: str + update_key: str + max_value: int + min_value: int + step: int + + +@dataclass +class DeconzNumberEntityDescription( + NumberEntityDescription, DeconzNumberEntityDescriptionBase +): """Class describing deCONZ number entities.""" entity_category = ENTITY_CATEGORY_CONFIG - device_property: str | None = None - suffix: str | None = None - update_key: str | None = None - max_value: int | None = None - min_value: int | None = None - step: int | None = None ENTITY_DESCRIPTIONS = { @@ -47,13 +58,19 @@ ENTITY_DESCRIPTIONS = { } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the deCONZ number entity.""" gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() @callback - def async_add_sensor(sensors=gateway.api.sensors.values()): + def async_add_sensor( + sensors: list[Presence] | ValuesView[Presence] = gateway.api.sensors.values(), + ) -> None: """Add number config sensor from deCONZ.""" entities = [] @@ -92,13 +109,19 @@ class DeconzNumber(DeconzDevice, NumberEntity): """Representation of a deCONZ number entity.""" TYPE = DOMAIN + _device: Presence - def __init__(self, device, gateway, description): + def __init__( + self, + device: Presence, + gateway: DeconzGateway, + description: DeconzNumberEntityDescription, + ) -> None: """Initialize deCONZ number entity.""" - self.entity_description = description + self.entity_description: DeconzNumberEntityDescription = description super().__init__(device, gateway) - self._attr_name = f"{self._device.name} {description.suffix}" + self._attr_name = f"{device.name} {description.suffix}" self._attr_max_value = description.max_value self._attr_min_value = description.min_value self._attr_step = description.step @@ -113,7 +136,7 @@ class DeconzNumber(DeconzDevice, NumberEntity): @property def value(self) -> float: """Return the value of the sensor property.""" - return getattr(self._device, self.entity_description.device_property) + return getattr(self._device, self.entity_description.device_property) # type: ignore[no-any-return] async def async_set_value(self, value: float) -> None: """Set sensor config.""" diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py index 69f3d48c82c..3d8e1aa27ba 100644 --- a/homeassistant/components/deconz/scene.py +++ b/homeassistant/components/deconz/scene.py @@ -1,19 +1,34 @@ """Support for deCONZ scenes.""" + +from __future__ import annotations + +from collections.abc import ValuesView from typing import Any +from pydeconz.group import DeconzScene as PydeconzScene + from homeassistant.components.scene import Scene -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .gateway import get_gateway_from_config_entry +from .gateway import DeconzGateway, get_gateway_from_config_entry -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up scenes for deCONZ component.""" gateway = get_gateway_from_config_entry(hass, config_entry) @callback - def async_add_scene(scenes=gateway.api.scenes.values()): + def async_add_scene( + scenes: list[PydeconzScene] + | ValuesView[PydeconzScene] = gateway.api.scenes.values(), + ) -> None: """Add scene from deCONZ.""" entities = [DeconzScene(scene, gateway) for scene in scenes] @@ -34,14 +49,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class DeconzScene(Scene): """Representation of a deCONZ scene.""" - def __init__(self, scene, gateway): + def __init__(self, scene: PydeconzScene, gateway: DeconzGateway) -> None: """Set up a scene.""" self._scene = scene self.gateway = gateway self._attr_name = scene.full_name - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to sensors events.""" self.gateway.deconz_ids[self.entity_id] = self._scene.deconz_id diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 3f8c22d43d6..e530e33e654 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -1,9 +1,14 @@ """Support for deCONZ sensors.""" +from __future__ import annotations + +from collections.abc import ValuesView + from pydeconz.sensor import ( AirQuality, Battery, Consumption, Daylight, + DeconzSensor as PydeconzSensor, GenericStatus, Humidity, LightLevel, @@ -22,6 +27,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_VOLTAGE, @@ -40,15 +46,17 @@ from homeassistant.const import ( PRESSURE_HPA, TEMP_CELSIUS, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import ATTR_DARK, ATTR_ON from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry +from .gateway import DeconzGateway, get_gateway_from_config_entry DECONZ_SENSORS = ( AirQuality, @@ -119,7 +127,11 @@ ENTITY_DESCRIPTIONS = { } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the deCONZ sensors.""" gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() @@ -127,13 +139,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): battery_handler = DeconzBatteryHandler(gateway) @callback - def async_add_sensor(sensors=gateway.api.sensors.values()): + def async_add_sensor( + sensors: list[PydeconzSensor] + | ValuesView[PydeconzSensor] = gateway.api.sensors.values(), + ) -> None: """Add sensors from deCONZ. Create DeconzBattery if sensor has a battery attribute. Create DeconzSensor if not a battery, switch or thermostat and not a binary sensor. """ - entities = [] + entities: list[DeconzBattery | DeconzSensor | DeconzTemperature] = [] for sensor in sensors: @@ -184,8 +199,9 @@ class DeconzSensor(DeconzDevice, SensorEntity): """Representation of a deCONZ sensor.""" TYPE = DOMAIN + _device: PydeconzSensor - def __init__(self, device, gateway): + def __init__(self, device: PydeconzSensor, gateway: DeconzGateway) -> None: """Initialize deCONZ binary sensor.""" super().__init__(device, gateway) @@ -193,19 +209,19 @@ class DeconzSensor(DeconzDevice, SensorEntity): self.entity_description = entity_description @callback - def async_update_callback(self): + def async_update_callback(self) -> None: """Update the sensor's state.""" keys = {"on", "reachable", "state"} if self._device.changed_keys.intersection(keys): super().async_update_callback() @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the sensor.""" - return self._device.state + return self._device.state # type: ignore[no-any-return] @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, bool | float | int | None]: """Return the state attributes of the sensor.""" attr = {} @@ -243,8 +259,9 @@ class DeconzTemperature(DeconzDevice, SensorEntity): """ TYPE = DOMAIN + _device: PydeconzSensor - def __init__(self, device, gateway): + def __init__(self, device: PydeconzSensor, gateway: DeconzGateway) -> None: """Initialize deCONZ temperature sensor.""" super().__init__(device, gateway) @@ -252,29 +269,30 @@ class DeconzTemperature(DeconzDevice, SensorEntity): self._attr_name = f"{self._device.name} Temperature" @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique identifier for this device.""" return f"{self.serial}-temperature" @callback - def async_update_callback(self): + def async_update_callback(self) -> None: """Update the sensor's state.""" keys = {"temperature", "reachable"} if self._device.changed_keys.intersection(keys): super().async_update_callback() @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the sensor.""" - return self._device.secondary_temperature + return self._device.secondary_temperature # type: ignore[no-any-return] class DeconzBattery(DeconzDevice, SensorEntity): """Battery class for when a device is only represented as an event.""" TYPE = DOMAIN + _device: PydeconzSensor - def __init__(self, device, gateway): + def __init__(self, device: PydeconzSensor, gateway: DeconzGateway) -> None: """Initialize deCONZ battery level sensor.""" super().__init__(device, gateway) @@ -282,14 +300,14 @@ class DeconzBattery(DeconzDevice, SensorEntity): self._attr_name = f"{self._device.name} Battery Level" @callback - def async_update_callback(self): + def async_update_callback(self) -> None: """Update the battery's state, if needed.""" keys = {"battery", "reachable"} if self._device.changed_keys.intersection(keys): super().async_update_callback() @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique identifier for this device. Normally there should only be one battery sensor per device from deCONZ. @@ -305,12 +323,12 @@ class DeconzBattery(DeconzDevice, SensorEntity): return f"{self.serial}-battery" @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the battery.""" - return self._device.battery + return self._device.battery # type: ignore[no-any-return] @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes of the battery.""" attr = {} @@ -325,21 +343,20 @@ class DeconzBattery(DeconzDevice, SensorEntity): class DeconzSensorStateTracker: """Track sensors without a battery state and signal when battery state exist.""" - def __init__(self, sensor, gateway): + def __init__(self, sensor: PydeconzSensor, gateway: DeconzGateway) -> None: """Set up tracker.""" self.sensor = sensor self.gateway = gateway sensor.register_callback(self.async_update_callback) @callback - def close(self): + def close(self) -> None: """Clean up tracker.""" self.sensor.remove_callback(self.async_update_callback) - self.gateway = None self.sensor = None @callback - def async_update_callback(self): + def async_update_callback(self) -> None: """Sensor state updated.""" if "battery" in self.sensor.changed_keys: async_dispatcher_send( @@ -352,13 +369,13 @@ class DeconzSensorStateTracker: class DeconzBatteryHandler: """Creates and stores trackers for sensors without a battery state.""" - def __init__(self, gateway): + def __init__(self, gateway: DeconzGateway) -> None: """Set up battery handler.""" self.gateway = gateway - self._trackers = set() + self._trackers: set[DeconzSensorStateTracker] = set() @callback - def create_tracker(self, sensor): + def create_tracker(self, sensor: PydeconzSensor) -> None: """Create new tracker for battery state.""" for tracker in self._trackers: if sensor == tracker.sensor: @@ -366,7 +383,7 @@ class DeconzBatteryHandler: self._trackers.add(DeconzSensorStateTracker(sensor, self.gateway)) @callback - def remove_tracker(self, sensor): + def remove_tracker(self, sensor: PydeconzSensor) -> None: """Remove tracker of battery state.""" for tracker in self._trackers: if sensor == tracker.sensor: diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index 535dd9807fb..529616138a2 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -1,9 +1,12 @@ """deCONZ services.""" +from types import MappingProxyType + from pydeconz.utils import normalize_bridge_id import voluptuous as vol -from homeassistant.core import callback +from homeassistant.components.deconz.gateway import DeconzGateway +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -55,15 +58,14 @@ SERVICE_TO_SCHEMA = { @callback -def async_setup_services(hass): +def async_setup_services(hass: HomeAssistant) -> None: """Set up services for deCONZ integration.""" - async def async_call_deconz_service(service_call): + async def async_call_deconz_service(service_call: ServiceCall) -> None: """Call correct deCONZ service.""" service = service_call.service service_data = service_call.data - gateway = get_master_gateway(hass) if CONF_BRIDGE_ID in service_data: found_gateway = False bridge_id = normalize_bridge_id(service_data[CONF_BRIDGE_ID]) @@ -77,6 +79,12 @@ def async_setup_services(hass): if not found_gateway: LOGGER.error("Could not find the gateway %s", bridge_id) return + else: + try: + gateway = get_master_gateway(hass) + except ValueError: + LOGGER.error("No master gateway available") + return if service == SERVICE_CONFIGURE_DEVICE: await async_configure_service(gateway, service_data) @@ -97,13 +105,15 @@ def async_setup_services(hass): @callback -def async_unload_services(hass): +def async_unload_services(hass: HomeAssistant) -> None: """Unload deCONZ services.""" for service in SUPPORTED_SERVICES: hass.services.async_remove(DOMAIN, service) -async def async_configure_service(gateway, data): +async def async_configure_service( + gateway: DeconzGateway, data: MappingProxyType +) -> None: """Set attribute of device in deCONZ. Entity is used to resolve to a device path (e.g. '/lights/1'). @@ -133,7 +143,7 @@ async def async_configure_service(gateway, data): await gateway.api.request("put", field, json=data) -async def async_refresh_devices_service(gateway): +async def async_refresh_devices_service(gateway: DeconzGateway) -> None: """Refresh available devices from deCONZ.""" gateway.ignore_state_updates = True await gateway.api.refresh_state() @@ -143,7 +153,7 @@ async def async_refresh_devices_service(gateway): gateway.async_add_device_callback(resource_type, force=True) -async def async_remove_orphaned_entries_service(gateway): +async def async_remove_orphaned_entries_service(gateway: DeconzGateway) -> None: """Remove orphaned deCONZ entries from device and entity registries.""" device_registry = dr.async_get(gateway.hass) entity_registry = er.async_get(gateway.hass) @@ -164,14 +174,14 @@ async def async_remove_orphaned_entries_service(gateway): connections={(CONNECTION_NETWORK_MAC, gateway.api.config.mac)}, identifiers=set(), ) - if gateway_host.id in devices_to_be_removed: + if gateway_host and gateway_host.id in devices_to_be_removed: devices_to_be_removed.remove(gateway_host.id) # Don't remove the Gateway service entry gateway_service = device_registry.async_get_device( identifiers={(DOMAIN, gateway.api.config.bridge_id)}, connections=set() ) - if gateway_service.id in devices_to_be_removed: + if gateway_service and gateway_service.id in devices_to_be_removed: devices_to_be_removed.remove(gateway_service.id) # Don't remove devices belonging to available events diff --git a/homeassistant/components/deconz/siren.py b/homeassistant/components/deconz/siren.py index c3679b6ad89..7f0f6cc39ed 100644 --- a/homeassistant/components/deconz/siren.py +++ b/homeassistant/components/deconz/siren.py @@ -1,5 +1,10 @@ """Support for deCONZ siren.""" +from __future__ import annotations + +from collections.abc import ValuesView +from typing import Any + from pydeconz.light import Siren from homeassistant.components.siren import ( @@ -10,20 +15,28 @@ from homeassistant.components.siren import ( SUPPORT_TURN_ON, SirenEntity, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry +from .gateway import DeconzGateway, get_gateway_from_config_entry -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up sirens for deCONZ component.""" gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() @callback - def async_add_siren(lights=gateway.api.lights.values()): + def async_add_siren( + lights: list[Siren] | ValuesView[Siren] = gateway.api.lights.values(), + ) -> None: """Add siren from deCONZ.""" entities = [] @@ -53,8 +66,9 @@ class DeconzSiren(DeconzDevice, SirenEntity): """Representation of a deCONZ siren.""" TYPE = DOMAIN + _device: Siren - def __init__(self, device, gateway) -> None: + def __init__(self, device: Siren, gateway: DeconzGateway) -> None: """Set up siren.""" super().__init__(device, gateway) @@ -63,17 +77,17 @@ class DeconzSiren(DeconzDevice, SirenEntity): ) @property - def is_on(self): + def is_on(self) -> bool: """Return true if siren is on.""" - return self._device.is_on + return self._device.is_on # type: ignore[no-any-return] - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on siren.""" data = {} if (duration := kwargs.get(ATTR_DURATION)) is not None: data["duration"] = duration * 10 await self._device.turn_on(**data) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off siren.""" await self._device.turn_off() diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index 39489fe1fc3..ab4577a427c 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -1,18 +1,29 @@ """Support for deCONZ switches.""" -from pydeconz.light import Siren +from __future__ import annotations + +from collections.abc import ValuesView +from typing import Any + +from pydeconz.light import Light, Siren from homeassistant.components.switch import DOMAIN, SwitchEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as DECONZ_DOMAIN, POWER_PLUGS from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up switches for deCONZ component. Switches are based on the same device class as lights in deCONZ. @@ -32,7 +43,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entity_registry.async_remove(entity_id) @callback - def async_add_switch(lights=gateway.api.lights.values()): + def async_add_switch( + lights: list[Light] | ValuesView[Light] = gateway.api.lights.values(), + ) -> None: """Add switch from deCONZ.""" entities = [] @@ -62,16 +75,17 @@ class DeconzPowerPlug(DeconzDevice, SwitchEntity): """Representation of a deCONZ power plug.""" TYPE = DOMAIN + _device: Light @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" - return self._device.state + return self._device.state # type: ignore[no-any-return] - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on switch.""" await self._device.set_state(on=True) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off switch.""" await self._device.set_state(on=False) diff --git a/homeassistant/components/deconz/translations/ja.json b/homeassistant/components/deconz/translations/ja.json index be03f3b2036..4b33589ddfc 100644 --- a/homeassistant/components/deconz/translations/ja.json +++ b/homeassistant/components/deconz/translations/ja.json @@ -1,14 +1,97 @@ { "config": { "abort": { - "already_configured": "\u30d6\u30ea\u30c3\u30b8\u306f\u3059\u3067\u306b\u69cb\u6210\u3055\u308c\u3066\u3044\u307e\u3059" + "already_configured": "\u30d6\u30ea\u30c3\u30b8\u306f\u3059\u3067\u306b\u69cb\u6210\u3055\u308c\u3066\u3044\u307e\u3059", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "no_bridges": "deCONZ\u30d6\u30ea\u30c3\u30b8\u306f\u691c\u51fa\u3055\u308c\u307e\u305b\u3093\u3067\u3057\u305f", + "no_hardware_available": "deCONZ\u306b\u63a5\u7d9a\u3055\u308c\u3066\u3044\u308b\u7121\u7dda\u30cf\u30fc\u30c9\u30a6\u30a7\u30a2\u304c\u3042\u308a\u307e\u305b\u3093", + "not_deconz_bridge": "deCONZ bridge\u3067\u306f\u3042\u308a\u307e\u305b\u3093", + "updated_instance": "\u65b0\u3057\u3044\u30db\u30b9\u30c8\u30a2\u30c9\u30ec\u30b9\u3067deCONZ\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u3092\u66f4\u65b0\u3057\u307e\u3057\u305f" }, "error": { "no_key": "API\u30ad\u30fc\u3092\u53d6\u5f97\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f" }, + "flow_title": "{host}", "step": { + "hassio_confirm": { + "description": "\u30a2\u30c9\u30aa\u30f3 {addon} \u304c\u3001\u63d0\u4f9b\u3059\u308bdeCONZ gateway\u306b\u63a5\u7d9a\u3059\u308b\u3088\u3046\u306bHome Assistant\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u304b\uff1f", + "title": "Home Assistant\u30a2\u30c9\u30aa\u30f3\u7d4c\u7531\u306e\u3001deCONZ Zigbee gateway" + }, "link": { + "description": "deCONZ gateway\u306e\u30ed\u30c3\u30af\u3092\u89e3\u9664\u3057\u3066\u3001Home Assistant\u306b\u767b\u9332\u3057\u307e\u3059\u3002 \n\n1. deCONZ\u8a2d\u5b9a -> \u30b2\u30fc\u30c8\u30a6\u30a7\u30a4 -> \u8a73\u7d30\u306b\u79fb\u52d5\n2. \"\u30a2\u30d7\u30ea\u306e\u8a8d\u8a3c(Authenticate app)\" \u30dc\u30bf\u30f3\u3092\u62bc\u3059", "title": "deCONZ\u3068\u30ea\u30f3\u30af\u3059\u308b" + }, + "manual_input": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + } + }, + "user": { + "data": { + "host": "\u691c\u51fa\u3055\u308c\u305fdeCONZ gateway\u3092\u9078\u629e\u3057\u307e\u3059" + } + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "\u4e21\u65b9\u306e\u30dc\u30bf\u30f3", + "bottom_buttons": "\u4e0b\u90e8\u306e\u30dc\u30bf\u30f3", + "button_1": "1\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "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", + "button_8": "8\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "close": "\u30af\u30ed\u30fc\u30ba", + "dim_down": "\u8584\u6697\u304f\u3059\u308b", + "dim_up": "\u5fae\u304b\u306b\u660e\u308b\u304f\u3059\u308b", + "left": "\u5de6", + "open": "\u30aa\u30fc\u30d7\u30f3", + "right": "\u53f3", + "side_1": "\u30b5\u30a4\u30c91", + "side_2": "\u30b5\u30a4\u30c92", + "side_3": "\u30b5\u30a4\u30c93", + "side_4": "\u30b5\u30a4\u30c94", + "side_5": "\u30b5\u30a4\u30c95", + "side_6": "\u30b5\u30a4\u30c96", + "top_buttons": "\u30c8\u30c3\u30d7\u30dc\u30bf\u30f3", + "turn_off": "\u30aa\u30d5\u306b\u3059\u308b", + "turn_on": "\u30aa\u30f3\u306b\u3059\u308b" + }, + "trigger_type": { + "remote_awakened": "\u30c7\u30d0\u30a4\u30b9\u304c\u76ee\u899a\u3081\u305f", + "remote_button_double_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u3092\u30c0\u30d6\u30eb\u30af\u30ea\u30c3\u30af", + "remote_button_long_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u3092\u62bc\u3057\u7d9a\u3051\u308b", + "remote_button_quadruple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30924\u56de(quadruple)\u30af\u30ea\u30c3\u30af", + "remote_button_quintuple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30925\u56de(quintuple)\u30af\u30ea\u30c3\u30af", + "remote_button_rotated_fast": "\u30dc\u30bf\u30f3\u304c\u9ad8\u901f\u56de\u8ee2\u3059\u308b \"{subtype}\"", + "remote_button_short_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u304c\u62bc\u3055\u308c\u307e\u3057\u305f\u3002", + "remote_button_triple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30923\u56de\u30af\u30ea\u30c3\u30af", + "remote_double_tap": "\u30c7\u30d0\u30a4\u30b9 \"{subtype}\" \u304c\u30c0\u30d6\u30eb\u30bf\u30c3\u30d7\u3055\u308c\u307e\u3057\u305f", + "remote_double_tap_any_side": "\u30c7\u30d0\u30a4\u30b9\u306e\u3044\u305a\u308c\u304b\u306e\u9762\u3092\u30c0\u30d6\u30eb\u30bf\u30c3\u30d7\u3057\u305f", + "remote_falling": "\u81ea\u7531\u843d\u4e0b\u6642\u306e\u30c7\u30d0\u30a4\u30b9(Device in free fall)", + "remote_flip_180_degrees": "\u30c7\u30d0\u30a4\u30b9\u304c180\u5ea6\u53cd\u8ee2", + "remote_flip_90_degrees": "\u30c7\u30d0\u30a4\u30b9\u304c90\u5ea6\u53cd\u8ee2", + "remote_gyro_activated": "\u30c7\u30d0\u30a4\u30b9\u304c\u63fa\u308c\u308b", + "remote_moved_any_side": "\u30c7\u30d0\u30a4\u30b9\u304c\u4efb\u610f\u306e\u9762\u3092\u4e0a\u306b\u3057\u3066\u79fb\u52d5\u3057\u305f", + "remote_turned_clockwise": "\u30c7\u30d0\u30a4\u30b9\u304c\u6642\u8a08\u56de\u308a\u306b", + "remote_turned_counter_clockwise": "\u30c7\u30d0\u30a4\u30b9\u304c\u53cd\u6642\u8a08\u56de\u308a\u306b" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ CLIP\u30bb\u30f3\u30b5\u30fc\u3092\u8a31\u53ef\u3059\u308b", + "allow_deconz_groups": "deCONZ light\u30b0\u30eb\u30fc\u30d7\u3092\u8a31\u53ef\u3059\u308b", + "allow_new_devices": "\u65b0\u3057\u3044\u30c7\u30d0\u30a4\u30b9\u306e\u81ea\u52d5\u8ffd\u52a0\u3092\u8a31\u53ef\u3059\u308b" + }, + "description": "deCONZ \u30c7\u30d0\u30a4\u30b9\u30bf\u30a4\u30d7\u306e\u53ef\u8996\u6027\u3092\u8a2d\u5b9a\u3057\u307e\u3059", + "title": "deCONZ\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" } } } diff --git a/homeassistant/components/deconz/translations/tr.json b/homeassistant/components/deconz/translations/tr.json index 22eea1278d7..5d259bd6be8 100644 --- a/homeassistant/components/deconz/translations/tr.json +++ b/homeassistant/components/deconz/translations/tr.json @@ -2,12 +2,28 @@ "config": { "abort": { "already_configured": "K\u00f6pr\u00fc zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", - "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor" + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "no_bridges": "DeCONZ k\u00f6pr\u00fcs\u00fc bulunamad\u0131", + "no_hardware_available": "deCONZ'a ba\u011fl\u0131 radyo donan\u0131m\u0131 yok", + "not_deconz_bridge": "deCONZ k\u00f6pr\u00fcs\u00fc de\u011fil", + "updated_instance": "DeCONZ yeni ana bilgisayar adresiyle g\u00fcncelle\u015ftirildi" }, + "error": { + "no_key": "API anahtar\u0131 al\u0131namad\u0131" + }, + "flow_title": "{host}", "step": { + "hassio_confirm": { + "description": "{addon} taraf\u0131ndan sa\u011flanan deCONZ a\u011f ge\u00e7idine ba\u011flanacak \u015fekilde yap\u0131land\u0131rmak istiyor musunuz?", + "title": "Home Assistant eklentisi arac\u0131l\u0131\u011f\u0131yla deCONZ Zigbee a\u011f ge\u00e7idi" + }, + "link": { + "description": "Home Assistant'a kaydolmak i\u00e7in deCONZ a\u011f ge\u00e7idinizin kilidini a\u00e7\u0131n. \n\n 1. deCONZ Ayarlar\u0131 - > A\u011f Ge\u00e7idi - > Geli\u015fmi\u015f se\u00e7ene\u011fine gidin\n 2. \"Uygulaman\u0131n kimli\u011fini do\u011frula\" d\u00fc\u011fmesine bas\u0131n", + "title": "deCONZ ile ba\u011flant\u0131" + }, "manual_input": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "port": "Port" } }, @@ -20,17 +36,51 @@ }, "device_automation": { "trigger_subtype": { + "both_buttons": "\u00c7ift d\u00fc\u011fmeler", + "bottom_buttons": "Alt d\u00fc\u011fmeler", + "button_1": "\u0130lk d\u00fc\u011fme", + "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", + "button_8": "Sekizinci d\u00fc\u011fme", + "close": "Kapat", + "dim_down": "K\u0131sma", + "dim_up": "A\u00e7ma", + "left": "Sol", + "open": "A\u00e7\u0131k", + "right": "Sa\u011f", + "side_1": "Yan 1", + "side_2": "Yan 2", + "side_3": "Yan 3", "side_4": "Yan 4", "side_5": "Yan 5", - "side_6": "Yan 6" + "side_6": "Yan 6", + "top_buttons": "\u00dcst d\u00fc\u011fmeler", + "turn_off": "Kapat", + "turn_on": "A\u00e7\u0131n" }, "trigger_type": { "remote_awakened": "Cihaz uyand\u0131", + "remote_button_double_press": "\" {subtype} \" d\u00fc\u011fmesine \u00e7ift t\u0131kland\u0131", + "remote_button_long_press": "\" {subtype} \" d\u00fc\u011fmesi s\u00fcrekli bas\u0131l\u0131", + "remote_button_long_release": "\" {subtype} \" d\u00fc\u011fmesi uzun bas\u0131ld\u0131ktan sonra b\u0131rak\u0131ld\u0131", + "remote_button_quadruple_press": "\" {subtype} \" d\u00fc\u011fmesi d\u00f6rt kez t\u0131kland\u0131", + "remote_button_quintuple_press": "\" {subtype} \" d\u00fc\u011fmesi be\u015f kez t\u0131kland\u0131", + "remote_button_rotated": "D\u00fc\u011fme d\u00f6nd\u00fcr\u00fcld\u00fc \" {subtype} \"", + "remote_button_rotated_fast": "D\u00fc\u011fme h\u0131zl\u0131 d\u00f6nd\u00fcr\u00fcld\u00fc \" {subtype} \"", + "remote_button_rotation_stopped": "{subtype} \" d\u00fc\u011fmesinin d\u00f6nd\u00fcr\u00fclmesi durduruldu", + "remote_button_short_press": "\" {subtype} \" d\u00fc\u011fmesine bas\u0131ld\u0131", + "remote_button_short_release": "\" {subtype} \" d\u00fc\u011fmesi b\u0131rak\u0131ld\u0131", + "remote_button_triple_press": "\" {subtype} \" d\u00fc\u011fmesine \u00fc\u00e7 kez t\u0131kland\u0131", "remote_double_tap": "\" {subtype} \" cihaz\u0131na iki kez hafif\u00e7e vuruldu", "remote_double_tap_any_side": "Cihaz herhangi bir tarafta \u00e7ift dokundu", "remote_falling": "Serbest d\u00fc\u015f\u00fc\u015fte cihaz", "remote_flip_180_degrees": "Cihaz 180 derece d\u00f6nd\u00fcr\u00fcld\u00fc", "remote_flip_90_degrees": "Cihaz 90 derece d\u00f6nd\u00fcr\u00fcld\u00fc", + "remote_gyro_activated": "Cihaz salland\u0131", "remote_moved": "Cihaz \" {subtype} \" yukar\u0131 ta\u015f\u0131nd\u0131", "remote_moved_any_side": "Cihaz herhangi bir taraf\u0131 yukar\u0131 gelecek \u015fekilde ta\u015f\u0131nd\u0131", "remote_rotate_from_side_1": "Cihaz, \"1. taraftan\" \" {subtype} \" e d\u00f6nd\u00fcr\u00fcld\u00fc", @@ -38,6 +88,7 @@ "remote_rotate_from_side_3": "Cihaz \"3. taraftan\" \" {subtype} \" e d\u00f6nd\u00fcr\u00fcld\u00fc", "remote_rotate_from_side_4": "Cihaz, \"4. taraf\" dan \" {subtype} \" e d\u00f6nd\u00fcr\u00fcld\u00fc", "remote_rotate_from_side_5": "Cihaz, \"5. taraf\" dan \" {subtype} \" e d\u00f6nd\u00fcr\u00fcld\u00fc", + "remote_rotate_from_side_6": "Cihaz \"yan 6\" \" {subtype} \" konumuna d\u00f6nd\u00fcr\u00fcld\u00fc", "remote_turned_clockwise": "Cihaz saat y\u00f6n\u00fcnde d\u00f6nd\u00fc", "remote_turned_counter_clockwise": "Cihaz saat y\u00f6n\u00fcn\u00fcn tersine d\u00f6nd\u00fc" } @@ -45,6 +96,12 @@ "options": { "step": { "deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ CLIP sens\u00f6rlerine izin ver", + "allow_deconz_groups": "deCONZ \u0131\u015f\u0131k gruplar\u0131na izin ver", + "allow_new_devices": "Yeni cihazlar\u0131n otomatik eklenmesine izin ver" + }, + "description": "deCONZ cihaz t\u00fcrlerinin g\u00f6r\u00fcn\u00fcrl\u00fc\u011f\u00fcn\u00fc yap\u0131land\u0131r\u0131n", "title": "deCONZ se\u00e7enekleri" } } diff --git a/homeassistant/components/delijn/sensor.py b/homeassistant/components/delijn/sensor.py index 395c2d93dff..310e6a7f7fc 100644 --- a/homeassistant/components/delijn/sensor.py +++ b/homeassistant/components/delijn/sensor.py @@ -82,6 +82,10 @@ class DeLijnPublicTransportSensor(SensorEntity): self._attributes["stopname"] = self._name + if not self.line.passages: + self._available = False + return + try: first = self.line.passages[0] if first["due_at_realtime"] is not None: @@ -97,8 +101,8 @@ class DeLijnPublicTransportSensor(SensorEntity): self._attributes["is_realtime"] = first["is_realtime"] self._attributes["next_passages"] = self.line.passages self._available = True - except (KeyError, IndexError): - _LOGGER.error("Invalid data received from De Lijn") + except (KeyError) as error: + _LOGGER.error("Invalid data received from De Lijn: %s", error) self._available = False @property diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index db53c1c528f..e4ec3bd671c 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -1,13 +1,20 @@ """Set up the demo environment that mimics interaction with devices.""" import asyncio +import datetime +from random import random from homeassistant import bootstrap, config_entries +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, +) from homeassistant.const import ( ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START, SOUND_PRESSURE_DB, ) import homeassistant.core as ha +import homeassistant.util.dt as dt_util DOMAIN = "demo" @@ -15,6 +22,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ "air_quality", "alarm_control_panel", "binary_sensor", + "button", "camera", "climate", "cover", @@ -149,6 +157,82 @@ async def async_setup(hass, config): return True +def _generate_mean_statistics(start, end, init_value, max_diff): + statistics = [] + mean = init_value + now = start + while now < end: + mean = mean + random() * max_diff - max_diff / 2 + statistics.append( + { + "start": now, + "mean": mean, + "min": mean - random() * max_diff, + "max": mean + random() * max_diff, + } + ) + now = now + datetime.timedelta(hours=1) + + return statistics + + +def _generate_sum_statistics(start, end, init_value, max_diff): + statistics = [] + now = start + sum_ = init_value + while now < end: + sum_ = sum_ + random() * max_diff + statistics.append( + { + "start": now, + "sum": sum_, + } + ) + now = now + datetime.timedelta(hours=1) + + return statistics + + +async def _insert_statistics(hass): + """Insert some fake statistics.""" + now = dt_util.now() + yesterday = now - datetime.timedelta(days=1) + yesterday_midnight = yesterday.replace(hour=0, minute=0, second=0, microsecond=0) + + # Fake yesterday's temperatures + metadata = { + "source": DOMAIN, + "statistic_id": f"{DOMAIN}:temperature_outdoor", + "unit_of_measurement": "°C", + "has_mean": True, + "has_sum": False, + } + statistics = _generate_mean_statistics( + yesterday_midnight, yesterday_midnight + datetime.timedelta(days=1), 15, 1 + ) + async_add_external_statistics(hass, metadata, statistics) + + # Fake yesterday's energy consumption + metadata = { + "source": DOMAIN, + "statistic_id": f"{DOMAIN}:energy_consumption", + "unit_of_measurement": "kWh", + "has_mean": False, + "has_sum": True, + } + statistic_id = f"{DOMAIN}:energy_consumption" + sum_ = 0 + last_stats = await hass.async_add_executor_job( + get_last_statistics, hass, 1, statistic_id, True + ) + if "domain:energy_consumption" in last_stats: + sum_ = last_stats["domain.electricity_total"]["sum"] or 0 + statistics = _generate_sum_statistics( + yesterday_midnight, yesterday_midnight + datetime.timedelta(days=1), sum_, 1 + ) + async_add_external_statistics(hass, metadata, statistics) + + async def async_setup_entry(hass, config_entry): """Set the config entry up.""" # Set up demo platforms with config entry @@ -156,6 +240,8 @@ async def async_setup_entry(hass, config_entry): hass.async_create_task( hass.config_entries.async_forward_entry_setup(config_entry, platform) ) + if "recorder" in hass.config.components: + await _insert_statistics(hass) return True diff --git a/homeassistant/components/demo/binary_sensor.py b/homeassistant/components/demo/binary_sensor.py index 7cf96d33f5c..8710308fc67 100644 --- a/homeassistant/components/demo/binary_sensor.py +++ b/homeassistant/components/demo/binary_sensor.py @@ -1,7 +1,6 @@ """Demo platform that has two fake binary sensors.""" from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_MOTION, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.helpers.entity import DeviceInfo @@ -14,10 +13,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities( [ DemoBinarySensor( - "binary_1", "Basement Floor Wet", False, DEVICE_CLASS_MOISTURE + "binary_1", + "Basement Floor Wet", + False, + BinarySensorDeviceClass.MOISTURE, ), DemoBinarySensor( - "binary_2", "Movement Backyard", True, DEVICE_CLASS_MOTION + "binary_2", "Movement Backyard", True, BinarySensorDeviceClass.MOTION ), ] ) @@ -31,7 +33,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class DemoBinarySensor(BinarySensorEntity): """representation of a Demo binary sensor.""" - def __init__(self, unique_id, name, state, device_class): + def __init__( + self, + unique_id: str, + name: str, + state: bool, + device_class: BinarySensorDeviceClass, + ) -> None: """Initialize the demo sensor.""" self._unique_id = unique_id self._name = name @@ -55,7 +63,7 @@ class DemoBinarySensor(BinarySensorEntity): return self._unique_id @property - def device_class(self): + def device_class(self) -> BinarySensorDeviceClass: """Return the class of this sensor.""" return self._sensor_type diff --git a/homeassistant/components/demo/button.py b/homeassistant/components/demo/button.py new file mode 100644 index 00000000000..9ef54f30db3 --- /dev/null +++ b/homeassistant/components/demo/button.py @@ -0,0 +1,65 @@ +"""Demo platform that offers a fake button entity.""" +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import DOMAIN + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType = None, +) -> None: + """Set up the demo Button entity.""" + async_add_entities( + [ + DemoButton( + unique_id="push", + name="Push", + icon="mdi:gesture-tap-button", + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoButton(ButtonEntity): + """Representation of a demo button entity.""" + + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + name: str, + icon: str, + ) -> None: + """Initialize the Demo button entity.""" + self._attr_unique_id = unique_id + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_icon = icon + self._attr_device_info = { + "identifiers": {(DOMAIN, unique_id)}, + "name": name, + } + + async def async_press(self) -> None: + """Send out a persistent notification.""" + self.hass.components.persistent_notification.async_create( + "Button pressed", title="Button" + ) diff --git a/homeassistant/components/demo/camera.py b/homeassistant/components/demo/camera.py index 572a5bf331e..5131741617e 100644 --- a/homeassistant/components/demo/camera.py +++ b/homeassistant/components/demo/camera.py @@ -24,13 +24,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class DemoCamera(Camera): """The representation of a Demo camera.""" + _attr_is_streaming = True + _attr_motion_detection_enabled = False + _attr_supported_features = SUPPORT_ON_OFF + def __init__(self, name, content_type): """Initialize demo camera component.""" super().__init__() - self._name = name + self._attr_name = name self.content_type = content_type - self._motion_status = False - self.is_streaming = True self._images_index = 0 async def async_camera_image( @@ -43,42 +45,24 @@ class DemoCamera(Camera): return await self.hass.async_add_executor_job(image_path.read_bytes) - @property - def name(self): - """Return the name of this camera.""" - return self._name - - @property - def supported_features(self): - """Camera support turn on/off features.""" - return SUPPORT_ON_OFF - - @property - def is_on(self): - """Whether camera is on (streaming).""" - return self.is_streaming - - @property - def motion_detection_enabled(self): - """Camera Motion Detection Status.""" - return self._motion_status - async def async_enable_motion_detection(self): """Enable the Motion detection in base station (Arm).""" - self._motion_status = True + self._attr_motion_detection_enabled = True self.async_write_ha_state() async def async_disable_motion_detection(self): """Disable the motion detection in base station (Disarm).""" - self._motion_status = False + self._attr_motion_detection_enabled = False self.async_write_ha_state() async def async_turn_off(self): """Turn off camera.""" - self.is_streaming = False + self._attr_is_streaming = False + self._attr_is_on = False self.async_write_ha_state() async def async_turn_on(self): """Turn on camera.""" - self.is_streaming = True + self._attr_is_streaming = True + self._attr_is_on = True self.async_write_ha_state() diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py index 444b6a2a90c..b4ca5541690 100644 --- a/homeassistant/components/demo/cover.py +++ b/homeassistant/components/demo/cover.py @@ -1,4 +1,6 @@ """Demo platform for the cover component.""" +from __future__ import annotations + from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, @@ -8,6 +10,7 @@ from homeassistant.components.cover import ( SUPPORT_OPEN_TILT, SUPPORT_SET_TILT_POSITION, SUPPORT_STOP_TILT, + CoverDeviceClass, CoverEntity, ) from homeassistant.core import callback @@ -28,7 +31,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= hass, "cover_4", "Garage Door", - device_class="garage", + device_class=CoverDeviceClass.GARAGE, supported_features=(SUPPORT_OPEN | SUPPORT_CLOSE), ), DemoCover( @@ -138,7 +141,7 @@ class DemoCover(CoverEntity): return self._is_opening @property - def device_class(self): + def device_class(self) -> CoverDeviceClass | None: """Return the class of this device, from component DEVICE_CLASSES.""" return self._device_class diff --git a/homeassistant/components/demo/humidifier.py b/homeassistant/components/demo/humidifier.py index 7ee5c0fc6ef..9065c5971fb 100644 --- a/homeassistant/components/demo/humidifier.py +++ b/homeassistant/components/demo/humidifier.py @@ -1,12 +1,8 @@ """Demo platform that offers a fake humidifier device.""" from __future__ import annotations -from homeassistant.components.humidifier import HumidifierEntity -from homeassistant.components.humidifier.const import ( - DEVICE_CLASS_DEHUMIDIFIER, - DEVICE_CLASS_HUMIDIFIER, - SUPPORT_MODES, -) +from homeassistant.components.humidifier import HumidifierDeviceClass, HumidifierEntity +from homeassistant.components.humidifier.const import SUPPORT_MODES SUPPORT_FLAGS = 0 @@ -19,13 +15,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= name="Humidifier", mode=None, target_humidity=68, - device_class=DEVICE_CLASS_HUMIDIFIER, + device_class=HumidifierDeviceClass.HUMIDIFIER, ), DemoHumidifier( name="Dehumidifier", mode=None, target_humidity=54, - device_class=DEVICE_CLASS_DEHUMIDIFIER, + device_class=HumidifierDeviceClass.DEHUMIDIFIER, ), DemoHumidifier( name="Hygrostat", @@ -54,7 +50,7 @@ class DemoHumidifier(HumidifierEntity): target_humidity: int, available_modes: list[str] | None = None, is_on: bool = True, - device_class: str | None = None, + device_class: HumidifierDeviceClass | None = None, ) -> None: """Initialize the humidifier device.""" self._attr_name = name diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py index 29e7e1395ee..d14f7ffba0e 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -103,7 +103,7 @@ class DemoLight(LightEntity): state, available=False, brightness=180, - ct=None, + ct=None, # pylint: disable=invalid-name effect_list=None, effect=None, hs_color=None, diff --git a/homeassistant/components/demo/manifest.json b/homeassistant/components/demo/manifest.json index 0997868fbfd..df6fa494079 100644 --- a/homeassistant/components/demo/manifest.json +++ b/homeassistant/components/demo/manifest.json @@ -2,7 +2,8 @@ "domain": "demo", "name": "Demo", "documentation": "https://www.home-assistant.io/integrations/demo", - "dependencies": ["conversation", "zone", "group"], + "after_dependencies": ["recorder"], + "dependencies": ["conversation", "group", "zone"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal", "iot_class": "calculated" diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index d471bdac85f..2625d8bca05 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -1,10 +1,7 @@ """Demo platform that offers a fake Number entity.""" from __future__ import annotations -from typing import Literal - -from homeassistant.components.number import NumberEntity -from homeassistant.components.number.const import MODE_AUTO, MODE_BOX, MODE_SLIDER +from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.helpers.entity import DeviceInfo @@ -21,7 +18,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= 42.0, "mdi:volume-high", False, - mode=MODE_SLIDER, + mode=NumberMode.SLIDER, ), DemoNumber( "pwm1", @@ -32,7 +29,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= 0.0, 1.0, 0.01, - MODE_BOX, + NumberMode.BOX, ), DemoNumber( "large_range", @@ -78,7 +75,7 @@ class DemoNumber(NumberEntity): min_value: float | None = None, max_value: float | None = None, step: float | None = None, - mode: Literal["auto", "box", "slider"] = MODE_AUTO, + mode: NumberMode = NumberMode.AUTO, ) -> None: """Initialize the Demo Number entity.""" self._attr_assumed_state = assumed diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index 413017ad2f1..20631e3eee6 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -3,17 +3,15 @@ from __future__ import annotations from typing import Any -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, CONCENTRATION_PARTS_PER_MILLION, - DEVICE_CLASS_CO, - DEVICE_CLASS_CO2, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, ENERGY_KILO_WATT_HOUR, PERCENTAGE, POWER_WATT, @@ -40,8 +38,8 @@ async def async_setup_platform( "sensor_1", "Outside Temperature", 15.6, - DEVICE_CLASS_TEMPERATURE, - STATE_CLASS_MEASUREMENT, + SensorDeviceClass.TEMPERATURE, + SensorStateClass.MEASUREMENT, TEMP_CELSIUS, 12, ), @@ -49,8 +47,8 @@ async def async_setup_platform( "sensor_2", "Outside Humidity", 54, - DEVICE_CLASS_HUMIDITY, - STATE_CLASS_MEASUREMENT, + SensorDeviceClass.HUMIDITY, + SensorStateClass.MEASUREMENT, PERCENTAGE, None, ), @@ -58,8 +56,8 @@ async def async_setup_platform( "sensor_3", "Carbon monoxide", 54, - DEVICE_CLASS_CO, - STATE_CLASS_MEASUREMENT, + SensorDeviceClass.CO, + SensorStateClass.MEASUREMENT, CONCENTRATION_PARTS_PER_MILLION, None, ), @@ -67,8 +65,8 @@ async def async_setup_platform( "sensor_4", "Carbon dioxide", 54, - DEVICE_CLASS_CO2, - STATE_CLASS_MEASUREMENT, + SensorDeviceClass.CO2, + SensorStateClass.MEASUREMENT, CONCENTRATION_PARTS_PER_MILLION, 14, ), @@ -76,8 +74,8 @@ async def async_setup_platform( "sensor_5", "Power consumption", 100, - DEVICE_CLASS_POWER, - STATE_CLASS_MEASUREMENT, + SensorDeviceClass.POWER, + SensorStateClass.MEASUREMENT, POWER_WATT, None, ), @@ -85,8 +83,8 @@ async def async_setup_platform( "sensor_6", "Today energy", 15, - DEVICE_CLASS_ENERGY, - STATE_CLASS_MEASUREMENT, + SensorDeviceClass.ENERGY, + SensorStateClass.MEASUREMENT, ENERGY_KILO_WATT_HOUR, None, ), @@ -113,8 +111,8 @@ class DemoSensor(SensorEntity): unique_id: str, name: str, state: StateType, - device_class: str | None, - state_class: str | None, + device_class: SensorDeviceClass, + state_class: SensorStateClass | None, unit_of_measurement: str | None, battery: StateType, ) -> None: diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py index dd969010bd7..09389f7c8cd 100644 --- a/homeassistant/components/demo/switch.py +++ b/homeassistant/components/demo/switch.py @@ -1,7 +1,7 @@ """Demo platform that has two fake switches.""" from __future__ import annotations -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.helpers.entity import DeviceInfo @@ -19,7 +19,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= False, "mdi:air-conditioner", False, - device_class="outlet", + device_class=SwitchDeviceClass.OUTLET, ), ] ) @@ -42,7 +42,7 @@ class DemoSwitch(SwitchEntity): state: bool, icon: str | None, assumed: bool, - device_class: str | None = None, + device_class: SwitchDeviceClass | None = None, ) -> None: """Initialize the Demo switch.""" self._attr_assumed_state = assumed diff --git a/homeassistant/components/demo/translations/de.json b/homeassistant/components/demo/translations/de.json index 74178521138..fd6239fa787 100644 --- a/homeassistant/components/demo/translations/de.json +++ b/homeassistant/components/demo/translations/de.json @@ -1,12 +1,6 @@ { "options": { "step": { - "init": { - "data": { - "one": "eins", - "other": "andere" - } - }, "options_1": { "data": { "bool": "Optionaler Boolescher Wert", diff --git a/homeassistant/components/demo/translations/hu.json b/homeassistant/components/demo/translations/hu.json index e77c21294b8..87810814aac 100644 --- a/homeassistant/components/demo/translations/hu.json +++ b/homeassistant/components/demo/translations/hu.json @@ -9,7 +9,7 @@ }, "options_1": { "data": { - "bool": "Opcion\u00e1lis logikai \u00e9rt\u00e9k", + "bool": "Opcion\u00e1lis logikai v\u00e1lt\u00f3", "constant": "\u00c1lland\u00f3", "int": "Numerikus bemenet" } diff --git a/homeassistant/components/demo/translations/ja.json b/homeassistant/components/demo/translations/ja.json index 713cdd6ae35..d987ee472e2 100644 --- a/homeassistant/components/demo/translations/ja.json +++ b/homeassistant/components/demo/translations/ja.json @@ -1,3 +1,21 @@ { + "options": { + "step": { + "options_1": { + "data": { + "bool": "\u30aa\u30d7\u30b7\u30e7\u30f3\u306e\u771f\u507d\u5024(booleans)", + "constant": "\u5b9a\u6570", + "int": "\u6570\u5024\u5165\u529b" + } + }, + "options_2": { + "data": { + "multi": "\u30de\u30eb\u30c1\u30bb\u30ec\u30af\u30c8", + "select": "\u9078\u629e\u80a2\u4e00\u3064\u3092\u9078\u629e", + "string": "\u6587\u5b57\u5217\u5024" + } + } + } + }, "title": "\u30c7\u30e2" } \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.id.json b/homeassistant/components/demo/translations/select.id.json new file mode 100644 index 00000000000..7f3e9109995 --- /dev/null +++ b/homeassistant/components/demo/translations/select.id.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Kecepatan Cahaya", + "ludicrous_speed": "Kecepatan Menggelikan", + "ridiculous_speed": "Kecepatan Konyol" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.ja.json b/homeassistant/components/demo/translations/select.ja.json new file mode 100644 index 00000000000..92f56768485 --- /dev/null +++ b/homeassistant/components/demo/translations/select.ja.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "\u30e9\u30a4\u30c8\u306e\u901f\u5ea6", + "ludicrous_speed": "\u3070\u304b\u3052\u305f\u901f\u5ea6", + "ridiculous_speed": "\u3068\u3093\u3067\u3082\u306a\u3044\u901f\u5ea6" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.tr.json b/homeassistant/components/demo/translations/select.tr.json new file mode 100644 index 00000000000..b24f9d925b9 --- /dev/null +++ b/homeassistant/components/demo/translations/select.tr.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "I\u015f\u0131k h\u0131z\u0131", + "ludicrous_speed": "Sa\u00e7ma H\u0131z", + "ridiculous_speed": "Anlams\u0131z H\u0131z" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/tr.json b/homeassistant/components/demo/translations/tr.json index 1ca389b0b97..1eea23e1bc5 100644 --- a/homeassistant/components/demo/translations/tr.json +++ b/homeassistant/components/demo/translations/tr.json @@ -1,9 +1,24 @@ { "options": { "step": { + "init": { + "data": { + "one": "Bo\u015f", + "other": "Bo\u015f" + } + }, "options_1": { "data": { - "constant": "Sabit" + "bool": "\u0130ste\u011fe ba\u011fl\u0131 boolean", + "constant": "Sabit", + "int": "Say\u0131sal giri\u015f" + } + }, + "options_2": { + "data": { + "multi": "\u00c7oklu se\u00e7im", + "select": "Bir se\u00e7enek se\u00e7in", + "string": "Dize de\u011feri" } } } diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py index 818c005b1cd..d58bbe963d7 100644 --- a/homeassistant/components/denonavr/__init__.py +++ b/homeassistant/components/denonavr/__init__.py @@ -4,7 +4,7 @@ import logging from denonavr.exceptions import AvrNetworkError, AvrTimoutError from homeassistant import config_entries, core -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, Platform from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er from homeassistant.helpers.httpx_client import get_async_client @@ -23,7 +23,7 @@ from .receiver import ConnectDenonAVR CONF_RECEIVER = "receiver" UNDO_UPDATE_LISTENER = "undo_update_listener" -PLATFORMS = ["media_player"] +PLATFORMS = [Platform.MEDIA_PLAYER] _LOGGER = logging.getLogger(__name__) @@ -72,7 +72,7 @@ async def async_unload_entry( hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() # Remove zone2 and zone3 entities if needed - entity_registry = await er.async_get_registry(hass) + entity_registry = er.async_get(hass) entries = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) unique_id = config_entry.unique_id or config_entry.entry_id zone2_id = f"{unique_id}-Zone2" diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py index ffb73327d31..2d5cef14f5b 100644 --- a/homeassistant/components/denonavr/config_flow.py +++ b/homeassistant/components/denonavr/config_flow.py @@ -211,7 +211,7 @@ class DenonAvrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): }, ) - async def async_step_ssdp(self, discovery_info: dict[str, Any]) -> FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered Denon AVR. This flow is triggered by the SSDP component. It will check if the @@ -219,21 +219,23 @@ class DenonAvrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """ # Filter out non-Denon AVRs#1 if ( - discovery_info.get(ssdp.ATTR_UPNP_MANUFACTURER) + discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) not in SUPPORTED_MANUFACTURERS ): return self.async_abort(reason="not_denonavr_manufacturer") # Check if required information is present to set the unique_id if ( - ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info - or ssdp.ATTR_UPNP_SERIAL not in discovery_info + ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info.upnp + or ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp ): return self.async_abort(reason="not_denonavr_missing") - self.model_name = discovery_info[ssdp.ATTR_UPNP_MODEL_NAME].replace("*", "") - self.serial_number = discovery_info[ssdp.ATTR_UPNP_SERIAL] - self.host = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname + self.model_name = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME].replace( + "*", "" + ) + self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + self.host = urlparse(discovery_info.ssdp_location).hostname if self.model_name in IGNORED_MODELS: return self.async_abort(reason="not_denonavr_manufacturer") @@ -245,7 +247,9 @@ class DenonAvrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.context.update( { "title_placeholders": { - "name": discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host) + "name": discovery_info.upnp.get( + ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host + ) } } ) diff --git a/homeassistant/components/denonavr/translations/bg.json b/homeassistant/components/denonavr/translations/bg.json new file mode 100644 index 00000000000..6ec6215c6e1 --- /dev/null +++ b/homeassistant/components/denonavr/translations/bg.json @@ -0,0 +1,15 @@ +{ + "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" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "IP \u0430\u0434\u0440\u0435\u0441" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/denonavr/translations/id.json b/homeassistant/components/denonavr/translations/id.json index 0bafe289842..b2543d1d877 100644 --- a/homeassistant/components/denonavr/translations/id.json +++ b/homeassistant/components/denonavr/translations/id.json @@ -37,6 +37,7 @@ "init": { "data": { "show_all_sources": "Tampilkan semua sumber", + "update_audyssey": "Perbarui pengaturan Audyssey", "zone2": "Siapkan Zona 2", "zone3": "Siapkan Zona 3" }, diff --git a/homeassistant/components/denonavr/translations/ja.json b/homeassistant/components/denonavr/translations/ja.json new file mode 100644 index 00000000000..4300e1f515b --- /dev/null +++ b/homeassistant/components/denonavr/translations/ja.json @@ -0,0 +1,49 @@ +{ + "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\u3002\u3082\u3046\u4e00\u5ea6\u3084\u308a\u76f4\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u4e3b\u96fb\u6e90\u30b1\u30fc\u30d6\u30eb\u3068\u30a4\u30fc\u30b5\u30cd\u30c3\u30c8\u30b1\u30fc\u30d6\u30eb\u3092\u53d6\u308a\u5916\u3057\u3066\u3001\u518d\u63a5\u7d9a\u3059\u308b\u3068\u554f\u984c\u304c\u89e3\u6c7a\u3059\u308b\u3053\u3068\u304c\u3042\u308a\u307e\u3059\u3002", + "not_denonavr_manufacturer": "Denon AVR\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30ec\u30b7\u30fc\u30d0\u30fc\u3067\u306f\u306a\u304f\u3001\u691c\u51fa\u3055\u308c\u305f\u30e1\u30fc\u30ab\u30fc\u304c\u4e00\u81f4\u3057\u307e\u305b\u3093\u3067\u3057\u305f", + "not_denonavr_missing": "Denon AVR\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30ec\u30b7\u30fc\u30d0\u30fc\u3067\u306f\u306a\u304f\u3001\u691c\u51fa\u60c5\u5831\u304c\u5b8c\u5168\u3067\u306f\u3042\u308a\u307e\u305b\u3093" + }, + "error": { + "discovery_error": "Denon AVR\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30ec\u30b7\u30fc\u30d0\u30fc\u306e\u691c\u51fa\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u53d7\u4fe1\u6a5f\u306e\u8ffd\u52a0\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "\u30c7\u30ce\u30f3(Denon)AVR\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30ec\u30b7\u30fc\u30d0\u30fc" + }, + "select": { + "data": { + "select_host": "\u53d7\u4fe1\u6a5f\u306eIP\u30a2\u30c9\u30ec\u30b9" + }, + "description": "\u8ffd\u52a0\u306e\u53d7\u4fe1\u6a5f\u3092\u63a5\u7d9a\u3059\u308b\u5834\u5408\u306f\u3001\u518d\u5ea6\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u5b9f\u884c\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u63a5\u7d9a\u3057\u305f\u3044\u53d7\u4fe1\u6a5f\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "user": { + "data": { + "host": "IP\u30a2\u30c9\u30ec\u30b9" + }, + "description": "\u53d7\u4fe1\u6a5f\u306b\u63a5\u7d9a\u3057\u307e\u3059\u3002IP\u30a2\u30c9\u30ec\u30b9\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u306a\u3044\u5834\u5408\u306f\u3001\u81ea\u52d5\u691c\u51fa\u304c\u4f7f\u7528\u3055\u308c\u307e\u3059", + "title": "\u30c7\u30ce\u30f3(Denon)AVR\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30ec\u30b7\u30fc\u30d0\u30fc" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_all_sources": "\u3059\u3079\u3066\u306e\u30bd\u30fc\u30b9\u3092\u8868\u793a", + "update_audyssey": "Audyssey\u8a2d\u5b9a\u3092\u66f4\u65b0", + "zone2": "\u30be\u30fc\u30f32\u306e\u8a2d\u5b9a", + "zone3": "\u30be\u30fc\u30f33\u306e\u8a2d\u5b9a" + }, + "description": "\u30aa\u30d7\u30b7\u30e7\u30f3\u8a2d\u5b9a\u306e\u6307\u5b9a", + "title": "\u30c7\u30ce\u30f3(Denon)AVR\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30ec\u30b7\u30fc\u30d0\u30fc" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/denonavr/translations/tr.json b/homeassistant/components/denonavr/translations/tr.json index f618d3a3038..2c32de293b8 100644 --- a/homeassistant/components/denonavr/translations/tr.json +++ b/homeassistant/components/denonavr/translations/tr.json @@ -3,13 +3,46 @@ "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\u011flan\u0131lamad\u0131, l\u00fctfen tekrar deneyin, ana g\u00fc\u00e7 ve ethernet kablolar\u0131n\u0131n ba\u011flant\u0131s\u0131n\u0131 kesip yeniden ba\u011flamak yard\u0131mc\u0131 olabilir" + "cannot_connect": "Ba\u011flan\u0131lamad\u0131, l\u00fctfen tekrar deneyin, ana g\u00fc\u00e7 ve ethernet kablolar\u0131n\u0131n ba\u011flant\u0131s\u0131n\u0131 kesip yeniden ba\u011flamak yard\u0131mc\u0131 olabilir", + "not_denonavr_manufacturer": "Denon AVR A\u011f Al\u0131c\u0131s\u0131 de\u011fil, \u00fcreticinin e\u015fle\u015fmedi\u011fi ke\u015ffedildi", + "not_denonavr_missing": "Denon AVR A\u011f Al\u0131c\u0131s\u0131 de\u011fil, ke\u015fif bilgileri tamamlanmad\u0131" }, + "error": { + "discovery_error": "Denon AVR A\u011f Al\u0131c\u0131s\u0131 bulunamad\u0131" + }, + "flow_title": "{name}", "step": { + "confirm": { + "description": "L\u00fctfen al\u0131c\u0131y\u0131 eklemeyi onaylay\u0131n", + "title": "Denon AVR A\u011f Al\u0131c\u0131lar\u0131" + }, + "select": { + "data": { + "select_host": "Al\u0131c\u0131 IP adresi" + }, + "description": "Ek al\u0131c\u0131lar ba\u011flamak istiyorsan\u0131z kurulumu yeniden \u00e7al\u0131\u015ft\u0131r\u0131n", + "title": "Ba\u011flamak istedi\u011finiz al\u0131c\u0131y\u0131 se\u00e7in" + }, "user": { "data": { - "host": "\u0130p Adresi" - } + "host": "IP Adresi" + }, + "description": "Al\u0131c\u0131n\u0131za ba\u011flan\u0131n, IP adresi ayarlanmazsa otomatik bulma kullan\u0131l\u0131r", + "title": "Denon AVR A\u011f Al\u0131c\u0131lar\u0131" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_all_sources": "T\u00fcm kaynaklar\u0131 g\u00f6ster", + "update_audyssey": "Audyssey ayarlar\u0131n\u0131 g\u00fcncelleyin", + "zone2": "B\u00f6lge 2'yi kurun", + "zone3": "B\u00f6lge 3'\u00fc kurun" + }, + "description": "\u0130ste\u011fe ba\u011fl\u0131 ayarlar\u0131 belirtin", + "title": "Denon AVR A\u011f Al\u0131c\u0131lar\u0131" } } } diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 45f5db57a90..f6de217ff2b 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -122,8 +122,7 @@ class DerivativeSensor(RestoreEntity, SensorEntity): async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() - state = await self.async_get_last_state() - if state is not None: + if (state := await self.async_get_last_state()) is not None: try: self._state = Decimal(state.state) except SyntaxError as err: diff --git a/homeassistant/components/device_automation/toggle_entity.py b/homeassistant/components/device_automation/toggle_entity.py index 99473777658..2d0254b9a0a 100644 --- a/homeassistant/components/device_automation/toggle_entity.py +++ b/homeassistant/components/device_automation/toggle_entity.py @@ -127,7 +127,9 @@ async def async_call_action_from_config( @callback -def async_condition_from_config(config: ConfigType) -> condition.ConditionCheckerType: +def async_condition_from_config( + hass: HomeAssistant, config: ConfigType +) -> condition.ConditionCheckerType: """Evaluate state based on configuration.""" if config[CONF_TYPE] == CONF_IS_ON: stat = "on" @@ -141,6 +143,8 @@ def async_condition_from_config(config: ConfigType) -> condition.ConditionChecke if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] + state_config = cv.STATE_CONDITION_SCHEMA(state_config) + state_config = condition.state_validate_config(hass, state_config) return condition.state_from_config(state_config) @@ -163,7 +167,7 @@ async def async_attach_trigger( if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] - state_config = state_trigger.TRIGGER_SCHEMA(state_config) + state_config = await state_trigger.async_validate_trigger_config(hass, state_config) return await state_trigger.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index fa537d4af53..035b1923c4c 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -19,6 +19,7 @@ from .const import ( # noqa: F401 CONF_SCAN_INTERVAL, CONF_TRACK_NEW, DOMAIN, + ENTITY_ID_FORMAT, SOURCE_TYPE_BLUETOOTH, SOURCE_TYPE_BLUETOOTH_LE, SOURCE_TYPE_GPS, diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index 09102372db6..216255b9cb6 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -6,6 +6,7 @@ from typing import Final LOGGER: Final = logging.getLogger(__package__) DOMAIN: Final = "device_tracker" +ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" PLATFORM_TYPE_LEGACY: Final = "legacy" PLATFORM_TYPE_ENTITY: Final = "entity_platform" diff --git a/homeassistant/components/device_tracker/device_condition.py b/homeassistant/components/device_tracker/device_condition.py index afa899444f6..2762a271cab 100644 --- a/homeassistant/components/device_tracker/device_condition.py +++ b/homeassistant/components/device_tracker/device_condition.py @@ -56,12 +56,9 @@ async def async_get_conditions( @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" - if config_validation: - config = CONDITION_SCHEMA(config) - reverse = config[CONF_TYPE] == "is_not_home" @callback diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index 49a52fa887e..926519c2243 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -89,7 +89,7 @@ async def async_attach_trigger( CONF_ZONE: config[CONF_ZONE], CONF_EVENT: event, } - zone_config = zone.TRIGGER_SCHEMA(zone_config) + zone_config = await zone.async_validate_trigger_config(hass, zone_config) return await zone.async_attach_trigger( hass, zone_config, action, automation_info, platform_type="device" ) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 94638c031a3..d81743c530a 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -788,8 +788,7 @@ class Device(RestoreEntity): async def async_added_to_hass(self) -> None: """Add an entity.""" await super().async_added_to_hass() - state = await self.async_get_last_state() - if not state: + if not (state := await self.async_get_last_state()): return self._state = state.state self.last_update_home = state.state == STATE_HOME diff --git a/homeassistant/components/device_tracker/translations/ja.json b/homeassistant/components/device_tracker/translations/ja.json index 6679d6cca06..53302c9eb29 100644 --- a/homeassistant/components/device_tracker/translations/ja.json +++ b/homeassistant/components/device_tracker/translations/ja.json @@ -1,8 +1,19 @@ { + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \u306f\u3001\u5728\u5b85\u3067\u3059", + "is_not_home": "{entity_name} \u306f\u3001\u4e0d\u5728\u3067\u3059" + }, + "trigger_type": { + "enters": "{entity_name} \u304c\u30be\u30fc\u30f3\u306b\u5165\u308b", + "leaves": "{entity_name} \u304c\u30be\u30fc\u30f3\u304b\u3089\u96e2\u308c\u308b" + } + }, "state": { "_": { "home": "\u5728\u5b85", - "not_home": "\u5916\u51fa" + "not_home": "\u96e2\u5e2d(away)" } - } + }, + "title": "\u30c7\u30d0\u30a4\u30b9\u30c8\u30e9\u30c3\u30ab\u30fc" } \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/tr.json b/homeassistant/components/device_tracker/translations/tr.json index 87042b6500e..c520ae1057b 100644 --- a/homeassistant/components/device_tracker/translations/tr.json +++ b/homeassistant/components/device_tracker/translations/tr.json @@ -1,5 +1,9 @@ { "device_automation": { + "condition_type": { + "is_home": "{entity_name} evde", + "is_not_home": "{entity_name} evde de\u011fil" + }, "trigger_type": { "enters": "{entity_name} bir b\u00f6lgeye girdi", "leaves": "{entity_name} bir b\u00f6lgeden ayr\u0131l\u0131yor" diff --git a/homeassistant/components/devolo_home_control/binary_sensor.py b/homeassistant/components/devolo_home_control/binary_sensor.py index c19d74b4c33..4fadc8b5f46 100644 --- a/homeassistant/components/devolo_home_control/binary_sensor.py +++ b/homeassistant/components/devolo_home_control/binary_sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -94,8 +95,12 @@ class DevoloBinaryDeviceEntity(DevoloDeviceEntity, BinarySensorEntity): self._value = self._binary_sensor_property.state + if self._attr_device_class == DEVICE_CLASS_SAFETY: + self._attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + if element_uid.startswith("devolo.WarningBinaryFI:"): self._attr_device_class = DEVICE_CLASS_PROBLEM + self._attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC self._attr_entity_registry_enabled_default = False @property diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py index e6b3dcbe329..e0e49197f45 100644 --- a/homeassistant/components/devolo_home_control/config_flow.py +++ b/homeassistant/components/devolo_home_control/config_flow.py @@ -6,11 +6,11 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.typing import DiscoveryInfoType from . import configure_mydevolo from .const import CONF_MYDEVOLO, DEFAULT_MYDEVOLO, DOMAIN, SUPPORTED_MODEL_TYPES @@ -45,11 +45,11 @@ class DevoloHomeControlFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self._show_form(step_id="user", errors={"base": "invalid_auth"}) async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" # Check if it is a gateway - if discovery_info.get("properties", {}).get("MT") in SUPPORTED_MODEL_TYPES: + if discovery_info.properties.get("MT") in SUPPORTED_MODEL_TYPES: await self._async_handle_discovery_without_unique_id() return await self.async_step_zeroconf_confirm() return self.async_abort(reason="Not a devolo Home Control gateway.") diff --git a/homeassistant/components/devolo_home_control/const.py b/homeassistant/components/devolo_home_control/const.py index b15c0acf622..e2ac3a42416 100644 --- a/homeassistant/components/devolo_home_control/const.py +++ b/homeassistant/components/devolo_home_control/const.py @@ -1,9 +1,18 @@ """Constants for the devolo_home_control integration.""" import re +from homeassistant.const import Platform + DOMAIN = "devolo_home_control" DEFAULT_MYDEVOLO = "https://www.mydevolo.com" -PLATFORMS = ["binary_sensor", "climate", "cover", "light", "sensor", "switch"] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.COVER, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, +] CONF_MYDEVOLO = "mydevolo_url" GATEWAY_SERIAL_PATTERN = re.compile(r"\d{16}") SUPPORTED_MODEL_TYPES = ["2600", "2601"] diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index 61c3e9a5c19..c342f691b00 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -12,11 +12,12 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE +from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -33,6 +34,16 @@ DEVICE_CLASS_MAPPING = { "voltage": DEVICE_CLASS_VOLTAGE, } +STATE_CLASS_MAPPING = { + "battery": STATE_CLASS_MEASUREMENT, + "temperature": STATE_CLASS_MEASUREMENT, + "light": STATE_CLASS_MEASUREMENT, + "humidity": STATE_CLASS_MEASUREMENT, + "current": STATE_CLASS_MEASUREMENT, + "total": STATE_CLASS_TOTAL_INCREASING, + "voltage": STATE_CLASS_MEASUREMENT, +} + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -106,6 +117,9 @@ class DevoloGenericMultiLevelDeviceEntity(DevoloMultiLevelDeviceEntity): self._attr_device_class = DEVICE_CLASS_MAPPING.get( self._multi_level_sensor_property.sensor_type ) + self._attr_state_class = STATE_CLASS_MAPPING.get( + self._multi_level_sensor_property.sensor_type + ) self._attr_native_unit_of_measurement = self._multi_level_sensor_property.unit self._value = self._multi_level_sensor_property.value @@ -132,6 +146,8 @@ class DevoloBatteryEntity(DevoloMultiLevelDeviceEntity): ) self._attr_device_class = DEVICE_CLASS_MAPPING.get("battery") + self._attr_state_class = STATE_CLASS_MAPPING.get("battery") + self._attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC self._attr_native_unit_of_measurement = PERCENTAGE self._value = device_instance.battery_level @@ -157,6 +173,7 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity): self._sensor_type = consumption self._attr_device_class = DEVICE_CLASS_MAPPING.get(consumption) + self._attr_state_class = STATE_CLASS_MAPPING.get(consumption) self._attr_native_unit_of_measurement = getattr( device_instance.consumption_property[element_uid], f"{consumption}_unit" ) diff --git a/homeassistant/components/devolo_home_control/translations/id.json b/homeassistant/components/devolo_home_control/translations/id.json index 41d2100b6ed..a4db1b3d6af 100644 --- a/homeassistant/components/devolo_home_control/translations/id.json +++ b/homeassistant/components/devolo_home_control/translations/id.json @@ -5,7 +5,8 @@ "reauth_successful": "Autentikasi ulang berhasil" }, "error": { - "invalid_auth": "Autentikasi tidak valid" + "invalid_auth": "Autentikasi tidak valid", + "reauth_failed": "Gunakan pengguna mydevolo yang sama seperti sebelumnya." }, "step": { "user": { diff --git a/homeassistant/components/devolo_home_control/translations/ja.json b/homeassistant/components/devolo_home_control/translations/ja.json new file mode 100644 index 00000000000..c52a21772b2 --- /dev/null +++ b/homeassistant/components/devolo_home_control/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "reauth_failed": "\u4ee5\u524d\u306e\u3068\u540c\u3058mydevolo\u30e6\u30fc\u30b6\u30fc\u3092\u4f7f\u7528\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "user": { + "data": { + "mydevolo_url": "mydevolo URL", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "E\u30e1\u30fc\u30eb / devolo ID" + } + }, + "zeroconf_confirm": { + "data": { + "mydevolo_url": "mydevolo URL", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "E\u30e1\u30fc\u30eb / devolo ID" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_control/translations/tr.json b/homeassistant/components/devolo_home_control/translations/tr.json index 4c6b158f694..0eebd9ec5ea 100644 --- a/homeassistant/components/devolo_home_control/translations/tr.json +++ b/homeassistant/components/devolo_home_control/translations/tr.json @@ -1,17 +1,27 @@ { "config": { "abort": { - "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { - "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "reauth_failed": "L\u00fctfen \u00f6ncekiyle ayn\u0131 mydevolo kullan\u0131c\u0131s\u0131n\u0131 kullan\u0131n." }, "step": { "user": { "data": { + "mydevolo_url": "mydevolo URL", "password": "Parola", "username": "E-posta / devolo ID" } + }, + "zeroconf_confirm": { + "data": { + "mydevolo_url": "mydevolo URL", + "password": "Parola", + "username": "E-posta / devolo kimli\u011fi" + } } } } diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py new file mode 100644 index 00000000000..f427e5acbfc --- /dev/null +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -0,0 +1,122 @@ +"""The devolo Home Network integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import async_timeout +from devolo_plc_api.device import Device +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.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONNECTED_PLC_DEVICES, + CONNECTED_WIFI_CLIENTS, + DOMAIN, + LONG_UPDATE_INTERVAL, + NEIGHBORING_WIFI_NETWORKS, + PLATFORMS, + SHORT_UPDATE_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up devolo Home Network from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + zeroconf_instance = await zeroconf.async_get_async_instance(hass) + async_client = get_async_client(hass) + + try: + device = Device( + ip=entry.data[CONF_IP_ADDRESS], zeroconf_instance=zeroconf_instance + ) + await device.async_connect(session_instance=async_client) + except DeviceNotFound as err: + raise ConfigEntryNotReady( + f"Unable to connect to {entry.data[CONF_IP_ADDRESS]}" + ) from err + + async def async_update_connected_plc_devices() -> dict[str, Any]: + """Fetch data from API endpoint.""" + try: + async with async_timeout.timeout(10): + return await device.plcnet.async_get_network_overview() # type: ignore[no-any-return, union-attr] + except DeviceUnavailable as err: + raise UpdateFailed(err) from err + + async def async_update_wifi_connected_station() -> dict[str, Any]: + """Fetch data from API endpoint.""" + try: + async with async_timeout.timeout(10): + return await device.device.async_get_wifi_connected_station() # type: ignore[no-any-return, union-attr] + except DeviceUnavailable as err: + raise UpdateFailed(err) from err + + async def async_update_wifi_neighbor_access_points() -> dict[str, Any]: + """Fetch data from API endpoint.""" + try: + async with async_timeout.timeout(30): + return await device.device.async_get_wifi_neighbor_access_points() # type: ignore[no-any-return, union-attr] + except DeviceUnavailable as err: + raise UpdateFailed(err) from err + + async def disconnect(event: Event) -> None: + """Disconnect from device.""" + await device.async_disconnect() + + coordinators: dict[str, DataUpdateCoordinator] = {} + if device.plcnet: + coordinators[CONNECTED_PLC_DEVICES] = DataUpdateCoordinator( + hass, + _LOGGER, + name=CONNECTED_PLC_DEVICES, + update_method=async_update_connected_plc_devices, + update_interval=LONG_UPDATE_INTERVAL, + ) + if device.device and "wifi1" in device.device.features: + coordinators[CONNECTED_WIFI_CLIENTS] = DataUpdateCoordinator( + hass, + _LOGGER, + name=CONNECTED_WIFI_CLIENTS, + update_method=async_update_wifi_connected_station, + update_interval=SHORT_UPDATE_INTERVAL, + ) + coordinators[NEIGHBORING_WIFI_NETWORKS] = DataUpdateCoordinator( + hass, + _LOGGER, + name=NEIGHBORING_WIFI_NETWORKS, + update_method=async_update_wifi_neighbor_access_points, + update_interval=LONG_UPDATE_INTERVAL, + ) + + hass.data[DOMAIN][entry.entry_id] = {"device": device, "coordinators": coordinators} + + for coordinator in coordinators.values(): + await coordinator.async_config_entry_first_refresh() + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + await hass.data[DOMAIN][entry.entry_id]["device"].async_disconnect() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/devolo_home_network/config_flow.py b/homeassistant/components/devolo_home_network/config_flow.py new file mode 100644 index 00000000000..765d16177d9 --- /dev/null +++ b/homeassistant/components/devolo_home_network/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for devolo Home Network integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from devolo_plc_api.device import Device +from devolo_plc_api.exceptions.device import DeviceNotFound +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.data_entry_flow import FlowResult +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, PRODUCT, SERIAL_NUMBER, TITLE + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_IP_ADDRESS): str}) + + +async def validate_input( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, str]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + zeroconf_instance = await zeroconf.async_get_instance(hass) + async_client = get_async_client(hass) + + device = Device(data[CONF_IP_ADDRESS], zeroconf_instance=zeroconf_instance) + + await device.async_connect(session_instance=async_client) + await device.async_disconnect() + + return { + SERIAL_NUMBER: str(device.serial_number), + TITLE: device.hostname.split(".")[0], + } + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for devolo Home Network.""" + + VERSION = 1 + + async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: + """Handle the initial step.""" + errors: dict = {} + + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + try: + info = await validate_input(self.hass, user_input) + except DeviceNotFound: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info[SERIAL_NUMBER], raise_on_progress=False) + self._abort_if_unique_id_configured() + 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_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zerooconf discovery.""" + if discovery_info.properties["MT"] in ["2600", "2601"]: + return self.async_abort(reason="home_control") + + await self.async_set_unique_id(discovery_info.properties["SN"]) + self._abort_if_unique_id_configured() + + self.context[CONF_HOST] = discovery_info.host + self.context["title_placeholders"] = { + PRODUCT: discovery_info.properties["Product"], + CONF_NAME: discovery_info.hostname.split(".")[0], + } + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: ConfigType | None = None + ) -> FlowResult: + """Handle a flow initiated by zeroconf.""" + title = self.context["title_placeholders"][CONF_NAME] + if user_input is not None: + data = { + CONF_IP_ADDRESS: self.context[CONF_HOST], + } + return self.async_create_entry(title=title, data=data) + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"host_name": title}, + ) diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py new file mode 100644 index 00000000000..bd7170bfde5 --- /dev/null +++ b/homeassistant/components/devolo_home_network/const.py @@ -0,0 +1,19 @@ +"""Constants for the devolo Home Network integration.""" + +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "devolo_home_network" +PLATFORMS = [Platform.SENSOR] + +PRODUCT = "product" +SERIAL_NUMBER = "serial_number" +TITLE = "title" + +LONG_UPDATE_INTERVAL = timedelta(minutes=5) +SHORT_UPDATE_INTERVAL = timedelta(seconds=15) + +CONNECTED_PLC_DEVICES = "connected_plc_devices" +CONNECTED_WIFI_CLIENTS = "connected_wifi_clients" +NEIGHBORING_WIFI_NETWORKS = "neighboring_wifi_networks" diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py new file mode 100644 index 00000000000..dbfe0e4035a --- /dev/null +++ b/homeassistant/components/devolo_home_network/entity.py @@ -0,0 +1,37 @@ +"""Generic platform.""" +from __future__ import annotations + +from devolo_plc_api.device import Device + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + + +class DevoloEntity(CoordinatorEntity): + """Representation of a devolo home network device.""" + + def __init__( + self, coordinator: DataUpdateCoordinator, device: Device, device_name: str + ) -> None: + """Initialize a devolo home network device.""" + super().__init__(coordinator) + + self._device = device + self._device_name = device_name + + self._attr_device_info = DeviceInfo( + configuration_url=f"http://{self._device.ip}", + identifiers={(DOMAIN, str(self._device.serial_number))}, + manufacturer="devolo", + model=self._device.product, + name=self._device_name, + sw_version=self._device.firmware_version, + ) + self._attr_unique_id = ( + f"{self._device.serial_number}_{self.entity_description.key}" + ) diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json new file mode 100644 index 00000000000..ef3f1f0c82a --- /dev/null +++ b/homeassistant/components/devolo_home_network/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "devolo_home_network", + "name": "devolo Home Network", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/devolo_home_network", + "requirements": ["devolo-plc-api==0.6.3"], + "zeroconf": ["_dvl-deviceapi._tcp.local."], + "codeowners": ["@2Fake", "@Shutgun"], + "quality_scale": "platinum", + "iot_class": "local_polling" +} diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py new file mode 100644 index 00000000000..b0f68ae280e --- /dev/null +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -0,0 +1,130 @@ +"""Platform for sensor integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from devolo_plc_api.device import Device + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + CONNECTED_PLC_DEVICES, + CONNECTED_WIFI_CLIENTS, + DOMAIN, + NEIGHBORING_WIFI_NETWORKS, +) +from .entity import DevoloEntity + + +@dataclass +class DevoloSensorRequiredKeysMixin: + """Mixin for required keys.""" + + value_func: Callable[[dict[str, Any]], int] + + +@dataclass +class DevoloSensorEntityDescription( + SensorEntityDescription, DevoloSensorRequiredKeysMixin +): + """Describes devolo sensor entity.""" + + +SENSOR_TYPES: dict[str, DevoloSensorEntityDescription] = { + CONNECTED_PLC_DEVICES: DevoloSensorEntityDescription( + key=CONNECTED_PLC_DEVICES, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_registry_enabled_default=False, + icon="mdi:lan", + name="Connected PLC devices", + value_func=lambda data: len( + {device["mac_address_from"] for device in data["network"]["data_rates"]} + ), + ), + CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription( + key=CONNECTED_WIFI_CLIENTS, + entity_registry_enabled_default=True, + icon="mdi:wifi", + name="Connected Wifi clients", + state_class=STATE_CLASS_MEASUREMENT, + value_func=lambda data: len(data["connected_stations"]), + ), + NEIGHBORING_WIFI_NETWORKS: DevoloSensorEntityDescription( + key=NEIGHBORING_WIFI_NETWORKS, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_registry_enabled_default=False, + icon="mdi:wifi-marker", + name="Neighboring Wifi networks", + value_func=lambda data: len(data["neighbor_aps"]), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Get all devices and sensors and setup them via config entry.""" + device: Device = hass.data[DOMAIN][entry.entry_id]["device"] + coordinators: dict[str, DataUpdateCoordinator] = hass.data[DOMAIN][entry.entry_id][ + "coordinators" + ] + + entities: list[DevoloSensorEntity] = [] + if device.plcnet: + entities.append( + DevoloSensorEntity( + coordinators[CONNECTED_PLC_DEVICES], + SENSOR_TYPES[CONNECTED_PLC_DEVICES], + device, + entry.title, + ) + ) + if device.device and "wifi1" in device.device.features: + entities.append( + DevoloSensorEntity( + coordinators[CONNECTED_WIFI_CLIENTS], + SENSOR_TYPES[CONNECTED_WIFI_CLIENTS], + device, + entry.title, + ) + ) + entities.append( + DevoloSensorEntity( + coordinators[NEIGHBORING_WIFI_NETWORKS], + SENSOR_TYPES[NEIGHBORING_WIFI_NETWORKS], + device, + entry.title, + ) + ) + async_add_entities(entities) + + +class DevoloSensorEntity(DevoloEntity, SensorEntity): + """Representation of a devolo sensor.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + description: DevoloSensorEntityDescription, + device: Device, + device_name: str, + ) -> None: + """Initialize entity.""" + self.entity_description: DevoloSensorEntityDescription = description + super().__init__(coordinator, device, device_name) + + @property + def native_value(self) -> int: + """State of the sensor.""" + return self.entity_description.value_func(self.coordinator.data) diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json new file mode 100644 index 00000000000..685e139d2b8 --- /dev/null +++ b/homeassistant/components/devolo_home_network/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "flow_title": "{product} ({name})", + "step": { + "user": { + "description": "[%key:common::config_flow::description::confirm_setup%]", + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + } + }, + "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" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "home_control": "The devolo Home Control Central Unit does not work with this integration." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/af.json b/homeassistant/components/devolo_home_network/translations/af.json new file mode 100644 index 00000000000..47aa8910aaa --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/af.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r be van \u00e1ll\u00edtva" + }, + "error": { + "cannot_connect": "Sikertelen kapcsol\u00f3d\u00e1s", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "user": { + "data": { + "ip_address": "IP c\u00edm" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/bg.json b/homeassistant/components/devolo_home_network/translations/bg.json new file mode 100644 index 00000000000..c1dc13fe2d7 --- /dev/null +++ b/homeassistant/components/devolo_home_network/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" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP \u0430\u0434\u0440\u0435\u0441" + }, + "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\u0432\u0430\u043d\u0435\u0442\u043e?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/ca.json b/homeassistant/components/devolo_home_network/translations/ca.json new file mode 100644 index 00000000000..c175a1a1246 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/ca.json @@ -0,0 +1,25 @@ +{ + "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." + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "Adre\u00e7a IP" + }, + "description": "Vols comen\u00e7ar la configuraci\u00f3?" + }, + "zeroconf_confirm": { + "description": "Vols afegir a Home Assistant el dispositiu de xarxa dom\u00e8stica devolo amb nom d'amfitri\u00f3 `{host_name}`?", + "title": "Dispositiu de xarxa dom\u00e8stica devolo descobert" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/de.json b/homeassistant/components/devolo_home_network/translations/de.json new file mode 100644 index 00000000000..c018c757d16 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/de.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "home_control": "Die devolo Home Control-Zentraleinheit funktioniert nicht mit dieser Integration." + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP-Adresse" + }, + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" + }, + "zeroconf_confirm": { + "description": "M\u00f6chtest du das devolo-Heimnetzwerkger\u00e4t mit dem Hostnamen `{host_name}` zum Home Assistant hinzuf\u00fcgen?", + "title": "Gefundenes devolo Heimnetzwerkger\u00e4t" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/en.json b/homeassistant/components/devolo_home_network/translations/en.json new file mode 100644 index 00000000000..39c0b6d331f --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/en.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "home_control": "The devolo Home Control Central Unit does not work with this integration." + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP Address" + }, + "description": "Do you want to start set up?" + }, + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/et.json b/homeassistant/components/devolo_home_network/translations/et.json new file mode 100644 index 00000000000..dff9df53c72 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/et.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "home_control": "Devolo Home Controli kesk\u00fcksus ei t\u00f6\u00f6ta selle sidumisega." + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "flow_title": "{product} ( {name} )", + "step": { + "user": { + "data": { + "ip_address": "IP aadress" + }, + "description": "Kas alutada seadistamist?" + }, + "zeroconf_confirm": { + "description": "Kas soovitd lisada devolo koduv\u00f5rgu seadme hostinimega `{host_name}` Home Assistanti?", + "title": "Avastati devolo koduv\u00f5rgu seade" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/fr.json b/homeassistant/components/devolo_home_network/translations/fr.json new file mode 100644 index 00000000000..1c0fc4827c2 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/fr.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "ip_address": "Adresse IP" + }, + "description": "Voulez-vous commencer la configuration ?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/he.json b/homeassistant/components/devolo_home_network/translations/he.json new file mode 100644 index 00000000000..6bb4c9a7ed3 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/he.json @@ -0,0 +1,20 @@ +{ + "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", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "\u05db\u05ea\u05d5\u05d1\u05ea IP" + }, + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/hu.json b/homeassistant/components/devolo_home_network/translations/hu.json new file mode 100644 index 00000000000..dfae08312df --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/hu.json @@ -0,0 +1,25 @@ +{ + "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." + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP c\u00edm" + }, + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" + }, + "zeroconf_confirm": { + "description": "Szeretn\u00e9 hozz\u00e1adni a `{host_name}`nev\u0171 a devolo otthoni h\u00e1l\u00f3zati eszk\u00f6zt Home Assistanthoz?", + "title": "Felfedezett devolo otthoni h\u00e1l\u00f3zati eszk\u00f6z" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/id.json b/homeassistant/components/devolo_home_network/translations/id.json new file mode 100644 index 00000000000..0950f6a2711 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/id.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "home_control": "Unit Central devolo Home Control tidak berfungsi dengan integrasi ini." + }, + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "Alamat IP" + }, + "description": "Ingin memulai penyiapan?" + }, + "zeroconf_confirm": { + "description": "Ingin menambahkan perangkat jaringan rumah devolo dengan nama host `{host_name}` ke Home Assistant?", + "title": "Menemukan perangkat jaringan rumah devolo" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/it.json b/homeassistant/components/devolo_home_network/translations/it.json new file mode 100644 index 00000000000..118ad0e79c6 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/it.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "home_control": "L'unit\u00e0 centrale devolo Home Control non funziona con questa integrazione." + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "Indirizzo IP" + }, + "description": "Vuoi iniziare la configurazione?" + }, + "zeroconf_confirm": { + "description": "Vuoi aggiungere il dispositivo di rete domestica devolo con il nome host `{host_name}` a Home Assistant?", + "title": "Rilevato dispositivo di rete domestica devolo" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/ja.json b/homeassistant/components/devolo_home_network/translations/ja.json new file mode 100644 index 00000000000..ee08879f713 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/ja.json @@ -0,0 +1,25 @@ +{ + "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\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u306f\u52d5\u4f5c\u3057\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP\u30a2\u30c9\u30ec\u30b9" + }, + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + }, + "zeroconf_confirm": { + "description": "\u30db\u30b9\u30c8\u540d\u304c `{host_name}` \u306e devolo\u793e\u306e\u30db\u30fc\u30e0\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30c7\u30d0\u30a4\u30b9\u3092Home Assistant\u306b\u8ffd\u52a0\u3057\u307e\u3059\u304b\uff1f", + "title": "devolo\u793e\u306e\u30db\u30fc\u30e0\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u6a5f\u5668\u3092\u767a\u898b" + } + } + } +} \ 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 new file mode 100644 index 00000000000..e8730f44b5e --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/nl.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "home_control": "De devolo Home Control Centrale Unit werkt niet met deze integratie." + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP-adres" + }, + "description": "Wilt u beginnen met instellen?" + }, + "zeroconf_confirm": { + "description": "Wilt u het devolo-thuisnetwerkapparaat met de hostnaam ` {host_name} ` aan Home Assistant toevoegen?", + "title": "Ontdekt devolo thuisnetwerk apparaat" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/no.json b/homeassistant/components/devolo_home_network/translations/no.json new file mode 100644 index 00000000000..405434abc4a --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/no.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "home_control": "Devolo Home Control Central Unit fungerer ikke med denne integrasjonen." + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + }, + "flow_title": "{product} ( {name} )", + "step": { + "user": { + "data": { + "ip_address": "IP adresse" + }, + "description": "Vil du starte oppsettet?" + }, + "zeroconf_confirm": { + "description": "Vil du legge til devolo hjemmenettverksenheten med vertsnavnet ` {host_name} ` til Home Assistant?", + "title": "Oppdaget devolo hjemmenettverksenhet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/pl.json b/homeassistant/components/devolo_home_network/translations/pl.json new file mode 100644 index 00000000000..4abe2667100 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/pl.json @@ -0,0 +1,25 @@ +{ + "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." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "Adres IP" + }, + "description": "Czy chcesz rozpocz\u0105\u0107 konfiguracj\u0119?" + }, + "zeroconf_confirm": { + "description": "Czy chcesz doda\u0107 do Home Assistanta urz\u0105dzenie sieciowe devolo o nazwie \"{host_name}\"?", + "title": "Wykryto urz\u0105dzenie sieciowe devolo" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/pt-BR.json b/homeassistant/components/devolo_home_network/translations/pt-BR.json new file mode 100644 index 00000000000..edffd23f3af --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/pt-BR.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositivo j\u00e1 configurado", + "home_control": "A Unidade Central de Home Control Devolo n\u00e3o funciona com esta integra\u00e7\u00e3o." + }, + "error": { + "cannot_connect": "Falha ao conectar", + "unknown": "Erro inesperado" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "Endere\u00e7o IP" + }, + "description": "Deseja iniciar a configura\u00e7\u00e3o?" + }, + "zeroconf_confirm": { + "description": "Deseja adicionar o dispositivo de rede dom\u00e9stica Devolo com o nome \"{host_name}\" ao Home Assistant?", + "title": "Dispositivo de rede dom\u00e9stica Devolo descoberto" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/ru.json b/homeassistant/components/devolo_home_network/translations/ru.json new file mode 100644 index 00000000000..4cc909b8816 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/ru.json @@ -0,0 +1,25 @@ +{ + "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." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP-\u0430\u0434\u0440\u0435\u0441" + }, + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0447\u0430\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443?" + }, + "zeroconf_confirm": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e devolo `{host_name}` \u0432 Home Assistant?", + "title": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e devolo" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/sl.json b/homeassistant/components/devolo_home_network/translations/sl.json new file mode 100644 index 00000000000..7f0bff8bc21 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/sl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "ip_address": "IP naslov" + }, + "description": "Ali \u017eelite za\u010deti z nastavitvijo?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/th.json b/homeassistant/components/devolo_home_network/translations/th.json new file mode 100644 index 00000000000..2fd6d1c083a --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/th.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u0e01\u0e32\u0e23\u0e40\u0e0a\u0e37\u0e48\u0e2d\u0e21\u0e15\u0e48\u0e2d\u0e25\u0e49\u0e21\u0e40\u0e2b\u0e25\u0e27", + "unknown": "\u0e02\u0e49\u0e2d\u0e1c\u0e34\u0e14\u0e1e\u0e25\u0e32\u0e14\u0e17\u0e35\u0e48\u0e44\u0e21\u0e48\u0e04\u0e32\u0e14\u0e04\u0e34\u0e14" + }, + "step": { + "user": { + "data": { + "ip_address": "IP \u0e41\u0e2d\u0e14\u0e40\u0e14\u0e23\u0e2a" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/tr.json b/homeassistant/components/devolo_home_network/translations/tr.json new file mode 100644 index 00000000000..841ae1773ca --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/tr.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "home_control": "devolo Ev Kontrol Merkezi Birimi bu entegrasyonla \u00e7al\u0131\u015fmaz." + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP Adresi" + }, + "description": "Kuruluma ba\u015flamak ister misiniz?" + }, + "zeroconf_confirm": { + "description": "{host_name} ` ana bilgisayar ad\u0131na sahip devolo ev a\u011f\u0131 cihaz\u0131n\u0131 Home Assistant'a eklemek ister misiniz?", + "title": "Ke\u015ffedilen devolo ev a\u011f\u0131 cihaz\u0131" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/zh-Hant.json b/homeassistant/components/devolo_home_network/translations/zh-Hant.json new file mode 100644 index 00000000000..17eb11eb070 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/zh-Hant.json @@ -0,0 +1,25 @@ +{ + "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" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP \u4f4d\u5740" + }, + "description": "\u662f\u5426\u8981\u958b\u59cb\u8a2d\u5b9a\uff1f" + }, + "zeroconf_confirm": { + "description": "\u662f\u5426\u8981\u5c07\u4e3b\u6a5f\u540d\u7a31\u70ba `{host_name}` \u7684 Devolo \u667a\u80fd\u5bb6\u5ead\u7db2\u8def\u88dd\u7f6e\u65b0\u589e\u81f3 Home Assistant\uff1f", + "title": "\u81ea\u52d5\u63a2\u7d22\u5230 Devolo \u667a\u80fd\u5bb6\u5ead\u7db2\u8def\u88dd\u7f6e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dexcom/const.py b/homeassistant/components/dexcom/const.py index 40b7e32df6c..cb75f3bd500 100644 --- a/homeassistant/components/dexcom/const.py +++ b/homeassistant/components/dexcom/const.py @@ -1,8 +1,8 @@ """Constants for the Dexcom integration.""" +from homeassistant.const import Platform DOMAIN = "dexcom" -PLATFORMS = ["sensor"] - +PLATFORMS = [Platform.SENSOR] GLUCOSE_VALUE_ICON = "mdi:diabetes" GLUCOSE_TREND_ICON = [ diff --git a/homeassistant/components/dexcom/manifest.json b/homeassistant/components/dexcom/manifest.json index 1321f38a0d7..1bf15776cad 100644 --- a/homeassistant/components/dexcom/manifest.json +++ b/homeassistant/components/dexcom/manifest.json @@ -3,7 +3,7 @@ "name": "Dexcom", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dexcom", - "requirements": ["pydexcom==0.2.0"], + "requirements": ["pydexcom==0.2.1"], "codeowners": ["@gagebenne"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/dexcom/translations/bg.json b/homeassistant/components/dexcom/translations/bg.json index 2ac8a444100..ec574a06c2f 100644 --- a/homeassistant/components/dexcom/translations/bg.json +++ b/homeassistant/components/dexcom/translations/bg.json @@ -1,7 +1,17 @@ { "config": { "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\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" + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "server": "\u0421\u044a\u0440\u0432\u044a\u0440", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/dexcom/translations/ja.json b/homeassistant/components/dexcom/translations/ja.json new file mode 100644 index 00000000000..21cc971beec --- /dev/null +++ b/homeassistant/components/dexcom/translations/ja.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "server": "\u30b5\u30fc\u30d0\u30fc", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "Dexcom Share\u306e\u8a8d\u8a3c\u60c5\u5831\u3092\u5165\u529b\u3059\u308b", + "title": "Dexcom\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "unit_of_measurement": "\u6e2c\u5b9a\u5358\u4f4d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dexcom/translations/tr.json b/homeassistant/components/dexcom/translations/tr.json index ec93dc078af..8ff66f07ccf 100644 --- a/homeassistant/components/dexcom/translations/tr.json +++ b/homeassistant/components/dexcom/translations/tr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Hesap zaten konfig\u00fcre edilmi\u015fi durumda" + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", @@ -12,8 +12,11 @@ "user": { "data": { "password": "Parola", + "server": "Sunucu", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "description": "Dexcom Share kimlik bilgilerini girin", + "title": "Dexcom entegrasyonunu kurun" } } }, diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index d52b30ccfb2..7da74fb66b0 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -1,11 +1,13 @@ """The dhcp integration.""" +from dataclasses import dataclass from datetime import timedelta import fnmatch from ipaddress import ip_address as make_ip_address import logging import os import threading +from typing import Any, Final from aiodiscover import DiscoverHosts from aiodiscover.discovery import ( @@ -31,29 +33,77 @@ from homeassistant.const import ( STATE_HOME, ) from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import discovery_flow from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.event import ( async_track_state_added_domain, async_track_time_interval, ) +from homeassistant.helpers.frame import report from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_dhcp from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.network import is_invalid, is_link_local, is_loopback +from .const import DOMAIN + FILTER = "udp and (port 67 or 68)" REQUESTED_ADDR = "requested_addr" MESSAGE_TYPE = "message-type" -HOSTNAME = "hostname" -MAC_ADDRESS = "macaddress" -IP_ADDRESS = "ip" +HOSTNAME: Final = "hostname" +MAC_ADDRESS: Final = "macaddress" +IP_ADDRESS: Final = "ip" DHCP_REQUEST = 3 SCAN_INTERVAL = timedelta(minutes=60) _LOGGER = logging.getLogger(__name__) +@dataclass +class DhcpServiceInfo(BaseServiceInfo): + """Prepared info from dhcp entries.""" + + ip: str + hostname: str + macaddress: str + + # Used to prevent log flooding. To be removed in 2022.6 + _warning_logged: bool = False + + def __getitem__(self, name: str) -> Any: + """ + Enable method for compatibility reason. + + Deprecated, and will be removed in version 2022.6. + """ + if not self._warning_logged: + report( + f"accessed discovery_info['{name}'] instead of discovery_info.{name}; this will fail in version 2022.6", + exclude_integrations={DOMAIN}, + error_if_core=False, + ) + self._warning_logged = True + return getattr(self, name) + + def get(self, name: str, default: Any = None) -> Any: + """ + Enable method for compatibility reason. + + Deprecated, and will be removed in version 2022.6. + """ + if not self._warning_logged: + report( + f"accessed discovery_info.get('{name}') instead of discovery_info.{name}; this will fail in version 2022.6", + exclude_integrations={DOMAIN}, + error_if_core=False, + ) + self._warning_logged = True + if hasattr(self, name): + return getattr(self, name) + return default + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the dhcp component.""" @@ -150,11 +200,11 @@ class WatcherBase: self.hass, entry["domain"], {"source": config_entries.SOURCE_DHCP}, - { - IP_ADDRESS: ip_address, - HOSTNAME: lowercase_hostname, - MAC_ADDRESS: data[MAC_ADDRESS], - }, + DhcpServiceInfo( + ip=ip_address, + hostname=lowercase_hostname, + macaddress=data[MAC_ADDRESS], + ), ) diff --git a/homeassistant/components/dht/sensor.py b/homeassistant/components/dht/sensor.py index 810db33e5e4..7c4ae5610f7 100644 --- a/homeassistant/components/dht/sensor.py +++ b/homeassistant/components/dht/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( CONF_MONITORED_CONDITIONS, @@ -44,12 +45,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=SENSOR_HUMIDITY, name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), ) diff --git a/homeassistant/components/dialogflow/translations/ja.json b/homeassistant/components/dialogflow/translations/ja.json new file mode 100644 index 00000000000..0cb6f57eae2 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/ja.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "webhook_not_internet_accessible": "Webhook\u30e1\u30c3\u30bb\u30fc\u30b8\u3092\u53d7\u4fe1\u3059\u308b\u306b\u306f\u3001Home Assistant\u306e\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306b\u3001\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u304b\u3089\u30a2\u30af\u30bb\u30b9\u3067\u304d\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "create_entry": { + "default": "Home Assistant\u306b\u30a4\u30d9\u30f3\u30c8\u3092\u9001\u4fe1\u3059\u308b\u306b\u306f\u3001[Dialogflow\u306ewebhook\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3]({dialogflow_url})\u3092\u8a2d\u5b9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\n\n\u6b21\u306e\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044:\n\n- URL: `{webhook_url}`\n- Method(\u65b9\u5f0f): POST\n\n\u8a73\u7d30\u306f[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8]({docs_url}) \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "user": { + "description": "Dialogflow\u3092\u8a2d\u5b9a\u3057\u3066\u3082\u3088\u308d\u3057\u3044\u3067\u3059\u304b\uff1f", + "title": "Dialogflow Webhook\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/translations/tr.json b/homeassistant/components/dialogflow/translations/tr.json index 84adcdf8225..520424e434f 100644 --- a/homeassistant/components/dialogflow/translations/tr.json +++ b/homeassistant/components/dialogflow/translations/tr.json @@ -3,6 +3,15 @@ "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr.", "webhook_not_internet_accessible": "Webhook mesajlar\u0131n\u0131 alabilmek i\u00e7in Home Assistant \u00f6rne\u011finize internetten eri\u015filebilmelidir." + }, + "create_entry": { + "default": "Etkinlikleri Home Assistant'a g\u00f6ndermek i\u00e7in [Dialogflow'un webhook entegrasyonunu]( {dialogflow_url} ) ayarlaman\u0131z gerekir. \n\n A\u015fa\u011f\u0131daki bilgileri doldurun: \n\n - URL: ` {webhook_url} `\n - Y\u00f6ntem: POST\n - \u0130\u00e7erik T\u00fcr\u00fc: uygulama/json \n\n Daha fazla ayr\u0131nt\u0131 i\u00e7in [belgelere]( {docs_url}" + }, + "step": { + "user": { + "description": "Dialogflow'u kurmak istedi\u011finizden emin misiniz?", + "title": "Dialogflow Webhook'u ayarlay\u0131n" + } } } } \ No newline at end of file diff --git a/homeassistant/components/digital_ocean/binary_sensor.py b/homeassistant/components/digital_ocean/binary_sensor.py index b5188092862..af74e1ffb13 100644 --- a/homeassistant/components/digital_ocean/binary_sensor.py +++ b/homeassistant/components/digital_ocean/binary_sensor.py @@ -43,8 +43,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): dev = [] for droplet in droplets: - droplet_id = digital.get_droplet_id(droplet) - if droplet_id is None: + if (droplet_id := digital.get_droplet_id(droplet)) is None: _LOGGER.error("Droplet %s is not available", droplet) return False dev.append(DigitalOceanBinarySensor(digital, droplet_id)) @@ -55,7 +54,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DigitalOceanBinarySensor(BinarySensorEntity): """Representation of a Digital Ocean droplet sensor.""" - def __init__(self, do, droplet_id): + def __init__(self, do, droplet_id): # pylint: disable=invalid-name """Initialize a new Digital Ocean sensor.""" self._digital_ocean = do self._droplet_id = droplet_id diff --git a/homeassistant/components/digital_ocean/switch.py b/homeassistant/components/digital_ocean/switch.py index d52c223c866..3ba60c4c457 100644 --- a/homeassistant/components/digital_ocean/switch.py +++ b/homeassistant/components/digital_ocean/switch.py @@ -40,8 +40,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): dev = [] for droplet in droplets: - droplet_id = digital.get_droplet_id(droplet) - if droplet_id is None: + if (droplet_id := digital.get_droplet_id(droplet)) is None: _LOGGER.error("Droplet %s is not available", droplet) return False dev.append(DigitalOceanSwitch(digital, droplet_id)) @@ -52,7 +51,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DigitalOceanSwitch(SwitchEntity): """Representation of a Digital Ocean droplet switch.""" - def __init__(self, do, droplet_id): + def __init__(self, do, droplet_id): # pylint: disable=invalid-name """Initialize a new Digital Ocean sensor.""" self._digital_ocean = do self._droplet_id = droplet_id diff --git a/homeassistant/components/directv/__init__.py b/homeassistant/components/directv/__init__.py index 2fec28db14a..55961869f79 100644 --- a/homeassistant/components/directv/__init__.py +++ b/homeassistant/components/directv/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta from directv import DIRECTV, DIRECTVError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv @@ -16,7 +16,7 @@ from .const import DOMAIN CONFIG_SCHEMA = cv.deprecated(DOMAIN) -PLATFORMS = ["media_player", "remote"] +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/directv/config_flow.py b/homeassistant/components/directv/config_flow.py index d2d1e2ec003..34a09a04811 100644 --- a/homeassistant/components/directv/config_flow.py +++ b/homeassistant/components/directv/config_flow.py @@ -8,13 +8,12 @@ from urllib.parse import urlparse from directv import DIRECTV, DIRECTVError import voluptuous as vol -from homeassistant.components.ssdp import ATTR_SSDP_LOCATION, ATTR_UPNP_SERIAL +from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import DiscoveryInfoType from .const import CONF_RECEIVER_ID, DOMAIN @@ -67,13 +66,15 @@ class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) - async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle SSDP discovery.""" - host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname + host = urlparse(discovery_info.ssdp_location).hostname receiver_id = None - if discovery_info.get(ATTR_UPNP_SERIAL): - receiver_id = discovery_info[ATTR_UPNP_SERIAL][4:] # strips off RID- + if discovery_info.upnp.get(ssdp.ATTR_UPNP_SERIAL): + receiver_id = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL][ + 4: + ] # strips off RID- self.context.update({"title_placeholders": {"name": host}}) diff --git a/homeassistant/components/directv/translations/de.json b/homeassistant/components/directv/translations/de.json index 5f06a68e715..5fb4d2e7b5b 100644 --- a/homeassistant/components/directv/translations/de.json +++ b/homeassistant/components/directv/translations/de.json @@ -10,10 +10,6 @@ "flow_title": "{name}", "step": { "ssdp_confirm": { - "data": { - "one": "eins", - "other": "andere" - }, "description": "M\u00f6chtest du {name} einrichten?" }, "user": { diff --git a/homeassistant/components/directv/translations/ja.json b/homeassistant/components/directv/translations/ja.json new file mode 100644 index 00000000000..413861bce12 --- /dev/null +++ b/homeassistant/components/directv/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", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name}", + "step": { + "ssdp_confirm": { + "description": "{name} \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/tr.json b/homeassistant/components/directv/translations/tr.json index daca8f1ef62..bcafc53ce66 100644 --- a/homeassistant/components/directv/translations/tr.json +++ b/homeassistant/components/directv/translations/tr.json @@ -7,13 +7,18 @@ "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, + "flow_title": "{name}", "step": { "ssdp_confirm": { + "data": { + "one": "Bo\u015f", + "other": "Bo\u015f" + }, "description": "{name} kurmak istiyor musunuz?" }, "user": { "data": { - "host": "Ana Bilgisayar" + "host": "Ana bilgisayar" } } } diff --git a/homeassistant/components/discord/notify.py b/homeassistant/components/discord/notify.py index 16f30fbf051..c3f7de94af0 100644 --- a/homeassistant/components/discord/notify.py +++ b/homeassistant/components/discord/notify.py @@ -62,7 +62,7 @@ class DiscordNotificationService(BaseNotificationService): embed = None if ATTR_EMBED in data: embedding = data[ATTR_EMBED] - fields = embedding.get(ATTR_EMBED_FIELDS) + fields = embedding.get(ATTR_EMBED_FIELDS) or [] if embedding: embed = discord.Embed(**embedding) diff --git a/homeassistant/components/dlna_dmr/__init__.py b/homeassistant/components/dlna_dmr/__init__.py index 6a53490819f..d34d8550355 100644 --- a/homeassistant/components/dlna_dmr/__init__.py +++ b/homeassistant/components/dlna_dmr/__init__.py @@ -2,12 +2,12 @@ from __future__ import annotations from homeassistant import config_entries -from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import LOGGER -PLATFORMS = [MEDIA_PLAYER_DOMAIN] +PLATFORMS = [Platform.MEDIA_PLAYER] async def async_setup_entry( diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index 8cd4f706087..69437d99e3d 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable import logging from pprint import pformat -from typing import Any, Mapping, Optional +from typing import Any, Mapping, Optional, cast from urllib.parse import urlparse from async_upnp_client.client import UpnpError @@ -25,7 +25,6 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import IntegrationError import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import DiscoveryInfoType from .const import ( CONF_CALLBACK_URL_OVERRIDE, @@ -57,7 +56,7 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize flow.""" - self._discoveries: dict[str, Mapping[str, Any]] = {} + self._discoveries: dict[str, ssdp.SsdpServiceInfo] = {} self._location: str | None = None self._udn: str | None = None self._device_type: str | None = None @@ -81,8 +80,7 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): LOGGER.debug("async_step_user: user_input: %s", user_input) if user_input is not None: - host = user_input.get(CONF_HOST) - if not host: + if not (host := user_input.get(CONF_HOST)): # No device chosen, user might want to directly enter an URL return await self.async_step_manual() # User has chosen a device, ask for confirmation @@ -90,14 +88,13 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self._async_set_info_from_discovery(discovery) return self._create_entry() - discoveries = await self._async_get_discoveries() - if not discoveries: + if not (discoveries := await self._async_get_discoveries()): # Nothing found, maybe the user knows an URL to try return await self.async_step_manual() self._discoveries = { - discovery.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) - or urlparse(discovery[ssdp.ATTR_SSDP_LOCATION]).hostname: discovery + discovery.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + or cast(str, urlparse(discovery.ssdp_location).hostname): discovery for discovery in discoveries } @@ -161,7 +158,7 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Find the device in the list of unconfigured devices for discovery in discoveries: - if discovery[ssdp.ATTR_SSDP_LOCATION] == self._location: + if discovery.ssdp_location == self._location: # Device found via SSDP, it shouldn't need polling self._options[CONF_POLL_AVAILABILITY] = False # Discovery info has everything required to create config entry @@ -207,7 +204,7 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._set_confirm_only() return self.async_show_form(step_id="import_turn_on", errors=errors) - async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a flow initialized by SSDP discovery.""" LOGGER.debug("async_step_ssdp: discovery_info %s", pformat(discovery_info)) @@ -219,7 +216,7 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Abort if the device doesn't support all services required for a DmrDevice. # Use the discovery_info instead of DmrDevice.is_profile_device to avoid # contacting the device again. - discovery_service_list = discovery_info.get(ssdp.ATTR_UPNP_SERVICE_LIST) + discovery_service_list = discovery_info.upnp.get(ssdp.ATTR_UPNP_SERVICE_LIST) if not discovery_service_list: return self.async_abort(reason="not_dmr") discovery_service_ids = { @@ -238,6 +235,10 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="already_in_progress") + # Abort if another config entry has the same location, in case the + # device doesn't have a static and unique UDN (breaking the UPnP spec). + self._async_abort_entries_match({CONF_URL: self._location}) + self.context["title_placeholders"] = {"name": self._name} return await self.async_step_confirm() @@ -328,20 +329,20 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=title, data=data, options=self._options) async def _async_set_info_from_discovery( - self, discovery_info: Mapping[str, Any], abort_if_configured: bool = True + self, discovery_info: ssdp.SsdpServiceInfo, abort_if_configured: bool = True ) -> None: """Set information required for a config entry from the SSDP discovery.""" LOGGER.debug( "_async_set_info_from_discovery: location: %s, UDN: %s", - discovery_info[ssdp.ATTR_SSDP_LOCATION], - discovery_info[ssdp.ATTR_SSDP_UDN], + discovery_info.ssdp_location, + discovery_info.ssdp_udn, ) if not self._location: - self._location = discovery_info[ssdp.ATTR_SSDP_LOCATION] + self._location = discovery_info.ssdp_location assert isinstance(self._location, str) - self._udn = discovery_info[ssdp.ATTR_SSDP_UDN] + self._udn = discovery_info.ssdp_udn await self.async_set_unique_id(self._udn) if abort_if_configured: @@ -350,21 +351,19 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): updates={CONF_URL: self._location}, reload_on_update=False ) - self._device_type = ( - discovery_info.get(ssdp.ATTR_SSDP_NT) or discovery_info[ssdp.ATTR_SSDP_ST] - ) + self._device_type = discovery_info.ssdp_nt or discovery_info.ssdp_st self._name = ( - discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) or urlparse(self._location).hostname or DEFAULT_NAME ) - async def _async_get_discoveries(self) -> list[Mapping[str, Any]]: + async def _async_get_discoveries(self) -> list[ssdp.SsdpServiceInfo]: """Get list of unconfigured DLNA devices discovered by SSDP.""" LOGGER.debug("_get_discoveries") # Get all compatible devices from ssdp's cache - discoveries: list[Mapping[str, Any]] = [] + discoveries: list[ssdp.SsdpServiceInfo] = [] for udn_st in DmrDevice.DEVICE_TYPES: st_discoveries = await ssdp.async_get_discovery_info_by_st( self.hass, udn_st @@ -377,9 +376,7 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): for entry in self._async_current_entries(include_ignore=False) } discoveries = [ - disc - for disc in discoveries - if disc[ssdp.ATTR_SSDP_UDN] not in current_unique_ids + disc for disc in discoveries if disc.ssdp_udn not in current_unique_ids ] return discoveries @@ -452,7 +449,7 @@ class DlnaDmrOptionsFlowHandler(config_entries.OptionsFlow): ) -def _is_ignored_device(discovery_info: Mapping[str, Any]) -> bool: +def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: """Return True if this device should be ignored for discovery. These devices are supported better by other integrations, so don't bug @@ -460,26 +457,34 @@ def _is_ignored_device(discovery_info: Mapping[str, Any]) -> bool: flow, which will list all discovered but unconfigured devices. """ # Did the discovery trigger more than just this flow? - if len(discovery_info.get(ssdp.ATTR_HA_MATCHING_DOMAINS, set())) > 1: + if len(discovery_info.x_homeassistant_matching_domains) > 1: LOGGER.debug( "Ignoring device supported by multiple integrations: %s", - discovery_info[ssdp.ATTR_HA_MATCHING_DOMAINS], + discovery_info.x_homeassistant_matching_domains, ) return True # Is the root device not a DMR? - if discovery_info.get(ssdp.ATTR_UPNP_DEVICE_TYPE) not in DmrDevice.DEVICE_TYPES: + if ( + discovery_info.upnp.get(ssdp.ATTR_UPNP_DEVICE_TYPE) + not in DmrDevice.DEVICE_TYPES + ): return True # Special cases for devices with other discovery methods (e.g. mDNS), or # that advertise multiple unrelated (sent in separate discovery packets) # UPnP devices. - manufacturer = discovery_info.get(ssdp.ATTR_UPNP_MANUFACTURER, "").lower() - model = discovery_info.get(ssdp.ATTR_UPNP_MODEL_NAME, "").lower() + manufacturer = discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER, "").lower() + model = discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "").lower() if manufacturer.startswith("xbmc") or model == "kodi": # kodi return True + if "philips" in manufacturer and "tv" in model: + # philips_js + # These TVs don't have a stable UDN, so also get discovered as a new + # device every time they are turned on. + return True if manufacturer.startswith("samsung") and "tv" in model: # samsungtv return True diff --git a/homeassistant/components/dlna_dmr/const.py b/homeassistant/components/dlna_dmr/const.py index f3217fdafff..20a978f9fda 100644 --- a/homeassistant/components/dlna_dmr/const.py +++ b/homeassistant/components/dlna_dmr/const.py @@ -5,6 +5,8 @@ from collections.abc import Mapping import logging from typing import Final +from async_upnp_client.profiles.dlna import PlayMode as _PlayMode + from homeassistant.components.media_player import const as _mp_const LOGGER = logging.getLogger(__package__) @@ -58,3 +60,114 @@ MEDIA_TYPE_MAP: Mapping[str, str] = { "object.container.storageFolder": _mp_const.MEDIA_TYPE_PLAYLIST, "object.container.bookmarkFolder": _mp_const.MEDIA_TYPE_PLAYLIST, } + +# Map media_player media_content_type to UPnP class. Not everything will map +# directly, in which case it's not specified and other defaults will be used. +MEDIA_UPNP_CLASS_MAP: Mapping[str, str] = { + _mp_const.MEDIA_TYPE_ALBUM: "object.container.album.musicAlbum", + _mp_const.MEDIA_TYPE_ARTIST: "object.container.person.musicArtist", + _mp_const.MEDIA_TYPE_CHANNEL: "object.item.videoItem.videoBroadcast", + _mp_const.MEDIA_TYPE_CHANNELS: "object.container.channelGroup", + _mp_const.MEDIA_TYPE_COMPOSER: "object.container.person.musicArtist", + _mp_const.MEDIA_TYPE_CONTRIBUTING_ARTIST: "object.container.person.musicArtist", + _mp_const.MEDIA_TYPE_EPISODE: "object.item.epgItem.videoProgram", + _mp_const.MEDIA_TYPE_GENRE: "object.container.genre", + _mp_const.MEDIA_TYPE_IMAGE: "object.item.imageItem", + _mp_const.MEDIA_TYPE_MOVIE: "object.item.videoItem.movie", + _mp_const.MEDIA_TYPE_MUSIC: "object.item.audioItem.musicTrack", + _mp_const.MEDIA_TYPE_PLAYLIST: "object.item.playlistItem", + _mp_const.MEDIA_TYPE_PODCAST: "object.item.audioItem.audioBook", + _mp_const.MEDIA_TYPE_SEASON: "object.item.epgItem.videoProgram", + _mp_const.MEDIA_TYPE_TRACK: "object.item.audioItem.musicTrack", + _mp_const.MEDIA_TYPE_TVSHOW: "object.item.videoItem.videoBroadcast", + _mp_const.MEDIA_TYPE_URL: "object.item.bookmarkItem", + _mp_const.MEDIA_TYPE_VIDEO: "object.item.videoItem", +} + +# Translation of MediaMetadata keys to DIDL-Lite keys. +# See https://developers.google.com/cast/docs/reference/messages#MediaData via +# https://www.home-assistant.io/integrations/media_player/ for HA keys. +# See http://www.upnp.org/specs/av/UPnP-av-ContentDirectory-v4-Service.pdf for +# DIDL-Lite keys. +MEDIA_METADATA_DIDL: Mapping[str, str] = { + "subtitle": "longDescription", + "releaseDate": "date", + "studio": "publisher", + "season": "episodeSeason", + "episode": "episodeNumber", + "albumName": "album", + "trackNumber": "originalTrackNumber", +} + +# For (un)setting repeat mode, map a combination of shuffle & repeat to a list +# of play modes in order of suitability. Fall back to _PlayMode.NORMAL in any +# case. NOTE: This list is slightly different to that in SHUFFLE_PLAY_MODES, +# due to fallback behaviour when turning on repeat modes. +REPEAT_PLAY_MODES: Mapping[tuple[bool, str], list[_PlayMode]] = { + (False, _mp_const.REPEAT_MODE_OFF): [ + _PlayMode.NORMAL, + ], + (False, _mp_const.REPEAT_MODE_ONE): [ + _PlayMode.REPEAT_ONE, + _PlayMode.REPEAT_ALL, + _PlayMode.NORMAL, + ], + (False, _mp_const.REPEAT_MODE_ALL): [ + _PlayMode.REPEAT_ALL, + _PlayMode.REPEAT_ONE, + _PlayMode.NORMAL, + ], + (True, _mp_const.REPEAT_MODE_OFF): [ + _PlayMode.SHUFFLE, + _PlayMode.RANDOM, + _PlayMode.NORMAL, + ], + (True, _mp_const.REPEAT_MODE_ONE): [ + _PlayMode.REPEAT_ONE, + _PlayMode.RANDOM, + _PlayMode.SHUFFLE, + _PlayMode.NORMAL, + ], + (True, _mp_const.REPEAT_MODE_ALL): [ + _PlayMode.RANDOM, + _PlayMode.REPEAT_ALL, + _PlayMode.SHUFFLE, + _PlayMode.NORMAL, + ], +} + +# For (un)setting shuffle mode, map a combination of shuffle & repeat to a list +# of play modes in order of suitability. Fall back to _PlayMode.NORMAL in any +# case. +SHUFFLE_PLAY_MODES: Mapping[tuple[bool, str], list[_PlayMode]] = { + (False, _mp_const.REPEAT_MODE_OFF): [ + _PlayMode.NORMAL, + ], + (False, _mp_const.REPEAT_MODE_ONE): [ + _PlayMode.REPEAT_ONE, + _PlayMode.REPEAT_ALL, + _PlayMode.NORMAL, + ], + (False, _mp_const.REPEAT_MODE_ALL): [ + _PlayMode.REPEAT_ALL, + _PlayMode.REPEAT_ONE, + _PlayMode.NORMAL, + ], + (True, _mp_const.REPEAT_MODE_OFF): [ + _PlayMode.SHUFFLE, + _PlayMode.RANDOM, + _PlayMode.NORMAL, + ], + (True, _mp_const.REPEAT_MODE_ONE): [ + _PlayMode.RANDOM, + _PlayMode.SHUFFLE, + _PlayMode.REPEAT_ONE, + _PlayMode.NORMAL, + ], + (True, _mp_const.REPEAT_MODE_ALL): [ + _PlayMode.RANDOM, + _PlayMode.SHUFFLE, + _PlayMode.REPEAT_ALL, + _PlayMode.NORMAL, + ], +} diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 962b2e167be..9bc020f3693 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.22.10"], + "requirements": ["async-upnp-client==0.22.12"], "dependencies": ["ssdp"], "ssdp": [ { diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 2835117e57c..2b529609d9e 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -2,15 +2,16 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping, Sequence +from collections.abc import Sequence +import contextlib from datetime import datetime, timedelta import functools -from typing import Any, Callable, TypeVar, cast +from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast from async_upnp_client import UpnpService, UpnpStateVariable from async_upnp_client.const import NotificationSubType from async_upnp_client.exceptions import UpnpError, UpnpResponseError -from async_upnp_client.profiles.dlna import DmrDevice, TransportState +from async_upnp_client.profiles.dlna import DmrDevice, PlayMode, TransportState from async_upnp_client.utils import async_get_local_ip import voluptuous as vol @@ -18,12 +19,19 @@ from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity from homeassistant.components.media_player.const import ( + ATTR_MEDIA_EXTRA, + REPEAT_MODE_ALL, + REPEAT_MODE_OFF, + REPEAT_MODE_ONE, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, + SUPPORT_REPEAT_SET, SUPPORT_SEEK, + SUPPORT_SELECT_SOUND_MODE, + SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, @@ -51,7 +59,11 @@ from .const import ( CONF_POLL_AVAILABILITY, DOMAIN, LOGGER as _LOGGER, + MEDIA_METADATA_DIDL, MEDIA_TYPE_MAP, + MEDIA_UPNP_CLASS_MAP, + REPEAT_PLAY_MODES, + SHUFFLE_PLAY_MODES, ) from .data import EventListenAddr, get_domain_data @@ -229,18 +241,18 @@ class DlnaDmrEntity(MediaPlayerEntity): await self._device_disconnect() async def async_ssdp_callback( - self, info: Mapping[str, Any], change: ssdp.SsdpChange + self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: """Handle notification from SSDP of device state change.""" _LOGGER.debug( "SSDP %s notification of device %s at %s", change, - info[ssdp.ATTR_SSDP_USN], - info.get(ssdp.ATTR_SSDP_LOCATION), + info.ssdp_usn, + info.ssdp_location, ) try: - bootid_str = info[ssdp.ATTR_SSDP_BOOTID] + bootid_str = info.ssdp_headers[ssdp.ATTR_SSDP_BOOTID] bootid: int | None = int(bootid_str, 10) except (KeyError, ValueError): bootid = None @@ -250,11 +262,9 @@ class DlnaDmrEntity(MediaPlayerEntity): if self._bootid is not None and self._bootid == bootid: # Store the new value (because our old value matches) so that we # can ignore subsequent ssdp:alive messages - try: - next_bootid_str = info[ssdp.ATTR_SSDP_NEXTBOOTID] + with contextlib.suppress(KeyError, ValueError): + next_bootid_str = info.ssdp_headers[ssdp.ATTR_SSDP_NEXTBOOTID] self._bootid = int(next_bootid_str, 10) - except (KeyError, ValueError): - pass # Nothing left to do until ssdp:alive comes through return @@ -268,7 +278,9 @@ class DlnaDmrEntity(MediaPlayerEntity): await self._device_disconnect() if change == ssdp.SsdpChange.ALIVE and not self._device: - location = info[ssdp.ATTR_SSDP_LOCATION] + if TYPE_CHECKING: + assert info.ssdp_location + location = info.ssdp_location try: await self._device_connect(location) except UpnpError as err: @@ -445,7 +457,21 @@ class DlnaDmrEntity(MediaPlayerEntity): if not state_variables: # Indicates a failure to resubscribe, check if device is still available self.check_available = True - self.async_write_ha_state() + + force_refresh = False + + if service.service_id == "urn:upnp-org:serviceId:AVTransport": + for state_variable in state_variables: + # Force a state refresh when player begins or pauses playback + # to update the position info. + if ( + state_variable.name == "TransportState" + and state_variable.value + in (TransportState.PLAYING, TransportState.PAUSED_PLAYBACK) + ): + force_refresh = True + + self.async_schedule_update_ha_state(force_refresh) @property def available(self) -> bool: @@ -515,6 +541,15 @@ class DlnaDmrEntity(MediaPlayerEntity): if self._device.can_seek_rel_time: supported_features |= SUPPORT_SEEK + play_modes = self._device.valid_play_modes + if play_modes & {PlayMode.RANDOM, PlayMode.SHUFFLE}: + supported_features |= SUPPORT_SHUFFLE_SET + if play_modes & {PlayMode.REPEAT_ONE, PlayMode.REPEAT_ALL}: + supported_features |= SUPPORT_REPEAT_SET + + if self._device.has_presets: + supported_features |= SUPPORT_SELECT_SOUND_MODE + return supported_features @property @@ -575,23 +610,43 @@ class DlnaDmrEntity(MediaPlayerEntity): ) -> None: """Play a piece of media.""" _LOGGER.debug("Playing media: %s, %s, %s", media_type, media_id, kwargs) - title = "Home Assistant" - assert self._device is not None + extra: dict[str, Any] = kwargs.get(ATTR_MEDIA_EXTRA) or {} + metadata: dict[str, Any] = extra.get("metadata") or {} + + title = extra.get("title") or metadata.get("title") or "Home Assistant" + if thumb := extra.get("thumb"): + metadata["album_art_uri"] = thumb + + # Translate metadata keys from HA names to DIDL-Lite names + for hass_key, didl_key in MEDIA_METADATA_DIDL.items(): + if hass_key in metadata: + metadata[didl_key] = metadata.pop(hass_key) + + # Create metadata specific to the given media type; different fields are + # available depending on what the upnp_class is. + upnp_class = MEDIA_UPNP_CLASS_MAP.get(media_type) + didl_metadata = await self._device.construct_play_media_metadata( + media_url=media_id, + media_title=title, + override_upnp_class=upnp_class, + meta_data=metadata, + ) # Stop current playing media if self._device.can_stop: await self.async_media_stop() # Queue media - await self._device.async_set_transport_uri(media_id, title) - await self._device.async_wait_for_can_play() + await self._device.async_set_transport_uri(media_id, title, didl_metadata) - # If already playing, no need to call Play - if self._device.transport_state == TransportState.PLAYING: + # If already playing, or don't want to autoplay, no need to call Play + autoplay = extra.get("autoplay", True) + if self._device.transport_state == TransportState.PLAYING or not autoplay: return # Play it + await self._device.async_wait_for_can_play() await self.async_media_play() @catch_request_errors @@ -606,6 +661,96 @@ class DlnaDmrEntity(MediaPlayerEntity): assert self._device is not None await self._device.async_next() + @property + def shuffle(self) -> bool | None: + """Boolean if shuffle is enabled.""" + if not self._device: + return None + + if not (play_mode := self._device.play_mode): + return None + + if play_mode == PlayMode.VENDOR_DEFINED: + return None + + return play_mode in (PlayMode.SHUFFLE, PlayMode.RANDOM) + + @catch_request_errors + async def async_set_shuffle(self, shuffle: bool) -> None: + """Enable/disable shuffle mode.""" + assert self._device is not None + + repeat = self.repeat or REPEAT_MODE_OFF + potential_play_modes = SHUFFLE_PLAY_MODES[(shuffle, repeat)] + + valid_play_modes = self._device.valid_play_modes + + for mode in potential_play_modes: + if mode in valid_play_modes: + await self._device.async_set_play_mode(mode) + return + + _LOGGER.debug( + "Couldn't find a suitable mode for shuffle=%s, repeat=%s", shuffle, repeat + ) + + @property + def repeat(self) -> str | None: + """Return current repeat mode.""" + if not self._device: + return None + + if not (play_mode := self._device.play_mode): + return None + + if play_mode == PlayMode.VENDOR_DEFINED: + return None + + if play_mode == PlayMode.REPEAT_ONE: + return REPEAT_MODE_ONE + + if play_mode in (PlayMode.REPEAT_ALL, PlayMode.RANDOM): + return REPEAT_MODE_ALL + + return REPEAT_MODE_OFF + + @catch_request_errors + async def async_set_repeat(self, repeat: str) -> None: + """Set repeat mode.""" + assert self._device is not None + + shuffle = self.shuffle or False + potential_play_modes = REPEAT_PLAY_MODES[(shuffle, repeat)] + + valid_play_modes = self._device.valid_play_modes + + for mode in potential_play_modes: + if mode in valid_play_modes: + await self._device.async_set_play_mode(mode) + return + + _LOGGER.debug( + "Couldn't find a suitable mode for shuffle=%s, repeat=%s", shuffle, repeat + ) + + @property + def sound_mode(self) -> str | None: + """Name of the current sound mode, not supported by DLNA.""" + return None + + @property + def sound_mode_list(self) -> list[str] | None: + """List of available sound modes.""" + if not self._device: + return None + return self._device.preset_names + + @catch_request_errors + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Select sound mode.""" + assert self._device is not None + await self._device.async_select_preset(sound_mode) + @property def media_title(self) -> str | None: """Title of current playing media.""" @@ -705,12 +850,10 @@ class DlnaDmrEntity(MediaPlayerEntity): not self._device.media_season_number or self._device.media_season_number == "0" ) and self._device.media_episode_number: - try: + with contextlib.suppress(ValueError): episode = int(self._device.media_episode_number, 10) if episode > 100: return str(episode // 100) - except ValueError: - pass return self._device.media_season_number @property @@ -723,12 +866,10 @@ class DlnaDmrEntity(MediaPlayerEntity): not self._device.media_season_number or self._device.media_season_number == "0" ) and self._device.media_episode_number: - try: + with contextlib.suppress(ValueError): episode = int(self._device.media_episode_number, 10) if episode > 100: return str(episode % 100) - except ValueError: - pass return self._device.media_episode_number @property @@ -737,3 +878,10 @@ class DlnaDmrEntity(MediaPlayerEntity): if not self._device: return None return self._device.media_channel_name + + @property + def media_playlist(self) -> str | None: + """Title of Playlist currently playing.""" + if not self._device: + return None + return self._device.media_playlist_title diff --git a/homeassistant/components/dlna_dmr/translations/fr.json b/homeassistant/components/dlna_dmr/translations/fr.json new file mode 100644 index 00000000000..4db8bec9bd6 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/fr.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion" + }, + "step": { + "confirm": { + "description": "Voulez-vous commencer la configuration ?" + }, + "manual": { + "data": { + "url": "URL" + } + }, + "user": { + "data": { + "host": "H\u00f4te", + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/he.json b/homeassistant/components/dlna_dmr/translations/he.json index fbdaa0403f4..7025721fa43 100644 --- a/homeassistant/components/dlna_dmr/translations/he.json +++ b/homeassistant/components/dlna_dmr/translations/he.json @@ -1,15 +1,25 @@ { "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", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" }, "flow_title": "{name}", "step": { "confirm": { "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" }, + "manual": { + "data": { + "url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8" + } + }, "user": { "data": { + "host": "\u05de\u05d0\u05e8\u05d7", "url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8" } } diff --git a/homeassistant/components/dlna_dmr/translations/hu.json b/homeassistant/components/dlna_dmr/translations/hu.json index 603e14b9e30..6596e71e05e 100644 --- a/homeassistant/components/dlna_dmr/translations/hu.json +++ b/homeassistant/components/dlna_dmr/translations/hu.json @@ -35,7 +35,7 @@ "host": "C\u00edm", "url": "URL" }, - "description": "Az eszk\u00f6z le\u00edr\u00e1s\u00e1nak XML-f\u00e1jl URL-c\u00edme", + "description": "V\u00e1lassz egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt vagy adj meg egy URL-t", "title": "DLNA digit\u00e1lis m\u00e9dia renderel\u0151" } } diff --git a/homeassistant/components/dlna_dmr/translations/id.json b/homeassistant/components/dlna_dmr/translations/id.json new file mode 100644 index 00000000000..152c4f73a56 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/id.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "alternative_integration": "Perangkat dapat didukung lebih baik lewat integrasi lainnya", + "cannot_connect": "Gagal terhubung", + "could_not_connect": "Gagal terhubung ke perangkat DLNA", + "discovery_error": "Gagal menemukan perangkat DLNA yang cocok", + "incomplete_config": "Konfigurasi tidak memiliki variabel yang diperlukan", + "non_unique_id": "Beberapa perangkat ditemukan dengan ID unik yang sama", + "not_dmr": "Perangkat bukan Digital Media Renderer yang didukung" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "could_not_connect": "Gagal terhubung ke perangkat DLNA", + "not_dmr": "Perangkat bukan Digital Media Renderer yang didukung" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Ingin memulai penyiapan?" + }, + "import_turn_on": { + "description": "Nyalakan perangkat dan klik kirim untuk melanjutkan migrasi" + }, + "manual": { + "data": { + "url": "URL" + }, + "description": "URL ke file XML deskripsi perangkat", + "title": "Koneksi perangkat DLNA DMR manual" + }, + "user": { + "data": { + "host": "Host", + "url": "URL" + }, + "description": "Pilih perangkat untuk dikonfigurasi atau biarkan kosong untuk memasukkan URL", + "title": "Perangkat DLNA DMR yang ditemukan" + } + } + }, + "options": { + "error": { + "invalid_url": "URL tidak valid" + }, + "step": { + "init": { + "data": { + "callback_url_override": "URL panggilan balik pendengar peristiwa", + "listen_port": "Port pendengar peristiwa (acak jika tidak diatur)", + "poll_availability": "Polling untuk ketersediaan perangkat" + }, + "title": "Konfigurasi Digital Media Renderer DLNA" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/it.json b/homeassistant/components/dlna_dmr/translations/it.json index 0ab40e3c804..545d3cadbcb 100644 --- a/homeassistant/components/dlna_dmr/translations/it.json +++ b/homeassistant/components/dlna_dmr/translations/it.json @@ -2,15 +2,18 @@ "config": { "abort": { "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "alternative_integration": "Il dispositivo \u00e8 meglio supportato da un'altra integrazione", + "cannot_connect": "Impossibile connettersi", "could_not_connect": "Impossibile connettersi al dispositivo DLNA", "discovery_error": "Impossibile individuare un dispositivo DLNA corrispondente", "incomplete_config": "Nella configurazione manca una variabile richiesta", "non_unique_id": "Pi\u00f9 dispositivi trovati con lo stesso ID univoco", - "not_dmr": "Il dispositivo non \u00e8 un Digital Media Renderer" + "not_dmr": "Il dispositivo non \u00e8 un Digital Media Renderer supportato" }, "error": { + "cannot_connect": "Impossibile connettersi", "could_not_connect": "Impossibile connettersi al dispositivo DLNA", - "not_dmr": "Il dispositivo non \u00e8 un Digital Media Renderer" + "not_dmr": "Il dispositivo non \u00e8 un Digital Media Renderer supportato" }, "flow_title": "{name}", "step": { @@ -20,12 +23,20 @@ "import_turn_on": { "description": "Accendi il dispositivo e fai clic su Invia per continuare la migrazione" }, - "user": { + "manual": { "data": { "url": "URL" }, "description": "URL di un file XML di descrizione del dispositivo", - "title": "DLNA Digital Media Renderer" + "title": "Connessione manuale del dispositivo DLNA DMR" + }, + "user": { + "data": { + "host": "Host", + "url": "URL" + }, + "description": "Scegli un dispositivo da configurare o lascia vuoto per inserire un URL", + "title": "Rilevati dispositivi DLNA DMR" } } }, diff --git a/homeassistant/components/dlna_dmr/translations/ja.json b/homeassistant/components/dlna_dmr/translations/ja.json index 1a0c1d7fdf4..9edb9156534 100644 --- a/homeassistant/components/dlna_dmr/translations/ja.json +++ b/homeassistant/components/dlna_dmr/translations/ja.json @@ -1,17 +1,58 @@ { "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "alternative_integration": "\u30c7\u30d0\u30a4\u30b9\u306f\u5225\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u3001\u3088\u308a\u9069\u5207\u306b\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "could_not_connect": "DLNA\u30c7\u30d0\u30a4\u30b9\u3078\u306e\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "discovery_error": "\u4e00\u81f4\u3059\u308bDLNA \u30c7\u30d0\u30a4\u30b9\u3092\u691c\u51fa\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f", + "incomplete_config": "\u8a2d\u5b9a\u306b\u5fc5\u8981\u306a\u5909\u6570\u304c\u3042\u308a\u307e\u305b\u3093", + "non_unique_id": "\u540c\u4e00\u306eID\u3067\u8907\u6570\u306e\u30c7\u30d0\u30a4\u30b9\u304c\u691c\u51fa\u3055\u308c\u307e\u3057\u305f", + "not_dmr": "\u30c7\u30d0\u30a4\u30b9\u304c\u3001\u672a\u30b5\u30dd\u30fc\u30c8\u306aDigital Media Renderer\u3067\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "could_not_connect": "DLNA\u30c7\u30d0\u30a4\u30b9\u3078\u306e\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "not_dmr": "\u30c7\u30d0\u30a4\u30b9\u304c\u3001\u672a\u30b5\u30dd\u30fc\u30c8\u306aDigital Media Renderer\u3067\u3059" + }, "flow_title": "{name}", "step": { - "user": { + "confirm": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + }, + "import_turn_on": { + "description": "\u30c7\u30d0\u30a4\u30b9\u306e\u96fb\u6e90\u3092\u5165\u308c\u3001\u9001\u4fe1(submit)\u3092\u30af\u30ea\u30c3\u30af\u3057\u3066\u79fb\u884c\u3092\u7d9a\u3051\u3066\u304f\u3060\u3055\u3044" + }, + "manual": { "data": { "url": "URL" - } + }, + "description": "\u30c7\u30d0\u30a4\u30b9\u8a18\u8ff0XML\u30d5\u30a1\u30a4\u30eb\u3078\u306eURL", + "title": "\u624b\u52d5\u3067DLNA DMR\u6a5f\u5668\u306b\u63a5\u7d9a" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "url": "URL" + }, + "description": "\u8a2d\u5b9a\u3059\u308b\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e\u3059\u308b\u304b\u3001\u7a7a\u767d\u306e\u307e\u307e\u306b\u3057\u3066URL\u3092\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "\u767a\u898b\u3055\u308c\u305fDLNA DMR\u6a5f\u5668" } } }, "options": { "error": { "invalid_url": "\u7121\u52b9\u306aURL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "\u30a4\u30d9\u30f3\u30c8\u30ea\u30b9\u30ca\u30fc\u306e\u30b3\u30fc\u30eb\u30d0\u30c3\u30afURL", + "listen_port": "\u30a4\u30d9\u30f3\u30c8\u30ea\u30b9\u30ca\u30fc\u30dd\u30fc\u30c8(\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u306a\u3044\u5834\u5408\u306f\u30e9\u30f3\u30c0\u30e0)", + "poll_availability": "\u30c7\u30d0\u30a4\u30b9\u306e\u53ef\u7528\u6027\u3092\u30dd\u30fc\u30ea\u30f3\u30b0" + }, + "title": "DLNA Digital Media Renderer\u306e\u8a2d\u5b9a" + } } } } \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/nl.json b/homeassistant/components/dlna_dmr/translations/nl.json index ff14a957526..5331f3340dd 100644 --- a/homeassistant/components/dlna_dmr/translations/nl.json +++ b/homeassistant/components/dlna_dmr/translations/nl.json @@ -8,12 +8,12 @@ "discovery_error": "Kan geen overeenkomend DLNA-apparaat vinden", "incomplete_config": "Configuratie mist een vereiste variabele", "non_unique_id": "Meerdere apparaten gevonden met hetzelfde unieke ID", - "not_dmr": "Apparaat is geen Digital Media Renderer" + "not_dmr": "Apparaat is een niet-ondersteund Digital Media Renderer" }, "error": { "cannot_connect": "Kan geen verbinding maken", "could_not_connect": "Mislukt om te verbinden met DNLA apparaat", - "not_dmr": "Apparaat is geen Digital Media Renderer" + "not_dmr": "Apparaat is een niet-ondersteund Digital Media Renderer" }, "flow_title": "{name}", "step": { @@ -35,8 +35,8 @@ "host": "Host", "url": "URL" }, - "description": "URL naar een XML-bestand met apparaatbeschrijvingen", - "title": "DLNA Digital Media Renderer" + "description": "Kies een apparaat om te configureren of laat leeg om een URL in te voeren", + "title": "Ontdekt DLNA Digital Media Renderer" } } }, diff --git a/homeassistant/components/dlna_dmr/translations/no.json b/homeassistant/components/dlna_dmr/translations/no.json index 3b0f5854aca..a1ce1fdce32 100644 --- a/homeassistant/components/dlna_dmr/translations/no.json +++ b/homeassistant/components/dlna_dmr/translations/no.json @@ -8,12 +8,12 @@ "discovery_error": "Kunne ikke finne en matchende DLNA -enhet", "incomplete_config": "Konfigurasjonen mangler en n\u00f8dvendig variabel", "non_unique_id": "Flere enheter ble funnet med samme unike ID", - "not_dmr": "Enheten er ikke en Digital Media Renderer" + "not_dmr": "Enheten er ikke en st\u00f8ttet Digital Media Renderer" }, "error": { "cannot_connect": "Tilkobling mislyktes", "could_not_connect": "Kunne ikke koble til DLNA -enhet", - "not_dmr": "Enheten er ikke en Digital Media Renderer" + "not_dmr": "Enheten er ikke en st\u00f8ttet Digital Media Renderer" }, "flow_title": "{name}", "step": { diff --git a/homeassistant/components/dlna_dmr/translations/pl.json b/homeassistant/components/dlna_dmr/translations/pl.json index 09fb3dce509..7f831c92f99 100644 --- a/homeassistant/components/dlna_dmr/translations/pl.json +++ b/homeassistant/components/dlna_dmr/translations/pl.json @@ -2,26 +2,56 @@ "config": { "abort": { "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + "alternative_integration": "Urz\u0105dzenie jest lepiej obs\u0142ugiwane przez inn\u0105 integracj\u0119", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "could_not_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia z urz\u0105dzeniem DLNA", + "discovery_error": "Nie uda\u0142o si\u0119 wykry\u0107 pasuj\u0105cego urz\u0105dzenia DLNA", + "incomplete_config": "W konfiguracji brakuje wymaganej zmiennej", + "non_unique_id": "Znaleziono wiele urz\u0105dze\u0144 z tym samym unikalnym identyfikatorem", + "not_dmr": "Urz\u0105dzenie nie jest obs\u0142ugiwanym rendererem multimedi\u00f3w cyfrowych (DMR)" }, "error": { - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "could_not_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia z urz\u0105dzeniem DLNA", + "not_dmr": "Urz\u0105dzenie nie jest obs\u0142ugiwanym rendererem multimedi\u00f3w cyfrowych (DMR)" }, "flow_title": "{name}", "step": { "confirm": { "description": "Czy chcesz rozpocz\u0105\u0107 konfiguracj\u0119?" }, + "import_turn_on": { + "description": "W\u0142\u0105cz urz\u0105dzenie i kliknij \"Zatwierd\u017a\", aby kontynuowa\u0107 migracj\u0119" + }, "manual": { "data": { "url": "URL" - } + }, + "description": "URL do pliku XML z opisem urz\u0105dzenia", + "title": "R\u0119czne pod\u0142\u0105czanie urz\u0105dzenia DLNA DMR" }, "user": { "data": { "host": "Nazwa hosta lub adres IP", "url": "URL" - } + }, + "description": "Wybierz urz\u0105dzenie do skonfigurowania lub pozostaw puste, aby wprowadzi\u0107 adres URL", + "title": "Wykryto urz\u0105dzenia DLNA DMR" + } + } + }, + "options": { + "error": { + "invalid_url": "Nieprawid\u0142owy adres URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "Adres Callback URL dla detektora zdarze\u0144", + "listen_port": "Port detektora zdarze\u0144 (losowy, je\u015bli nie jest ustawiony)", + "poll_availability": "Sondowanie na dost\u0119pno\u015b\u0107 urz\u0105dze\u0144" + }, + "title": "Konfiguracja rendera multimedi\u00f3w cyfrowych (DMR) dla DLNA" } } } diff --git a/homeassistant/components/dlna_dmr/translations/sl.json b/homeassistant/components/dlna_dmr/translations/sl.json new file mode 100644 index 00000000000..5a85ea9dd01 --- /dev/null +++ b/homeassistant/components/dlna_dmr/translations/sl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "alternative_integration": "Naprava je bolje podprta z drugo integracijo", + "cannot_connect": "Povezava ni uspela" + }, + "error": { + "cannot_connect": "Povezava ni uspela" + }, + "step": { + "manual": { + "data": { + "url": "URL" + } + }, + "user": { + "data": { + "host": "Gostitelj" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dlna_dmr/translations/tr.json b/homeassistant/components/dlna_dmr/translations/tr.json index 64e3f950b25..3a19f9455ad 100644 --- a/homeassistant/components/dlna_dmr/translations/tr.json +++ b/homeassistant/components/dlna_dmr/translations/tr.json @@ -1,16 +1,58 @@ { "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "alternative_integration": "Cihaz ba\u015fka bir entegrasyon taraf\u0131ndan daha iyi destekleniyor", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "could_not_connect": "DLNA cihaz\u0131na ba\u011flan\u0131lamad\u0131", + "discovery_error": "E\u015fle\u015fen bir DLNA cihaz\u0131 bulunamad\u0131", + "incomplete_config": "Yap\u0131land\u0131rmada gerekli bir de\u011fi\u015fken eksik", + "non_unique_id": "Ayn\u0131 benzersiz kimli\u011fe sahip birden fazla cihaz bulundu", + "not_dmr": "Cihaz, desteklenen bir Dijital Medya Olu\u015fturucu de\u011fil" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "could_not_connect": "DLNA cihaz\u0131na ba\u011flan\u0131lamad\u0131", + "not_dmr": "Cihaz, desteklenen bir Dijital Medya Olu\u015fturucu de\u011fil" + }, + "flow_title": "{name}", "step": { - "user": { + "confirm": { + "description": "Kuruluma ba\u015flamak ister misiniz?" + }, + "import_turn_on": { + "description": "L\u00fctfen cihaz\u0131 a\u00e7\u0131n ve ta\u015f\u0131maya devam etmek i\u00e7in g\u00f6nder'i t\u0131klay\u0131n" + }, + "manual": { "data": { "url": "URL" - } + }, + "description": "Ayg\u0131t a\u00e7\u0131klamas\u0131 XML dosyas\u0131n\u0131n URL'si", + "title": "Manuel DLNA DMR ayg\u0131t ba\u011flant\u0131s\u0131" + }, + "user": { + "data": { + "host": "Ana bilgisayar", + "url": "URL" + }, + "description": "Yap\u0131land\u0131rmak i\u00e7in bir cihaz se\u00e7in veya bir URL girmek i\u00e7in bo\u015f b\u0131rak\u0131n", + "title": "Ke\u015ffedilen DLNA DMR cihazlar\u0131" } } }, "options": { "error": { "invalid_url": "Ge\u00e7ersiz URL" + }, + "step": { + "init": { + "data": { + "callback_url_override": "Olay dinleyici geri \u00e7a\u011f\u0131rma URL'si", + "listen_port": "Olay dinleyici ba\u011flant\u0131 noktas\u0131 (ayarlanmam\u0131\u015fsa rastgele)", + "poll_availability": "Cihaz kullan\u0131labilirli\u011fi i\u00e7in anket" + }, + "title": "DLNA Dijital Medya \u0130\u015fleyici yap\u0131land\u0131rmas\u0131" + } } } } \ No newline at end of file diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index 3bd82a7ff8e..7497304f9e1 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -269,9 +269,7 @@ class ConfiguredDoorBird: if not self.webhook_is_registered(url): self.device.change_favorite("http", f"Home Assistant ({event})", url) - fav_id = self.get_webhook_id(url) - - if not fav_id: + if not self.get_webhook_id(url): _LOGGER.warning( 'Could not find favorite for URL "%s". ' 'Skipping sensor "%s"', url, diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index 16606156314..8331570fd2f 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -125,7 +125,7 @@ class DoorBirdCamera(DoorBirdEntity, Camera): try: websession = async_get_clientsession(self.hass) - with async_timeout.timeout(_TIMEOUT): + async with async_timeout.timeout(_TIMEOUT): response = await websession.get(self._url) self._last_image = await response.read() diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index 01fcc2b2c22..31ddd1f6193 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -8,8 +8,10 @@ import requests import voluptuous as vol from homeassistant import config_entries, core, exceptions +from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.util.network import is_link_local from .const import CONF_EVENTS, DOMAIN, DOORBIRD_OUI @@ -90,10 +92,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data = self.discovery_schema or _schema_with_defaults() return self.async_show_form(step_id="user", data_schema=data, errors=errors) - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Prepare configuration for a discovered doorbird device.""" - macaddress = discovery_info["properties"]["macaddress"] - host = discovery_info[CONF_HOST] + macaddress = discovery_info.properties["macaddress"] + host = discovery_info.host if macaddress[:6] != DOORBIRD_OUI: return self.async_abort(reason="not_doorbird_device") @@ -109,7 +113,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="not_doorbird_device") chop_ending = "._axis-video._tcp.local." - friendly_hostname = discovery_info["name"] + friendly_hostname = discovery_info.name if friendly_hostname.endswith(chop_ending): friendly_hostname = friendly_hostname[: -len(chop_ending)] diff --git a/homeassistant/components/doorbird/const.py b/homeassistant/components/doorbird/const.py index 46a95f0d500..46c37e5b050 100644 --- a/homeassistant/components/doorbird/const.py +++ b/homeassistant/components/doorbird/const.py @@ -1,8 +1,8 @@ """The DoorBird integration constants.""" - +from homeassistant.const import Platform DOMAIN = "doorbird" -PLATFORMS = ["switch", "camera"] +PLATFORMS = [Platform.SWITCH, Platform.CAMERA] DOOR_STATION = "door_station" DOOR_STATION_INFO = "door_station_info" DOOR_STATION_EVENT_ENTITY_IDS = "door_station_event_entity_ids" diff --git a/homeassistant/components/doorbird/translations/ja.json b/homeassistant/components/doorbird/translations/ja.json new file mode 100644 index 00000000000..179edc8943c --- /dev/null +++ b/homeassistant/components/doorbird/translations/ja.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "link_local_address": "\u30ed\u30fc\u30ab\u30eb\u30a2\u30c9\u30ec\u30b9\u306e\u30ea\u30f3\u30af\u306b\u306f\u5bfe\u5fdc\u3057\u3066\u3044\u307e\u305b\u3093", + "not_doorbird_device": "\u3053\u306e\u30c7\u30d0\u30a4\u30b9\u306f\u3001DoorBird\u3067\u306f\u3042\u308a\u307e\u305b\u3093" + }, + "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" + }, + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u30c7\u30d0\u30a4\u30b9\u540d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "DoorBird\u306b\u63a5\u7d9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "\u30a4\u30d9\u30f3\u30c8\u306e\u30b3\u30f3\u30de\u533a\u5207\u308a\u30ea\u30b9\u30c8\u3002" + }, + "description": "\u8ffd\u8de1\u3059\u308b\u30a4\u30d9\u30f3\u30c8\u3054\u3068\u306b\u3001\u30b3\u30f3\u30de\u533a\u5207\u308a\u3067\u30a4\u30d9\u30f3\u30c8\u540d\u3092\u8ffd\u52a0\u3057\u307e\u3059\u3002\u3053\u3053\u306b\u5165\u529b\u3057\u305f\u5f8c\u3001DoorBird\u30a2\u30d7\u30ea\u3092\u4f7f\u7528\u3057\u3066\u7279\u5b9a\u306e\u30a4\u30d9\u30f3\u30c8\u306b\u5272\u308a\u5f53\u3066\u307e\u3059\u3002https://www.home-assistant.io/integrations/doorbird/#events. \u306e\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u4f8b: somebody_pressed_the_button, motion" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/tr.json b/homeassistant/components/doorbird/translations/tr.json index d7a1ca8a93a..8f36c27fc40 100644 --- a/homeassistant/components/doorbird/translations/tr.json +++ b/homeassistant/components/doorbird/translations/tr.json @@ -1,21 +1,35 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "link_local_address": "Ba\u011flant\u0131 yerel adresleri desteklenmiyor", + "not_doorbird_device": "Bu cihaz bir DoorBird de\u011fil" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "unknown": "Beklenmeyen hata" }, + "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "name": "Cihaz ad\u0131", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "DoorBird'e ba\u011flan\u0131n" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "Virg\u00fclle ayr\u0131lm\u0131\u015f olaylar\u0131n listesi." + }, + "description": "\u0130zlemek istedi\u011finiz her etkinlik i\u00e7in virg\u00fclle ayr\u0131lm\u0131\u015f bir etkinlik ad\u0131 ekleyin. Bunlar\u0131 buraya girdikten sonra, onlar\u0131 belirli bir etkinli\u011fe atamak i\u00e7in DoorBird uygulamas\u0131n\u0131 kullan\u0131n. https://www.home-assistant.io/integrations/doorbird/#events adresindeki belgelere bak\u0131n. \u00d6rnek: birisi_pressed_the_button, hareket" } } } diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index ba90fa9b697..8f5620bfd7c 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -5,17 +5,8 @@ import logging from dsmr_parser import obis_references -from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, -) -from homeassistant.const import ( - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_GAS, - DEVICE_CLASS_POWER, - DEVICE_CLASS_VOLTAGE, -) +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import Platform from .models import DSMRSensorEntityDescription @@ -23,8 +14,7 @@ DOMAIN = "dsmr" LOGGER = logging.getLogger(__package__) -PLATFORMS = ["sensor"] - +PLATFORMS = [Platform.SENSOR] CONF_DSMR_VERSION = "dsmr_version" CONF_RECONNECT_INTERVAL = "reconnect_interval" CONF_PRECISION = "precision" @@ -50,16 +40,16 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key=obis_references.CURRENT_ELECTRICITY_USAGE, name="Power Consumption", - device_class=DEVICE_CLASS_POWER, + device_class=SensorDeviceClass.POWER, force_update=True, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( key=obis_references.CURRENT_ELECTRICITY_DELIVERY, name="Power Production", - device_class=DEVICE_CLASS_POWER, + device_class=SensorDeviceClass.POWER, force_update=True, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_ACTIVE_TARIFF, @@ -71,75 +61,75 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key=obis_references.ELECTRICITY_USED_TARIFF_1, name="Energy Consumption (tarif 1)", dsmr_versions={"2.2", "4", "5", "5B", "5L"}, - device_class=DEVICE_CLASS_ENERGY, + device_class=SensorDeviceClass.ENERGY, force_update=True, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_USED_TARIFF_2, name="Energy Consumption (tarif 2)", dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_DELIVERED_TARIFF_1, name="Energy Production (tarif 1)", dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_DELIVERED_TARIFF_2, name="Energy Production (tarif 2)", dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE, name="Power Consumption Phase L1", - device_class=DEVICE_CLASS_POWER, + device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE, name="Power Consumption Phase L2", - device_class=DEVICE_CLASS_POWER, + device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE, name="Power Consumption Phase L3", - device_class=DEVICE_CLASS_POWER, + device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE, name="Power Production Phase L1", - device_class=DEVICE_CLASS_POWER, + device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE, name="Power Production Phase L2", - device_class=DEVICE_CLASS_POWER, + device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE, name="Power Production Phase L3", - device_class=DEVICE_CLASS_POWER, + device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( key=obis_references.SHORT_POWER_FAILURE_COUNT, @@ -197,84 +187,84 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key=obis_references.INSTANTANEOUS_VOLTAGE_L1, name="Voltage Phase L1", - device_class=DEVICE_CLASS_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( key=obis_references.INSTANTANEOUS_VOLTAGE_L2, name="Voltage Phase L2", - device_class=DEVICE_CLASS_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( key=obis_references.INSTANTANEOUS_VOLTAGE_L3, name="Voltage Phase L3", - device_class=DEVICE_CLASS_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( key=obis_references.INSTANTANEOUS_CURRENT_L1, name="Current Phase L1", - device_class=DEVICE_CLASS_CURRENT, + device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( key=obis_references.INSTANTANEOUS_CURRENT_L2, name="Current Phase L2", - device_class=DEVICE_CLASS_CURRENT, + device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( key=obis_references.INSTANTANEOUS_CURRENT_L3, name="Current Phase L3", - device_class=DEVICE_CLASS_CURRENT, + device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( key=obis_references.LUXEMBOURG_ELECTRICITY_USED_TARIFF_GLOBAL, name="Energy Consumption (total)", dsmr_versions={"5L"}, force_update=True, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.LUXEMBOURG_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, name="Energy Production (total)", dsmr_versions={"5L"}, force_update=True, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.SWEDEN_ELECTRICITY_USED_TARIFF_GLOBAL, name="Energy Consumption (total)", dsmr_versions={"5S"}, force_update=True, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.SWEDEN_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, name="Energy Production (total)", dsmr_versions={"5S"}, force_update=True, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_IMPORTED_TOTAL, name="Energy Consumption (total)", dsmr_versions={"2.2", "4", "5", "5B"}, force_update=True, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.HOURLY_GAS_METER_READING, @@ -282,8 +272,8 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( dsmr_versions={"4", "5", "5L"}, is_gas=True, force_update=True, - device_class=DEVICE_CLASS_GAS, - state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.BELGIUM_HOURLY_GAS_METER_READING, @@ -291,8 +281,8 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( dsmr_versions={"5B"}, is_gas=True, force_update=True, - device_class=DEVICE_CLASS_GAS, - state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.GAS_METER_READING, @@ -300,7 +290,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( dsmr_versions={"2.2"}, is_gas=True, force_update=True, - device_class=DEVICE_CLASS_GAS, - state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, ), ) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 12b2a17016a..c016a25ad55 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -260,8 +260,7 @@ class DSMREntity(SensorEntity): @property def native_value(self) -> StateType: """Return the state of sensor, if available, translate if needed.""" - value = self.get_dsmr_object_attr("value") - if value is None: + if (value := self.get_dsmr_object_attr("value")) is None: return None if self.entity_description.key == obis_ref.ELECTRICITY_ACTIVE_TARIFF: diff --git a/homeassistant/components/dsmr/translations/bg.json b/homeassistant/components/dsmr/translations/bg.json index 439b8d63d8d..153afa164a2 100644 --- a/homeassistant/components/dsmr/translations/bg.json +++ b/homeassistant/components/dsmr/translations/bg.json @@ -3,12 +3,12 @@ "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_communicate": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u0430 \u043a\u043e\u043c\u0443\u043d\u0438\u043a\u0430\u0446\u0438\u044f", - "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" }, "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_communicate": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u0430 \u043a\u043e\u043c\u0443\u043d\u0438\u043a\u0430\u0446\u0438\u044f", - "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" }, "step": { "setup_network": { diff --git a/homeassistant/components/dsmr/translations/id.json b/homeassistant/components/dsmr/translations/id.json index 2e56dd3b0a6..2c1eeccca17 100644 --- a/homeassistant/components/dsmr/translations/id.json +++ b/homeassistant/components/dsmr/translations/id.json @@ -2,18 +2,29 @@ "config": { "abort": { "already_configured": "Perangkat sudah dikonfigurasi", + "cannot_communicate": "Gagal berkomunikasi", "cannot_connect": "Gagal terhubung" }, "error": { "already_configured": "Perangkat sudah dikonfigurasi", + "cannot_communicate": "Gagal berkomunikasi", "cannot_connect": "Gagal terhubung" }, "step": { "setup_network": { "data": { + "dsmr_version": "Pilih versi DSMR", "host": "Host", "port": "Port" - } + }, + "title": "Pilih alamat koneksi" + }, + "setup_serial": { + "data": { + "dsmr_version": "Pilih versi DSMR", + "port": "Pilih perangkat" + }, + "title": "Perangkat" }, "setup_serial_manual_path": { "data": { diff --git a/homeassistant/components/dsmr/translations/ja.json b/homeassistant/components/dsmr/translations/ja.json new file mode 100644 index 00000000000..53c7d5c1050 --- /dev/null +++ b/homeassistant/components/dsmr/translations/ja.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_communicate": "\u901a\u4fe1\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "error": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_communicate": "\u901a\u4fe1\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "setup_network": { + "data": { + "dsmr_version": "DSMR\u30d0\u30fc\u30b8\u30e7\u30f3\u3092\u9078\u629e", + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "title": "\u63a5\u7d9a\u30a2\u30c9\u30ec\u30b9\u306e\u9078\u629e" + }, + "setup_serial": { + "data": { + "dsmr_version": "DSMR\u30d0\u30fc\u30b8\u30e7\u30f3\u3092\u9078\u629e", + "port": "\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e" + }, + "title": "\u30c7\u30d0\u30a4\u30b9" + }, + "setup_serial_manual_path": { + "data": { + "port": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "title": "\u30d1\u30b9" + }, + "user": { + "data": { + "type": "\u63a5\u7d9a\u30bf\u30a4\u30d7" + }, + "title": "\u63a5\u7d9a\u30bf\u30a4\u30d7\u306e\u9078\u629e" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "time_between_update": "\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u66f4\u65b0\u306e\u6700\u5c0f\u6642\u9593[\u79d2]" + }, + "title": "DSMR\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dsmr/translations/tr.json b/homeassistant/components/dsmr/translations/tr.json index 0857160dc51..1b282b456d6 100644 --- a/homeassistant/components/dsmr/translations/tr.json +++ b/homeassistant/components/dsmr/translations/tr.json @@ -1,7 +1,47 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_communicate": "\u0130leti\u015fim kurulamad\u0131", + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "error": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_communicate": "\u0130leti\u015fim kurulamad\u0131", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "one": "Bo\u015f", + "other": "Bo\u015f" + }, + "step": { + "one": "Bo\u015f", + "other": "Bo\u015f", + "setup_network": { + "data": { + "dsmr_version": "DSMR s\u00fcr\u00fcm\u00fcn\u00fc se\u00e7in", + "host": "Ana bilgisayar", + "port": "Port" + }, + "title": "Ba\u011flant\u0131 adresini se\u00e7in" + }, + "setup_serial": { + "data": { + "dsmr_version": "DSMR s\u00fcr\u00fcm\u00fcn\u00fc se\u00e7in", + "port": "Cihaz se\u00e7" + }, + "title": "Cihaz" + }, + "setup_serial_manual_path": { + "data": { + "port": "USB Cihaz Yolu" + }, + "title": "Yol" + }, + "user": { + "data": { + "type": "Ba\u011flant\u0131 t\u00fcr\u00fc" + }, + "title": "Ba\u011flant\u0131 t\u00fcr\u00fcn\u00fc se\u00e7in" + } } }, "options": { diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 1c719bc890b..4645aef9a7a 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -24,6 +24,7 @@ from homeassistant.const import ( POWER_KILO_WATT, VOLUME_CUBIC_METERS, ) +from homeassistant.util import dt as dt_util PRICE_EUR_KWH: Final = f"EUR/{ENERGY_KILO_WATT_HOUR}" PRICE_EUR_M3: Final = f"EUR/{VOLUME_CUBIC_METERS}" @@ -202,6 +203,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Telegram timestamp", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_TIMESTAMP, + state=dt_util.parse_datetime, ), DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/delivered", @@ -222,6 +224,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Gas meter read", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_TIMESTAMP, + state=dt_util.parse_datetime, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity1", diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py index 76353415d4f..2271b107b36 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -1,5 +1,4 @@ """Integrate with DuckDNS.""" -from asyncio import iscoroutinefunction from datetime import timedelta import logging @@ -102,10 +101,6 @@ async def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False) @bind_hass def async_track_time_interval_backoff(hass, action, intervals) -> CALLBACK_TYPE: """Add a listener that fires repetitively at every timedelta interval.""" - if not iscoroutinefunction: - _LOGGER.error("Action needs to be a coroutine and return True/False") - return - if not isinstance(intervals, (list, tuple)): intervals = (intervals,) remove = None diff --git a/homeassistant/components/dunehd/__init__.py b/homeassistant/components/dunehd/__init__.py index 24851dac4e8..839f79bc3f4 100644 --- a/homeassistant/components/dunehd/__init__.py +++ b/homeassistant/components/dunehd/__init__.py @@ -6,12 +6,12 @@ from typing import Final from pdunehd import DuneHDPlayer from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from .const import DOMAIN -PLATFORMS: Final[list[str]] = ["media_player"] +PLATFORMS: Final[list[Platform]] = [Platform.MEDIA_PLAYER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/dunehd/translations/bg.json b/homeassistant/components/dunehd/translations/bg.json new file mode 100644 index 00000000000..56eb33213e4 --- /dev/null +++ b/homeassistant/components/dunehd/translations/bg.json @@ -0,0 +1,19 @@ +{ + "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": { + "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_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" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dunehd/translations/ja.json b/homeassistant/components/dunehd/translations/ja.json new file mode 100644 index 00000000000..2bf0ff94c48 --- /dev/null +++ b/homeassistant/components/dunehd/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "already_configured": "\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", + "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "Dune HD\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u3002\u8a2d\u5b9a\u306b\u554f\u984c\u304c\u3042\u308b\u5834\u5408\u306f\u3001https://www.home-assistant.io/integrations/dunehd \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044\n\n\u304d\u3061\u3093\u3068\u30d7\u30ec\u30fc\u30e4\u30fc\u306e\u96fb\u6e90\u304c\u5165\u3063\u3066\u3044\u308b\u3053\u3068\u3082\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Dune HD" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dunehd/translations/ru.json b/homeassistant/components/dunehd/translations/ru.json index be35fe8b092..c1537de579f 100644 --- a/homeassistant/components/dunehd/translations/ru.json +++ b/homeassistant/components/dunehd/translations/ru.json @@ -13,7 +13,7 @@ "data": { "host": "\u0425\u043e\u0441\u0442" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Dune HD. \u0415\u0441\u043b\u0438 \u0443 \u0412\u0430\u0441 \u0432\u043e\u0437\u043d\u0438\u043a\u043b\u0438 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u043e\u0439, \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 \u043f\u043e \u0430\u0434\u0440\u0435\u0441\u0443: https://www.home-assistant.io/integrations/dunehd\n\n\u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0412\u0430\u0448 \u043f\u043b\u0435\u0435\u0440 \u0432\u043a\u043b\u044e\u0447\u0435\u043d.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Dune HD. \u0415\u0441\u043b\u0438 \u0443 \u0412\u0430\u0441 \u0432\u043e\u0437\u043d\u0438\u043a\u043b\u0438 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u043e\u0439, \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 \u043f\u043e \u0430\u0434\u0440\u0435\u0441\u0443: https://www.home-assistant.io/integrations/dunehd\n\n\u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0412\u0430\u0448 \u043f\u043b\u0435\u0435\u0440 \u0432\u043a\u043b\u044e\u0447\u0435\u043d.", "title": "Dune HD" } } diff --git a/homeassistant/components/dunehd/translations/tr.json b/homeassistant/components/dunehd/translations/tr.json index 0f8c17228fd..d51539ba12d 100644 --- a/homeassistant/components/dunehd/translations/tr.json +++ b/homeassistant/components/dunehd/translations/tr.json @@ -5,13 +5,15 @@ }, "error": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi" }, "step": { "user": { "data": { - "host": "Ana Bilgisayar" + "host": "Ana bilgisayar" }, + "description": "Dune HD entegrasyonunu ayarlay\u0131n. Yap\u0131land\u0131rmayla ilgili sorunlar\u0131n\u0131z varsa \u015fu adrese gidin: https://www.home-assistant.io/integrations/dunehd \n\n Oynat\u0131c\u0131n\u0131z\u0131n a\u00e7\u0131k oldu\u011fundan emin olun.", "title": "Dune HD" } } diff --git a/homeassistant/components/dynalite/const.py b/homeassistant/components/dynalite/const.py index eda43305461..83cc639d1da 100644 --- a/homeassistant/components/dynalite/const.py +++ b/homeassistant/components/dynalite/const.py @@ -1,12 +1,12 @@ """Constants for the Dynalite component.""" import logging -from homeassistant.const import CONF_ROOM +from homeassistant.const import CONF_ROOM, Platform LOGGER = logging.getLogger(__package__) DOMAIN = "dynalite" -PLATFORMS = ["light", "switch", "cover"] +PLATFORMS = [Platform.LIGHT, Platform.SWITCH, Platform.COVER] CONF_ACTIVE = "active" diff --git a/homeassistant/components/dyson/__init__.py b/homeassistant/components/dyson/__init__.py deleted file mode 100644 index d8023b42973..00000000000 --- a/homeassistant/components/dyson/__init__.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Support for Dyson Pure Cool Link devices.""" -import logging - -from libpurecool.dyson import DysonAccount -import voluptuous as vol - -from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity - -_LOGGER = logging.getLogger(__name__) - -CONF_LANGUAGE = "language" -CONF_RETRY = "retry" - -DEFAULT_TIMEOUT = 5 -DEFAULT_RETRY = 10 -DYSON_DEVICES = "dyson_devices" -PLATFORMS = ["sensor", "fan", "vacuum", "climate", "air_quality"] - -DOMAIN = "dyson" - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_LANGUAGE): cv.string, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_RETRY, default=DEFAULT_RETRY): cv.positive_int, - vol.Optional(CONF_DEVICES, default=[]): vol.All(cv.ensure_list, [dict]), - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -def setup(hass, config): - """Set up the Dyson parent component.""" - _LOGGER.info("Creating new Dyson component") - - if DYSON_DEVICES not in hass.data: - hass.data[DYSON_DEVICES] = [] - - dyson_account = DysonAccount( - config[DOMAIN].get(CONF_USERNAME), - config[DOMAIN].get(CONF_PASSWORD), - config[DOMAIN].get(CONF_LANGUAGE), - ) - - logged = dyson_account.login() - - timeout = config[DOMAIN].get(CONF_TIMEOUT) - retry = config[DOMAIN].get(CONF_RETRY) - - if not logged: - _LOGGER.error("Not connected to Dyson account. Unable to add devices") - return False - - _LOGGER.info("Connected to Dyson account") - dyson_devices = dyson_account.devices() - if CONF_DEVICES in config[DOMAIN] and config[DOMAIN].get(CONF_DEVICES): - configured_devices = config[DOMAIN].get(CONF_DEVICES) - for device in configured_devices: - dyson_device = next( - (d for d in dyson_devices if d.serial == device["device_id"]), None - ) - if dyson_device: - try: - connected = dyson_device.connect(device["device_ip"]) - if connected: - _LOGGER.info("Connected to device %s", dyson_device) - hass.data[DYSON_DEVICES].append(dyson_device) - else: - _LOGGER.warning("Unable to connect to device %s", dyson_device) - except OSError as ose: - _LOGGER.error( - "Unable to connect to device %s: %s", - str(dyson_device.network_device), - str(ose), - ) - else: - _LOGGER.warning( - "Unable to find device %s in Dyson account", device["device_id"] - ) - else: - # Not yet reliable - for device in dyson_devices: - _LOGGER.info( - "Trying to connect to device %s with timeout=%i and retry=%i", - device, - timeout, - retry, - ) - connected = device.auto_connect(timeout, retry) - if connected: - _LOGGER.info("Connected to device %s", device) - hass.data[DYSON_DEVICES].append(device) - else: - _LOGGER.warning("Unable to connect to device %s", device) - - # Start fan/sensors components - if hass.data[DYSON_DEVICES]: - _LOGGER.debug("Starting sensor/fan components") - for platform in PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, config) - - return True - - -class DysonEntity(Entity): - """Representation of a Dyson entity.""" - - def __init__(self, device, state_type): - """Initialize the entity.""" - self._device = device - self._state_type = state_type - - async def async_added_to_hass(self): - """Call when entity is added to hass.""" - self._device.add_message_listener(self.on_message_filter) - - def on_message_filter(self, message): - """Filter new messages received.""" - if self._state_type is None or isinstance(message, self._state_type): - _LOGGER.debug( - "Message received for device %s : %s", - self.name, - message, - ) - self.on_message(message) - - def on_message(self, message): - """Handle new messages received.""" - self.schedule_update_ha_state() - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return the name of the Dyson sensor.""" - return self._device.name - - @property - def unique_id(self): - """Return the sensor's unique id.""" - return self._device.serial diff --git a/homeassistant/components/dyson/air_quality.py b/homeassistant/components/dyson/air_quality.py deleted file mode 100644 index 48b66fe7683..00000000000 --- a/homeassistant/components/dyson/air_quality.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Support for Dyson Pure Cool Air Quality Sensors.""" -from libpurecool.dyson_pure_cool import DysonPureCool -from libpurecool.dyson_pure_state_v2 import DysonEnvironmentalSensorV2State - -from homeassistant.components.air_quality import AirQualityEntity - -from . import DYSON_DEVICES, DysonEntity - -ATTRIBUTION = "Dyson purifier air quality sensor" - -DYSON_AIQ_DEVICES = "dyson_aiq_devices" - -ATTR_VOC = "volatile_organic_compounds" - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Dyson Sensors.""" - - if discovery_info is None: - return - - hass.data.setdefault(DYSON_AIQ_DEVICES, []) - - # Get Dyson Devices from parent component - device_ids = [device.unique_id for device in hass.data[DYSON_AIQ_DEVICES]] - new_entities = [] - for device in hass.data[DYSON_DEVICES]: - if isinstance(device, DysonPureCool) and device.serial not in device_ids: - new_entities.append(DysonAirSensor(device)) - - if not new_entities: - return - - hass.data[DYSON_AIQ_DEVICES].extend(new_entities) - add_entities(hass.data[DYSON_AIQ_DEVICES]) - - -class DysonAirSensor(DysonEntity, AirQualityEntity): - """Representation of a generic Dyson air quality sensor.""" - - def __init__(self, device): - """Create a new generic air quality Dyson sensor.""" - super().__init__(device, DysonEnvironmentalSensorV2State) - self._old_value = None - - def on_message(self, message): - """Handle new messages which are received from the fan.""" - if ( - self._old_value is None - or self._old_value != self._device.environmental_state - ): - self._old_value = self._device.environmental_state - self.schedule_update_ha_state() - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - - @property - def air_quality_index(self): - """Return the Air Quality Index (AQI).""" - return max( - self.particulate_matter_2_5, - self.particulate_matter_10, - self.nitrogen_dioxide, - self.volatile_organic_compounds, - ) - - @property - def particulate_matter_2_5(self): - """Return the particulate matter 2.5 level.""" - return int(self._device.environmental_state.particulate_matter_25) - - @property - def particulate_matter_10(self): - """Return the particulate matter 10 level.""" - return int(self._device.environmental_state.particulate_matter_10) - - @property - def nitrogen_dioxide(self): - """Return the NO2 (nitrogen dioxide) level.""" - return int(self._device.environmental_state.nitrogen_dioxide) - - @property - def volatile_organic_compounds(self): - """Return the VOC (Volatile Organic Compounds) level.""" - return int(self._device.environmental_state.volatile_organic_compounds) - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - return {ATTR_VOC: self.volatile_organic_compounds} diff --git a/homeassistant/components/dyson/climate.py b/homeassistant/components/dyson/climate.py deleted file mode 100644 index 19993a6498c..00000000000 --- a/homeassistant/components/dyson/climate.py +++ /dev/null @@ -1,316 +0,0 @@ -"""Support for Dyson Pure Hot+Cool link fan.""" -import logging - -from libpurecool.const import ( - AutoMode, - FanPower, - FanSpeed, - FanState, - FocusMode, - HeatMode, - HeatState, - HeatTarget, -) -from libpurecool.dyson_pure_hotcool import DysonPureHotCool -from libpurecool.dyson_pure_hotcool_link import DysonPureHotCoolLink -from libpurecool.dyson_pure_state import DysonPureHotCoolState -from libpurecool.dyson_pure_state_v2 import DysonPureHotCoolV2State - -from homeassistant.components.climate import ClimateEntity -from homeassistant.components.climate.const import ( - CURRENT_HVAC_COOL, - CURRENT_HVAC_HEAT, - CURRENT_HVAC_IDLE, - CURRENT_HVAC_OFF, - FAN_AUTO, - FAN_DIFFUSE, - FAN_FOCUS, - FAN_HIGH, - FAN_LOW, - FAN_MEDIUM, - FAN_OFF, - HVAC_MODE_COOL, - HVAC_MODE_HEAT, - HVAC_MODE_OFF, - SUPPORT_FAN_MODE, - SUPPORT_TARGET_TEMPERATURE, -) -from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS - -from . import DYSON_DEVICES, DysonEntity - -_LOGGER = logging.getLogger(__name__) - -SUPPORT_FAN = [FAN_FOCUS, FAN_DIFFUSE] -SUPPORT_FAN_PCOOL = [FAN_OFF, FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] -SUPPORT_HVAC = [HVAC_MODE_COOL, HVAC_MODE_HEAT] -SUPPORT_HVAC_PCOOL = [HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF] -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE - -DYSON_KNOWN_CLIMATE_DEVICES = "dyson_known_climate_devices" - -SPEED_MAP = { - FanSpeed.FAN_SPEED_1.value: FAN_LOW, - FanSpeed.FAN_SPEED_2.value: FAN_LOW, - FanSpeed.FAN_SPEED_3.value: FAN_LOW, - FanSpeed.FAN_SPEED_4.value: FAN_LOW, - FanSpeed.FAN_SPEED_AUTO.value: FAN_AUTO, - FanSpeed.FAN_SPEED_5.value: FAN_MEDIUM, - FanSpeed.FAN_SPEED_6.value: FAN_MEDIUM, - FanSpeed.FAN_SPEED_7.value: FAN_MEDIUM, - FanSpeed.FAN_SPEED_8.value: FAN_HIGH, - FanSpeed.FAN_SPEED_9.value: FAN_HIGH, - FanSpeed.FAN_SPEED_10.value: FAN_HIGH, -} - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Dyson fan components.""" - if discovery_info is None: - return - - known_devices = hass.data.setdefault(DYSON_KNOWN_CLIMATE_DEVICES, set()) - - # Get Dyson Devices from parent component - new_entities = [] - - for device in hass.data[DYSON_DEVICES]: - if device.serial not in known_devices: - if isinstance(device, DysonPureHotCool): - dyson_entity = DysonPureHotCoolEntity(device) - new_entities.append(dyson_entity) - known_devices.add(device.serial) - elif isinstance(device, DysonPureHotCoolLink): - dyson_entity = DysonPureHotCoolLinkEntity(device) - new_entities.append(dyson_entity) - known_devices.add(device.serial) - - add_entities(new_entities) - - -class DysonClimateEntity(DysonEntity, ClimateEntity): - """Representation of a Dyson climate fan.""" - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def current_temperature(self): - """Return the current temperature.""" - if ( - self._device.environmental_state - and self._device.environmental_state.temperature - ): - temperature_kelvin = self._device.environmental_state.temperature - return float(f"{temperature_kelvin - 273:.1f}") - return None - - @property - def target_temperature(self): - """Return the target temperature.""" - heat_target = int(self._device.state.heat_target) / 10 - return int(heat_target - 273) - - @property - def current_humidity(self): - """Return the current humidity.""" - # Humidity equaling to 0 means invalid value so we don't check for None here - # https://github.com/home-assistant/core/pull/45172#discussion_r559069756 - if ( - self._device.environmental_state - and self._device.environmental_state.humidity - ): - return self._device.environmental_state.humidity - return None - - @property - def min_temp(self): - """Return the minimum temperature.""" - return 1 - - @property - def max_temp(self): - """Return the maximum temperature.""" - return 37 - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - if (target_temp := kwargs.get(ATTR_TEMPERATURE)) is None: - _LOGGER.error("Missing target temperature %s", kwargs) - return - target_temp = int(target_temp) - _LOGGER.debug("Set %s temperature %s", self.name, target_temp) - # Limit the target temperature into acceptable range. - target_temp = min(self.max_temp, target_temp) - target_temp = max(self.min_temp, target_temp) - self.set_heat_target(HeatTarget.celsius(target_temp)) - - def set_heat_target(self, heat_target): - """Set heating target temperature.""" - - -class DysonPureHotCoolLinkEntity(DysonClimateEntity): - """Representation of a Dyson climate fan.""" - - def __init__(self, device): - """Initialize the fan.""" - super().__init__(device, DysonPureHotCoolState) - - @property - def hvac_mode(self): - """Return hvac operation ie. heat, cool mode. - - Need to be one of HVAC_MODE_*. - """ - if self._device.state.heat_mode == HeatMode.HEAT_ON.value: - return HVAC_MODE_HEAT - return HVAC_MODE_COOL - - @property - def hvac_modes(self): - """Return the list of available hvac operation modes. - - Need to be a subset of HVAC_MODES. - """ - return SUPPORT_HVAC - - @property - def hvac_action(self): - """Return the current running hvac operation if supported. - - Need to be one of CURRENT_HVAC_*. - """ - if self._device.state.heat_mode == HeatMode.HEAT_ON.value: - if self._device.state.heat_state == HeatState.HEAT_STATE_ON.value: - return CURRENT_HVAC_HEAT - return CURRENT_HVAC_IDLE - return CURRENT_HVAC_COOL - - @property - def fan_mode(self): - """Return the fan setting.""" - if self._device.state.focus_mode == FocusMode.FOCUS_ON.value: - return FAN_FOCUS - return FAN_DIFFUSE - - @property - def fan_modes(self): - """Return the list of available fan modes.""" - return SUPPORT_FAN - - def set_heat_target(self, heat_target): - """Set heating target temperature.""" - self._device.set_configuration( - heat_target=heat_target, heat_mode=HeatMode.HEAT_ON - ) - - def set_fan_mode(self, fan_mode): - """Set new fan mode.""" - _LOGGER.debug("Set %s focus mode %s", self.name, fan_mode) - if fan_mode == FAN_FOCUS: - self._device.set_configuration(focus_mode=FocusMode.FOCUS_ON) - elif fan_mode == FAN_DIFFUSE: - self._device.set_configuration(focus_mode=FocusMode.FOCUS_OFF) - - def set_hvac_mode(self, hvac_mode): - """Set new target hvac mode.""" - _LOGGER.debug("Set %s heat mode %s", self.name, hvac_mode) - if hvac_mode == HVAC_MODE_HEAT: - self._device.set_configuration(heat_mode=HeatMode.HEAT_ON) - elif hvac_mode == HVAC_MODE_COOL: - self._device.set_configuration(heat_mode=HeatMode.HEAT_OFF) - - -class DysonPureHotCoolEntity(DysonClimateEntity): - """Representation of a Dyson climate hot+cool fan.""" - - def __init__(self, device): - """Initialize the fan.""" - super().__init__(device, DysonPureHotCoolV2State) - - @property - def hvac_mode(self): - """Return hvac operation ie. heat, cool mode. - - Need to be one of HVAC_MODE_*. - """ - if self._device.state.fan_power == FanPower.POWER_OFF.value: - return HVAC_MODE_OFF - if self._device.state.heat_mode == HeatMode.HEAT_ON.value: - return HVAC_MODE_HEAT - return HVAC_MODE_COOL - - @property - def hvac_modes(self): - """Return the list of available hvac operation modes. - - Need to be a subset of HVAC_MODES. - """ - return SUPPORT_HVAC_PCOOL - - @property - def hvac_action(self): - """Return the current running hvac operation if supported. - - Need to be one of CURRENT_HVAC_*. - """ - if self._device.state.fan_power == FanPower.POWER_OFF.value: - return CURRENT_HVAC_OFF - if self._device.state.heat_mode == HeatMode.HEAT_ON.value: - if self._device.state.heat_state == HeatState.HEAT_STATE_ON.value: - return CURRENT_HVAC_HEAT - return CURRENT_HVAC_IDLE - return CURRENT_HVAC_COOL - - @property - def fan_mode(self): - """Return the fan setting.""" - if ( - self._device.state.auto_mode != AutoMode.AUTO_ON.value - and self._device.state.fan_state == FanState.FAN_OFF.value - ): - return FAN_OFF - - return SPEED_MAP[self._device.state.speed] - - @property - def fan_modes(self): - """Return the list of available fan modes.""" - return SUPPORT_FAN_PCOOL - - def set_heat_target(self, heat_target): - """Set heating target temperature.""" - self._device.set_heat_target(heat_target) - - def set_fan_mode(self, fan_mode): - """Set new fan mode.""" - _LOGGER.debug("Set %s focus mode %s", self.name, fan_mode) - if fan_mode == FAN_OFF: - self._device.turn_off() - elif fan_mode == FAN_LOW: - self._device.set_fan_speed(FanSpeed.FAN_SPEED_4) - elif fan_mode == FAN_MEDIUM: - self._device.set_fan_speed(FanSpeed.FAN_SPEED_7) - elif fan_mode == FAN_HIGH: - self._device.set_fan_speed(FanSpeed.FAN_SPEED_10) - elif fan_mode == FAN_AUTO: - self._device.enable_auto_mode() - - def set_hvac_mode(self, hvac_mode): - """Set new target hvac mode.""" - _LOGGER.debug("Set %s heat mode %s", self.name, hvac_mode) - if hvac_mode == HVAC_MODE_OFF: - self._device.turn_off() - elif self._device.state.fan_power == FanPower.POWER_OFF.value: - self._device.turn_on() - if hvac_mode == HVAC_MODE_HEAT: - self._device.enable_heat_mode() - elif hvac_mode == HVAC_MODE_COOL: - self._device.disable_heat_mode() diff --git a/homeassistant/components/dyson/fan.py b/homeassistant/components/dyson/fan.py deleted file mode 100644 index 38b4b511df4..00000000000 --- a/homeassistant/components/dyson/fan.py +++ /dev/null @@ -1,469 +0,0 @@ -"""Support for Dyson Pure Cool link fan.""" -from __future__ import annotations - -import logging -import math - -from libpurecool.const import FanMode, FanSpeed, NightMode, Oscillation -from libpurecool.dyson_pure_cool import DysonPureCool -from libpurecool.dyson_pure_cool_link import DysonPureCoolLink -from libpurecool.dyson_pure_state import DysonPureCoolState -from libpurecool.dyson_pure_state_v2 import DysonPureCoolV2State -import voluptuous as vol - -from homeassistant.components.fan import SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity -from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.util.percentage import ( - int_states_in_range, - percentage_to_ranged_value, - ranged_value_to_percentage, -) - -from . import DYSON_DEVICES, DysonEntity - -_LOGGER = logging.getLogger(__name__) - -ATTR_NIGHT_MODE = "night_mode" -ATTR_AUTO_MODE = "auto_mode" -ATTR_ANGLE_LOW = "angle_low" -ATTR_ANGLE_HIGH = "angle_high" -ATTR_FLOW_DIRECTION_FRONT = "flow_direction_front" -ATTR_TIMER = "timer" -ATTR_HEPA_FILTER = "hepa_filter" -ATTR_CARBON_FILTER = "carbon_filter" -ATTR_DYSON_SPEED = "dyson_speed" -ATTR_DYSON_SPEED_LIST = "dyson_speed_list" - -DYSON_DOMAIN = "dyson" -DYSON_FAN_DEVICES = "dyson_fan_devices" - -SERVICE_SET_NIGHT_MODE = "set_night_mode" -SERVICE_SET_AUTO_MODE = "set_auto_mode" -SERVICE_SET_ANGLE = "set_angle" -SERVICE_SET_FLOW_DIRECTION_FRONT = "set_flow_direction_front" -SERVICE_SET_TIMER = "set_timer" -SERVICE_SET_DYSON_SPEED = "set_speed" - -SET_NIGHT_MODE_SCHEMA = { - vol.Required(ATTR_NIGHT_MODE): cv.boolean, -} - -SET_AUTO_MODE_SCHEMA = { - vol.Required(ATTR_AUTO_MODE): cv.boolean, -} - -SET_ANGLE_SCHEMA = { - vol.Required(ATTR_ANGLE_LOW): cv.positive_int, - vol.Required(ATTR_ANGLE_HIGH): cv.positive_int, -} - -SET_FLOW_DIRECTION_FRONT_SCHEMA = { - vol.Required(ATTR_FLOW_DIRECTION_FRONT): cv.boolean, -} - -SET_TIMER_SCHEMA = { - vol.Required(ATTR_TIMER): cv.positive_int, -} - -SET_DYSON_SPEED_SCHEMA = { - vol.Required(ATTR_DYSON_SPEED): cv.positive_int, -} - - -PRESET_MODE_AUTO = "auto" -PRESET_MODES = [PRESET_MODE_AUTO] - -ORDERED_DYSON_SPEEDS = [ - FanSpeed.FAN_SPEED_1, - FanSpeed.FAN_SPEED_2, - FanSpeed.FAN_SPEED_3, - FanSpeed.FAN_SPEED_4, - FanSpeed.FAN_SPEED_5, - FanSpeed.FAN_SPEED_6, - FanSpeed.FAN_SPEED_7, - FanSpeed.FAN_SPEED_8, - FanSpeed.FAN_SPEED_9, - FanSpeed.FAN_SPEED_10, -] -DYSON_SPEED_TO_INT_VALUE = {k: int(k.value) for k in ORDERED_DYSON_SPEEDS} -INT_VALUE_TO_DYSON_SPEED = {v: k for k, v in DYSON_SPEED_TO_INT_VALUE.items()} - -SPEED_LIST_DYSON = list(DYSON_SPEED_TO_INT_VALUE.values()) - -SPEED_RANGE = ( - SPEED_LIST_DYSON[0], - SPEED_LIST_DYSON[-1], -) # off is not included - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Dyson fan components.""" - - if discovery_info is None: - return - - _LOGGER.debug("Creating new Dyson fans") - if DYSON_FAN_DEVICES not in hass.data: - hass.data[DYSON_FAN_DEVICES] = [] - - # Get Dyson Devices from parent component - has_purecool_devices = False - device_serials = [device.serial for device in hass.data[DYSON_FAN_DEVICES]] - for device in hass.data[DYSON_DEVICES]: - if device.serial not in device_serials: - if isinstance(device, DysonPureCool): - has_purecool_devices = True - dyson_entity = DysonPureCoolEntity(device) - hass.data[DYSON_FAN_DEVICES].append(dyson_entity) - elif isinstance(device, DysonPureCoolLink): - dyson_entity = DysonPureCoolLinkEntity(device) - hass.data[DYSON_FAN_DEVICES].append(dyson_entity) - - async_add_entities(hass.data[DYSON_FAN_DEVICES]) - - # Register custom services - platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( - SERVICE_SET_NIGHT_MODE, SET_NIGHT_MODE_SCHEMA, "set_night_mode" - ) - platform.async_register_entity_service( - SERVICE_SET_AUTO_MODE, SET_AUTO_MODE_SCHEMA, "set_auto_mode" - ) - platform.async_register_entity_service( - SERVICE_SET_DYSON_SPEED, SET_DYSON_SPEED_SCHEMA, "service_set_dyson_speed" - ) - if has_purecool_devices: - platform.async_register_entity_service( - SERVICE_SET_ANGLE, SET_ANGLE_SCHEMA, "set_angle" - ) - platform.async_register_entity_service( - SERVICE_SET_FLOW_DIRECTION_FRONT, - SET_FLOW_DIRECTION_FRONT_SCHEMA, - "set_flow_direction_front", - ) - platform.async_register_entity_service( - SERVICE_SET_TIMER, SET_TIMER_SCHEMA, "set_timer" - ) - - -class DysonFanEntity(DysonEntity, FanEntity): - """Representation of a Dyson fan.""" - - @property - def percentage(self): - """Return the current speed percentage.""" - if self.auto_mode: - return None - return ranged_value_to_percentage(SPEED_RANGE, int(self._device.state.speed)) - - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - return int_states_in_range(SPEED_RANGE) - - @property - def preset_modes(self): - """Return the available preset modes.""" - return PRESET_MODES - - @property - def preset_mode(self): - """Return the current preset mode.""" - if self.auto_mode: - return PRESET_MODE_AUTO - return None - - @property - def dyson_speed(self): - """Return the current speed.""" - if self._device.state.speed == FanSpeed.FAN_SPEED_AUTO.value: - return self._device.state.speed - return int(self._device.state.speed) - - @property - def dyson_speed_list(self) -> list: - """Get the list of available dyson speeds.""" - return SPEED_LIST_DYSON - - @property - def night_mode(self): - """Return Night mode.""" - return self._device.state.night_mode == "ON" - - @property - def auto_mode(self): - """Return auto mode.""" - raise NotImplementedError - - @property - def supported_features(self) -> int: - """Flag supported features.""" - return SUPPORT_OSCILLATE | SUPPORT_SET_SPEED - - @property - def extra_state_attributes(self) -> dict: - """Return optional state attributes.""" - return { - ATTR_NIGHT_MODE: self.night_mode, - ATTR_AUTO_MODE: self.auto_mode, - ATTR_DYSON_SPEED: self.dyson_speed, - ATTR_DYSON_SPEED_LIST: self.dyson_speed_list, - } - - def set_auto_mode(self, auto_mode: bool) -> None: - """Set auto mode.""" - raise NotImplementedError - - def set_percentage(self, percentage: int) -> None: - """Set the speed percentage of the fan.""" - if percentage == 0: - self.turn_off() - return - dyson_speed = INT_VALUE_TO_DYSON_SPEED[ - math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) - ] - self.set_dyson_speed(dyson_speed) - - def set_preset_mode(self, preset_mode: str) -> None: - """Set a preset mode on the fan.""" - self._valid_preset_mode_or_raise(preset_mode) - # There currently is only one - self.set_auto_mode(True) - - def set_dyson_speed(self, speed: FanSpeed) -> None: - """Set the exact speed of the fan.""" - raise NotImplementedError - - def service_set_dyson_speed(self, dyson_speed: int) -> None: - """Handle the service to set dyson speed.""" - if dyson_speed not in SPEED_LIST_DYSON: - raise ValueError(f'"{dyson_speed}" is not a valid Dyson speed') - _LOGGER.debug("Set exact speed to %s", dyson_speed) - speed = FanSpeed(f"{int(dyson_speed):04d}") - self.set_dyson_speed(speed) - - def turn_on( - self, - speed: str | None = None, - percentage: int | None = None, - preset_mode: str | None = None, - **kwargs, - ) -> None: - """Turn on the fan.""" - _LOGGER.debug("Turn on fan %s with percentage %s", self.name, percentage) - if preset_mode: - self.set_preset_mode(preset_mode) - elif percentage is None: - # percentage not set, just turn on - self._device.set_configuration(fan_mode=FanMode.FAN) - else: - self.set_percentage(percentage) - - -class DysonPureCoolLinkEntity(DysonFanEntity): - """Representation of a Dyson fan.""" - - def __init__(self, device): - """Initialize the fan.""" - super().__init__(device, DysonPureCoolState) - - def turn_off(self, **kwargs) -> None: - """Turn off the fan.""" - _LOGGER.debug("Turn off fan %s", self.name) - self._device.set_configuration(fan_mode=FanMode.OFF) - - def set_dyson_speed(self, speed: FanSpeed) -> None: - """Set the exact speed of the fan.""" - self._device.set_configuration(fan_mode=FanMode.FAN, fan_speed=speed) - - def oscillate(self, oscillating: bool) -> None: - """Turn on/off oscillating.""" - _LOGGER.debug("Turn oscillation %s for device %s", oscillating, self.name) - - if oscillating: - self._device.set_configuration(oscillation=Oscillation.OSCILLATION_ON) - else: - self._device.set_configuration(oscillation=Oscillation.OSCILLATION_OFF) - - @property - def oscillating(self): - """Return the oscillation state.""" - return self._device.state.oscillation == "ON" - - @property - def is_on(self): - """Return true if the entity is on.""" - return self._device.state.fan_mode in ["FAN", "AUTO"] - - def set_night_mode(self, night_mode: bool) -> None: - """Turn fan in night mode.""" - _LOGGER.debug("Set %s night mode %s", self.name, night_mode) - if night_mode: - self._device.set_configuration(night_mode=NightMode.NIGHT_MODE_ON) - else: - self._device.set_configuration(night_mode=NightMode.NIGHT_MODE_OFF) - - @property - def auto_mode(self): - """Return auto mode.""" - return self._device.state.fan_mode == "AUTO" - - def set_auto_mode(self, auto_mode: bool) -> None: - """Turn fan in auto mode.""" - _LOGGER.debug("Set %s auto mode %s", self.name, auto_mode) - if auto_mode: - self._device.set_configuration(fan_mode=FanMode.AUTO) - else: - self._device.set_configuration(fan_mode=FanMode.FAN) - - -class DysonPureCoolEntity(DysonFanEntity): - """Representation of a Dyson Purecool (TP04/DP04) fan.""" - - def __init__(self, device): - """Initialize the fan.""" - super().__init__(device, DysonPureCoolV2State) - - def turn_on( - self, - speed: str | None = None, - percentage: int | None = None, - preset_mode: str | None = None, - **kwargs, - ) -> None: - """Turn on the fan.""" - _LOGGER.debug("Turn on fan %s with percentage %s", self.name, percentage) - if preset_mode: - self.set_preset_mode(preset_mode) - elif percentage is None: - # percentage not set, just turn on - self._device.turn_on() - else: - self.set_percentage(percentage) - - def turn_off(self, **kwargs): - """Turn off the fan.""" - _LOGGER.debug("Turn off fan %s", self.name) - self._device.turn_off() - - def set_dyson_speed(self, speed: FanSpeed) -> None: - """Set the exact speed of the purecool fan.""" - self._device.set_fan_speed(speed) - - def oscillate(self, oscillating: bool) -> None: - """Turn on/off oscillating.""" - _LOGGER.debug("Turn oscillation %s for device %s", oscillating, self.name) - - if oscillating: - self._device.enable_oscillation() - else: - self._device.disable_oscillation() - - def set_night_mode(self, night_mode: bool) -> None: - """Turn on/off night mode.""" - _LOGGER.debug("Turn night mode %s for device %s", night_mode, self.name) - - if night_mode: - self._device.enable_night_mode() - else: - self._device.disable_night_mode() - - def set_auto_mode(self, auto_mode: bool) -> None: - """Turn auto mode on/off.""" - _LOGGER.debug("Turn auto mode %s for device %s", auto_mode, self.name) - if auto_mode: - self._device.enable_auto_mode() - else: - self._device.disable_auto_mode() - - def set_angle(self, angle_low: int, angle_high: int) -> None: - """Set device angle.""" - _LOGGER.debug( - "set low %s and high angle %s for device %s", - angle_low, - angle_high, - self.name, - ) - self._device.enable_oscillation(angle_low, angle_high) - - def set_flow_direction_front(self, flow_direction_front: bool) -> None: - """Set frontal airflow direction.""" - _LOGGER.debug( - "Set frontal flow direction to %s for device %s", - flow_direction_front, - self.name, - ) - - if flow_direction_front: - self._device.enable_frontal_direction() - else: - self._device.disable_frontal_direction() - - def set_timer(self, timer) -> None: - """Set timer.""" - _LOGGER.debug("Set timer to %s for device %s", timer, self.name) - - if timer == 0: - self._device.disable_sleep_timer() - else: - self._device.enable_sleep_timer(timer) - - @property - def oscillating(self): - """Return the oscillation state.""" - return self._device.state and self._device.state.oscillation == "OION" - - @property - def is_on(self): - """Return true if the entity is on.""" - return self._device.state.fan_power == "ON" - - @property - def auto_mode(self): - """Return Auto mode.""" - return self._device.state.auto_mode == "ON" - - @property - def angle_low(self): - """Return angle high.""" - return int(self._device.state.oscillation_angle_low) - - @property - def angle_high(self): - """Return angle low.""" - return int(self._device.state.oscillation_angle_high) - - @property - def flow_direction_front(self): - """Return frontal flow direction.""" - return self._device.state.front_direction == "ON" - - @property - def timer(self): - """Return timer.""" - return self._device.state.sleep_timer - - @property - def hepa_filter(self): - """Return the HEPA filter state.""" - return int(self._device.state.hepa_filter_state) - - @property - def carbon_filter(self): - """Return the carbon filter state.""" - if self._device.state.carbon_filter_state == "INV": - return self._device.state.carbon_filter_state - return int(self._device.state.carbon_filter_state) - - @property - def extra_state_attributes(self) -> dict: - """Return optional state attributes.""" - return { - **super().extra_state_attributes, - ATTR_ANGLE_LOW: self.angle_low, - ATTR_ANGLE_HIGH: self.angle_high, - ATTR_FLOW_DIRECTION_FRONT: self.flow_direction_front, - ATTR_TIMER: self.timer, - ATTR_HEPA_FILTER: self.hepa_filter, - ATTR_CARBON_FILTER: self.carbon_filter, - } diff --git a/homeassistant/components/dyson/manifest.json b/homeassistant/components/dyson/manifest.json deleted file mode 100644 index 0f5da0691c4..00000000000 --- a/homeassistant/components/dyson/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "dyson", - "name": "Dyson", - "documentation": "https://www.home-assistant.io/integrations/dyson", - "requirements": ["libpurecool==0.6.4"], - "after_dependencies": ["zeroconf"], - "codeowners": [], - "iot_class": "local_push" -} diff --git a/homeassistant/components/dyson/sensor.py b/homeassistant/components/dyson/sensor.py deleted file mode 100644 index be83a7e4373..00000000000 --- a/homeassistant/components/dyson/sensor.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Support for Dyson Pure Cool Link Sensors.""" -from libpurecool.dyson_pure_cool import DysonPureCool -from libpurecool.dyson_pure_cool_link import DysonPureCoolLink - -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE, - PERCENTAGE, - STATE_OFF, - TEMP_CELSIUS, - TIME_HOURS, -) - -from . import DYSON_DEVICES, DysonEntity - -SENSOR_ATTRIBUTES = { - "air_quality": {ATTR_ICON: "mdi:fan"}, - "dust": {ATTR_ICON: "mdi:cloud"}, - "humidity": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - "temperature": {ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE}, - "filter_life": { - ATTR_ICON: "mdi:filter-outline", - ATTR_UNIT_OF_MEASUREMENT: TIME_HOURS, - }, - "carbon_filter_state": { - ATTR_ICON: "mdi:filter-outline", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - "combi_filter_state": { - ATTR_ICON: "mdi:filter-outline", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - "hepa_filter_state": { - ATTR_ICON: "mdi:filter-outline", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, -} - -SENSOR_NAMES = { - "air_quality": "AQI", - "dust": "Dust", - "humidity": "Humidity", - "temperature": "Temperature", - "filter_life": "Filter Life", - "carbon_filter_state": "Carbon Filter Remaining Life", - "combi_filter_state": "Combi Filter Remaining Life", - "hepa_filter_state": "HEPA Filter Remaining Life", -} - -DYSON_SENSOR_DEVICES = "dyson_sensor_devices" - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Dyson Sensors.""" - - if discovery_info is None: - return - - hass.data.setdefault(DYSON_SENSOR_DEVICES, []) - unit = hass.config.units.temperature_unit - devices = hass.data[DYSON_SENSOR_DEVICES] - - # Get Dyson Devices from parent component - device_ids = [device.unique_id for device in hass.data[DYSON_SENSOR_DEVICES]] - new_entities = [] - for device in hass.data[DYSON_DEVICES]: - if isinstance(device, DysonPureCool): - if f"{device.serial}-temperature" not in device_ids: - new_entities.append(DysonTemperatureSensor(device, unit)) - if f"{device.serial}-humidity" not in device_ids: - new_entities.append(DysonHumiditySensor(device)) - - # For PureCool+Humidify devices, a single filter exists, called "Combi Filter". - # It's reported with the HEPA state, while the Carbon state is set to INValid. - if device.state and device.state.carbon_filter_state == "INV": - if f"{device.serial}-hepa_filter_state" not in device_ids: - new_entities.append(DysonHepaFilterLifeSensor(device, "combi")) - else: - if f"{device.serial}-hepa_filter_state" not in device_ids: - new_entities.append(DysonHepaFilterLifeSensor(device)) - if f"{device.serial}-carbon_filter_state" not in device_ids: - new_entities.append(DysonCarbonFilterLifeSensor(device)) - elif isinstance(device, DysonPureCoolLink): - new_entities.append(DysonFilterLifeSensor(device)) - new_entities.append(DysonDustSensor(device)) - new_entities.append(DysonHumiditySensor(device)) - new_entities.append(DysonTemperatureSensor(device, unit)) - new_entities.append(DysonAirQualitySensor(device)) - - if not new_entities: - return - - devices.extend(new_entities) - add_entities(devices) - - -class DysonSensor(DysonEntity, SensorEntity): - """Representation of a generic Dyson sensor.""" - - def __init__(self, device, sensor_type): - """Create a new generic Dyson sensor.""" - super().__init__(device, None) - self._old_value = None - self._sensor_type = sensor_type - self._attributes = SENSOR_ATTRIBUTES[sensor_type] - - def on_message(self, message): - """Handle new messages which are received from the fan.""" - # Prevent refreshing if not needed - if self._old_value is None or self._old_value != self.state: - self._old_value = self.state - self.schedule_update_ha_state() - - @property - def name(self): - """Return the name of the Dyson sensor name.""" - return f"{super().name} {SENSOR_NAMES[self._sensor_type]}" - - @property - def unique_id(self): - """Return the sensor's unique id.""" - return f"{self._device.serial}-{self._sensor_type}" - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._attributes.get(ATTR_UNIT_OF_MEASUREMENT) - - @property - def icon(self): - """Return the icon for this sensor.""" - return self._attributes.get(ATTR_ICON) - - @property - def device_class(self): - """Return the device class of this sensor.""" - return self._attributes.get(ATTR_DEVICE_CLASS) - - -class DysonFilterLifeSensor(DysonSensor): - """Representation of Dyson Filter Life sensor (in hours).""" - - def __init__(self, device): - """Create a new Dyson Filter Life sensor.""" - super().__init__(device, "filter_life") - - @property - def native_value(self): - """Return filter life in hours.""" - return int(self._device.state.filter_life) - - -class DysonCarbonFilterLifeSensor(DysonSensor): - """Representation of Dyson Carbon Filter Life sensor (in percent).""" - - def __init__(self, device): - """Create a new Dyson Carbon Filter Life sensor.""" - super().__init__(device, "carbon_filter_state") - - @property - def native_value(self): - """Return filter life remaining in percent.""" - return int(self._device.state.carbon_filter_state) - - -class DysonHepaFilterLifeSensor(DysonSensor): - """Representation of Dyson HEPA (or Combi) Filter Life sensor (in percent).""" - - def __init__(self, device, filter_type="hepa"): - """Create a new Dyson Filter Life sensor.""" - super().__init__(device, f"{filter_type}_filter_state") - - @property - def native_value(self): - """Return filter life remaining in percent.""" - return int(self._device.state.hepa_filter_state) - - -class DysonDustSensor(DysonSensor): - """Representation of Dyson Dust sensor (lower is better).""" - - def __init__(self, device): - """Create a new Dyson Dust sensor.""" - super().__init__(device, "dust") - - @property - def native_value(self): - """Return Dust value.""" - return self._device.environmental_state.dust - - -class DysonHumiditySensor(DysonSensor): - """Representation of Dyson Humidity sensor.""" - - def __init__(self, device): - """Create a new Dyson Humidity sensor.""" - super().__init__(device, "humidity") - - @property - def native_value(self): - """Return Humidity value.""" - if self._device.environmental_state.humidity == 0: - return STATE_OFF - return self._device.environmental_state.humidity - - -class DysonTemperatureSensor(DysonSensor): - """Representation of Dyson Temperature sensor.""" - - def __init__(self, device, unit): - """Create a new Dyson Temperature sensor.""" - super().__init__(device, "temperature") - self._unit = unit - - @property - def native_value(self): - """Return Temperature value.""" - temperature_kelvin = self._device.environmental_state.temperature - if temperature_kelvin == 0: - return STATE_OFF - if self._unit == TEMP_CELSIUS: - return float(f"{(temperature_kelvin - 273.15):.1f}") - return float(f"{(temperature_kelvin * 9 / 5 - 459.67):.1f}") - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit - - -class DysonAirQualitySensor(DysonSensor): - """Representation of Dyson Air Quality sensor (lower is better).""" - - def __init__(self, device): - """Create a new Dyson Air Quality sensor.""" - super().__init__(device, "air_quality") - - @property - def native_value(self): - """Return Air Quality value.""" - return int(self._device.environmental_state.volatil_organic_compounds) diff --git a/homeassistant/components/dyson/services.yaml b/homeassistant/components/dyson/services.yaml deleted file mode 100644 index 10b27c1c5e6..00000000000 --- a/homeassistant/components/dyson/services.yaml +++ /dev/null @@ -1,108 +0,0 @@ -# Describes the format for available fan services - -set_night_mode: - name: Set night mode - description: Set the fan in night mode. - target: - entity: - integration: dyson - domain: fan - fields: - night_mode: - name: Night mode - description: Night mode status - required: true - selector: - boolean: - -set_auto_mode: - name: Set auto mode - description: Set the fan in auto mode. - target: - entity: - integration: dyson - domain: fan - fields: - auto_mode: - name: Auto Mode - description: Auto mode status - required: true - selector: - boolean: - -set_angle: - name: Set angle - description: Set the oscillation angle of the selected fan(s). - target: - entity: - integration: dyson - domain: fan - fields: - angle_low: - name: Angle low - description: The angle at which the oscillation should start - required: true - selector: - number: - min: 5 - max: 355 - unit_of_measurement: '°' - angle_high: - name: Angle high - description: The angle at which the oscillation should end - required: true - selector: - number: - min: 5 - max: 355 - unit_of_measurement: '°' - -set_flow_direction_front: - name: Set flow direction front - description: Set the fan flow direction. - target: - entity: - integration: dyson - domain: fan - fields: - flow_direction_front: - name: Flow direction front - description: Frontal flow direction - required: true - selector: - boolean: - -set_timer: - name: Set timer - description: Set the sleep timer. - target: - entity: - integration: dyson - domain: fan - fields: - timer: - name: Timer - description: The value in minutes to set the timer to, 0 to disable it - required: true - selector: - number: - min: 0 - max: 720 - unit_of_measurement: minutes - -set_speed: - name: Set speed - description: Set the exact speed of the fan. - target: - entity: - integration: dyson - domain: fan - fields: - dyson_speed: - name: Speed - description: Speed - required: true - selector: - number: - min: 1 - max: 10 diff --git a/homeassistant/components/dyson/vacuum.py b/homeassistant/components/dyson/vacuum.py deleted file mode 100644 index f4035d33cf3..00000000000 --- a/homeassistant/components/dyson/vacuum.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Support for the Dyson 360 eye vacuum cleaner robot.""" -import logging - -from libpurecool.const import Dyson360EyeMode, PowerMode -from libpurecool.dyson_360_eye import Dyson360Eye - -from homeassistant.components.vacuum import ( - SUPPORT_BATTERY, - SUPPORT_FAN_SPEED, - SUPPORT_PAUSE, - SUPPORT_RETURN_HOME, - SUPPORT_STATUS, - SUPPORT_STOP, - SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, - VacuumEntity, -) -from homeassistant.helpers.icon import icon_for_battery_level - -from . import DYSON_DEVICES, DysonEntity - -_LOGGER = logging.getLogger(__name__) - -ATTR_CLEAN_ID = "clean_id" -ATTR_FULL_CLEAN_TYPE = "full_clean_type" -ATTR_POSITION = "position" - -DYSON_360_EYE_DEVICES = "dyson_360_eye_devices" - -SUPPORT_DYSON = ( - SUPPORT_TURN_ON - | SUPPORT_TURN_OFF - | SUPPORT_PAUSE - | SUPPORT_RETURN_HOME - | SUPPORT_FAN_SPEED - | SUPPORT_STATUS - | SUPPORT_BATTERY - | SUPPORT_STOP -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Dyson 360 Eye robot vacuum platform.""" - _LOGGER.debug("Creating new Dyson 360 Eye robot vacuum") - if DYSON_360_EYE_DEVICES not in hass.data: - hass.data[DYSON_360_EYE_DEVICES] = [] - - # Get Dyson Devices from parent component - for device in [d for d in hass.data[DYSON_DEVICES] if isinstance(d, Dyson360Eye)]: - dyson_entity = Dyson360EyeDevice(device) - hass.data[DYSON_360_EYE_DEVICES].append(dyson_entity) - - add_entities(hass.data[DYSON_360_EYE_DEVICES]) - return True - - -class Dyson360EyeDevice(DysonEntity, VacuumEntity): - """Dyson 360 Eye robot vacuum device.""" - - def __init__(self, device): - """Dyson 360 Eye robot vacuum device.""" - super().__init__(device, None) - - @property - def status(self): - """Return the status of the vacuum cleaner.""" - dyson_labels = { - Dyson360EyeMode.INACTIVE_CHARGING: "Stopped - Charging", - Dyson360EyeMode.INACTIVE_CHARGED: "Stopped - Charged", - Dyson360EyeMode.FULL_CLEAN_PAUSED: "Paused", - Dyson360EyeMode.FULL_CLEAN_RUNNING: "Cleaning", - Dyson360EyeMode.FULL_CLEAN_ABORTED: "Returning home", - Dyson360EyeMode.FULL_CLEAN_INITIATED: "Start cleaning", - Dyson360EyeMode.FAULT_USER_RECOVERABLE: "Error - device blocked", - Dyson360EyeMode.FAULT_REPLACE_ON_DOCK: "Error - Replace device on dock", - Dyson360EyeMode.FULL_CLEAN_FINISHED: "Finished", - Dyson360EyeMode.FULL_CLEAN_NEEDS_CHARGE: "Need charging", - } - return dyson_labels.get(self._device.state.state, self._device.state.state) - - @property - def battery_level(self): - """Return the battery level of the vacuum cleaner.""" - return self._device.state.battery_level - - @property - def fan_speed(self): - """Return the fan speed of the vacuum cleaner.""" - speed_labels = {PowerMode.MAX: "Max", PowerMode.QUIET: "Quiet"} - return speed_labels[self._device.state.power_mode] - - @property - def fan_speed_list(self): - """Get the list of available fan speed steps of the vacuum cleaner.""" - return ["Quiet", "Max"] - - @property - def extra_state_attributes(self): - """Return the specific state attributes of this vacuum cleaner.""" - return {ATTR_POSITION: str(self._device.state.position)} - - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._device.state.state in [ - Dyson360EyeMode.FULL_CLEAN_INITIATED, - Dyson360EyeMode.FULL_CLEAN_ABORTED, - Dyson360EyeMode.FULL_CLEAN_RUNNING, - ] - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return True - - @property - def supported_features(self): - """Flag vacuum cleaner robot features that are supported.""" - return SUPPORT_DYSON - - @property - def battery_icon(self): - """Return the battery icon for the vacuum cleaner.""" - charging = self._device.state.state in [Dyson360EyeMode.INACTIVE_CHARGING] - return icon_for_battery_level( - battery_level=self.battery_level, charging=charging - ) - - def turn_on(self, **kwargs): - """Turn the vacuum on.""" - _LOGGER.debug("Turn on device %s", self.name) - if self._device.state.state in [Dyson360EyeMode.FULL_CLEAN_PAUSED]: - self._device.resume() - else: - self._device.start() - - def turn_off(self, **kwargs): - """Turn the vacuum off and return to home.""" - _LOGGER.debug("Turn off device %s", self.name) - self._device.pause() - - def stop(self, **kwargs): - """Stop the vacuum cleaner.""" - _LOGGER.debug("Stop device %s", self.name) - self._device.pause() - - def set_fan_speed(self, fan_speed, **kwargs): - """Set fan speed.""" - _LOGGER.debug("Set fan speed %s on device %s", fan_speed, self.name) - power_modes = {"Quiet": PowerMode.QUIET, "Max": PowerMode.MAX} - self._device.set_power_mode(power_modes[fan_speed]) - - def start_pause(self, **kwargs): - """Start, pause or resume the cleaning task.""" - if self._device.state.state in [Dyson360EyeMode.FULL_CLEAN_PAUSED]: - _LOGGER.debug("Resume device %s", self.name) - self._device.resume() - elif self._device.state.state in [ - Dyson360EyeMode.INACTIVE_CHARGED, - Dyson360EyeMode.INACTIVE_CHARGING, - ]: - _LOGGER.debug("Start device %s", self.name) - self._device.start() - else: - _LOGGER.debug("Pause device %s", self.name) - self._device.pause() - - def return_to_base(self, **kwargs): - """Set the vacuum cleaner to return to the dock.""" - _LOGGER.debug("Return to base device %s", self.name) - self._device.abort() diff --git a/homeassistant/components/eafm/sensor.py b/homeassistant/components/eafm/sensor.py index e5edf8e0b99..40059f71f1a 100644 --- a/homeassistant/components/eafm/sensor.py +++ b/homeassistant/components/eafm/sensor.py @@ -8,6 +8,7 @@ import async_timeout from homeassistant.components.sensor import SensorEntity from homeassistant.const import ATTR_ATTRIBUTION, LENGTH_METERS 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 ( CoordinatorEntity, @@ -123,7 +124,7 @@ class Measurement(CoordinatorEntity, SensorEntity): def device_info(self): """Return the device info.""" return DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, "measure-id", self.station_id)}, manufacturer="https://environment.data.gov.uk/", model=self.parameter_name, diff --git a/homeassistant/components/eafm/translations/bg.json b/homeassistant/components/eafm/translations/bg.json new file mode 100644 index 00000000000..37b6f40c82e --- /dev/null +++ b/homeassistant/components/eafm/translations/bg.json @@ -0,0 +1,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" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eafm/translations/ja.json b/homeassistant/components/eafm/translations/ja.json new file mode 100644 index 00000000000..aff9730fcf9 --- /dev/null +++ b/homeassistant/components/eafm/translations/ja.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "no_stations": "\u6d2a\u6c34\u76e3\u8996(Track a flood monitoring)\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002" + }, + "step": { + "user": { + "data": { + "station": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3" + }, + "description": "\u76e3\u8996\u3057\u305f\u3044\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "\u6d2a\u6c34\u76e3\u8996(Track a flood monitoring)\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u3092\u8ffd\u8de1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eafm/translations/tr.json b/homeassistant/components/eafm/translations/tr.json index 4ed0f406e57..6755aa58de2 100644 --- a/homeassistant/components/eafm/translations/tr.json +++ b/homeassistant/components/eafm/translations/tr.json @@ -9,6 +9,7 @@ "data": { "station": "\u0130stasyon" }, + "description": "\u0130zlemek istedi\u011finiz istasyonu se\u00e7in", "title": "Ak\u0131\u015f izleme istasyonunu takip edin" } } diff --git a/homeassistant/components/ebusd/translations/ja.json b/homeassistant/components/ebusd/translations/ja.json new file mode 100644 index 00000000000..c43ca27b22a --- /dev/null +++ b/homeassistant/components/ebusd/translations/ja.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "\u65e5", + "night": "\u591c" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/translations/tr.json b/homeassistant/components/ebusd/translations/tr.json new file mode 100644 index 00000000000..5e802f16a5d --- /dev/null +++ b/homeassistant/components/ebusd/translations/tr.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "G\u00fcn", + "night": "Gece" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index c1d11a8ee7b..bf6d0b922dd 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -3,7 +3,11 @@ "name": "ecobee", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecobee", - "requirements": ["python-ecobee-api==0.2.11"], - "codeowners": ["@marthoc"], + "requirements": [ + "python-ecobee-api==0.2.14" + ], + "codeowners": [ + "@marthoc" + ], "iot_class": "cloud_polling" -} +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/ja.json b/homeassistant/components/ecobee/translations/ja.json new file mode 100644 index 00000000000..73ac4cd1611 --- /dev/null +++ b/homeassistant/components/ecobee/translations/ja.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "pin_request_failed": "ecobee\u304b\u3089\u306ePIN\u30ea\u30af\u30a8\u30b9\u30c8\u4e2d\u306b\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f; API\u30ad\u30fc\u304c\u6b63\u3057\u3044\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "token_request_failed": "ecobee\u304b\u3089\u306e\u30c8\u30fc\u30af\u30f3\u306e\u30ea\u30af\u30a8\u30b9\u30c8\u4e2d\u306b\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002\u3082\u3046\u4e00\u5ea6\u3084\u308a\u76f4\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "authorize": { + "description": "\u3053\u306e\u30a2\u30d7\u30ea\u3092 https://www.ecobee.com/consumerportal/index.html \u3067PIN\u30b3\u30fc\u30c9\u3067\u8a8d\u8a3c\u3057\u3066\u304f\u3060\u3055\u3044\u3002 \n\n {pin}\n\n\u6b21\u306b\u3001\u9001\u4fe1(submit) \u3092\u62bc\u3057\u307e\u3059\u3002", + "title": "ecobee.com\u306e\u30a2\u30d7\u30ea\u3092\u8a8d\u8a3c\u3059\u308b" + }, + "user": { + "data": { + "api_key": "API\u30ad\u30fc" + }, + "description": "ecobee.com \u304b\u3089\u53d6\u5f97\u3057\u305fAPI\u30ad\u30fc\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "ecobee API\u30ad\u30fc" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/tr.json b/homeassistant/components/ecobee/translations/tr.json index 23ece38682d..049af38b514 100644 --- a/homeassistant/components/ecobee/translations/tr.json +++ b/homeassistant/components/ecobee/translations/tr.json @@ -3,11 +3,21 @@ "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, + "error": { + "pin_request_failed": "ecobee'den PIN istenirken hata olu\u015ftu; l\u00fctfen API anahtar\u0131n\u0131n do\u011fru oldu\u011funu do\u011frulay\u0131n.", + "token_request_failed": "ecobee'den anahtar istenirken hata olu\u015ftu; l\u00fctfen tekrar deneyin." + }, "step": { + "authorize": { + "description": "L\u00fctfen bu uygulamay\u0131 https://www.ecobee.com/consumerportal/index.html adresinde PIN koduyla yetkilendirin: \n\n {pin}\n\n Ard\u0131ndan G\u00f6nder'e bas\u0131n.", + "title": "Uygulamay\u0131 ecobee.com'da yetkilendirin" + }, "user": { "data": { "api_key": "API Anahtar\u0131" - } + }, + "description": "L\u00fctfen ecobee.com'dan al\u0131nan API anahtar\u0131n\u0131 girin.", + "title": "ecobee API anahtar\u0131" } } } diff --git a/homeassistant/components/econet/translations/ja.json b/homeassistant/components/econet/translations/ja.json new file mode 100644 index 00000000000..4c29b8d305d --- /dev/null +++ b/homeassistant/components/econet/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", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "user": { + "data": { + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "title": "Rheem EcoNet Account\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/econet/translations/tr.json b/homeassistant/components/econet/translations/tr.json index 237a87d0268..5261e78e7e4 100644 --- a/homeassistant/components/econet/translations/tr.json +++ b/homeassistant/components/econet/translations/tr.json @@ -12,8 +12,8 @@ "step": { "user": { "data": { - "email": "Email", - "password": "\u015eifre" + "email": "E-posta", + "password": "Parola" }, "title": "Rheem EcoNet Hesab\u0131n\u0131 Kur" } diff --git a/homeassistant/components/efergy/manifest.json b/homeassistant/components/efergy/manifest.json index 17f104c561f..966df3ed858 100644 --- a/homeassistant/components/efergy/manifest.json +++ b/homeassistant/components/efergy/manifest.json @@ -3,7 +3,7 @@ "name": "Efergy", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/efergy", - "requirements": ["pyefergy==0.1.3"], + "requirements": ["pyefergy==0.1.5"], "codeowners": ["@tkdrob"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/efergy/translations/fr.json b/homeassistant/components/efergy/translations/fr.json new file mode 100644 index 00000000000..1e0299533ea --- /dev/null +++ b/homeassistant/components/efergy/translations/fr.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/id.json b/homeassistant/components/efergy/translations/id.json new file mode 100644 index 00000000000..234e5122db2 --- /dev/null +++ b/homeassistant/components/efergy/translations/id.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "api_key": "Kunci API" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/ja.json b/homeassistant/components/efergy/translations/ja.json index c2ff6bbb145..98dd06ab4f0 100644 --- a/homeassistant/components/efergy/translations/ja.json +++ b/homeassistant/components/efergy/translations/ja.json @@ -1,10 +1,20 @@ { "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" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, "step": { "user": { "data": { "api_key": "API\u30ad\u30fc" - } + }, + "title": "Efergy" } } } diff --git a/homeassistant/components/efergy/translations/pl.json b/homeassistant/components/efergy/translations/pl.json index 9ef0e4a5a43..b96038d24b3 100644 --- a/homeassistant/components/efergy/translations/pl.json +++ b/homeassistant/components/efergy/translations/pl.json @@ -13,7 +13,8 @@ "user": { "data": { "api_key": "Klucz API" - } + }, + "title": "Efergy" } } } diff --git a/homeassistant/components/efergy/translations/sl.json b/homeassistant/components/efergy/translations/sl.json new file mode 100644 index 00000000000..269215c5943 --- /dev/null +++ b/homeassistant/components/efergy/translations/sl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "api_key": "API Klju\u010d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/tr.json b/homeassistant/components/efergy/translations/tr.json index 212abb7cb64..e13f215b5fb 100644 --- a/homeassistant/components/efergy/translations/tr.json +++ b/homeassistant/components/efergy/translations/tr.json @@ -1,10 +1,11 @@ { "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\u011flant\u0131 ba\u015far\u0131s\u0131z", + "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "unknown": "Beklenmeyen hata" }, @@ -12,7 +13,8 @@ "user": { "data": { "api_key": "API Anahtar\u0131" - } + }, + "title": "Efergy" } } } diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index 7413e5009de..09229ce767e 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -38,6 +38,7 @@ DOMAIN = "eight_sleep" HEAT_ENTITY = "heat" USER_ENTITY = "user" + HEAT_SCAN_INTERVAL = timedelta(seconds=60) USER_SCAN_INTERVAL = timedelta(seconds=300) @@ -48,18 +49,9 @@ NAME_MAP = { "left_current_sleep": "Left Sleep Session", "left_current_sleep_fitness": "Left Sleep Fitness", "left_last_sleep": "Left Previous Sleep Session", - "left_bed_state": "Left Bed State", - "left_presence": "Left Bed Presence", - "left_bed_temp": "Left Bed Temperature", - "left_sleep_stage": "Left Sleep Stage", "right_current_sleep": "Right Sleep Session", "right_current_sleep_fitness": "Right Sleep Fitness", "right_last_sleep": "Right Previous Sleep Session", - "right_bed_state": "Right Bed State", - "right_presence": "Right Bed Presence", - "right_bed_temp": "Right Bed Temperature", - "right_sleep_stage": "Right Sleep Stage", - "room_temp": "Room Temperature", } SENSORS = [ @@ -67,7 +59,7 @@ SENSORS = [ "current_sleep_fitness", "last_sleep", "bed_state", - "bed_temp", + "bed_temperature", "sleep_stage", ] @@ -104,6 +96,14 @@ CONFIG_SCHEMA = vol.Schema( ) +def _get_device_unique_id(eight: EightSleep, user_obj: EightUser = None) -> str: + """Get the device's unique ID.""" + unique_id = eight.deviceid + if user_obj: + unique_id = f"{unique_id}.{user_obj.userid}.{user_obj.side}" + return unique_id + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Eight Sleep component.""" @@ -143,11 +143,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: sensors = [] binary_sensors = [] if eight.users: - for obj in eight.users.values(): + for user, obj in eight.users.items(): for sensor in SENSORS: - sensors.append(f"{obj.side}_{sensor}") - binary_sensors.append(f"{obj.side}_presence") - sensors.append("room_temp") + sensors.append((obj.side, sensor)) + binary_sensors.append((obj.side, "bed_presence")) + sensors.append((None, "room_temperature")) else: # No users, cannot continue return False @@ -173,9 +173,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: duration = params.pop(ATTR_HEAT_DURATION, 0) for sens in sensor: - side = sens.split("_")[1] + side = sens[0] userid = eight.fetch_userid(side) - usrobj: EightUser = eight.users[userid] + usrobj = eight.users[userid] await usrobj.set_heating_level(target, duration) await heat_coordinator.async_request_refresh() @@ -199,9 +199,12 @@ class EightSleepHeatDataCoordinator(DataUpdateCoordinator): _LOGGER, name=f"{DOMAIN}_heat", update_interval=HEAT_SCAN_INTERVAL, - update_method=self.api.update_device_data, + update_method=self._async_update_data, ) + async def _async_update_data(self) -> None: + await self.api.update_device_data() + class EightSleepUserDataCoordinator(DataUpdateCoordinator): """Class to retrieve user data from Eight Sleep.""" @@ -214,14 +217,57 @@ class EightSleepUserDataCoordinator(DataUpdateCoordinator): _LOGGER, name=f"{DOMAIN}_user", update_interval=USER_SCAN_INTERVAL, - update_method=self.api.update_user_data, + update_method=self._async_update_data, ) + async def _async_update_data(self) -> None: + await self.api.update_user_data() -class EightSleepEntity(CoordinatorEntity): - """The Eight Sleep device entity.""" - def __init__(self, coordinator: DataUpdateCoordinator, eight: EightSleep) -> None: +class EightSleepBaseEntity(CoordinatorEntity): + """The base Eight Sleep entity class.""" + + def __init__( + self, + name: str, + coordinator: EightSleepUserDataCoordinator | EightSleepHeatDataCoordinator, + eight: EightSleep, + side: str | None, + sensor: str, + ) -> None: """Initialize the data object.""" super().__init__(coordinator) self._eight = eight + self._side = side + self._sensor = sensor + self._usrobj: EightUser = None + if self._side: + self._usrobj = self._eight.users[self._eight.fetch_userid(self._side)] + full_sensor_name = self._sensor + if self._side is not None: + full_sensor_name = f"{self._side}_{full_sensor_name}" + mapped_name = NAME_MAP.get( + full_sensor_name, full_sensor_name.replace("_", " ").title() + ) + + self._attr_name = f"{name} {mapped_name}" + self._attr_unique_id = ( + f"{_get_device_unique_id(eight, self._usrobj)}.{self._sensor}" + ) + + +class EightSleepUserEntity(EightSleepBaseEntity): + """The Eight Sleep user entity.""" + + def __init__( + self, + name: str, + coordinator: EightSleepUserDataCoordinator, + eight: EightSleep, + side: str | None, + sensor: str, + units: str, + ) -> None: + """Initialize the data object.""" + super().__init__(name, coordinator, eight, side, sensor) + self._units = units diff --git a/homeassistant/components/eight_sleep/binary_sensor.py b/homeassistant/components/eight_sleep/binary_sensor.py index 5b6e1f6a9c3..7240d65d262 100644 --- a/homeassistant/components/eight_sleep/binary_sensor.py +++ b/homeassistant/components/eight_sleep/binary_sensor.py @@ -1,25 +1,25 @@ """Support for Eight Sleep binary sensors.""" +from __future__ import annotations + import logging from pyeight.eight import EightSleep -from pyeight.user import EightUser from homeassistant.components.binary_sensor import ( DEVICE_CLASS_OCCUPANCY, BinarySensorEntity, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import ( CONF_BINARY_SENSORS, DATA_API, DATA_EIGHT, DATA_HEAT, - NAME_MAP, - EightSleepEntity, + EightSleepBaseEntity, + EightSleepHeatDataCoordinator, ) _LOGGER = logging.getLogger(__name__) @@ -37,55 +37,40 @@ async def async_setup_platform( name = "Eight" sensors = discovery_info[CONF_BINARY_SENSORS] - eight = hass.data[DATA_EIGHT][DATA_API] - heat_coordinator = hass.data[DATA_EIGHT][DATA_HEAT] + eight: EightSleep = hass.data[DATA_EIGHT][DATA_API] + heat_coordinator: EightSleepHeatDataCoordinator = hass.data[DATA_EIGHT][DATA_HEAT] - all_sensors = [] + all_sensors = [ + EightHeatSensor(name, heat_coordinator, eight, side, sensor) + for side, sensor in sensors + ] - for sensor in sensors: - all_sensors.append(EightHeatSensor(name, heat_coordinator, eight, sensor)) - - async_add_entities(all_sensors, True) + async_add_entities(all_sensors) -class EightHeatSensor(EightSleepEntity, BinarySensorEntity): +class EightHeatSensor(EightSleepBaseEntity, BinarySensorEntity): """Representation of a Eight Sleep heat-based sensor.""" def __init__( self, name: str, - coordinator: DataUpdateCoordinator, + coordinator: EightSleepHeatDataCoordinator, eight: EightSleep, + side: str | None, sensor: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, eight) - - self._sensor = sensor - self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) - self._state = None - - self._side = self._sensor.split("_")[0] - self._userid = self._eight.fetch_userid(self._side) - self._usrobj: EightUser = self._eight.users[self._userid] - - self._attr_name = f"{name} {self._mapped_name}" + super().__init__(name, coordinator, eight, side, sensor) self._attr_device_class = DEVICE_CLASS_OCCUPANCY _LOGGER.debug( "Presence Sensor: %s, Side: %s, User: %s", self._sensor, self._side, - self._userid, + self._usrobj.userid, ) @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return bool(self._state) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._state = self._usrobj.bed_presence - super()._handle_coordinator_update() + return bool(self._usrobj.bed_presence) diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index c7c58c05e7d..42270ad4fc4 100644 --- a/homeassistant/components/eight_sleep/sensor.py +++ b/homeassistant/components/eight_sleep/sensor.py @@ -1,23 +1,16 @@ """Support for Eight Sleep sensors.""" from __future__ import annotations -from collections.abc import Mapping import logging from typing import Any from pyeight.eight import EightSleep -from pyeight.user import EightUser from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ( - DEVICE_CLASS_TEMPERATURE, - PERCENTAGE, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import PERCENTAGE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import ( CONF_SENSORS, @@ -25,10 +18,10 @@ from . import ( DATA_EIGHT, DATA_HEAT, DATA_USER, - NAME_MAP, - EightSleepEntity, + EightSleepBaseEntity, EightSleepHeatDataCoordinator, EightSleepUserDataCoordinator, + EightSleepUserEntity, ) ATTR_ROOM_TEMP = "Room Temperature" @@ -63,7 +56,7 @@ async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType = None, + discovery_info: dict[str, list[tuple[str, str]]] = None, ) -> None: """Set up the eight sleep sensors.""" if discovery_info is None: @@ -71,7 +64,7 @@ async def async_setup_platform( name = "Eight" sensors = discovery_info[CONF_SENSORS] - eight = hass.data[DATA_EIGHT][DATA_API] + eight: EightSleep = hass.data[DATA_EIGHT][DATA_API] heat_coordinator: EightSleepHeatDataCoordinator = hass.data[DATA_EIGHT][DATA_HEAT] user_coordinator: EightSleepUserDataCoordinator = hass.data[DATA_EIGHT][DATA_USER] @@ -80,24 +73,26 @@ async def async_setup_platform( else: units = "us" - all_sensors: list[EightSleepEntity] = [] + all_sensors: list[SensorEntity] = [] - for sensor in sensors: - if "bed_state" in sensor: - all_sensors.append(EightHeatSensor(name, heat_coordinator, eight, sensor)) - elif "room_temp" in sensor: + for side, sensor in sensors: + if sensor == "bed_state": all_sensors.append( - EightRoomSensor(name, user_coordinator, eight, sensor, units) + EightHeatSensor(name, heat_coordinator, eight, side, sensor) + ) + elif sensor == "room_temperature": + all_sensors.append( + EightRoomSensor(name, user_coordinator, eight, side, sensor, units) ) else: all_sensors.append( - EightUserSensor(name, user_coordinator, eight, sensor, units) + EightUserSensor(name, user_coordinator, eight, side, sensor, units) ) - async_add_entities(all_sensors, True) + async_add_entities(all_sensors) -class EightHeatSensor(EightSleepEntity, SensorEntity): +class EightHeatSensor(EightSleepBaseEntity, SensorEntity): """Representation of an eight sleep heat-based sensor.""" def __init__( @@ -105,51 +100,27 @@ class EightHeatSensor(EightSleepEntity, SensorEntity): name: str, coordinator: EightSleepHeatDataCoordinator, eight: EightSleep, + side: str | None, sensor: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, eight) - - self._sensor = sensor - self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) - self._name = f"{name} {self._mapped_name}" - self._state = None - - self._side = self._sensor.split("_")[0] - self._userid = self._eight.fetch_userid(self._side) - self._usrobj: EightUser = self._eight.users[self._userid] + super().__init__(name, coordinator, eight, side, sensor) + self._attr_native_unit_of_measurement = PERCENTAGE _LOGGER.debug( "Heat Sensor: %s, Side: %s, User: %s", self._sensor, self._side, - self._userid, + self._usrobj.userid, ) @property - def name(self) -> str: - """Return the name of the sensor, if any.""" - return self._name - - @property - def native_value(self) -> str | None: + def native_value(self) -> int: """Return the state of the sensor.""" - return self._state + return self._usrobj.heating_level @property - def native_unit_of_measurement(self) -> str: - """Return the unit the value is expressed in.""" - return PERCENTAGE - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - _LOGGER.debug("Updating Heat sensor: %s", self._sensor) - self._state = self._usrobj.heating_level - super()._handle_coordinator_update() - - @property - def extra_state_attributes(self) -> Mapping[str, Any]: + def extra_state_attributes(self) -> dict[str, Any]: """Return device state attributes.""" return { ATTR_TARGET_HEAT: self._usrobj.target_heating_level, @@ -158,7 +129,17 @@ class EightHeatSensor(EightSleepEntity, SensorEntity): } -class EightUserSensor(EightSleepEntity, SensorEntity): +def _get_breakdown_percent( + attr: dict[str, Any], key: str, denominator: int | float +) -> int | float: + """Get a breakdown percent.""" + try: + return round((attr["breakdown"][key] / denominator) * 100, 2) + except ZeroDivisionError: + return 0 + + +class EightUserSensor(EightSleepUserEntity, SensorEntity): """Representation of an eight sleep user-based sensor.""" def __init__( @@ -166,180 +147,138 @@ class EightUserSensor(EightSleepEntity, SensorEntity): name: str, coordinator: EightSleepUserDataCoordinator, eight: EightSleep, + side: str | None, sensor: str, units: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, eight) + super().__init__(name, coordinator, eight, side, sensor, units) - self._sensor = sensor - self._sensor_root = self._sensor.split("_", 1)[1] - self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) - self._name = f"{name} {self._mapped_name}" - self._state = None - self._attr = None - self._units = units - - self._side = self._sensor.split("_", 1)[0] - self._userid = self._eight.fetch_userid(self._side) - self._usrobj: EightUser = self._eight.users[self._userid] + if self._sensor == "bed_temperature": + self._attr_icon = "mdi:thermometer" _LOGGER.debug( "User Sensor: %s, Side: %s, User: %s", self._sensor, self._side, - self._userid, + self._usrobj.userid if self._usrobj else None, ) @property - def name(self) -> str: - """Return the name of the sensor, if any.""" - return self._name - - @property - def native_value(self) -> str | None: + def native_value(self) -> str | int | float | None: """Return the state of the sensor.""" - return self._state + if "current" in self._sensor: + if "fitness" in self._sensor: + return self._usrobj.current_sleep_fitness_score + return self._usrobj.current_sleep_score + + if "last" in self._sensor: + return self._usrobj.last_sleep_score + + if self._sensor == "bed_temperature": + temp = self._usrobj.current_values["bed_temp"] + try: + if self._units == "si": + return round(temp, 2) + return round((temp * 1.8) + 32, 2) + except TypeError: + return None + + if self._sensor == "sleep_stage": + return self._usrobj.current_values["stage"] + + return None @property def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" - if ( - "current_sleep" in self._sensor - or "last_sleep" in self._sensor - or "current_sleep_fitness" in self._sensor - ): + if self._sensor in ("current_sleep", "last_sleep", "current_sleep_fitness"): return "Score" - if "bed_temp" in self._sensor: + if self._sensor == "bed_temperature": if self._units == "si": return TEMP_CELSIUS return TEMP_FAHRENHEIT return None - @property - def device_class(self) -> str | None: - """Return the class of this device, from component DEVICE_CLASSES.""" - if "bed_temp" in self._sensor: - return DEVICE_CLASS_TEMPERATURE - return None + def _get_rounded_value( + self, attr: dict[str, Any], key: str, use_units: bool = True + ) -> int | float | None: + """Get rounded value based on units for given key.""" + try: + if self._units == "si" or not use_units: + return round(attr["room_temp"], 2) + return round((attr["room_temp"] * 1.8) + 32, 2) + except TypeError: + return None - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - _LOGGER.debug("Updating User sensor: %s", self._sensor) + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return device state attributes.""" + attr = None if "current" in self._sensor: if "fitness" in self._sensor: - self._state = self._usrobj.current_sleep_fitness_score - self._attr = self._usrobj.current_fitness_values + attr = self._usrobj.current_fitness_values else: - self._state = self._usrobj.current_sleep_score - self._attr = self._usrobj.current_values + attr = self._usrobj.current_values elif "last" in self._sensor: - self._state = self._usrobj.last_sleep_score - self._attr = self._usrobj.last_values - elif "bed_temp" in self._sensor: - temp = self._usrobj.current_values["bed_temp"] - try: - if self._units == "si": - self._state = round(temp, 2) - else: - self._state = round((temp * 1.8) + 32, 2) - except TypeError: - self._state = None - elif "sleep_stage" in self._sensor: - self._state = self._usrobj.current_values["stage"] + attr = self._usrobj.last_values - super()._handle_coordinator_update() - - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return device state attributes.""" - if self._attr is None: + if attr is None: # Skip attributes if sensor type doesn't support return None - if "fitness" in self._sensor_root: + if "fitness" in self._sensor: state_attr = { - ATTR_FIT_DATE: self._attr["date"], - ATTR_FIT_DURATION_SCORE: self._attr["duration"], - ATTR_FIT_ASLEEP_SCORE: self._attr["asleep"], - ATTR_FIT_OUT_SCORE: self._attr["out"], - ATTR_FIT_WAKEUP_SCORE: self._attr["wakeup"], + ATTR_FIT_DATE: attr["date"], + ATTR_FIT_DURATION_SCORE: attr["duration"], + ATTR_FIT_ASLEEP_SCORE: attr["asleep"], + ATTR_FIT_OUT_SCORE: attr["out"], + ATTR_FIT_WAKEUP_SCORE: attr["wakeup"], } return state_attr - state_attr = {ATTR_SESSION_START: self._attr["date"]} - state_attr[ATTR_TNT] = self._attr["tnt"] - state_attr[ATTR_PROCESSING] = self._attr["processing"] + state_attr = {ATTR_SESSION_START: attr["date"]} + state_attr[ATTR_TNT] = attr["tnt"] + state_attr[ATTR_PROCESSING] = attr["processing"] - sleep_time = ( - sum(self._attr["breakdown"].values()) - self._attr["breakdown"]["awake"] - ) - state_attr[ATTR_SLEEP_DUR] = sleep_time - try: - state_attr[ATTR_LIGHT_PERC] = round( - (self._attr["breakdown"]["light"] / sleep_time) * 100, 2 + if attr.get("breakdown") is not None: + sleep_time = sum(attr["breakdown"].values()) - attr["breakdown"]["awake"] + state_attr[ATTR_SLEEP_DUR] = sleep_time + state_attr[ATTR_LIGHT_PERC] = _get_breakdown_percent( + attr, "light", sleep_time ) - except ZeroDivisionError: - state_attr[ATTR_LIGHT_PERC] = 0 - try: - state_attr[ATTR_DEEP_PERC] = round( - (self._attr["breakdown"]["deep"] / sleep_time) * 100, 2 + state_attr[ATTR_DEEP_PERC] = _get_breakdown_percent( + attr, "deep", sleep_time ) - except ZeroDivisionError: - state_attr[ATTR_DEEP_PERC] = 0 + state_attr[ATTR_REM_PERC] = _get_breakdown_percent(attr, "rem", sleep_time) - try: - state_attr[ATTR_REM_PERC] = round( - (self._attr["breakdown"]["rem"] / sleep_time) * 100, 2 + room_temp = self._get_rounded_value(attr, "room_temp") + bed_temp = self._get_rounded_value(attr, "bed_temp") + + if "current" in self._sensor: + state_attr[ATTR_RESP_RATE] = self._get_rounded_value( + attr, "resp_rate", False ) - except ZeroDivisionError: - state_attr[ATTR_REM_PERC] = 0 - - try: - if self._units == "si": - room_temp = round(self._attr["room_temp"], 2) - else: - room_temp = round((self._attr["room_temp"] * 1.8) + 32, 2) - except TypeError: - room_temp = None - - try: - if self._units == "si": - bed_temp = round(self._attr["bed_temp"], 2) - else: - bed_temp = round((self._attr["bed_temp"] * 1.8) + 32, 2) - except TypeError: - bed_temp = None - - if "current" in self._sensor_root: - try: - state_attr[ATTR_RESP_RATE] = round(self._attr["resp_rate"], 2) - except TypeError: - state_attr[ATTR_RESP_RATE] = None - try: - state_attr[ATTR_HEART_RATE] = round(self._attr["heart_rate"], 2) - except TypeError: - state_attr[ATTR_HEART_RATE] = None - state_attr[ATTR_SLEEP_STAGE] = self._attr["stage"] + state_attr[ATTR_HEART_RATE] = self._get_rounded_value( + attr, "heart_rate", False + ) + state_attr[ATTR_SLEEP_STAGE] = attr["stage"] state_attr[ATTR_ROOM_TEMP] = room_temp state_attr[ATTR_BED_TEMP] = bed_temp - elif "last" in self._sensor_root: - try: - state_attr[ATTR_AVG_RESP_RATE] = round(self._attr["resp_rate"], 2) - except TypeError: - state_attr[ATTR_AVG_RESP_RATE] = None - try: - state_attr[ATTR_AVG_HEART_RATE] = round(self._attr["heart_rate"], 2) - except TypeError: - state_attr[ATTR_AVG_HEART_RATE] = None + elif "last" in self._sensor: + state_attr[ATTR_AVG_RESP_RATE] = self._get_rounded_value( + attr, "resp_rate", False + ) + state_attr[ATTR_AVG_HEART_RATE] = self._get_rounded_value( + attr, "heart_rate", False + ) state_attr[ATTR_AVG_ROOM_TEMP] = room_temp state_attr[ATTR_AVG_BED_TEMP] = bed_temp return state_attr -class EightRoomSensor(EightSleepEntity, SensorEntity): +class EightRoomSensor(EightSleepUserEntity, SensorEntity): """Representation of an eight sleep room sensor.""" def __init__( @@ -347,51 +286,25 @@ class EightRoomSensor(EightSleepEntity, SensorEntity): name: str, coordinator: EightSleepUserDataCoordinator, eight: EightSleep, + side: str | None, sensor: str, units: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, eight) + super().__init__(name, coordinator, eight, side, sensor, units) - self._sensor = sensor - self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) - self._name = f"{name} {self._mapped_name}" - self._state = None - self._attr = None - self._units = units + self._attr_icon = "mdi:thermometer" + self._attr_native_unit_of_measurement: str = ( + TEMP_CELSIUS if self._units == "si" else TEMP_FAHRENHEIT + ) @property - def name(self) -> str: - """Return the name of the sensor, if any.""" - return self._name - - @property - def native_value(self) -> str | None: + def native_value(self) -> int | float | None: """Return the state of the sensor.""" - return self._state - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - _LOGGER.debug("Updating Room sensor: %s", self._sensor) temp = self._eight.room_temperature() try: if self._units == "si": - self._state = round(temp, 2) - else: - self._state = round((temp * 1.8) + 32, 2) + return round(temp, 2) + return round((temp * 1.8) + 32, 2) except TypeError: - self._state = None - super()._handle_coordinator_update() - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit the value is expressed in.""" - if self._units == "si": - return TEMP_CELSIUS - return TEMP_FAHRENHEIT - - @property - def device_class(self) -> str: - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_TEMPERATURE + return None diff --git a/homeassistant/components/elgato/__init__.py b/homeassistant/components/elgato/__init__.py index 21b8de53c17..c074b3303e7 100644 --- a/homeassistant/components/elgato/__init__.py +++ b/homeassistant/components/elgato/__init__.py @@ -3,6 +3,7 @@ import logging from elgato import Elgato, ElgatoConnectionError +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT @@ -10,9 +11,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DATA_ELGATO_CLIENT, DOMAIN +from .const import DOMAIN -PLATFORMS = [LIGHT_DOMAIN] +PLATFORMS = [BUTTON_DOMAIN, LIGHT_DOMAIN] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -31,8 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: logging.getLogger(__name__).debug("Unable to connect: %s", exception) raise ConfigEntryNotReady from exception - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {DATA_ELGATO_CLIENT: elgato} + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = elgato hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py new file mode 100644 index 00000000000..4e77f05e415 --- /dev/null +++ b/homeassistant/components/elgato/button.py @@ -0,0 +1,62 @@ +"""Support for Elgato button.""" +from __future__ import annotations + +import logging + +from elgato import Elgato, ElgatoError, Info + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_CONFIG +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Elgato button based on a config entry.""" + elgato: Elgato = hass.data[DOMAIN][entry.entry_id] + info = await elgato.info() + async_add_entities([ElgatoIdentifyButton(elgato, info)]) + + +class ElgatoIdentifyButton(ButtonEntity): + """Defines an Elgato identify button.""" + + def __init__(self, elgato: Elgato, info: Info) -> None: + """Initialize the button entity.""" + self.elgato = elgato + self._info = info + self.entity_description = ButtonEntityDescription( + key="identify", + name="Identify", + icon="mdi:help", + entity_category=ENTITY_CATEGORY_CONFIG, + ) + self._attr_unique_id = f"{info.serial_number}_{self.entity_description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this Elgato Light.""" + return DeviceInfo( + identifiers={(DOMAIN, self._info.serial_number)}, + manufacturer="Elgato", + model=self._info.product_name, + name=self._info.product_name, + sw_version=f"{self._info.firmware_version} ({self._info.firmware_build_number})", + ) + + async def async_press(self) -> None: + """Identify the light, will make it blink.""" + try: + await self.elgato.identify() + except ElgatoError: + _LOGGER.exception("An error occurred while identifying the Elgato Light") diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py index 6008ccbee77..12d1b5d1d93 100644 --- a/homeassistant/components/elgato/config_flow.py +++ b/homeassistant/components/elgato/config_flow.py @@ -6,6 +6,7 @@ from typing import Any from elgato import Elgato, ElgatoError import voluptuous as vol +from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import callback @@ -41,10 +42,12 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): return self._async_create_entry() - async def async_step_zeroconf(self, discovery_info: dict[str, Any]) -> FlowResult: + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle zeroconf discovery.""" - self.host = discovery_info[CONF_HOST] - self.port = discovery_info[CONF_PORT] + self.host = discovery_info.host + self.port = discovery_info.port or 9123 try: await self._get_elgato_serial_number() diff --git a/homeassistant/components/elgato/const.py b/homeassistant/components/elgato/const.py index 03a52b7e305..6e63b598346 100644 --- a/homeassistant/components/elgato/const.py +++ b/homeassistant/components/elgato/const.py @@ -3,9 +3,6 @@ # Integration domain DOMAIN = "elgato" -# Home Assistant data keys -DATA_ELGATO_CLIENT = "elgato_client" - # Attributes ATTR_ON = "on" diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index 8ed443b65e1..d8a6e74c41a 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import ( async_get_current_platform, ) -from .const import DATA_ELGATO_CLIENT, DOMAIN, SERVICE_IDENTIFY +from .const import DOMAIN, SERVICE_IDENTIFY _LOGGER = logging.getLogger(__name__) @@ -37,7 +37,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Elgato Light based on a config entry.""" - elgato: Elgato = hass.data[DOMAIN][entry.entry_id][DATA_ELGATO_CLIENT] + elgato: Elgato = hass.data[DOMAIN][entry.entry_id] info = await elgato.info() settings = await elgato.settings() async_add_entities([ElgatoLight(elgato, info, settings)], True) diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json index 7a095cb5917..ebc2aca6527 100644 --- a/homeassistant/components/elgato/manifest.json +++ b/homeassistant/components/elgato/manifest.json @@ -3,7 +3,7 @@ "name": "Elgato Light", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/elgato", - "requirements": ["elgato==2.1.1"], + "requirements": ["elgato==2.2.0"], "zeroconf": ["_elg._tcp.local."], "codeowners": ["@frenck"], "quality_scale": "platinum", diff --git a/homeassistant/components/elgato/translations/bg.json b/homeassistant/components/elgato/translations/bg.json index d00cbb30191..0e3a6d80ab1 100644 --- a/homeassistant/components/elgato/translations/bg.json +++ b/homeassistant/components/elgato/translations/bg.json @@ -1,14 +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", "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" }, "error": { "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" }, + "flow_title": "{serial_number}", "step": { "user": { "data": { + "host": "\u0425\u043e\u0441\u0442", "port": "\u041f\u043e\u0440\u0442" } } diff --git a/homeassistant/components/elgato/translations/ja.json b/homeassistant/components/elgato/translations/ja.json new file mode 100644 index 00000000000..d7686d574fc --- /dev/null +++ b/homeassistant/components/elgato/translations/ja.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\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" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{serial_number}", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "Elgato Key Light\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3001Home Assistant\u3068\u9023\u643a\u3059\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002" + }, + "zeroconf_confirm": { + "description": "\u30b7\u30ea\u30a2\u30eb\u756a\u53f7 `{serial_number}` \u306e\u3001Elgato Light\u3092Home Assistant\u306b\u8ffd\u52a0\u3057\u307e\u3059\u304b\uff1f", + "title": "Elgato Light device\u3092\u767a\u898b" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/tr.json b/homeassistant/components/elgato/translations/tr.json index b2d1753fd68..c49720b9525 100644 --- a/homeassistant/components/elgato/translations/tr.json +++ b/homeassistant/components/elgato/translations/tr.json @@ -7,12 +7,18 @@ "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, + "flow_title": "{serial_number}", "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "port": "Port" - } + }, + "description": "Elgato Light'\u0131n\u0131z\u0131 Home Assistant ile entegre olacak \u015fekilde ayarlay\u0131n." + }, + "zeroconf_confirm": { + "description": "Seri numaras\u0131 ` {serial_number} ` olan Elgato Light'\u0131 Home Assistant'a eklemek ister misiniz?", + "title": "Ke\u015ffedilen Elgato Light cihaz\u0131" } } } diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 3b59fffe553..07111282c3d 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -319,7 +319,7 @@ async def async_wait_for_elk_to_sync(elk, timeout, conf_host): elk.add_handler("login", login_status) elk.add_handler("sync_complete", sync_complete) try: - with async_timeout.timeout(timeout): + async with async_timeout.timeout(timeout): await event.wait() except asyncio.TimeoutError: _LOGGER.error( diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index 5b3a20b3448..04881b9f085 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -117,8 +117,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity): self._element.add_callback(self._watch_area) # We do not get changed_by back from resync. - last_state = await self.async_get_last_state() - if not last_state: + if not (last_state := await self.async_get_last_state()): return if ATTR_CHANGED_BY_KEYPAD in last_state.attributes: diff --git a/homeassistant/components/elkm1/translations/ja.json b/homeassistant/components/elkm1/translations/ja.json new file mode 100644 index 00000000000..4dc86431964 --- /dev/null +++ b/homeassistant/components/elkm1/translations/ja.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "address_already_configured": "\u3053\u306e\u30a2\u30c9\u30ec\u30b9\u306eElkM1\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_configured": "\u3053\u306e\u30d7\u30ec\u30d5\u30a3\u30c3\u30af\u30b9\u3092\u6301\u3064ElkM1\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "address": "IP\u30a2\u30c9\u30ec\u30b9\u307e\u305f\u306f\u30c9\u30e1\u30a4\u30f3\u3001\u30b7\u30ea\u30a2\u30eb\u3067\u63a5\u7d9a\u3059\u308b\u5834\u5408\u306f\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8\u3002", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "prefix": "\u30e6\u30cb\u30fc\u30af(\u4e00\u610f)\u306a\u30d7\u30ec\u30d5\u30a3\u30c3\u30af\u30b9(ElkM1\u304c1\u3064\u3057\u304b\u306a\u3044\u5834\u5408\u306f\u7a7a\u767d\u306e\u307e\u307e\u306b\u3057\u307e\u3059)", + "protocol": "\u30d7\u30ed\u30c8\u30b3\u30eb", + "temperature_unit": "ElkM1\u304c\u4f7f\u7528\u3059\u308b\u6e29\u5ea6\u5358\u4f4d\u3002", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Elk-M1 Control\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/tr.json b/homeassistant/components/elkm1/translations/tr.json index 9259220985b..3b62ad5e079 100644 --- a/homeassistant/components/elkm1/translations/tr.json +++ b/homeassistant/components/elkm1/translations/tr.json @@ -12,9 +12,15 @@ "step": { "user": { "data": { + "address": "Seri yoluyla ba\u011flan\u0131l\u0131yorsa IP adresi veya etki alan\u0131 veya seri ba\u011flant\u0131 noktas\u0131.", "password": "Parola", + "prefix": "Benzersiz bir \u00f6nek (yaln\u0131zca bir ElkM1'iniz varsa bo\u015f b\u0131rak\u0131n).", + "protocol": "Protokol", + "temperature_unit": "ElkM1'in kulland\u0131\u011f\u0131 s\u0131cakl\u0131k birimi.", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "description": "Adres dizesi, 'g\u00fcvenli' ve 'g\u00fcvenli olmayan' i\u00e7in 'adres[:port]' bi\u00e7iminde olmal\u0131d\u0131r. \u00d6rnek: '192.168.1.1'. Ba\u011flant\u0131 noktas\u0131 iste\u011fe ba\u011fl\u0131d\u0131r ve varsay\u0131lan olarak 'g\u00fcvenli olmayan' i\u00e7in 2101 ve 'g\u00fcvenli' i\u00e7in 2601'dir. Seri protokol i\u00e7in adres 'tty[:baud]' bi\u00e7iminde olmal\u0131d\u0131r. \u00d6rnek: '/dev/ttyS1'. Baud iste\u011fe ba\u011fl\u0131d\u0131r ve varsay\u0131lan olarak 115200'd\u00fcr.", + "title": "Elk-M1 Kontrol\u00fcne Ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/emby/manifest.json b/homeassistant/components/emby/manifest.json index 7c1295b0e58..00c05702db7 100644 --- a/homeassistant/components/emby/manifest.json +++ b/homeassistant/components/emby/manifest.json @@ -2,7 +2,7 @@ "domain": "emby", "name": "Emby", "documentation": "https://www.home-assistant.io/integrations/emby", - "requirements": ["pyemby==1.7"], + "requirements": ["pyemby==1.8"], "codeowners": ["@mezz64"], "iot_class": "local_push" } diff --git a/homeassistant/components/emonitor/config_flow.py b/homeassistant/components/emonitor/config_flow.py index cc63e707013..a77289d469e 100644 --- a/homeassistant/components/emonitor/config_flow.py +++ b/homeassistant/components/emonitor/config_flow.py @@ -6,8 +6,9 @@ import aiohttp import voluptuous as vol from homeassistant import config_entries, core -from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import format_mac @@ -62,12 +63,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_dhcp(self, discovery_info): + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" - self.discovered_ip = discovery_info[IP_ADDRESS] - await self.async_set_unique_id(format_mac(discovery_info[MAC_ADDRESS])) + self.discovered_ip = discovery_info.ip + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) self._abort_if_unique_id_configured(updates={CONF_HOST: self.discovered_ip}) - name = name_short_mac(short_mac(discovery_info[MAC_ADDRESS])) + name = name_short_mac(short_mac(discovery_info.macaddress)) self.context["title_placeholders"] = {"name": name} try: self.discovered_info = await fetch_mac_and_title( diff --git a/homeassistant/components/emonitor/translations/ja.json b/homeassistant/components/emonitor/translations/ja.json new file mode 100644 index 00000000000..feeb93f5c86 --- /dev/null +++ b/homeassistant/components/emonitor/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" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "{name} ({host})\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b?", + "title": "SiteSage Emonitor\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/emonitor/translations/tr.json b/homeassistant/components/emonitor/translations/tr.json new file mode 100644 index 00000000000..58c48f5f688 --- /dev/null +++ b/homeassistant/components/emonitor/translations/tr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "{name} ( {host} ) kurulumu yapmak istiyor musunuz?", + "title": "SiteSage Emonitor Kurulumu" + }, + "user": { + "data": { + "host": "Ana bilgisayar" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 3cfa710703c..4dec35a80e8 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -5,7 +5,6 @@ from aiohttp import web import voluptuous as vol from homeassistant.components.network import async_get_source_ip -from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.const import ( CONF_ENTITIES, CONF_TYPE, @@ -106,7 +105,7 @@ ATTR_EMULATED_HUE_NAME = "emulated_hue_name" async def async_setup(hass, yaml_config): """Activate the emulated_hue component.""" - local_ip = await async_get_source_ip(hass, PUBLIC_TARGET_IP) + local_ip = await async_get_source_ip(hass) config = Config(hass, yaml_config.get(DOMAIN, {}), local_ip) await config.async_setup() diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index bb3ac2082f8..39a0a3da054 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -2,7 +2,7 @@ "domain": "emulated_kasa", "name": "Emulated Kasa", "documentation": "https://www.home-assistant.io/integrations/emulated_kasa", - "requirements": ["sense_energy==0.9.2"], + "requirements": ["sense_energy==0.9.3"], "codeowners": ["@kbickar"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/emulated_roku/__init__.py b/homeassistant/components/emulated_roku/__init__.py index 32e08342191..3d84bf20f44 100644 --- a/homeassistant/components/emulated_roku/__init__.py +++ b/homeassistant/components/emulated_roku/__init__.py @@ -3,7 +3,6 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.network import async_get_source_ip -from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv @@ -71,9 +70,7 @@ async def async_setup_entry(hass, config_entry): name = config[CONF_NAME] listen_port = config[CONF_LISTEN_PORT] - host_ip = config.get(CONF_HOST_IP) or await async_get_source_ip( - hass, PUBLIC_TARGET_IP - ) + host_ip = config.get(CONF_HOST_IP) or await async_get_source_ip(hass) advertise_ip = config.get(CONF_ADVERTISE_IP) advertise_port = config.get(CONF_ADVERTISE_PORT) upnp_bind_multicast = config.get(CONF_UPNP_BIND_MULTICAST) diff --git a/homeassistant/components/emulated_roku/translations/id.json b/homeassistant/components/emulated_roku/translations/id.json index 9ffcedf5d19..30aa33240de 100644 --- a/homeassistant/components/emulated_roku/translations/id.json +++ b/homeassistant/components/emulated_roku/translations/id.json @@ -9,6 +9,7 @@ "advertise_ip": "Umumkan Alamat IP", "advertise_port": "Umumkan Port", "host_ip": "Alamat IP Host", + "listen_port": "Port untuk Mendengarkan", "name": "Nama", "upnp_bind_multicast": "Bind multicast (True/False)" }, diff --git a/homeassistant/components/emulated_roku/translations/ja.json b/homeassistant/components/emulated_roku/translations/ja.json new file mode 100644 index 00000000000..2ea21df53a1 --- /dev/null +++ b/homeassistant/components/emulated_roku/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" + }, + "step": { + "user": { + "data": { + "advertise_ip": "IP\u30a2\u30c9\u30ec\u30b9\u3092\u30a2\u30c9\u30d0\u30bf\u30a4\u30ba\u3059\u308b", + "advertise_port": "\u30a2\u30c9\u30d0\u30bf\u30a4\u30ba \u30dd\u30fc\u30c8", + "host_ip": "\u30db\u30b9\u30c8\u306eIP\u30a2\u30c9\u30ec\u30b9", + "listen_port": "\u30ea\u30c3\u30b9\u30f3 \u30dd\u30fc\u30c8", + "name": "\u540d\u524d", + "upnp_bind_multicast": "\u30d0\u30a4\u30f3\u30c9 \u30de\u30eb\u30c1\u30ad\u30e3\u30b9\u30c8 (True/False)" + }, + "title": "\u30b5\u30fc\u30d0\u30fc\u69cb\u6210\u306e\u5b9a\u7fa9" + } + } + }, + "title": "Roku\u3092\u30a8\u30df\u30e5\u30ec\u30fc\u30c8" +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/translations/tr.json b/homeassistant/components/emulated_roku/translations/tr.json index 5307276a71d..271d43d8763 100644 --- a/homeassistant/components/emulated_roku/translations/tr.json +++ b/homeassistant/components/emulated_roku/translations/tr.json @@ -2,6 +2,20 @@ "config": { "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "step": { + "user": { + "data": { + "advertise_ip": "IP Adresini Tan\u0131t", + "advertise_port": "Ba\u011flant\u0131 Noktas\u0131n\u0131 Tan\u0131t", + "host_ip": "Ana Bilgisayar IP Adresi", + "listen_port": "Ba\u011flant\u0131 Noktas\u0131n\u0131 Dinle", + "name": "Ad", + "upnp_bind_multicast": "\u00c7ok noktaya yay\u0131n\u0131 ba\u011fla (Do\u011fru/Yanl\u0131\u015f)" + }, + "title": "Sunucu yap\u0131land\u0131rmas\u0131n\u0131 tan\u0131mlay\u0131n" + } } - } + }, + "title": "Emulated Roku" } \ No newline at end of file diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 8cd5702deb7..0800b02330e 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -22,6 +22,7 @@ from homeassistant.const import ( ENERGY_KILO_WATT_HOUR, ENERGY_MEGA_WATT_HOUR, ENERGY_WATT_HOUR, + ENTITY_CATEGORY_SYSTEM, VOLUME_CUBIC_METERS, ) from homeassistant.core import ( @@ -196,8 +197,7 @@ class SensorManager: ): return - current_entity = to_remove.pop(key, None) - if current_entity: + if current_entity := to_remove.pop(key, None): current_entity.update_config(config) return @@ -215,6 +215,7 @@ class EnergyCostSensor(SensorEntity): utility. """ + _attr_entity_category = ENTITY_CATEGORY_SYSTEM _wrong_state_class_reported = False _wrong_unit_reported = False diff --git a/homeassistant/components/energy/translations/id.json b/homeassistant/components/energy/translations/id.json new file mode 100644 index 00000000000..168ae4ae877 --- /dev/null +++ b/homeassistant/components/energy/translations/id.json @@ -0,0 +1,3 @@ +{ + "title": "Energi" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/ja.json b/homeassistant/components/energy/translations/ja.json new file mode 100644 index 00000000000..6dd35f6b4d7 --- /dev/null +++ b/homeassistant/components/energy/translations/ja.json @@ -0,0 +1,3 @@ +{ + "title": "\u30a8\u30cd\u30eb\u30ae\u30fc" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/tr.json b/homeassistant/components/energy/translations/tr.json new file mode 100644 index 00000000000..4198959715c --- /dev/null +++ b/homeassistant/components/energy/translations/tr.json @@ -0,0 +1,3 @@ +{ + "title": "Enerji" +} \ No newline at end of file diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index b2a939bffce..d77ea75d36c 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -67,8 +67,10 @@ class EnergyPreferencesValidation: return dataclasses.asdict(self) -async def _async_validate_usage_stat( +@callback +def _async_validate_usage_stat( hass: HomeAssistant, + metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]], stat_id: str, allowed_device_classes: Sequence[str], allowed_units: Mapping[str, Sequence[str]], @@ -76,14 +78,6 @@ async def _async_validate_usage_stat( result: list[ValidationIssue], ) -> None: """Validate a statistic.""" - metadata = await hass.async_add_executor_job( - functools.partial( - recorder.statistics.get_metadata, - hass, - statistic_ids=(stat_id,), - ) - ) - if stat_id not in metadata: result.append(ValidationIssue("statistics_not_defined", stat_id)) @@ -201,18 +195,14 @@ def _async_validate_price_entity( result.append(ValidationIssue(unit_error, entity_id, unit)) -async def _async_validate_cost_stat( - hass: HomeAssistant, stat_id: str, result: list[ValidationIssue] +@callback +def _async_validate_cost_stat( + hass: HomeAssistant, + metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]], + stat_id: str, + result: list[ValidationIssue], ) -> None: """Validate that the cost stat is correct.""" - metadata = await hass.async_add_executor_job( - functools.partial( - recorder.statistics.get_metadata, - hass, - statistic_ids=(stat_id,), - ) - ) - if stat_id not in metadata: result.append(ValidationIssue("statistics_not_defined", stat_id)) @@ -266,154 +256,247 @@ def _async_validate_auto_generated_cost_entity( async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: """Validate the energy configuration.""" manager = await data.async_get_manager(hass) + statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]] = {} + validate_calls = [] + wanted_statistics_metadata = set() result = EnergyPreferencesValidation() if manager.data is None: return result + # Create a list of validation checks for source in manager.data["energy_sources"]: source_result: list[ValidationIssue] = [] result.energy_sources.append(source_result) if source["type"] == "grid": for flow in source["flow_from"]: - await _async_validate_usage_stat( - hass, - flow["stat_energy_from"], - ENERGY_USAGE_DEVICE_CLASSES, - ENERGY_USAGE_UNITS, - ENERGY_UNIT_ERROR, - source_result, + wanted_statistics_metadata.add(flow["stat_energy_from"]) + validate_calls.append( + functools.partial( + _async_validate_usage_stat, + hass, + statistics_metadata, + flow["stat_energy_from"], + ENERGY_USAGE_DEVICE_CLASSES, + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) ) if flow.get("stat_cost") is not None: - await _async_validate_cost_stat( - hass, flow["stat_cost"], source_result + wanted_statistics_metadata.add(flow["stat_cost"]) + validate_calls.append( + functools.partial( + _async_validate_cost_stat, + hass, + statistics_metadata, + flow["stat_cost"], + source_result, + ) ) elif flow.get("entity_energy_price") is not None: - _async_validate_price_entity( - hass, - flow["entity_energy_price"], - source_result, - ENERGY_PRICE_UNITS, - ENERGY_PRICE_UNIT_ERROR, + validate_calls.append( + functools.partial( + _async_validate_price_entity, + hass, + flow["entity_energy_price"], + source_result, + ENERGY_PRICE_UNITS, + ENERGY_PRICE_UNIT_ERROR, + ) ) if flow.get("entity_energy_from") is not None and ( flow.get("entity_energy_price") is not None or flow.get("number_energy_price") is not None ): - _async_validate_auto_generated_cost_entity( - hass, - flow["entity_energy_from"], - source_result, + validate_calls.append( + functools.partial( + _async_validate_auto_generated_cost_entity, + hass, + flow["entity_energy_from"], + source_result, + ) ) for flow in source["flow_to"]: - await _async_validate_usage_stat( - hass, - flow["stat_energy_to"], - ENERGY_USAGE_DEVICE_CLASSES, - ENERGY_USAGE_UNITS, - ENERGY_UNIT_ERROR, - source_result, + wanted_statistics_metadata.add(flow["stat_energy_to"]) + validate_calls.append( + functools.partial( + _async_validate_usage_stat, + hass, + statistics_metadata, + flow["stat_energy_to"], + ENERGY_USAGE_DEVICE_CLASSES, + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) ) if flow.get("stat_compensation") is not None: - await _async_validate_cost_stat( - hass, flow["stat_compensation"], source_result + wanted_statistics_metadata.add(flow["stat_compensation"]) + validate_calls.append( + functools.partial( + _async_validate_cost_stat, + hass, + statistics_metadata, + flow["stat_compensation"], + source_result, + ) ) elif flow.get("entity_energy_price") is not None: - _async_validate_price_entity( - hass, - flow["entity_energy_price"], - source_result, - ENERGY_PRICE_UNITS, - ENERGY_PRICE_UNIT_ERROR, + validate_calls.append( + functools.partial( + _async_validate_price_entity, + hass, + flow["entity_energy_price"], + source_result, + ENERGY_PRICE_UNITS, + ENERGY_PRICE_UNIT_ERROR, + ) ) if flow.get("entity_energy_to") is not None and ( flow.get("entity_energy_price") is not None or flow.get("number_energy_price") is not None ): - _async_validate_auto_generated_cost_entity( - hass, - flow["entity_energy_to"], - source_result, + validate_calls.append( + functools.partial( + _async_validate_auto_generated_cost_entity, + hass, + flow["entity_energy_to"], + source_result, + ) ) elif source["type"] == "gas": - await _async_validate_usage_stat( - hass, - source["stat_energy_from"], - GAS_USAGE_DEVICE_CLASSES, - GAS_USAGE_UNITS, - GAS_UNIT_ERROR, - source_result, + wanted_statistics_metadata.add(source["stat_energy_from"]) + validate_calls.append( + functools.partial( + _async_validate_usage_stat, + hass, + statistics_metadata, + source["stat_energy_from"], + GAS_USAGE_DEVICE_CLASSES, + GAS_USAGE_UNITS, + GAS_UNIT_ERROR, + source_result, + ) ) if source.get("stat_cost") is not None: - await _async_validate_cost_stat( - hass, source["stat_cost"], source_result + wanted_statistics_metadata.add(source["stat_cost"]) + validate_calls.append( + functools.partial( + _async_validate_cost_stat, + hass, + statistics_metadata, + source["stat_cost"], + source_result, + ) ) elif source.get("entity_energy_price") is not None: - _async_validate_price_entity( - hass, - source["entity_energy_price"], - source_result, - GAS_PRICE_UNITS, - GAS_PRICE_UNIT_ERROR, + validate_calls.append( + functools.partial( + _async_validate_price_entity, + hass, + source["entity_energy_price"], + source_result, + GAS_PRICE_UNITS, + GAS_PRICE_UNIT_ERROR, + ) ) if source.get("entity_energy_from") is not None and ( source.get("entity_energy_price") is not None or source.get("number_energy_price") is not None ): - _async_validate_auto_generated_cost_entity( - hass, - source["entity_energy_from"], - source_result, + validate_calls.append( + functools.partial( + _async_validate_auto_generated_cost_entity, + hass, + source["entity_energy_from"], + source_result, + ) ) elif source["type"] == "solar": - await _async_validate_usage_stat( - hass, - source["stat_energy_from"], - ENERGY_USAGE_DEVICE_CLASSES, - ENERGY_USAGE_UNITS, - ENERGY_UNIT_ERROR, - source_result, + wanted_statistics_metadata.add(source["stat_energy_from"]) + validate_calls.append( + functools.partial( + _async_validate_usage_stat, + hass, + statistics_metadata, + source["stat_energy_from"], + ENERGY_USAGE_DEVICE_CLASSES, + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) ) elif source["type"] == "battery": - await _async_validate_usage_stat( - hass, - source["stat_energy_from"], - ENERGY_USAGE_DEVICE_CLASSES, - ENERGY_USAGE_UNITS, - ENERGY_UNIT_ERROR, - source_result, + wanted_statistics_metadata.add(source["stat_energy_from"]) + validate_calls.append( + functools.partial( + _async_validate_usage_stat, + hass, + statistics_metadata, + source["stat_energy_from"], + ENERGY_USAGE_DEVICE_CLASSES, + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) ) - await _async_validate_usage_stat( - hass, - source["stat_energy_to"], - ENERGY_USAGE_DEVICE_CLASSES, - ENERGY_USAGE_UNITS, - ENERGY_UNIT_ERROR, - source_result, + wanted_statistics_metadata.add(source["stat_energy_to"]) + validate_calls.append( + functools.partial( + _async_validate_usage_stat, + hass, + statistics_metadata, + source["stat_energy_to"], + ENERGY_USAGE_DEVICE_CLASSES, + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + source_result, + ) ) for device in manager.data["device_consumption"]: device_result: list[ValidationIssue] = [] result.device_consumption.append(device_result) - await _async_validate_usage_stat( - hass, - device["stat_consumption"], - ENERGY_USAGE_DEVICE_CLASSES, - ENERGY_USAGE_UNITS, - ENERGY_UNIT_ERROR, - device_result, + wanted_statistics_metadata.add(device["stat_consumption"]) + validate_calls.append( + functools.partial( + _async_validate_usage_stat, + hass, + statistics_metadata, + device["stat_consumption"], + ENERGY_USAGE_DEVICE_CLASSES, + ENERGY_USAGE_UNITS, + ENERGY_UNIT_ERROR, + device_result, + ) ) + # Fetch the needed statistics metadata + statistics_metadata.update( + await hass.async_add_executor_job( + functools.partial( + recorder.statistics.get_metadata, + hass, + statistic_ids=list(wanted_statistics_metadata), + ) + ) + ) + + # Execute all the validation checks + for call in validate_calls: + call() + return result diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index 7af7b306f79..d243faae89f 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -2,18 +2,22 @@ from __future__ import annotations import asyncio +from collections import defaultdict +from datetime import datetime, timedelta import functools +from itertools import chain from types import ModuleType from typing import Any, Awaitable, Callable, cast import voluptuous as vol -from homeassistant.components import websocket_api +from homeassistant.components import recorder, websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) from homeassistant.helpers.singleton import singleton +from homeassistant.util import dt as dt_util from .const import DOMAIN from .data import ( @@ -44,6 +48,7 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_info) websocket_api.async_register_command(hass, ws_validate) websocket_api.async_register_command(hass, ws_solar_forecast) + websocket_api.async_register_command(hass, ws_get_fossil_energy_consumption) @singleton("energy_platforms") @@ -218,3 +223,147 @@ async def ws_solar_forecast( forecasts[config_entry_id] = forecast connection.send_result(msg["id"], forecasts) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "energy/fossil_energy_consumption", + vol.Required("start_time"): str, + vol.Required("end_time"): str, + vol.Required("energy_statistic_ids"): [str], + vol.Required("co2_statistic_id"): str, + vol.Required("period"): vol.Any("5minute", "hour", "day", "month"), + } +) +@websocket_api.async_response +async def ws_get_fossil_energy_consumption( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Calculate amount of fossil based energy.""" + start_time_str = msg["start_time"] + end_time_str = msg["end_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 := 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 + + statistic_ids = list(msg["energy_statistic_ids"]) + statistic_ids.append(msg["co2_statistic_id"]) + + # Fetch energy + CO2 statistics + statistics = await hass.async_add_executor_job( + recorder.statistics.statistics_during_period, + hass, + start_time, + end_time, + statistic_ids, + "hour", + True, + ) + + def _combine_sum_statistics( + stats: dict[str, list[dict[str, Any]]], statistic_ids: list[str] + ) -> dict[datetime, float]: + """Combine multiple statistics, returns a dict indexed by start time.""" + result: defaultdict[datetime, float] = defaultdict(float) + seen: defaultdict[datetime, set[str]] = defaultdict(set) + + for statistics_id, stat in stats.items(): + if statistics_id not in statistic_ids: + continue + for period in stat: + if period["sum"] is None or statistics_id in seen[period["start"]]: + continue + result[period["start"]] += period["sum"] + seen[period["start"]].add(statistics_id) + + return {key: result[key] for key in sorted(result)} + + def _calculate_deltas(sums: dict[datetime, float]) -> dict[datetime, float]: + prev: float | None = None + result: dict[datetime, float] = {} + for period, sum_ in sums.items(): + if prev is not None: + result[period] = sum_ - prev + prev = sum_ + return result + + def _reduce_deltas( + stat_list: list[dict[str, Any]], + same_period: Callable[[datetime, datetime], bool], + period_start_end: Callable[[datetime], tuple[datetime, datetime]], + period: timedelta, + ) -> list[dict[str, Any]]: + """Reduce hourly deltas to daily or monthly deltas.""" + result: list[dict[str, Any]] = [] + deltas: list[float] = [] + if not stat_list: + return result + prev_stat: dict[str, Any] = stat_list[0] + + # Loop over the hourly deltas + a fake entry to end the period + for statistic in chain( + stat_list, ({"start": stat_list[-1]["start"] + period},) + ): + if not same_period(prev_stat["start"], statistic["start"]): + start, _ = period_start_end(prev_stat["start"]) + # The previous statistic was the last entry of the period + result.append( + { + "start": start.isoformat(), + "delta": sum(deltas), + } + ) + deltas = [] + if statistic.get("delta") is not None: + deltas.append(statistic["delta"]) + prev_stat = statistic + + return result + + merged_energy_statistics = _combine_sum_statistics( + statistics, msg["energy_statistic_ids"] + ) + energy_deltas = _calculate_deltas(merged_energy_statistics) + indexed_co2_statistics = { + period["start"]: period["mean"] + for period in statistics.get(msg["co2_statistic_id"], {}) + } + + # Calculate amount of fossil based energy, assume 100% fossil if missing + fossil_energy = [ + {"start": start, "delta": delta * indexed_co2_statistics.get(start, 100) / 100} + for start, delta in energy_deltas.items() + ] + + if msg["period"] == "hour": + reduced_fossil_energy = [ + {"start": period["start"].isoformat(), "delta": period["delta"]} + for period in fossil_energy + ] + + elif msg["period"] == "day": + reduced_fossil_energy = _reduce_deltas( + fossil_energy, + recorder.statistics.same_day, + recorder.statistics.day_start_end, + timedelta(days=1), + ) + else: + reduced_fossil_energy = _reduce_deltas( + fossil_energy, + recorder.statistics.same_month, + recorder.statistics.month_start_end, + timedelta(days=1), + ) + + result = {period["start"]: period["delta"] for period in reduced_fossil_energy} + connection.send_result(msg["id"], result) diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index ca0f5e95109..9c4c9df73e9 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -139,8 +139,7 @@ class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity): if self._attr_native_value is not None: return - state = await self.async_get_last_state() - if state is not None: + if (state := await self.async_get_last_state()) is not None: self._attr_native_value = state.state def value_changed(self, packet): diff --git a/homeassistant/components/enocean/translations/ja.json b/homeassistant/components/enocean/translations/ja.json new file mode 100644 index 00000000000..e0ec74d778f --- /dev/null +++ b/homeassistant/components/enocean/translations/ja.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "invalid_dongle_path": "\u30c9\u30f3\u30b0\u30eb\u30d1\u30b9\u304c\u7121\u52b9", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "invalid_dongle_path": "\u3053\u306e\u30d1\u30b9\u306b\u6709\u52b9\u306a\u30c9\u30f3\u30b0\u30eb\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "step": { + "detect": { + "data": { + "path": "USB\u30c9\u30f3\u30b0\u30eb\u306e\u30d1\u30b9" + }, + "title": "ENOcean dongle\u306e\u30d1\u30b9\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "manual": { + "data": { + "path": "USB\u30c9\u30f3\u30b0\u30eb\u306e\u30d1\u30b9" + }, + "title": "ENOcean dongle\u306e\u30d1\u30b9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/enocean/translations/tr.json b/homeassistant/components/enocean/translations/tr.json index b4e6be555ff..b070fddf012 100644 --- a/homeassistant/components/enocean/translations/tr.json +++ b/homeassistant/components/enocean/translations/tr.json @@ -11,12 +11,14 @@ "detect": { "data": { "path": "USB dongle yolu" - } + }, + "title": "ENOcean dongle yolunu se\u00e7in" }, "manual": { "data": { "path": "USB dongle yolu" - } + }, + "title": "Enocean dongle yolunu girin" } } } diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index 69c488169a6..7b3765bd25c 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -75,6 +75,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: envoy_reader.get_inverters = False await coordinator.async_config_entry_first_refresh() + if not entry.unique_id: + try: + serial = await envoy_reader.get_full_serial_number() + except httpx.HTTPError: + pass + else: + hass.config_entries.async_update_entry(entry, unique_id=serial) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { COORDINATOR: coordinator, NAME: name, diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 8a4b4b19e58..d1e0febe2e6 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Enphase Envoy integration.""" from __future__ import annotations +import contextlib import logging from typing import Any @@ -9,6 +10,7 @@ import httpx import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.const import ( CONF_HOST, CONF_IP_ADDRESS, @@ -30,7 +32,7 @@ ENVOY = "Envoy" CONF_SERIAL = "serial" -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> EnvoyReader: """Validate the user input allows us to connect.""" envoy_reader = EnvoyReader( data[CONF_HOST], @@ -47,6 +49,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, except (RuntimeError, httpx.HTTPError) as err: raise CannotConnect from err + return envoy_reader + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Enphase Envoy.""" @@ -58,7 +62,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.ip_address = None self.name = None self.username = None - self.serial = None self._reauth_entry = None @callback @@ -99,11 +102,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if CONF_HOST in entry.data } - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle a flow initialized by zeroconf discovery.""" - self.serial = discovery_info["properties"]["serialnum"] - await self.async_set_unique_id(self.serial) - self.ip_address = discovery_info[CONF_HOST] + serial = discovery_info.properties["serialnum"] + await self.async_set_unique_id(serial) + self.ip_address = discovery_info.host self._abort_if_unique_id_configured({CONF_HOST: self.ip_address}) for entry in self._async_current_entries(include_ignore=False): if ( @@ -111,9 +116,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): and CONF_HOST in entry.data and entry.data[CONF_HOST] == self.ip_address ): - title = f"{ENVOY} {self.serial}" if entry.title == ENVOY else ENVOY + title = f"{ENVOY} {serial}" if entry.title == ENVOY else ENVOY self.hass.config_entries.async_update_entry( - entry, title=title, unique_id=self.serial + entry, title=title, unique_id=serial ) self.hass.async_create_task( self.hass.config_entries.async_reload(entry.entry_id) @@ -129,6 +134,24 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return await self.async_step_user() + def _async_envoy_name(self) -> str: + """Return the name of the envoy.""" + if self.name: + return self.name + if self.unique_id: + return f"{ENVOY} {self.unique_id}" + return ENVOY + + async def _async_set_unique_id_from_envoy(self, envoy_reader: EnvoyReader) -> bool: + """Set the unique id by fetching it from the envoy.""" + serial = None + with contextlib.suppress(httpx.HTTPError): + serial = await envoy_reader.get_full_serial_number() + if serial: + await self.async_set_unique_id(serial) + return True + return False + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -142,7 +165,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ): return self.async_abort(reason="already_configured") try: - await validate_input(self.hass, user_input) + envoy_reader = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -152,21 +175,28 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: data = user_input.copy() - if self.serial: - data[CONF_NAME] = f"{ENVOY} {self.serial}" - else: - data[CONF_NAME] = self.name or ENVOY + data[CONF_NAME] = self._async_envoy_name() + if self._reauth_entry: self.hass.config_entries.async_update_entry( self._reauth_entry, data=data, ) return self.async_abort(reason="reauth_successful") + + if not self.unique_id and await self._async_set_unique_id_from_envoy( + envoy_reader + ): + data[CONF_NAME] = self._async_envoy_name() + + if self.unique_id: + self._abort_if_unique_id_configured({CONF_HOST: data[CONF_HOST]}) + return self.async_create_entry(title=data[CONF_NAME], data=data) - if self.serial: + if self.unique_id: self.context["title_placeholders"] = { - CONF_SERIAL: self.serial, + CONF_SERIAL: self.unique_id, CONF_HOST: self.ip_address, } return self.async_show_form( diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 9e948eaf842..d7ad10ca062 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -3,7 +3,7 @@ "name": "Enphase Envoy", "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "requirements": [ - "envoy_reader==0.20.0" + "envoy_reader==0.20.1" ], "codeowners": [ "@gtdiehl" @@ -15,4 +15,4 @@ } ], "iot_class": "local_polling" -} \ No newline at end of file +} diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index b42f6bfb50f..822ee14fc9e 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -3,6 +3,7 @@ "flow_title": "{serial} ({host})", "step": { "user": { + "description": "For newer models, enter username `envoy` without a password. For older models, enter username `installer` without a password. For all other models, enter a valid username and password.", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", diff --git a/homeassistant/components/enphase_envoy/translations/en.json b/homeassistant/components/enphase_envoy/translations/en.json index 2cdb75a6b53..5d4617ed9fa 100644 --- a/homeassistant/components/enphase_envoy/translations/en.json +++ b/homeassistant/components/enphase_envoy/translations/en.json @@ -16,7 +16,8 @@ "host": "Host", "password": "Password", "username": "Username" - } + }, + "description": "For newer models, enter username `envoy` without a password. For older models, enter username `installer` without a password. For all other models, enter a valid username and password." } } } diff --git a/homeassistant/components/enphase_envoy/translations/ja.json b/homeassistant/components/enphase_envoy/translations/ja.json new file mode 100644 index 00000000000..e14d0fe713b --- /dev/null +++ b/homeassistant/components/enphase_envoy/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", + "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" + }, + "flow_title": "{serial} ({host})", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/enphase_envoy/translations/tr.json b/homeassistant/components/enphase_envoy/translations/tr.json new file mode 100644 index 00000000000..5cbf60dbc06 --- /dev/null +++ b/homeassistant/components/enphase_envoy/translations/tr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "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", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "{serial} ( {host} )", + "step": { + "user": { + "data": { + "host": "Ana bilgisayar", + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index 01d74179f41..5e52d3631f6 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -78,7 +78,7 @@ def trigger_import(hass, config): CONF_LATITUDE, CONF_LONGITUDE, CONF_LANGUAGE, - ): # pylint: disable=consider-using-tuple + ): if config.get(key): data[key] = config[key] diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index a4707bb7576..86f6299585c 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -55,6 +55,7 @@ class ECCamera(CoordinatorEntity, Camera): self._attr_name = f"{coordinator.config_entry.title} Radar" self._attr_unique_id = f"{coordinator.config_entry.unique_id}-radar" self._attr_attribution = self.radar_object.metadata["attribution"] + self._attr_entity_registry_enabled_default = False self.content_type = "image/gif" self.image = None diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 3a2ee1d8b8f..b340674b480 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -2,7 +2,7 @@ "domain": "environment_canada", "name": "Environment Canada", "documentation": "https://www.home-assistant.io/integrations/environment_canada", - "requirements": ["env_canada==0.5.14"], + "requirements": ["env_canada==0.5.18"], "codeowners": ["@gwww", "@michaeldavie"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/homeassistant/components/environment_canada/translations/de.json b/homeassistant/components/environment_canada/translations/de.json index 573e006aa3d..c351683007f 100644 --- a/homeassistant/components/environment_canada/translations/de.json +++ b/homeassistant/components/environment_canada/translations/de.json @@ -3,8 +3,8 @@ "error": { "bad_station_id": "Die Stations-ID ist ung\u00fcltig, fehlt oder wurde in der Stations-ID-Datenbank nicht gefunden", "cannot_connect": "Verbindung fehlgeschlagen", - "error_response": "Fehlerhafte Antwort von Environment Canada", - "too_many_attempts": "Verbindungen zu Environment Canada sind in ihrer Geschwindigkeit begrenzt; versuches in 60 Sekunden erneut.", + "error_response": "Fehlerhafte Antwort vom Standort Kanada", + "too_many_attempts": "Verbindungen zum Standort Kanada sind in ihrer Geschwindigkeit begrenzt; versuche es in 60 Sekunden erneut.", "unknown": "Unerwarteter Fehler" }, "step": { @@ -16,7 +16,7 @@ "station": "ID der Wetterstation" }, "description": "Es muss entweder eine Stations-ID oder der Breitengrad/L\u00e4ngengrad angegeben werden. Als Standardwerte f\u00fcr Breitengrad/L\u00e4ngengrad werden die in Ihrer Home Assistant-Installation konfigurierten Werte verwendet. Bei Angabe von Koordinaten wird die den Koordinaten am n\u00e4chsten gelegene Wetterstation verwendet. Wenn ein Stationscode verwendet wird, muss er dem Format entsprechen: PP/Code, wobei PP f\u00fcr die zweistellige Provinz und Code f\u00fcr die Stationskennung steht. Die Liste der Stations-IDs findest du hier: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Die Wetterinformationen k\u00f6nnen entweder in Englisch oder Franz\u00f6sisch abgerufen werden.", - "title": "Environment Canada: Wetterstandort und Sprache" + "title": "Standort Kanada: Wetterstandort und Sprache" } } } diff --git a/homeassistant/components/environment_canada/translations/fr.json b/homeassistant/components/environment_canada/translations/fr.json new file mode 100644 index 00000000000..84333da4f4a --- /dev/null +++ b/homeassistant/components/environment_canada/translations/fr.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/id.json b/homeassistant/components/environment_canada/translations/id.json new file mode 100644 index 00000000000..df3f087332e --- /dev/null +++ b/homeassistant/components/environment_canada/translations/id.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "bad_station_id": "ID Stasiun tidak valid, tidak ada, atau tidak ditemukan di basis data ID stasiun", + "cannot_connect": "Gagal terhubung", + "error_response": "Kesalahan balasan dari Environment Canada", + "too_many_attempts": "Koneksi ke Environment Canada dibatasi; Coba lagi dalam 60 detik", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "language": "Bahasa informasi cuaca", + "latitude": "Lintang", + "longitude": "Bujur", + "station": "ID stasiun cuaca" + }, + "description": "Salah satu dari ID stasiun atau lintang/bujur harus ditentukan. Lintang/bujur default yang digunakan adalah nilai yang dikonfigurasi dalam instalasi Home Assistant Anda. Stasiun cuaca terdekat dengan koordinat akan digunakan jika koordinat ditentukan. Jika menggunakan kode stasiun, formatnya harus berupa: PP/kode, di mana PP adalah provinsi dua huruf dan kode adalah ID stasiun. Daftar ID stasiun dapat ditemukan di sini: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Informasi cuaca dapat diperoleh dalam bahasa Inggris atau Prancis.", + "title": "Environment Canada: lokasi cuaca dan bahasa" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/ja.json b/homeassistant/components/environment_canada/translations/ja.json index 0d27b8acbe5..e9057e7a48b 100644 --- a/homeassistant/components/environment_canada/translations/ja.json +++ b/homeassistant/components/environment_canada/translations/ja.json @@ -1,5 +1,12 @@ { "config": { + "error": { + "bad_station_id": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3ID\u304c\u7121\u52b9\u3001\u6b20\u843d\u3057\u3066\u3044\u308b\u3001\u307e\u305f\u306f\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3ID \u30c7\u30fc\u30bf\u30d9\u30fc\u30b9\u5185\u3067\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "error_response": "\u30ab\u30ca\u30c0\u74b0\u5883\u304b\u3089\u306e\u5fdc\u7b54\u30a8\u30e9\u30fc", + "too_many_attempts": "\u30ab\u30ca\u30c0\u74b0\u5883\u7701\u3078\u306e\u63a5\u7d9a\u306f\u30ec\u30fc\u30c8\u5236\u9650\u3055\u308c\u3066\u3044\u307e\u3059\u300260\u79d2\u5f8c\u306b\u518d\u8a66\u884c\u3057\u3066\u304f\u3060\u3055\u3044", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, "step": { "user": { "data": { @@ -7,7 +14,9 @@ "latitude": "\u7def\u5ea6", "longitude": "\u7d4c\u5ea6", "station": "\u30a6\u30a7\u30b6\u30fc\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3ID" - } + }, + "description": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3ID\u307e\u305f\u306f\u7def\u5ea6/\u7d4c\u5ea6\u306e\u3044\u305a\u308c\u304b\u3092\u6307\u5b9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\u4f7f\u7528\u3055\u308c\u308b\u30c7\u30d5\u30a9\u30eb\u30c8\u306e\u7def\u5ea6/\u7d4c\u5ea6\u306f\u3001Home Assistant\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3067\u69cb\u6210\u3055\u308c\u305f\u5024\u3067\u3059\u3002\u5ea7\u6a19\u3092\u6307\u5b9a\u3059\u308b\u5834\u5408\u306f\u3001\u5ea7\u6a19\u306b\u6700\u3082\u8fd1\u3044\u6c17\u8c61\u89b3\u6e2c\u6240\u304c\u4f7f\u7528\u3055\u308c\u307e\u3059\u3002\u89b3\u6e2c\u6240\u30b3\u30fc\u30c9\u3092\u4f7f\u7528\u3059\u308b\u5834\u5408\u306f\u3001PP/code\u306e\u5f62\u5f0f\u306b\u5f93\u3046\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\u3053\u3053\u3067\u3001PP\u306f2\u6587\u5b57\u306e\u5dde\u3001code\u306f\u89b3\u6e2c\u6240ID\u3067\u3059\u3002\u89b3\u6e2c\u6240ID\u306e\u30ea\u30b9\u30c8\u306f\u3001https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv \u306b\u3042\u308a\u307e\u3059\u3002\u6c17\u8c61\u60c5\u5831\u306f\u82f1\u8a9e\u307e\u305f\u306f\u30d5\u30e9\u30f3\u30b9\u8a9e\u3067\u53d6\u5f97\u3067\u304d\u307e\u3059\u3002", + "title": "\u30ab\u30ca\u30c0\u74b0\u5883\u7701: \u5929\u6c17\u306e\u5834\u6240\u3068\u8a00\u8a9e" } } } diff --git a/homeassistant/components/environment_canada/translations/pl.json b/homeassistant/components/environment_canada/translations/pl.json index b840de8a10e..4f4611a80ca 100644 --- a/homeassistant/components/environment_canada/translations/pl.json +++ b/homeassistant/components/environment_canada/translations/pl.json @@ -1,7 +1,10 @@ { "config": { "error": { + "bad_station_id": "Identyfikator stacji jest nieprawid\u0142owy, brakuje go lub nie mo\u017cna go znale\u017a\u0107 w bazie danych identyfikator\u00f3w stacji", "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "error_response": "B\u0142\u0119dna odpowied\u017a z Environment Canada", + "too_many_attempts": "Po\u0142\u0105czenia z Environment Canada s\u0105 ograniczone; spr\u00f3buj ponownie za 60 sekund", "unknown": "Nieoczekiwany b\u0142\u0105d" }, "step": { @@ -11,7 +14,9 @@ "latitude": "Szeroko\u015b\u0107 geograficzna", "longitude": "D\u0142ugo\u015b\u0107 geograficzna", "station": "Identyfikator stacji pogodowej" - } + }, + "description": "Nale\u017cy poda\u0107 identyfikator stacji lub szeroko\u015b\u0107/d\u0142ugo\u015b\u0107 geograficzn\u0105. Domy\u015blna szeroko\u015b\u0107/d\u0142ugo\u015b\u0107 geograficzna to warto\u015bci skonfigurowane w instalacji Home Assistant. Zostanie u\u017cyta najbli\u017csza stacja pogodowa dla tych wsp\u00f3\u0142rz\u0119dnych. Je\u015bli u\u017cywany jest kod stacji, musi on mie\u0107 format: PP/kod, gdzie PP to dwuliterowa prowincja, a kod to identyfikator stacji. List\u0119 identyfikator\u00f3w stacji mo\u017cna znale\u017a\u0107 tutaj: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Informacje o pogodzie mo\u017cna pobra\u0107 w j\u0119zyku angielskim lub francuskim.", + "title": "Environment Canada: lokalizacja pogody i j\u0119zyk" } } } diff --git a/homeassistant/components/environment_canada/translations/sl.json b/homeassistant/components/environment_canada/translations/sl.json new file mode 100644 index 00000000000..5bbbcfbf23e --- /dev/null +++ b/homeassistant/components/environment_canada/translations/sl.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Povezava ni uspela", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "language": "Jezik vremenskih informacij", + "station": "ID vremenske postaje" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/translations/tr.json b/homeassistant/components/environment_canada/translations/tr.json index afd8eb43d46..a12e6add669 100644 --- a/homeassistant/components/environment_canada/translations/tr.json +++ b/homeassistant/components/environment_canada/translations/tr.json @@ -2,7 +2,9 @@ "config": { "error": { "bad_station_id": "\u0130stasyon Kimli\u011fi ge\u00e7ersiz, eksik veya istasyon kimli\u011fi veritaban\u0131nda bulunamad\u0131", - "cannot_connect": "Ba\u011flant\u0131 ba\u015far\u0131s\u0131z", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "error_response": "Environment Canada'dan hatal\u0131 yan\u0131t", + "too_many_attempts": "Environment Kanada'ya ba\u011flant\u0131lar s\u0131n\u0131rl\u0131d\u0131r; 60 saniye sonra tekrar deneyin", "unknown": "Beklenmeyen hata" }, "step": { @@ -13,7 +15,8 @@ "longitude": "Boylam", "station": "Hava istasyonu ID" }, - "description": "Bir istasyon kimli\u011fi veya enlem/boylam belirtilmelidir. Kullan\u0131lan varsay\u0131lan enlem/boylam, Home Assistant kurulumunuzda yap\u0131land\u0131r\u0131lan de\u011ferlerdir. Koordinatlar belirtilirse, koordinatlara en yak\u0131n meteoroloji istasyonu kullan\u0131lacakt\u0131r. Bir istasyon kodu kullan\u0131l\u0131yorsa, \u015fu bi\u00e7imde olmal\u0131d\u0131r: PP/kod, burada PP iki harfli ildir ve kod istasyon kimli\u011fidir. \u0130stasyon kimliklerinin listesi burada bulunabilir: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Hava durumu bilgileri \u0130ngilizce veya Frans\u0131zca olarak al\u0131nabilir." + "description": "Bir istasyon kimli\u011fi veya enlem/boylam belirtilmelidir. Kullan\u0131lan varsay\u0131lan enlem/boylam, Home Assistant kurulumunuzda yap\u0131land\u0131r\u0131lan de\u011ferlerdir. Koordinatlar belirtilirse, koordinatlara en yak\u0131n meteoroloji istasyonu kullan\u0131lacakt\u0131r. Bir istasyon kodu kullan\u0131l\u0131yorsa, \u015fu bi\u00e7imde olmal\u0131d\u0131r: PP/kod, burada PP iki harfli ildir ve kod istasyon kimli\u011fidir. \u0130stasyon kimliklerinin listesi burada bulunabilir: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Hava durumu bilgileri \u0130ngilizce veya Frans\u0131zca olarak al\u0131nabilir.", + "title": "Environment Canada: hava konumu durumu ve dili" } } } diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py index 75d4bff3dd1..d5a8b39e8f9 100644 --- a/homeassistant/components/envisalink/__init__.py +++ b/homeassistant/components/envisalink/__init__.py @@ -198,8 +198,7 @@ async def async_setup(hass, config): _LOGGER.info("Start envisalink") controller.start() - result = await sync_connect - if not result: + if not await sync_connect: return False # Load sub-components for Envisalink diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index 5223a9663d0..6abeb3b0ba6 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -100,8 +100,7 @@ class EpsonProjectorMediaPlayer(MediaPlayerEntity): _LOGGER.debug("Setting unique_id for projector") if self._unique_id: return False - uid = await self._projector.get_serial_number() - if uid: + if uid := await self._projector.get_serial_number(): self.hass.config_entries.async_update_entry(self._entry, unique_id=uid) registry = async_get_entity_registry(self.hass) old_entity_id = registry.async_get_entity_id( diff --git a/homeassistant/components/epson/translations/id.json b/homeassistant/components/epson/translations/id.json index fd6c2bc2491..6538f89ab14 100644 --- a/homeassistant/components/epson/translations/id.json +++ b/homeassistant/components/epson/translations/id.json @@ -1,7 +1,8 @@ { "config": { "error": { - "cannot_connect": "Gagal terhubung" + "cannot_connect": "Gagal terhubung", + "powered_off": "Apakah proyektor dinyalakan? Anda perlu menyalakan proyektor untuk konfigurasi awal." }, "step": { "user": { diff --git a/homeassistant/components/epson/translations/ja.json b/homeassistant/components/epson/translations/ja.json new file mode 100644 index 00000000000..e48317bcee3 --- /dev/null +++ b/homeassistant/components/epson/translations/ja.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "powered_off": "\u30d7\u30ed\u30b8\u30a7\u30af\u30bf\u30fc\u306e\u96fb\u6e90\u306f\u5165\u3063\u3066\u3044\u307e\u3059\u304b\uff1f\u521d\u671f\u8a2d\u5b9a\u3092\u884c\u3046\u305f\u3081\u306b\u306f\u3001\u30d7\u30ed\u30b8\u30a7\u30af\u30bf\u30fc\u306e\u96fb\u6e90\u3092\u5165\u308c\u3066\u304a\u304f\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/epson/translations/tr.json b/homeassistant/components/epson/translations/tr.json index cb0a09cb26a..a369ce95960 100644 --- a/homeassistant/components/epson/translations/tr.json +++ b/homeassistant/components/epson/translations/tr.json @@ -1,13 +1,14 @@ { "config": { "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "powered_off": "Projekt\u00f6r a\u00e7\u0131k m\u0131? \u0130lk yap\u0131land\u0131rma i\u00e7in projekt\u00f6r\u00fc a\u00e7man\u0131z gerekir." }, "step": { "user": { "data": { - "host": "Ana Bilgisayar", - "name": "\u0130sim" + "host": "Ana bilgisayar", + "name": "Ad" } } } diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 2825e036884..8e7f2524a35 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -225,7 +225,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Forward Home Assistant states updates to ESPHome.""" # Only communicate changes to the state or attribute tracked - if ( + if event.data.get("new_state") is None or ( event.data.get("old_state") is not None and "new_state" in event.data and ( @@ -267,7 +267,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: assert cli.api_version is not None entry_data.api_version = cli.api_version entry_data.available = True - device_id = await _async_setup_device_registry( + device_id = _async_setup_device_registry( hass, entry, entry_data.device_info ) entry_data.async_update_device_state(hass) @@ -320,7 +320,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def _async_setup_device_registry( +@callback +def _async_setup_device_registry( hass: HomeAssistant, entry: ConfigEntry, device_info: EsphomeDeviceInfo ) -> str: """Set up device registry feature for a particular config entry.""" @@ -455,8 +456,7 @@ async def _setup_services( for service in services: if service.key in old_services: # Already exists - matching = old_services.pop(service.key) - if matching != service: + if (matching := old_services.pop(service.key)) != service: # Need to re-register to_unregister.append(matching) to_register.append(service) diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 338f3787090..ffe322b6259 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from aioesphomeapi import BinarySensorInfo, BinarySensorState -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import DEVICE_CLASSES, BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -45,8 +45,10 @@ class EsphomeBinarySensor( return self._state.state @property - def device_class(self) -> str: + def device_class(self) -> str | None: """Return the class of this device, from component DEVICE_CLASSES.""" + if self._static_info.device_class not in DEVICE_CLASSES: + return None return self._static_info.device_class @property diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py new file mode 100644 index 00000000000..5b6f2c153c8 --- /dev/null +++ b/homeassistant/components/esphome/button.py @@ -0,0 +1,51 @@ +"""Support for ESPHome buttons.""" +from __future__ import annotations + +from contextlib import suppress +from typing import Any + +from aioesphomeapi import ButtonInfo, EntityState + +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import EsphomeEntity, platform_async_setup_entry + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up ESPHome buttons based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + component_key="button", + info_type=ButtonInfo, + entity_type=EsphomeButton, + state_type=EntityState, + ) + + +class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity): + """A button implementation for ESPHome.""" + + @property + def device_class(self) -> ButtonDeviceClass | None: + """Return the class of this entity.""" + with suppress(ValueError): + return ButtonDeviceClass(self._static_info.device_class) + return None + + @callback + def _on_device_update(self) -> None: + """Update the entity state when device info has changed.""" + # This override the EsphomeEntity method as the button entity + # never gets a state update. + self._on_state_update() + + async def async_press(self, **kwargs: Any) -> None: + """Press the button.""" + await self._client.button_command(self._static_info.key) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index a794404b685..b73743ee950 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -20,7 +20,6 @@ from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.typing import DiscoveryInfoType from . import CONF_NOISE_PSK, DOMAIN, DomainData @@ -72,6 +71,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._port = entry.data[CONF_PORT] self._password = entry.data[CONF_PASSWORD] self._noise_psk = entry.data.get(CONF_NOISE_PSK) + self._name = entry.title return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -139,26 +139,24 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" # Hostname is format: livingroom.local. - local_name = discovery_info["hostname"][:-1] + local_name = discovery_info.hostname[:-1] node_name = local_name[: -len(".local")] - address = discovery_info["properties"].get("address", local_name) + address = discovery_info.properties.get("address", local_name) # Check if already configured await self.async_set_unique_id(node_name) - self._abort_if_unique_id_configured( - updates={CONF_HOST: discovery_info[CONF_HOST]} - ) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) for entry in self._async_current_entries(): already_configured = False if CONF_HOST in entry.data and entry.data[CONF_HOST] in ( address, - discovery_info[CONF_HOST], + discovery_info.host, ): # Is this address or IP address already configured? already_configured = True @@ -175,14 +173,17 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): if not entry.unique_id: self.hass.config_entries.async_update_entry( entry, - data={**entry.data, CONF_HOST: discovery_info[CONF_HOST]}, + data={ + **entry.data, + CONF_HOST: discovery_info.host, + }, unique_id=node_name, ) return self.async_abort(reason="already_configured") - self._host = discovery_info[CONF_HOST] - self._port = discovery_info[CONF_PORT] + self._host = discovery_info.host + self._port = discovery_info.port self._name = node_name return await self.async_step_discovery_confirm() diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index e055ffc5d03..0e23050646f 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -8,6 +8,7 @@ from aioesphomeapi import CoverInfo, CoverOperation, CoverState from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, + DEVICE_CLASSES, SUPPORT_CLOSE, SUPPORT_CLOSE_TILT, SUPPORT_OPEN, @@ -57,8 +58,10 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): return flags @property - def device_class(self) -> str: + def device_class(self) -> str | None: """Return the class of this device, from component DEVICE_CLASSES.""" + if self._static_info.device_class not in DEVICE_CLASSES: + return None return self._static_info.device_class @property diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 51fc18ee37e..e7bbc27141c 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -26,6 +26,7 @@ from aioesphomeapi import ( TextSensorInfo, UserService, ) +from aioesphomeapi.model import ButtonInfo from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -37,6 +38,7 @@ SAVE_DELAY = 120 # Mapping from ESPHome info type to HA platform INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], str] = { BinarySensorInfo: "binary_sensor", + ButtonInfo: "button", CameraInfo: "camera", ClimateInfo: "climate", CoverInfo: "cover", @@ -137,8 +139,7 @@ class RuntimeEntryData: async def async_load_from_store(self) -> tuple[list[EntityInfo], list[UserService]]: """Load the retained data from store and return de-serialized data.""" - restored = await self.store.async_load() - if restored is None: + if (restored := await self.store.async_load()) is None: return [], [] restored = cast("dict[str, Any]", restored) self._storage_contents = restored.copy() diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 247c78abb92..527fc6bf768 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==10.2.0"], + "requirements": ["aioesphomeapi==10.6.0"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index c8baa1f112e..be27779437d 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -3,14 +3,19 @@ from __future__ import annotations import math -from aioesphomeapi import NumberInfo, NumberState +from aioesphomeapi import NumberInfo, NumberMode as EsphomeNumberMode, NumberState -from homeassistant.components.number import NumberEntity +from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from . import ( + EsphomeEntity, + EsphomeEnumMapper, + esphome_state_property, + platform_async_setup_entry, +) async def async_setup_entry( @@ -30,6 +35,15 @@ async def async_setup_entry( ) +NUMBER_MODES: EsphomeEnumMapper[EsphomeNumberMode, NumberMode] = EsphomeEnumMapper( + { + EsphomeNumberMode.AUTO: NumberMode.AUTO, + EsphomeNumberMode.BOX: NumberMode.BOX, + EsphomeNumberMode.SLIDER: NumberMode.SLIDER, + } +) + + # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property # pylint: disable=invalid-overridden-method @@ -52,6 +66,18 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): """Return the increment/decrement step.""" return super()._static_info.step + @property + def unit_of_measurement(self) -> str | None: + """Return the unit of measurement.""" + return super()._static_info.unit_of_measurement + + @property + def mode(self) -> NumberMode: + """Return the mode of the entity.""" + if self._static_info.mode: + return NUMBER_MODES.from_esphome(self._static_info.mode) + return NumberMode.AUTO + @esphome_state_property def value(self) -> float | None: """Return the state of the entity.""" diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index b2758c91b68..c3f9eff6060 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -1,6 +1,7 @@ """Support for esphome sensors.""" from __future__ import annotations +from datetime import datetime import math from aioesphomeapi import ( @@ -78,14 +79,14 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): return self._static_info.force_update @esphome_state_property - def native_value(self) -> str | None: + def native_value(self) -> datetime | str | None: """Return the state of the entity.""" if math.isnan(self._state.state): return None if self._state.missing_state: return None if self.device_class == DEVICE_CLASS_TIMESTAMP: - return dt.utc_from_timestamp(self._state.state).isoformat() + return dt.utc_from_timestamp(self._state.state) return f"{self._state.state:.{self._static_info.accuracy_decimals}f}" @property diff --git a/homeassistant/components/esphome/translations/fr.json b/homeassistant/components/esphome/translations/fr.json index 9f6f092afff..860755e97ba 100644 --- a/homeassistant/components/esphome/translations/fr.json +++ b/homeassistant/components/esphome/translations/fr.json @@ -7,6 +7,7 @@ "error": { "connection_error": "Impossible de se connecter \u00e0 ESP. Assurez-vous que votre fichier YAML contient une ligne 'api:'.", "invalid_auth": "Authentification invalide", + "invalid_psk": "La cl\u00e9 de chiffrement de transport n\u2019est pas valide. Assurez-vous qu\u2019elle correspond \u00e0 ce que vous avez dans votre configuration", "resolve_error": "Impossible de r\u00e9soudre l'adresse de l'ESP. Si cette erreur persiste, veuillez d\u00e9finir une adresse IP statique: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "ESPHome: {name}", @@ -21,6 +22,12 @@ "description": "Voulez-vous ajouter le n\u0153ud ESPHome ` {name} ` \u00e0 Home Assistant?", "title": "N\u0153ud ESPHome d\u00e9couvert" }, + "encryption_key": { + "data": { + "noise_psk": "Cl\u00e9 de chiffrement" + }, + "description": "Entrez la cl\u00e9 de chiffrement que vous avez d\u00e9finie dans votre configuration pour {name}." + }, "user": { "data": { "host": "H\u00f4te", diff --git a/homeassistant/components/esphome/translations/id.json b/homeassistant/components/esphome/translations/id.json index 530d86e2f56..e099405bf0d 100644 --- a/homeassistant/components/esphome/translations/id.json +++ b/homeassistant/components/esphome/translations/id.json @@ -8,6 +8,7 @@ "error": { "connection_error": "Tidak dapat terhubung ke ESP. Pastikan file YAML Anda mengandung baris 'api:'.", "invalid_auth": "Autentikasi tidak valid", + "invalid_psk": "Kunci enkripsi transport tidak valid. Pastikan kuncinya sesuai dengan yang ada pada konfigurasi Anda", "resolve_error": "Tidak dapat menemukan alamat ESP. Jika kesalahan ini terus terjadi, atur alamat IP statis: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "{name}", @@ -22,6 +23,18 @@ "description": "Ingin menambahkan node ESPHome `{name}` ke Home Assistant?", "title": "Perangkat node ESPHome yang ditemukan" }, + "encryption_key": { + "data": { + "noise_psk": "Kunci enkripsi" + }, + "description": "Masukkan kunci enkripsi yang Anda atur dalam konfigurasi Anda untuk {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Kunci enkripsi" + }, + "description": "Perangkat ESPHome {name} mengaktifkan enkripsi transport atau telah mengubah kunci enkripsi. Masukkan kunci yang diperbarui." + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/translations/ja.json b/homeassistant/components/esphome/translations/ja.json new file mode 100644 index 00000000000..12c452a0139 --- /dev/null +++ b/homeassistant/components/esphome/translations/ja.json @@ -0,0 +1,47 @@ +{ + "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", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "connection_error": "ESP\u306b\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093\u3002YAML\u30d5\u30a1\u30a4\u30eb\u306b 'api:' \u306e\u884c\u304c\u542b\u307e\u308c\u3066\u3044\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_psk": "\u30c8\u30e9\u30f3\u30b9\u30dd\u30fc\u30c8\u6697\u53f7\u5316\u30ad\u30fc\u304c\u7121\u52b9\u3067\u3059\u3002\u8a2d\u5b9a\u3068\u4e00\u81f4\u3057\u3066\u3044\u308b\u304b\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "resolve_error": "ESP\u306e\u30a2\u30c9\u30ec\u30b9\u3092\u89e3\u6c7a\u3067\u304d\u307e\u305b\u3093\u3002\u3053\u306e\u30a8\u30e9\u30fc\u304c\u89e3\u6c7a\u3057\u306a\u3044\u5834\u5408\u306f\u3001IP\u30a2\u30c9\u30ec\u30b9\u3092\u9759\u7684\u306b\u8a2d\u5b9a\u3057\u3066\u304f\u3060\u3055\u3044\u3002https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "{name}", + "step": { + "authenticate": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "{name} \u3067\u8a2d\u5b9a\u3057\u305f\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "discovery_confirm": { + "description": "ESPHome\u306e\u30ce\u30fc\u30c9 `{name}` \u3092Home Assistant\u306b\u8ffd\u52a0\u3057\u307e\u3059\u304b\uff1f", + "title": "\u691c\u51fa\u3055\u308c\u305fESPHome\u306e\u30ce\u30fc\u30c9" + }, + "encryption_key": { + "data": { + "noise_psk": "\u6697\u53f7\u5316\u30ad\u30fc" + }, + "description": "{name} \u3067\u8a2d\u5b9a\u3057\u305f\u6697\u53f7\u5316\u30ad\u30fc\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "reauth_confirm": { + "data": { + "noise_psk": "\u6697\u53f7\u5316\u30ad\u30fc" + }, + "description": "ESPHome\u30c7\u30d0\u30a4\u30b9 {name} \u3001\u30c8\u30e9\u30f3\u30b9\u30dd\u30fc\u30c8\u306e\u6697\u53f7\u5316\u3092\u6709\u52b9\u306b\u3057\u305f\u304b\u3001\u6697\u53f7\u5316\u306e\u30ad\u30fc\u304c\u5909\u66f4\u3055\u308c\u307e\u3057\u305f\u3002\u66f4\u65b0\u3055\u308c\u305f\u30ad\u30fc\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "\u3042\u306a\u305f\u306e[ESPHome](https://esphomelib.com/)\u306e\u30ce\u30fc\u30c9\u306e\u63a5\u7d9a\u8a2d\u5b9a\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/pl.json b/homeassistant/components/esphome/translations/pl.json index 2fa5f37ff18..da6154e9fb6 100644 --- a/homeassistant/components/esphome/translations/pl.json +++ b/homeassistant/components/esphome/translations/pl.json @@ -8,6 +8,7 @@ "error": { "connection_error": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia z ESP. Upewnij si\u0119, \u017ce Tw\u00f3j plik YAML zawiera lini\u0119 'api:'.", "invalid_auth": "Niepoprawne uwierzytelnienie", + "invalid_psk": "Klucz szyfruj\u0105cy transport jest nieprawid\u0142owy. Upewnij si\u0119, \u017ce pasuje do tego, kt\u00f3ry masz w swojej konfiguracji.", "resolve_error": "Nie mo\u017cna rozpozna\u0107 adresu ESP. Je\u015bli ten b\u0142\u0105d b\u0119dzie si\u0119 powtarza\u0142, nale\u017cy ustawi\u0107 statyczny adres IP: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "flow_title": "{name}", @@ -22,6 +23,18 @@ "description": "Czy chcesz doda\u0107 w\u0119ze\u0142 ESPHome `{name}` do Home Assistanta?", "title": "Znaleziono w\u0119ze\u0142 ESPHome" }, + "encryption_key": { + "data": { + "noise_psk": "Klucz szyfruj\u0105cy" + }, + "description": "Wprowad\u017a klucz szyfruj\u0105cy ustawiony w konfiguracji dla {name}." + }, + "reauth_confirm": { + "data": { + "noise_psk": "Klucz szyfruj\u0105cy" + }, + "description": "Urz\u0105dzenie ESPHome {name} w\u0142\u0105czy\u0142o szyfrowanie transportu lub zmieni\u0142o klucz szyfruj\u0105cy. Wprowad\u017a zaktualizowany klucz." + }, "user": { "data": { "host": "Nazwa hosta lub adres IP", diff --git a/homeassistant/components/esphome/translations/tr.json b/homeassistant/components/esphome/translations/tr.json index 81f85d4980b..31f54809e95 100644 --- a/homeassistant/components/esphome/translations/tr.json +++ b/homeassistant/components/esphome/translations/tr.json @@ -2,11 +2,16 @@ "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" + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { - "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + "connection_error": "ESP'ye ba\u011flan\u0131lam\u0131yor. L\u00fctfen YAML dosyan\u0131z\u0131n bir 'api:' sat\u0131r\u0131 i\u00e7erdi\u011finden emin olun.", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "invalid_psk": "Aktar\u0131m \u015fifreleme anahtar\u0131 ge\u00e7ersiz. L\u00fctfen yap\u0131land\u0131rman\u0131zda sahip oldu\u011funuzla e\u015fle\u015fti\u011finden emin olun", + "resolve_error": "ESP'nin adresi \u00e7\u00f6z\u00fclemiyor. Bu hata devam ederse, l\u00fctfen statik bir IP adresi ayarlay\u0131n: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "{name}", "step": { "authenticate": { "data": { @@ -15,13 +20,27 @@ "description": "L\u00fctfen yap\u0131land\u0131rman\u0131zda {name} i\u00e7in belirledi\u011finiz parolay\u0131 girin." }, "discovery_confirm": { + "description": "ESPHome d\u00fc\u011f\u00fcm\u00fcn\u00fc ` {name} ` Home Assistant'a eklemek istiyor musunuz?", "title": "Ke\u015ffedilen ESPHome d\u00fc\u011f\u00fcm\u00fc" }, + "encryption_key": { + "data": { + "noise_psk": "\u015eifreleme anahtar\u0131" + }, + "description": "{name} i\u00e7in yap\u0131land\u0131rman\u0131zda belirledi\u011finiz \u015fifreleme anahtar\u0131n\u0131 girin." + }, + "reauth_confirm": { + "data": { + "noise_psk": "\u015eifreleme anahtar\u0131" + }, + "description": "ESPHome cihaz\u0131 {name} aktar\u0131m \u015fifrelemesini etkinle\u015ftirdi veya \u015fifreleme anahtar\u0131n\u0131 de\u011fi\u015ftirdi. L\u00fctfen g\u00fcncellenmi\u015f anahtar\u0131 girin." + }, "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "port": "Port" - } + }, + "description": "L\u00fctfen [ESPHome](https://esphomelib.com/) d\u00fc\u011f\u00fcm\u00fcn\u00fcz\u00fcn ba\u011flant\u0131 ayarlar\u0131n\u0131 girin." } } } diff --git a/homeassistant/components/evil_genius_labs/__init__.py b/homeassistant/components/evil_genius_labs/__init__.py new file mode 100644 index 00000000000..78445a42e7d --- /dev/null +++ b/homeassistant/components/evil_genius_labs/__init__.py @@ -0,0 +1,99 @@ +"""The Evil Genius Labs integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import cast + +from async_timeout import timeout +import pyevilgenius + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + aiohttp_client, + device_registry as dr, + update_coordinator, +) +from homeassistant.helpers.entity import DeviceInfo + +from .const import DOMAIN + +PLATFORMS = ["light"] + +UPDATE_INTERVAL = 10 + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Evil Genius Labs from a config entry.""" + coordinator = EvilGeniusUpdateCoordinator( + hass, + entry.title, + pyevilgenius.EvilGeniusDevice( + entry.data["host"], aiohttp_client.async_get_clientsession(hass) + ), + ) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class EvilGeniusUpdateCoordinator(update_coordinator.DataUpdateCoordinator[dict]): + """Update coordinator for Evil Genius data.""" + + info: dict + + def __init__( + self, hass: HomeAssistant, name: str, client: pyevilgenius.EvilGeniusDevice + ) -> None: + """Initialize the data update coordinator.""" + self.client = client + super().__init__( + hass, + logging.getLogger(__name__), + name=name, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + + @property + def device_name(self) -> str: + """Return the device name.""" + return cast(str, self.data["name"]["value"]) + + async def _async_update_data(self) -> dict: + """Update Evil Genius data.""" + if not hasattr(self, "info"): + async with timeout(5): + self.info = await self.client.get_info() + + async with timeout(5): + return cast(dict, await self.client.get_data()) + + +class EvilGeniusEntity(update_coordinator.CoordinatorEntity): + """Base entity for Evil Genius.""" + + coordinator: EvilGeniusUpdateCoordinator + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + info = self.coordinator.info + return DeviceInfo( + identifiers={(DOMAIN, info["wiFiChipId"])}, + connections={(dr.CONNECTION_NETWORK_MAC, info["macAddress"])}, + name=self.coordinator.device_name, + manufacturer="Evil Genius Labs", + sw_version=info["coreVersion"].replace("_", "."), + configuration_url=self.coordinator.client.url, + ) diff --git a/homeassistant/components/evil_genius_labs/config_flow.py b/homeassistant/components/evil_genius_labs/config_flow.py new file mode 100644 index 00000000000..f4f7b464904 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/config_flow.py @@ -0,0 +1,84 @@ +"""Config flow for Evil Genius Labs integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +import pyevilgenius +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + hub = pyevilgenius.EvilGeniusDevice( + data["host"], aiohttp_client.async_get_clientsession(hass) + ) + + try: + data = await hub.get_data() + info = await hub.get_info() + except aiohttp.ClientError as err: + raise CannotConnect from err + + return {"title": data["name"]["value"], "unique_id": info["wiFiChipId"]} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Evil Genius Labs.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required("host"): str, + } + ), + ) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info["unique_id"]) + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required("host", default=user_input["host"]): str, + } + ), + errors=errors, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/evil_genius_labs/const.py b/homeassistant/components/evil_genius_labs/const.py new file mode 100644 index 00000000000..c335e5eaee2 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/const.py @@ -0,0 +1,3 @@ +"""Constants for the Evil Genius Labs integration.""" + +DOMAIN = "evil_genius_labs" diff --git a/homeassistant/components/evil_genius_labs/light.py b/homeassistant/components/evil_genius_labs/light.py new file mode 100644 index 00000000000..cb837668a4c --- /dev/null +++ b/homeassistant/components/evil_genius_labs/light.py @@ -0,0 +1,120 @@ +"""Light platform for Evil Genius Light.""" +from __future__ import annotations + +from typing import Any, cast + +from async_timeout import timeout + +from homeassistant.components import light +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import EvilGeniusEntity, EvilGeniusUpdateCoordinator +from .const import DOMAIN +from .util import update_when_done + +HA_NO_EFFECT = "None" +FIB_NO_EFFECT = "Solid Color" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Evil Genius light platform.""" + coordinator: EvilGeniusUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([EvilGeniusLight(coordinator)]) + + +class EvilGeniusLight(EvilGeniusEntity, light.LightEntity): + """Evil Genius Labs light.""" + + _attr_supported_features = ( + light.SUPPORT_BRIGHTNESS | light.SUPPORT_EFFECT | light.SUPPORT_COLOR + ) + _attr_supported_color_modes = {light.COLOR_MODE_RGB} + _attr_color_mode = light.COLOR_MODE_RGB + + def __init__(self, coordinator: EvilGeniusUpdateCoordinator) -> None: + """Initialize the Evil Genius light.""" + super().__init__(coordinator) + self._attr_unique_id = self.coordinator.info["wiFiChipId"] + self._attr_effect_list = [ + pattern + for pattern in self.coordinator.data["pattern"]["options"] + if pattern != FIB_NO_EFFECT + ] + self._attr_effect_list.insert(0, HA_NO_EFFECT) + + @property + def name(self) -> str: + """Return name.""" + return cast(str, self.coordinator.data["name"]["value"]) + + @property + def is_on(self) -> bool: + """Return if light is on.""" + return cast(int, self.coordinator.data["power"]["value"]) == 1 + + @property + def brightness(self) -> int: + """Return brightness.""" + return cast(int, self.coordinator.data["brightness"]["value"]) + + @property + def rgb_color(self) -> tuple[int, int, int]: + """Return the rgb color value [int, int, int].""" + return cast( + "tuple[int, int, int]", + tuple( + int(val) + for val in self.coordinator.data["solidColor"]["value"].split(",") + ), + ) + + @property + def effect(self) -> str: + """Return current effect.""" + value = cast( + str, + self.coordinator.data["pattern"]["options"][ + self.coordinator.data["pattern"]["value"] + ], + ) + if value == FIB_NO_EFFECT: + return HA_NO_EFFECT + return value + + @update_when_done + async def async_turn_on( + self, + **kwargs: Any, + ) -> None: + """Turn light on.""" + if (brightness := kwargs.get(light.ATTR_BRIGHTNESS)) is not None: + async with timeout(5): + await self.coordinator.client.set_path_value("brightness", brightness) + + # Setting a color will change the effect to "Solid Color" so skip setting effect + if (rgb_color := kwargs.get(light.ATTR_RGB_COLOR)) is not None: + async with timeout(5): + await self.coordinator.client.set_rgb_color(*rgb_color) + + elif (effect := kwargs.get(light.ATTR_EFFECT)) is not None: + if effect == HA_NO_EFFECT: + effect = FIB_NO_EFFECT + async with timeout(5): + await self.coordinator.client.set_path_value( + "pattern", self.coordinator.data["pattern"]["options"].index(effect) + ) + + async with timeout(5): + await self.coordinator.client.set_path_value("power", 1) + + @update_when_done + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn light off.""" + async with timeout(5): + await self.coordinator.client.set_path_value("power", 0) diff --git a/homeassistant/components/evil_genius_labs/manifest.json b/homeassistant/components/evil_genius_labs/manifest.json new file mode 100644 index 00000000000..698c13b43e6 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "evil_genius_labs", + "name": "Evil Genius Labs", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/evil_genius_labs", + "requirements": ["pyevilgenius==1.0.0"], + "codeowners": ["@balloob"], + "iot_class": "local_polling" +} diff --git a/homeassistant/components/evil_genius_labs/strings.json b/homeassistant/components/evil_genius_labs/strings.json new file mode 100644 index 00000000000..16c5de158a9 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/evil_genius_labs/translations/bg.json b/homeassistant/components/evil_genius_labs/translations/bg.json new file mode 100644 index 00000000000..dcdcdcfc186 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/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/evil_genius_labs/translations/ca.json b/homeassistant/components/evil_genius_labs/translations/ca.json new file mode 100644 index 00000000000..e77c84008c7 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/ca.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/de.json b/homeassistant/components/evil_genius_labs/translations/de.json new file mode 100644 index 00000000000..e45f7c2b063 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/de.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/en.json b/homeassistant/components/evil_genius_labs/translations/en.json new file mode 100644 index 00000000000..b059e11aa28 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/es.json b/homeassistant/components/evil_genius_labs/translations/es.json new file mode 100644 index 00000000000..d0f2d525bfd --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "No se pudo conectar", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/et.json b/homeassistant/components/evil_genius_labs/translations/et.json new file mode 100644 index 00000000000..dedad93b36c --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/et.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/fr.json b/homeassistant/components/evil_genius_labs/translations/fr.json new file mode 100644 index 00000000000..bd75678406e --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/fr.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/he.json b/homeassistant/components/evil_genius_labs/translations/he.json new file mode 100644 index 00000000000..00011f86933 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/he.json @@ -0,0 +1,15 @@ +{ + "config": { + "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" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/hu.json b/homeassistant/components/evil_genius_labs/translations/hu.json new file mode 100644 index 00000000000..6ed148ceabb --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/hu.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "host": "C\u00edm" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/id.json b/homeassistant/components/evil_genius_labs/translations/id.json new file mode 100644 index 00000000000..66c930e348b --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/id.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/it.json b/homeassistant/components/evil_genius_labs/translations/it.json new file mode 100644 index 00000000000..8e3e5b34899 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/ja.json b/homeassistant/components/evil_genius_labs/translations/ja.json new file mode 100644 index 00000000000..db894cded4e --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/ja.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/nl.json b/homeassistant/components/evil_genius_labs/translations/nl.json new file mode 100644 index 00000000000..29eb87c44a0 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/no.json b/homeassistant/components/evil_genius_labs/translations/no.json new file mode 100644 index 00000000000..17c1c3b70dc --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/no.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "host": "Vert" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/pl.json b/homeassistant/components/evil_genius_labs/translations/pl.json new file mode 100644 index 00000000000..a8bab923c35 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/pl.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/ru.json b/homeassistant/components/evil_genius_labs/translations/ru.json new file mode 100644 index 00000000000..f34931312ec --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/ru.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/sl.json b/homeassistant/components/evil_genius_labs/translations/sl.json new file mode 100644 index 00000000000..3d0d816aa12 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Povezava ni uspela", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "host": "Gostitelj" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/tr.json b/homeassistant/components/evil_genius_labs/translations/tr.json new file mode 100644 index 00000000000..b97a1354e2e --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/tr.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "host": "Ana bilgisayar" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/translations/zh-Hant.json b/homeassistant/components/evil_genius_labs/translations/zh-Hant.json new file mode 100644 index 00000000000..a91b061b492 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/util.py b/homeassistant/components/evil_genius_labs/util.py new file mode 100644 index 00000000000..42088f69797 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/util.py @@ -0,0 +1,21 @@ +"""Utilities for Evil Genius Labs.""" +from collections.abc import Callable +from functools import wraps +from typing import Any, TypeVar, cast + +from . import EvilGeniusEntity + +CallableT = TypeVar("CallableT", bound=Callable) + + +def update_when_done(func: CallableT) -> CallableT: + """Decorate function to trigger update when function is done.""" + + @wraps(func) + async def wrapper(self: EvilGeniusEntity, *args: Any, **kwargs: Any) -> Any: + """Wrap function.""" + result = await func(self, *args, **kwargs) + await self.coordinator.async_request_refresh() + return result + + return cast(CallableT, wrapper) diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json index 1108f1a6f83..4618f7e4404 100644 --- a/homeassistant/components/ezviz/manifest.json +++ b/homeassistant/components/ezviz/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/ezviz", "dependencies": ["ffmpeg"], "codeowners": ["@RenierM26", "@baqs"], - "requirements": ["pyezviz==0.1.9.4"], + "requirements": ["pyezviz==0.2.0.5"], "config_flow": true, "iot_class": "cloud_polling" } diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index 3ea650154f0..5197982a2c5 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -36,6 +36,8 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { key="PIR_Status", device_class=DEVICE_CLASS_MOTION, ), + "last_alarm_type_code": SensorEntityDescription(key="last_alarm_type_code"), + "last_alarm_type_name": SensorEntityDescription(key="last_alarm_type_name"), } diff --git a/homeassistant/components/ezviz/translations/ja.json b/homeassistant/components/ezviz/translations/ja.json new file mode 100644 index 00000000000..4780b5fd10a --- /dev/null +++ b/homeassistant/components/ezviz/translations/ja.json @@ -0,0 +1,52 @@ +{ + "config": { + "abort": { + "already_configured_account": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "ezviz_cloud_account_missing": "Ezviz cloud account\u304c\u3042\u308a\u307e\u305b\u3093\u3002Ezviz cloud account\u3092\u518d\u8a2d\u5b9a\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "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" + }, + "flow_title": "{serial}", + "step": { + "confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "IP {ip_address} \u3092\u6301\u3064Ezviz\u30ab\u30e1\u30e9 {serial} \u306eRTSP\u8a8d\u8a3c\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u767a\u898b\u3055\u308c\u305fEzviz\u30ab\u30e1\u30e9" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "url": "URL", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Ezviz Cloud\u306b\u63a5\u7d9a" + }, + "user_custom_url": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "url": "URL", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u30ea\u30fc\u30b8\u30e7\u30f3\u306eURL\u3092\u624b\u52d5\u3067\u6307\u5b9a\u3059\u308b", + "title": "\u30ab\u30b9\u30bf\u30e0 Ezviz Cloud\u306b\u63a5\u7d9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ffmpeg_arguments": "ffmpeg\u306b\u6e21\u3055\u308c\u308b\u30ab\u30e1\u30e9\u7528\u306e\u5f15\u6570", + "timeout": "\u30ea\u30af\u30a8\u30b9\u30c8\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8(\u79d2)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ezviz/translations/tr.json b/homeassistant/components/ezviz/translations/tr.json new file mode 100644 index 00000000000..a1ba775da7f --- /dev/null +++ b/homeassistant/components/ezviz/translations/tr.json @@ -0,0 +1,52 @@ +{ + "config": { + "abort": { + "already_configured_account": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "ezviz_cloud_account_missing": "Ezviz bulut hesab\u0131 eksik. L\u00fctfen Ezviz bulut hesab\u0131n\u0131 yeniden yap\u0131land\u0131r\u0131n", + "unknown": "Beklenmeyen hata" + }, + "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" + }, + "flow_title": "{serial}", + "step": { + "confirm": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "IP {ip_address} {serial} i\u00e7in RTSP kimlik bilgilerini girin", + "title": "Ke\u015ffedilen Ezviz Kamera" + }, + "user": { + "data": { + "password": "Parola", + "url": "URL", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "title": "Ezviz Cloud'a ba\u011flan\u0131n" + }, + "user_custom_url": { + "data": { + "password": "Parola", + "url": "URL", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "B\u00f6lge URL'nizi manuel olarak belirtin", + "title": "\u00d6zel Ezviz URL'sine ba\u011flan\u0131n" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ffmpeg_arguments": "Kameralar i\u00e7in ffmpeg'e ge\u00e7irilen arg\u00fcmanlar", + "timeout": "\u0130stek Zaman A\u015f\u0131m\u0131 (saniye)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/__init__.py b/homeassistant/components/faa_delays/__init__.py index e27916ec6c1..205fa016130 100644 --- a/homeassistant/components/faa_delays/__init__.py +++ b/homeassistant/components/faa_delays/__init__.py @@ -56,7 +56,7 @@ class FAADataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self): try: - with timeout(10): + async with timeout(10): await self.data.update() except ClientConnectionError as err: raise UpdateFailed(err) from err diff --git a/homeassistant/components/faa_delays/translations/ja.json b/homeassistant/components/faa_delays/translations/ja.json new file mode 100644 index 00000000000..144133ae2a0 --- /dev/null +++ b/homeassistant/components/faa_delays/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u3053\u306e\u7a7a\u6e2f\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_airport": "\u7a7a\u6e2f\u30b3\u30fc\u30c9\u304c\u7121\u52b9\u3067\u3059", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "id": "\u7a7a\u6e2f" + }, + "description": "IATA\u5f62\u5f0f\u3067\u7c73\u56fd\u306e\u7a7a\u6e2f\u30b3\u30fc\u30c9(US Airport Code)\u3092\u5165\u529b\u3057\u307e\u3059", + "title": "FAA Delays" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/tr.json b/homeassistant/components/faa_delays/translations/tr.json new file mode 100644 index 00000000000..bcca79ce392 --- /dev/null +++ b/homeassistant/components/faa_delays/translations/tr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Bu havaalan\u0131 zaten yap\u0131land\u0131r\u0131lm\u0131\u015f." + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_airport": "Havaalan\u0131 kodu ge\u00e7erli de\u011fil", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "id": "Havaliman\u0131" + }, + "description": "ABD Havaalan\u0131 Kodu'nu IATA Format\u0131nda Girin", + "title": "FAA Gecikmeleri" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 3a5b9bcda67..9289c899b57 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -235,6 +235,13 @@ class FanEntity(ToggleEntity): """Base class for fan entities.""" entity_description: FanEntityDescription + _attr_current_direction: str | None = None + _attr_oscillating: bool | None = None + _attr_percentage: int | None + _attr_preset_mode: str | None + _attr_preset_modes: list[str] | None + _attr_speed_count: int + _attr_supported_features: int = 0 @_fan_native def set_speed(self, speed: str) -> None: @@ -469,6 +476,9 @@ class FanEntity(ToggleEntity): @property def percentage(self) -> int | None: """Return the current speed as a percentage.""" + if hasattr(self, "_attr_percentage"): + return self._attr_percentage + if ( not self._implemented_preset_mode and self.preset_modes @@ -482,6 +492,9 @@ class FanEntity(ToggleEntity): @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" + if hasattr(self, "_attr_speed_count"): + return self._attr_speed_count + speed_list = speed_list_without_preset_modes(self.speed_list) if speed_list: return len(speed_list) @@ -505,12 +518,12 @@ class FanEntity(ToggleEntity): @property def current_direction(self) -> str | None: """Return the current direction of the fan.""" - return None + return self._attr_current_direction @property - def oscillating(self): + def oscillating(self) -> bool | None: """Return whether or not the fan is currently oscillating.""" - return None + return self._attr_oscillating @property def capability_attributes(self): @@ -629,7 +642,7 @@ class FanEntity(ToggleEntity): @property def supported_features(self) -> int: """Flag supported features.""" - return 0 + return self._attr_supported_features @property def preset_mode(self) -> str | None: @@ -637,6 +650,9 @@ class FanEntity(ToggleEntity): Requires SUPPORT_SET_SPEED. """ + if hasattr(self, "_attr_preset_mode"): + return self._attr_preset_mode + speed = self.speed if self.preset_modes and speed in self.preset_modes: return speed @@ -648,6 +664,9 @@ class FanEntity(ToggleEntity): Requires SUPPORT_SET_SPEED. """ + if hasattr(self, "_attr_preset_modes"): + return self._attr_preset_modes + return preset_modes_from_speed_list(self.speed_list) diff --git a/homeassistant/components/fan/device_condition.py b/homeassistant/components/fan/device_condition.py index 56d9208b2d2..b0882137d7f 100644 --- a/homeassistant/components/fan/device_condition.py +++ b/homeassistant/components/fan/device_condition.py @@ -56,11 +56,9 @@ async def async_get_conditions( @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" - if config_validation: - config = CONDITION_SCHEMA(config) if config[CONF_TYPE] == "is_on": state = STATE_ON else: diff --git a/homeassistant/components/fan/translations/ca.json b/homeassistant/components/fan/translations/ca.json index da5296b34f0..7c1789aeb24 100644 --- a/homeassistant/components/fan/translations/ca.json +++ b/homeassistant/components/fan/translations/ca.json @@ -15,8 +15,8 @@ }, "state": { "_": { - "off": "off", - "on": "on" + "off": "OFF", + "on": "ON" } }, "title": "Ventilador" diff --git a/homeassistant/components/fan/translations/ja.json b/homeassistant/components/fan/translations/ja.json index 15dd3796187..46e5a16a4d3 100644 --- a/homeassistant/components/fan/translations/ja.json +++ b/homeassistant/components/fan/translations/ja.json @@ -1,8 +1,23 @@ { + "device_automation": { + "action_type": { + "turn_off": "\u30aa\u30d5\u306b\u3059\u308b {entity_name}", + "turn_on": "\u30aa\u30f3\u306b\u3059\u308b {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u306f\u30aa\u30d5\u3067\u3059", + "is_on": "{entity_name} \u304c\u30aa\u30f3\u3067\u3059" + }, + "trigger_type": { + "turned_off": "{entity_name} \u30aa\u30d5\u306b\u306a\u308a\u307e\u3057\u305f", + "turned_on": "{entity_name} \u30aa\u30f3\u306b\u306a\u3063\u3066\u3044\u307e\u3059" + } + }, "state": { "_": { "off": "\u30aa\u30d5", "on": "\u30aa\u30f3" } - } + }, + "title": "\u30d5\u30a1\u30f3" } \ No newline at end of file diff --git a/homeassistant/components/fan/translations/tr.json b/homeassistant/components/fan/translations/tr.json index 52a07c35d83..5f43af3e005 100644 --- a/homeassistant/components/fan/translations/tr.json +++ b/homeassistant/components/fan/translations/tr.json @@ -4,6 +4,10 @@ "turn_off": "{entity_name} kapat", "turn_on": "{entity_name} a\u00e7\u0131n" }, + "condition_type": { + "is_off": "{entity_name} kapal\u0131", + "is_on": "{entity_name} a\u00e7\u0131k" + }, "trigger_type": { "turned_off": "{entity_name} kapat\u0131ld\u0131", "turned_on": "{entity_name} a\u00e7\u0131ld\u0131" diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index 2a82cee7cea..8363981b526 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -49,8 +49,7 @@ class SpeedtestSensor(RestoreEntity, SensorEntity): ) ) - state = await self.async_get_last_state() - if not state: + if not (state := await self.async_get_last_state()): return self._attr_native_value = state.state diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py index a87b1609ec9..f2e415daec9 100644 --- a/homeassistant/components/fireservicerota/sensor.py +++ b/homeassistant/components/fireservicerota/sensor.py @@ -102,8 +102,7 @@ class IncidentsSensor(RestoreEntity, SensorEntity): """Run when about to be added to hass.""" await super().async_added_to_hass() - state = await self.async_get_last_state() - if state: + if state := await self.async_get_last_state(): self._state = state.state self._state_attributes = state.attributes if "id" in self._state_attributes: diff --git a/homeassistant/components/fireservicerota/translations/ja.json b/homeassistant/components/fireservicerota/translations/ja.json new file mode 100644 index 00000000000..00358bb1f36 --- /dev/null +++ b/homeassistant/components/fireservicerota/translations/ja.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "create_entry": { + "default": "\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "reauth": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u8a8d\u8a3c\u30c8\u30fc\u30af\u30f3\u304c\u7121\u52b9\u306b\u306a\u3063\u305f\u306e\u3067\u3001\u30ed\u30b0\u30a4\u30f3\u3057\u3066\u518d\u4f5c\u6210\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "url": "Web\u30b5\u30a4\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fireservicerota/translations/tr.json b/homeassistant/components/fireservicerota/translations/tr.json index f54d10f6cbf..2b9c1b9cb0a 100644 --- a/homeassistant/components/fireservicerota/translations/tr.json +++ b/homeassistant/components/fireservicerota/translations/tr.json @@ -13,15 +13,15 @@ "step": { "reauth": { "data": { - "password": "\u015eifre" + "password": "Parola" }, "description": "Kimlik do\u011frulama jetonlar\u0131 ge\u00e7ersiz, yeniden olu\u015fturmak i\u00e7in oturum a\u00e7\u0131n." }, "user": { "data": { - "password": "\u015eifre", + "password": "Parola", "url": "Web sitesi", - "username": "Kullan\u0131c\u0131 ad\u0131" + "username": "Kullan\u0131c\u0131 Ad\u0131" } } } diff --git a/homeassistant/components/firmata/__init__.py b/homeassistant/components/firmata/__init__.py index d98866f900b..d147d84b341 100644 --- a/homeassistant/components/firmata/__init__.py +++ b/homeassistant/components/firmata/__init__.py @@ -189,7 +189,7 @@ async def async_setup_entry( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_shutdown) ) - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={}, diff --git a/homeassistant/components/firmata/translations/bg.json b/homeassistant/components/firmata/translations/bg.json new file mode 100644 index 00000000000..c30e629d8ad --- /dev/null +++ b/homeassistant/components/firmata/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/firmata/translations/ja.json b/homeassistant/components/firmata/translations/ja.json new file mode 100644 index 00000000000..c0253537836 --- /dev/null +++ b/homeassistant/components/firmata/translations/ja.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py index f5cedad243d..babcdc6649a 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -23,7 +23,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DISPATCH_DETECTION, DOMAIN -PLATFORMS = ["binary_sensor", "fan", "light", "sensor", "number"] +PLATFORMS = ["binary_sensor", "fan", "light", "number", "sensor"] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py index 7cb7c7cd18e..bbc04a9607c 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -17,6 +17,7 @@ from homeassistant.components.fan import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( @@ -50,7 +51,7 @@ PRESET_TO_COMMAND = { } -class UnsupportedPreset(Exception): +class UnsupportedPreset(HomeAssistantError): """The preset is unsupported.""" diff --git a/homeassistant/components/fjaraskupan/number.py b/homeassistant/components/fjaraskupan/number.py index d5862bf2e7f..66f719abd6f 100644 --- a/homeassistant/components/fjaraskupan/number.py +++ b/homeassistant/components/fjaraskupan/number.py @@ -22,7 +22,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up sensors dynamically through discovery.""" + """Set up number entities dynamically through discovery.""" def _constructor(device_state: DeviceState) -> list[Entity]: return [ @@ -40,7 +40,6 @@ class PeriodicVentingTime(CoordinatorEntity[State], NumberEntity): _attr_max_value: float = 59 _attr_min_value: float = 0 _attr_step: float = 1 - _attr_entity_registry_enabled_default = True _attr_entity_category = ENTITY_CATEGORY_CONFIG _attr_unit_of_measurement = TIME_MINUTES @@ -50,7 +49,7 @@ class PeriodicVentingTime(CoordinatorEntity[State], NumberEntity): device: Device, device_info: DeviceInfo, ) -> None: - """Init sensor.""" + """Init number entities.""" super().__init__(coordinator) self._device = device self._attr_unique_id = f"{device.address}-periodic-venting" diff --git a/homeassistant/components/fjaraskupan/translations/ja.json b/homeassistant/components/fjaraskupan/translations/ja.json new file mode 100644 index 00000000000..f22b3c04c84 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/ja.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "confirm": { + "description": "Fj\u00e4r\u00e5skupan\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/tr.json b/homeassistant/components/fjaraskupan/translations/tr.json new file mode 100644 index 00000000000..c64f41f5c88 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/tr.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", + "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + }, + "step": { + "confirm": { + "description": "Fj\u00e4r\u00e5skupan'\u0131 kurmak istiyor musunuz?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flick_electric/config_flow.py b/homeassistant/components/flick_electric/config_flow.py index c76b44396f5..7f21397d5a7 100644 --- a/homeassistant/components/flick_electric/config_flow.py +++ b/homeassistant/components/flick_electric/config_flow.py @@ -45,7 +45,7 @@ class FlickConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - with async_timeout.timeout(60): + async with async_timeout.timeout(60): token = await auth.async_get_access_token() except asyncio.TimeoutError as err: raise CannotConnect() from err diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py index 938507e4b0c..7ca3b99f928 100644 --- a/homeassistant/components/flick_electric/sensor.py +++ b/homeassistant/components/flick_electric/sensor.py @@ -67,7 +67,7 @@ class FlickPricingSensor(SensorEntity): if self._price and self._price.end_at >= utcnow(): return # Power price data is still valid - with async_timeout.timeout(60): + async with async_timeout.timeout(60): self._price = await self._api.getPricing() self._attributes[ATTR_START_AT] = self._price.start_at diff --git a/homeassistant/components/flick_electric/translations/ja.json b/homeassistant/components/flick_electric/translations/ja.json new file mode 100644 index 00000000000..6091cfde5c6 --- /dev/null +++ b/homeassistant/components/flick_electric/translations/ja.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "client_id": "\u30af\u30e9\u30a4\u30a2\u30f3\u30c8ID(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "client_secret": "\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u30b7\u30fc\u30af\u30ec\u30c3\u30c8(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Flick\u306e\u30ed\u30b0\u30a4\u30f3\u8a8d\u8a3c\u60c5\u5831" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flick_electric/translations/tr.json b/homeassistant/components/flick_electric/translations/tr.json index a83e1936fb4..64be92a8e5f 100644 --- a/homeassistant/components/flick_electric/translations/tr.json +++ b/homeassistant/components/flick_electric/translations/tr.json @@ -11,9 +11,12 @@ "step": { "user": { "data": { + "client_id": "\u0130stemci Kimli\u011fi (iste\u011fe ba\u011fl\u0131)", + "client_secret": "\u0130stemci Gizlili\u011fi (iste\u011fe ba\u011fl\u0131)", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "Flick oturum a\u00e7ma kimlik bilgileri" } } } diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index e79ba131618..527742539c5 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -1,8 +1,6 @@ """Sensor platform for the Flipr's pool_sensor.""" from __future__ import annotations -from datetime import datetime - from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, @@ -60,7 +58,4 @@ class FliprSensor(FliprEntity, SensorEntity): @property def native_value(self): """State of the sensor.""" - state = self.coordinator.data[self.entity_description.key] - if isinstance(state, datetime): - return state.isoformat() - return state + return self.coordinator.data[self.entity_description.key] diff --git a/homeassistant/components/flipr/translations/id.json b/homeassistant/components/flipr/translations/id.json index 63751867097..0f1758f2d8f 100644 --- a/homeassistant/components/flipr/translations/id.json +++ b/homeassistant/components/flipr/translations/id.json @@ -6,14 +6,24 @@ "error": { "cannot_connect": "Gagal terhubung", "invalid_auth": "Autentikasi tidak valid", + "no_flipr_id_found": "Tidak ada id flipr yang terkait dengan akun Anda untuk saat ini. Anda harus memverifikasinya dengan aplikasi seluler Flipr terlebih dahulu.", "unknown": "Kesalahan yang tidak diharapkan" }, "step": { + "flipr_id": { + "data": { + "flipr_id": "ID Flipr" + }, + "description": "Pilih ID Flipr Anda dari daftar", + "title": "Pilih ID Flipr Anda" + }, "user": { "data": { "email": "Email", "password": "Kata Sandi" - } + }, + "description": "Hubungkan menggunakan akun Flipr Anda.", + "title": "Hubungkan ke Flipr" } } } diff --git a/homeassistant/components/flipr/translations/ja.json b/homeassistant/components/flipr/translations/ja.json new file mode 100644 index 00000000000..7d87c4a3c39 --- /dev/null +++ b/homeassistant/components/flipr/translations/ja.json @@ -0,0 +1,30 @@ +{ + "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "no_flipr_id_found": "\u73fe\u5728\u3001\u3042\u306a\u305f\u306e\u30a2\u30ab\u30a6\u30f3\u30c8\u306b\u95a2\u9023\u4ed8\u3051\u3089\u308c\u3066\u3044\u308bflipr id\u306f\u3042\u308a\u307e\u305b\u3093\u3002\u307e\u305a\u306f\u3001Flipr\u306e\u30e2\u30d0\u30a4\u30eb\u30a2\u30d7\u30ea\u3067\u52d5\u4f5c\u3057\u3066\u3044\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u307f\u3066\u304f\u3060\u3055\u3044\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr ID" + }, + "description": "\u30ea\u30b9\u30c8\u306e\u4e2d\u304b\u3089FliprID\u3092\u9078\u3076", + "title": "Flipr\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "user": { + "data": { + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u3042\u306a\u305f\u306eFlipr\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u4f7f\u7528\u3057\u3066\u63a5\u7d9a\u3057\u307e\u3059\u3002", + "title": "Flipr\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/tr.json b/homeassistant/components/flipr/translations/tr.json new file mode 100644 index 00000000000..e5649496f38 --- /dev/null +++ b/homeassistant/components/flipr/translations/tr.json @@ -0,0 +1,30 @@ +{ + "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", + "no_flipr_id_found": "\u015eu anda hesab\u0131n\u0131zla ili\u015fkilendirilmi\u015f bir flipr kimli\u011fi yok. \u00d6nce Flipr'\u0131n mobil uygulamas\u0131yla \u00e7al\u0131\u015ft\u0131\u011f\u0131n\u0131 do\u011frulaman\u0131z gerekir.", + "unknown": "Beklenmeyen hata" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr Kimli\u011fi" + }, + "description": "Listeden Flipr kimli\u011finizi se\u00e7in", + "title": "Flipr'inizi se\u00e7in" + }, + "user": { + "data": { + "email": "E-posta", + "password": "Parola" + }, + "description": "Flipr hesab\u0131n\u0131z\u0131 kullanarak ba\u011flan\u0131n.", + "title": "Flipr'e ba\u011flan" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flo/device.py b/homeassistant/components/flo/device.py index f32aa7e6e32..cc32acb485c 100644 --- a/homeassistant/components/flo/device.py +++ b/homeassistant/components/flo/device.py @@ -42,7 +42,11 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): try: async with timeout(10): await asyncio.gather( - *[self._update_device(), self._update_consumption_data()] + *[ + self.send_presence_ping(), + self._update_device(), + self._update_consumption_data(), + ] ) except (RequestError) as error: raise UpdateFailed(error) from error @@ -188,6 +192,10 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): """Return the battery level for battery-powered device, e.g. leak detectors.""" return self._device_information["battery"]["level"] + async def send_presence_ping(self): + """Send Flo a presence ping.""" + await self.api_client.presence.ping() + async def async_set_mode_home(self): """Set the Flo location to home mode.""" await self.api_client.location.set_mode_home(self._flo_location_id) diff --git a/homeassistant/components/flo/manifest.json b/homeassistant/components/flo/manifest.json index 11972f5056b..6d1e002012c 100644 --- a/homeassistant/components/flo/manifest.json +++ b/homeassistant/components/flo/manifest.json @@ -3,7 +3,7 @@ "name": "Flo", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/flo", - "requirements": ["aioflo==0.4.1"], + "requirements": ["aioflo==2021.11.0"], "codeowners": ["@dmulcahey"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/flo/translations/bg.json b/homeassistant/components/flo/translations/bg.json index 2ac8a444100..7b92255e7c9 100644 --- a/homeassistant/components/flo/translations/bg.json +++ b/homeassistant/components/flo/translations/bg.json @@ -1,7 +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" + }, "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\u0445 \u043f\u0440\u0438 \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", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/flo/translations/ja.json b/homeassistant/components/flo/translations/ja.json new file mode 100644 index 00000000000..a9d2ddfd3ac --- /dev/null +++ b/homeassistant/components/flo/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flo/translations/tr.json b/homeassistant/components/flo/translations/tr.json index 40c9c39b967..3fdcebd112c 100644 --- a/homeassistant/components/flo/translations/tr.json +++ b/homeassistant/components/flo/translations/tr.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" } diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py index de5c078f714..ee89937599a 100644 --- a/homeassistant/components/flock/notify.py +++ b/homeassistant/components/flock/notify.py @@ -41,7 +41,7 @@ class FlockNotificationService(BaseNotificationService): _LOGGER.debug("Attempting to call Flock at %s", self._url) try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): response = await self._session.post(self._url, json=payload) result = await response.json() diff --git a/homeassistant/components/flume/translations/id.json b/homeassistant/components/flume/translations/id.json index f72e27ece8d..9aea48afae0 100644 --- a/homeassistant/components/flume/translations/id.json +++ b/homeassistant/components/flume/translations/id.json @@ -13,7 +13,9 @@ "reauth_confirm": { "data": { "password": "Kata Sandi" - } + }, + "description": "Kata sandi untuk {username} tidak lagi berlaku.", + "title": "Autentikasi ulang Akun Flume Anda" }, "user": { "data": { diff --git a/homeassistant/components/flume/translations/ja.json b/homeassistant/components/flume/translations/ja.json new file mode 100644 index 00000000000..f476c0fd35f --- /dev/null +++ b/homeassistant/components/flume/translations/ja.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "{username} \u306e\u30d1\u30b9\u30ef\u30fc\u30c9\u306f\u7121\u52b9\u306b\u306a\u308a\u307e\u3057\u305f\u3002", + "title": "Flume\u30a2\u30ab\u30a6\u30f3\u30c8\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "client_id": "\u30af\u30e9\u30a4\u30a2\u30f3\u30c8ID", + "client_secret": "\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u30b7\u30fc\u30af\u30ec\u30c3\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "Flume Personal API\u306b\u30a2\u30af\u30bb\u30b9\u3059\u308b\u306b\u306f\u3001https://portal.flumetech.com/settings#token \u3067\u3001'Client ID' \u3068 'Client Secret' \u3092\u30ea\u30af\u30a8\u30b9\u30c8\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "title": "Flume\u30a2\u30ab\u30a6\u30f3\u30c8\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/tr.json b/homeassistant/components/flume/translations/tr.json index a83e1936fb4..dbdd21a3d49 100644 --- a/homeassistant/components/flume/translations/tr.json +++ b/homeassistant/components/flume/translations/tr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", @@ -9,11 +10,22 @@ "unknown": "Beklenmeyen hata" }, "step": { + "reauth_confirm": { + "data": { + "password": "Parola" + }, + "description": "{username} i\u00e7in \u015fifre art\u0131k ge\u00e7erli de\u011fil.", + "title": "Flume Hesab\u0131n\u0131z\u0131 Yeniden Do\u011frulay\u0131n" + }, "user": { "data": { + "client_id": "\u0130stemci Kimli\u011fi", + "client_secret": "\u0130stemci Anahtar\u0131", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "description": "Flume Ki\u015fisel API'sine eri\u015fmek i\u00e7in https://portal.flumetech.com/settings#token adresinden bir 'M\u00fc\u015fteri Kimli\u011fi' ve '\u0130stemci Anahtar\u0131' talep etmeniz gerekir.", + "title": "Flume Hesab\u0131n\u0131za ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/flunearyou/__init__.py b/homeassistant/components/flunearyou/__init__.py index 86a86e440c9..beb7bec2c2f 100644 --- a/homeassistant/components/flunearyou/__init__.py +++ b/homeassistant/components/flunearyou/__init__.py @@ -26,8 +26,6 @@ PLATFORMS = ["sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Flu Near You as config entry.""" - hass.data.setdefault(DOMAIN, {}) - websession = aiohttp_client.async_get_clientsession(hass) client = Client(session=websession) @@ -64,6 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data_init_tasks.append(coordinator.async_refresh()) await asyncio.gather(*data_init_tasks) + hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinators hass.config_entries.async_setup_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/flunearyou/sensor.py b/homeassistant/components/flunearyou/sensor.py index a30c2423253..0017d868964 100644 --- a/homeassistant/components/flunearyou/sensor.py +++ b/homeassistant/components/flunearyou/sensor.py @@ -10,12 +10,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_STATE, - CONF_LATITUDE, - CONF_LONGITUDE, -) +from homeassistant.const import ATTR_STATE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -34,8 +29,6 @@ ATTR_STATE_REPORTS_LAST_WEEK = "state_reports_last_week" ATTR_STATE_REPORTS_THIS_WEEK = "state_reports_this_week" ATTR_ZIP_CODE = "zip_code" -DEFAULT_ATTRIBUTION = "Data provided by Flu Near You" - SENSOR_TYPE_CDC_LEVEL = "level" SENSOR_TYPE_CDC_LEVEL2 = "level2" SENSOR_TYPE_USER_CHICK = "chick" @@ -140,8 +133,6 @@ async def async_setup_entry( class FluNearYouSensor(CoordinatorEntity, SensorEntity): """Define a base Flu Near You sensor.""" - DEFAULT_EXTRA_STATE_ATTRIBUTES = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - def __init__( self, coordinator: DataUpdateCoordinator, @@ -166,7 +157,6 @@ class CdcSensor(FluNearYouSensor): def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return entity specific state attributes.""" return { - **self.DEFAULT_EXTRA_STATE_ATTRIBUTES, ATTR_REPORTED_DATE: self.coordinator.data["week_date"], ATTR_STATE: self.coordinator.data["name"], } @@ -186,7 +176,6 @@ class UserSensor(FluNearYouSensor): def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return entity specific state attributes.""" attrs = { - **self.DEFAULT_EXTRA_STATE_ATTRIBUTES, ATTR_CITY: self.coordinator.data["local"]["city"].split("(")[0], ATTR_REPORTED_LATITUDE: self.coordinator.data["local"]["latitude"], ATTR_REPORTED_LONGITUDE: self.coordinator.data["local"]["longitude"], diff --git a/homeassistant/components/flunearyou/translations/ja.json b/homeassistant/components/flunearyou/translations/ja.json new file mode 100644 index 00000000000..23df88d984b --- /dev/null +++ b/homeassistant/components/flunearyou/translations/ja.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\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": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6" + }, + "description": "\u30e6\u30fc\u30b6\u30fc\u30d9\u30fc\u30b9\u306e\u30ec\u30dd\u30fc\u30c8\u3068CDC\u306e\u30ec\u30dd\u30fc\u30c8\u3092\u30da\u30a2\u306b\u3057\u3066\u5ea7\u6a19\u3067\u30e2\u30cb\u30bf\u30fc\u3057\u307e\u3059\u3002", + "title": "\u8fd1\u304f\u306eFlu\u3092\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/tr.json b/homeassistant/components/flunearyou/translations/tr.json index 6e749e3c827..3a21364502e 100644 --- a/homeassistant/components/flunearyou/translations/tr.json +++ b/homeassistant/components/flunearyou/translations/tr.json @@ -11,7 +11,9 @@ "data": { "latitude": "Enlem", "longitude": "Boylam" - } + }, + "description": "Bir \u00e7ift koordinat i\u00e7in kullan\u0131c\u0131 tabanl\u0131 raporlar\u0131 ve CDC raporlar\u0131n\u0131 izleyin.", + "title": "Flu Near You'yu Yap\u0131land\u0131r\u0131n" } } } diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 717a3c9e2b0..7ea86af2e9d 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -1,6 +1,7 @@ """The Flux LED/MagicLight integration.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from typing import Any, Final @@ -8,6 +9,8 @@ from typing import Any, Final from flux_led import DeviceType from flux_led.aio import AIOWifiLedBulb from flux_led.aioscanner import AIOBulbScanner +from flux_led.const import ATTR_ID, ATTR_IPADDR, ATTR_MODEL, ATTR_MODEL_DESCRIPTION +from flux_led.scanner import FluxLEDDiscovery from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry @@ -24,18 +27,19 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import ( DISCOVER_SCAN_TIMEOUT, DOMAIN, - FLUX_HOST, FLUX_LED_DISCOVERY, + FLUX_LED_DISCOVERY_LOCK, FLUX_LED_EXCEPTIONS, - FLUX_MAC, - FLUX_MODEL, SIGNAL_STATE_UPDATED, STARTUP_SCAN_TIMEOUT, ) _LOGGER = logging.getLogger(__name__) -PLATFORMS_BY_TYPE: Final = {DeviceType.Bulb: ["light"], DeviceType.Switch: ["switch"]} +PLATFORMS_BY_TYPE: Final = { + DeviceType.Bulb: ["light", "number"], + DeviceType.Switch: ["switch"], +} DISCOVERY_INTERVAL: Final = timedelta(minutes=15) REQUEST_REFRESH_DELAY: Final = 1.5 @@ -46,44 +50,60 @@ def async_wifi_bulb_for_host(host: str) -> AIOWifiLedBulb: return AIOWifiLedBulb(host) +@callback +def async_name_from_discovery(device: FluxLEDDiscovery) -> str: + """Convert a flux_led discovery to a human readable name.""" + mac_address = device[ATTR_ID] + if mac_address is None: + return device[ATTR_IPADDR] + short_mac = mac_address[-6:] + if device[ATTR_MODEL_DESCRIPTION]: + return f"{device[ATTR_MODEL_DESCRIPTION]} {short_mac}" + return f"{device[ATTR_MODEL]} {short_mac}" + + @callback def async_update_entry_from_discovery( - hass: HomeAssistant, entry: config_entries.ConfigEntry, device: dict[str, Any] + hass: HomeAssistant, entry: config_entries.ConfigEntry, device: FluxLEDDiscovery ) -> None: """Update a config entry from a flux_led discovery.""" - name = f"{device[FLUX_MODEL]} {device[FLUX_MAC]}" + name = async_name_from_discovery(device) + mac_address = device[ATTR_ID] + assert mac_address is not None hass.config_entries.async_update_entry( entry, data={**entry.data, CONF_NAME: name}, title=name, - unique_id=dr.format_mac(device[FLUX_MAC]), + unique_id=dr.format_mac(mac_address), ) async def async_discover_devices( hass: HomeAssistant, timeout: int, address: str | None = None -) -> list[dict[str, str]]: +) -> list[FluxLEDDiscovery]: """Discover flux led devices.""" - scanner = AIOBulbScanner() - try: - discovered: list[dict[str, str]] = await scanner.async_scan( - timeout=timeout, address=address - ) - except OSError as ex: - _LOGGER.debug("Scanning failed with error: %s", ex) - return [] - else: - return discovered + domain_data = hass.data.setdefault(DOMAIN, {}) + if FLUX_LED_DISCOVERY_LOCK not in domain_data: + domain_data[FLUX_LED_DISCOVERY_LOCK] = asyncio.Lock() + async with domain_data[FLUX_LED_DISCOVERY_LOCK]: + scanner = AIOBulbScanner() + try: + discovered = await scanner.async_scan(timeout=timeout, address=address) + except OSError as ex: + _LOGGER.debug("Scanning failed with error: %s", ex) + return [] + else: + return discovered async def async_discover_device( hass: HomeAssistant, host: str -) -> dict[str, str] | None: +) -> FluxLEDDiscovery | None: """Direct discovery at a single ip instead of broadcast.""" # If we are missing the unique_id we should be able to fetch it # from the device by doing a directed discovery at the host only for device in await async_discover_devices(hass, DISCOVER_SCAN_TIMEOUT, host): - if device[FLUX_HOST] == host: + if device[ATTR_IPADDR] == host: return device return None @@ -91,7 +111,7 @@ async def async_discover_device( @callback def async_trigger_discovery( hass: HomeAssistant, - discovered_devices: list[dict[str, Any]], + discovered_devices: list[FluxLEDDiscovery], ) -> None: """Trigger config flows for discovered devices.""" for device in discovered_devices: @@ -99,14 +119,14 @@ def async_trigger_discovery( hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DISCOVERY}, - data=device, + data={**device}, ) ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the flux_led component.""" - domain_data = hass.data[DOMAIN] = {} + domain_data = hass.data.setdefault(DOMAIN, {}) domain_data[FLUX_LED_DISCOVERY] = await async_discover_devices( hass, STARTUP_SCAN_TIMEOUT ) diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index 306dbc2c25e..cefecf216db 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -2,12 +2,14 @@ from __future__ import annotations import logging -from typing import Any, Final +from typing import Any, Final, cast +from flux_led.const import ATTR_ID, ATTR_IPADDR, ATTR_MODEL, ATTR_MODEL_DESCRIPTION +from flux_led.scanner import FluxLEDDiscovery import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODE, CONF_NAME, CONF_PROTOCOL from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -17,6 +19,7 @@ from homeassistant.helpers.typing import DiscoveryInfoType from . import ( async_discover_device, async_discover_devices, + async_name_from_discovery, async_update_entry_from_discovery, async_wifi_bulb_for_host, ) @@ -27,10 +30,7 @@ from .const import ( DEFAULT_EFFECT_SPEED, DISCOVER_SCAN_TIMEOUT, DOMAIN, - FLUX_HOST, FLUX_LED_EXCEPTIONS, - FLUX_MAC, - FLUX_MODEL, TRANSITION_GRADUAL, TRANSITION_JUMP, TRANSITION_STROBE, @@ -38,6 +38,7 @@ from .const import ( CONF_DEVICE: Final = "device" + _LOGGER = logging.getLogger(__name__) @@ -48,8 +49,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" - self._discovered_devices: dict[str, dict[str, Any]] = {} - self._discovered_device: dict[str, Any] = {} + self._discovered_devices: dict[str, FluxLEDDiscovery] = {} + self._discovered_device: FluxLEDDiscovery | None = None @staticmethod @callback @@ -82,65 +83,89 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): }, ) - async def async_step_dhcp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle discovery via dhcp.""" - self._discovered_device = { - FLUX_HOST: discovery_info[IP_ADDRESS], - FLUX_MODEL: discovery_info[HOSTNAME], - FLUX_MAC: discovery_info[MAC_ADDRESS].replace(":", ""), - } + self._discovered_device = FluxLEDDiscovery( + ipaddr=discovery_info.ip, + model=discovery_info.hostname, + id=discovery_info.macaddress.replace(":", ""), + model_num=None, + version_num=None, + firmware_date=None, + model_info=None, + model_description=None, + ) return await self._async_handle_discovery() async def async_step_discovery( self, discovery_info: DiscoveryInfoType ) -> FlowResult: """Handle discovery.""" - self._discovered_device = discovery_info + self._discovered_device = cast(FluxLEDDiscovery, discovery_info) return await self._async_handle_discovery() async def _async_handle_discovery(self) -> FlowResult: """Handle any discovery.""" device = self._discovered_device - mac = dr.format_mac(device[FLUX_MAC]) - host = device[FLUX_HOST] + assert device is not None + mac_address = device[ATTR_ID] + assert mac_address is not None + mac = dr.format_mac(mac_address) + host = device[ATTR_IPADDR] await self.async_set_unique_id(mac) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) for entry in self._async_current_entries(include_ignore=False): - if entry.data[CONF_HOST] == host and not entry.unique_id: - async_update_entry_from_discovery(self.hass, entry, device) + if entry.data[CONF_HOST] == host: + if not entry.unique_id: + async_update_entry_from_discovery(self.hass, entry, device) return self.async_abort(reason="already_configured") self.context[CONF_HOST] = host for progress in self._async_in_progress(): if progress.get("context", {}).get(CONF_HOST) == host: return self.async_abort(reason="already_in_progress") + if not device[ATTR_MODEL_DESCRIPTION]: + try: + device = await self._async_try_connect( + host, device[ATTR_ID], device[ATTR_MODEL] + ) + except FLUX_LED_EXCEPTIONS: + return self.async_abort(reason="cannot_connect") + else: + if device[ATTR_MODEL_DESCRIPTION]: + self._discovered_device = device return await self.async_step_discovery_confirm() async def async_step_discovery_confirm( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Confirm discovery.""" + assert self._discovered_device is not None + device = self._discovered_device + mac_address = device[ATTR_ID] + assert mac_address is not None if user_input is not None: return self._async_create_entry_from_device(self._discovered_device) self._set_confirm_only() - placeholders = self._discovered_device + placeholders = { + "model": device[ATTR_MODEL_DESCRIPTION] or device[ATTR_MODEL], + "id": mac_address[-6:], + "ipaddr": device[ATTR_IPADDR], + } self.context["title_placeholders"] = placeholders return self.async_show_form( step_id="discovery_confirm", description_placeholders=placeholders ) @callback - def _async_create_entry_from_device(self, device: dict[str, Any]) -> FlowResult: + def _async_create_entry_from_device(self, device: FluxLEDDiscovery) -> FlowResult: """Create a config entry from a device.""" - self._async_abort_entries_match({CONF_HOST: device[FLUX_HOST]}) - if device.get(FLUX_MAC): - name = f"{device[FLUX_MODEL]} {device[FLUX_MAC]}" - else: - name = device[FLUX_HOST] + self._async_abort_entries_match({CONF_HOST: device[ATTR_IPADDR]}) + name = async_name_from_discovery(device) return self.async_create_entry( title=name, data={ - CONF_HOST: device[FLUX_HOST], + CONF_HOST: device[ATTR_IPADDR], CONF_NAME: name, }, ) @@ -154,13 +179,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not (host := user_input[CONF_HOST]): return await self.async_step_pick_device() try: - device = await self._async_try_connect(host) + device = await self._async_try_connect(host, None, None) except FLUX_LED_EXCEPTIONS: errors["base"] = "cannot_connect" else: - if device[FLUX_MAC]: + mac_address = device[ATTR_ID] + if mac_address is not None: await self.async_set_unique_id( - dr.format_mac(device[FLUX_MAC]), raise_on_progress=False + dr.format_mac(mac_address), raise_on_progress=False ) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) return self._async_create_entry_from_device(device) @@ -188,13 +214,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): discovered_devices = await async_discover_devices( self.hass, DISCOVER_SCAN_TIMEOUT ) - self._discovered_devices = { - dr.format_mac(device[FLUX_MAC]): device for device in discovered_devices - } + self._discovered_devices = {} + for device in discovered_devices: + mac_address = device[ATTR_ID] + assert mac_address is not None + self._discovered_devices[dr.format_mac(mac_address)] = device devices_name = { - mac: f"{device[FLUX_MODEL]} {mac} ({device[FLUX_HOST]})" + mac: f"{async_name_from_discovery(device)} ({device[ATTR_IPADDR]})" for mac, device in self._discovered_devices.items() - if mac not in current_unique_ids and device[FLUX_HOST] not in current_hosts + if mac not in current_unique_ids + and device[ATTR_IPADDR] not in current_hosts } # Check if there is at least one device if not devices_name: @@ -204,17 +233,35 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(devices_name)}), ) - async def _async_try_connect(self, host: str) -> dict[str, Any]: + async def _async_try_connect( + self, host: str, mac_address: str | None, model: str | None + ) -> FluxLEDDiscovery: """Try to connect.""" self._async_abort_entries_match({CONF_HOST: host}) - if device := await async_discover_device(self.hass, host): + if (device := await async_discover_device(self.hass, host)) and device[ + ATTR_MODEL_DESCRIPTION + ]: + # Older models do not return enough information + # to build the model description via UDP so we have + # to fallback to making a tcp connection to avoid + # identifying the device as the chip model number + # AKA `HF-LPB100-ZJ200` return device bulb = async_wifi_bulb_for_host(host) try: await bulb.async_setup(lambda: None) finally: await bulb.async_stop() - return {FLUX_MAC: None, FLUX_MODEL: None, FLUX_HOST: host} + return FluxLEDDiscovery( + ipaddr=host, + model=model, + id=mac_address, + model_num=bulb.model_num, + version_num=bulb.version_num, + firmware_date=None, + model_info=None, + model_description=bulb.model_data.description, + ) class OptionsFlow(config_entries.OptionsFlow): diff --git a/homeassistant/components/flux_led/const.py b/homeassistant/components/flux_led/const.py index e7f9509c54b..88c50402a05 100644 --- a/homeassistant/components/flux_led/const.py +++ b/homeassistant/components/flux_led/const.py @@ -4,8 +4,31 @@ import asyncio import socket from typing import Final +from flux_led.const import ( + COLOR_MODE_CCT as FLUX_COLOR_MODE_CCT, + COLOR_MODE_RGB as FLUX_COLOR_MODE_RGB, + COLOR_MODE_RGBW as FLUX_COLOR_MODE_RGBW, + COLOR_MODE_RGBWW as FLUX_COLOR_MODE_RGBWW, +) + +from homeassistant.components.light import ( + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_RGB, + COLOR_MODE_RGBW, + COLOR_MODE_RGBWW, +) + DOMAIN: Final = "flux_led" + +FLUX_COLOR_MODE_TO_HASS: Final = { + FLUX_COLOR_MODE_RGB: COLOR_MODE_RGB, + FLUX_COLOR_MODE_RGBW: COLOR_MODE_RGBW, + FLUX_COLOR_MODE_RGBWW: COLOR_MODE_RGBWW, + FLUX_COLOR_MODE_CCT: COLOR_MODE_COLOR_TEMP, +} + + API: Final = "flux_api" SIGNAL_STATE_UPDATED = "flux_led_{}_state_updated" @@ -16,6 +39,7 @@ DEFAULT_SCAN_INTERVAL: Final = 5 DEFAULT_EFFECT_SPEED: Final = 50 FLUX_LED_DISCOVERY: Final = "flux_led_discovery" +FLUX_LED_DISCOVERY_LOCK: Final = "flux_led_discovery_lock" FLUX_LED_EXCEPTIONS: Final = ( asyncio.TimeoutError, @@ -48,10 +72,9 @@ CONF_SPEED_PCT: Final = "speed_pct" CONF_TRANSITION: Final = "transition" +EFFECT_SPEED_SUPPORT_MODES: Final = {COLOR_MODE_RGB, COLOR_MODE_RGBW, COLOR_MODE_RGBWW} + + CONF_CUSTOM_EFFECT_COLORS: Final = "custom_effect_colors" CONF_CUSTOM_EFFECT_SPEED_PCT: Final = "custom_effect_speed_pct" CONF_CUSTOM_EFFECT_TRANSITION: Final = "custom_effect_transition" - -FLUX_HOST: Final = "ipaddr" -FLUX_MAC: Final = "id" -FLUX_MODEL: Final = "model" diff --git a/homeassistant/components/flux_led/entity.py b/homeassistant/components/flux_led/entity.py index 4183ccc14cd..f4425f3b2a2 100644 --- a/homeassistant/components/flux_led/entity.py +++ b/homeassistant/components/flux_led/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations from abc import abstractmethod -from typing import Any, cast +from typing import Any from flux_led.aiodevice import AIOWifiLedBulb @@ -42,32 +42,11 @@ class FluxEntity(CoordinatorEntity): sw_version=str(self._device.version_num), ) - @property - def is_on(self) -> bool: - """Return true if device is on.""" - return cast(bool, self._device.is_on) - @property def extra_state_attributes(self) -> dict[str, str]: """Return the attributes.""" return {"ip_address": self._device.ipaddr} - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the specified device on.""" - await self._async_turn_on(**kwargs) - self.async_write_ha_state() - await self.coordinator.async_request_refresh() - - @abstractmethod - async def _async_turn_on(self, **kwargs: Any) -> None: - """Turn the specified device on.""" - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the specified device off.""" - await self._device.async_turn_off() - self.async_write_ha_state() - await self.coordinator.async_request_refresh() - @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" @@ -85,3 +64,28 @@ class FluxEntity(CoordinatorEntity): ) ) await super().async_added_to_hass() + + +class FluxOnOffEntity(FluxEntity): + """Representation of a Flux entity that supports on/off.""" + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self._device.is_on + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the specified device on.""" + await self._async_turn_on(**kwargs) + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + + @abstractmethod + async def _async_turn_on(self, **kwargs: Any) -> None: + """Turn the specified device on.""" + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the specified device off.""" + await self._device.async_turn_off() + self.async_write_ha_state() + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index c632492ea81..d364d8b9581 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -3,22 +3,14 @@ from __future__ import annotations import ast import logging -import random -from typing import Any, Final, cast +from typing import Any, Final -from flux_led.const import ( - COLOR_MODE_CCT as FLUX_COLOR_MODE_CCT, - COLOR_MODE_DIM as FLUX_COLOR_MODE_DIM, - COLOR_MODE_RGB as FLUX_COLOR_MODE_RGB, - COLOR_MODE_RGBW as FLUX_COLOR_MODE_RGBW, - COLOR_MODE_RGBWW as FLUX_COLOR_MODE_RGBWW, -) +from flux_led.const import ATTR_ID, ATTR_IPADDR from flux_led.utils import ( color_temp_to_white_levels, rgbcw_brightness, rgbcw_to_rgbwc, rgbw_brightness, - rgbww_brightness, ) import voluptuous as vol @@ -31,15 +23,7 @@ from homeassistant.components.light import ( ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, ATTR_WHITE, - COLOR_MODE_BRIGHTNESS, - COLOR_MODE_COLOR_TEMP, - COLOR_MODE_ONOFF, - COLOR_MODE_RGB, - COLOR_MODE_RGBW, COLOR_MODE_RGBWW, - COLOR_MODE_WHITE, - EFFECT_COLORLOOP, - EFFECT_RANDOM, PLATFORM_SCHEMA, SUPPORT_EFFECT, SUPPORT_TRANSITION, @@ -54,15 +38,13 @@ from homeassistant.const import ( CONF_NAME, CONF_PROTOCOL, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.color import ( - color_hs_to_RGB, - color_RGB_to_hs, color_temperature_kelvin_to_mired, color_temperature_mired_to_kelvin, ) @@ -79,9 +61,7 @@ from .const import ( CONF_TRANSITION, DEFAULT_EFFECT_SPEED, DOMAIN, - FLUX_HOST, FLUX_LED_DISCOVERY, - FLUX_MAC, MODE_AUTO, MODE_RGB, MODE_RGBW, @@ -90,75 +70,26 @@ from .const import ( TRANSITION_JUMP, TRANSITION_STROBE, ) -from .entity import FluxEntity +from .entity import FluxOnOffEntity +from .util import _effect_brightness, _flux_color_mode_to_hass, _hass_color_modes _LOGGER = logging.getLogger(__name__) -SUPPORT_FLUX_LED: Final = SUPPORT_TRANSITION - - -FLUX_COLOR_MODE_TO_HASS: Final = { - FLUX_COLOR_MODE_RGB: COLOR_MODE_RGB, - FLUX_COLOR_MODE_RGBW: COLOR_MODE_RGBW, - FLUX_COLOR_MODE_RGBWW: COLOR_MODE_RGBWW, - FLUX_COLOR_MODE_CCT: COLOR_MODE_COLOR_TEMP, +MODE_ATTRS = { + ATTR_EFFECT, + ATTR_COLOR_TEMP, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, + ATTR_WHITE, } -EFFECT_SUPPORT_MODES = {COLOR_MODE_RGB, COLOR_MODE_RGBW, COLOR_MODE_RGBWW} - # Constant color temp values for 2 flux_led special modes # Warm-white and Cool-white modes COLOR_TEMP_WARM_VS_COLD_WHITE_CUT_OFF: Final = 285 -# List of supported effects which aren't already declared in LIGHT -EFFECT_RED_FADE: Final = "red_fade" -EFFECT_GREEN_FADE: Final = "green_fade" -EFFECT_BLUE_FADE: Final = "blue_fade" -EFFECT_YELLOW_FADE: Final = "yellow_fade" -EFFECT_CYAN_FADE: Final = "cyan_fade" -EFFECT_PURPLE_FADE: Final = "purple_fade" -EFFECT_WHITE_FADE: Final = "white_fade" -EFFECT_RED_GREEN_CROSS_FADE: Final = "rg_cross_fade" -EFFECT_RED_BLUE_CROSS_FADE: Final = "rb_cross_fade" -EFFECT_GREEN_BLUE_CROSS_FADE: Final = "gb_cross_fade" -EFFECT_COLORSTROBE: Final = "colorstrobe" -EFFECT_RED_STROBE: Final = "red_strobe" -EFFECT_GREEN_STROBE: Final = "green_strobe" -EFFECT_BLUE_STROBE: Final = "blue_strobe" -EFFECT_YELLOW_STROBE: Final = "yellow_strobe" -EFFECT_CYAN_STROBE: Final = "cyan_strobe" -EFFECT_PURPLE_STROBE: Final = "purple_strobe" -EFFECT_WHITE_STROBE: Final = "white_strobe" -EFFECT_COLORJUMP: Final = "colorjump" EFFECT_CUSTOM: Final = "custom" -EFFECT_MAP: Final = { - EFFECT_COLORLOOP: 0x25, - EFFECT_RED_FADE: 0x26, - EFFECT_GREEN_FADE: 0x27, - EFFECT_BLUE_FADE: 0x28, - EFFECT_YELLOW_FADE: 0x29, - EFFECT_CYAN_FADE: 0x2A, - EFFECT_PURPLE_FADE: 0x2B, - EFFECT_WHITE_FADE: 0x2C, - EFFECT_RED_GREEN_CROSS_FADE: 0x2D, - EFFECT_RED_BLUE_CROSS_FADE: 0x2E, - EFFECT_GREEN_BLUE_CROSS_FADE: 0x2F, - EFFECT_COLORSTROBE: 0x30, - EFFECT_RED_STROBE: 0x31, - EFFECT_GREEN_STROBE: 0x32, - EFFECT_BLUE_STROBE: 0x33, - EFFECT_YELLOW_STROBE: 0x34, - EFFECT_CYAN_STROBE: 0x35, - EFFECT_PURPLE_STROBE: 0x36, - EFFECT_WHITE_STROBE: 0x37, - EFFECT_COLORJUMP: 0x38, -} -EFFECT_ID_NAME: Final = {v: k for k, v in EFFECT_MAP.items()} -EFFECT_CUSTOM_CODE: Final = 0x60 - -FLUX_EFFECT_LIST: Final = sorted(EFFECT_MAP) + [EFFECT_RANDOM] - SERVICE_CUSTOM_EFFECT: Final = "set_custom_effect" CUSTOM_EFFECT_DICT: Final = { @@ -196,15 +127,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def _flux_color_mode_to_hass(flux_color_mode: str, flux_color_modes: set[str]) -> str: - """Map the flux color mode to Home Assistant color mode.""" - if flux_color_mode == FLUX_COLOR_MODE_DIM: - if len(flux_color_modes) > 1: - return COLOR_MODE_WHITE - return COLOR_MODE_BRIGHTNESS - return FLUX_COLOR_MODE_TO_HASS.get(flux_color_mode, COLOR_MODE_ONOFF) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -214,7 +136,7 @@ async def async_setup_platform( """Set up the flux led platform.""" domain_data = hass.data[DOMAIN] discovered_mac_by_host = { - device[FLUX_HOST]: device[FLUX_MAC] + device[ATTR_IPADDR]: device[ATTR_ID] for device in domain_data[FLUX_LED_DISCOVERY] } for host, device_config in config.get(CONF_DEVICES, {}).items(): @@ -290,9 +212,11 @@ async def async_setup_entry( ) -class FluxLight(FluxEntity, CoordinatorEntity, LightEntity): +class FluxLight(FluxOnOffEntity, CoordinatorEntity, LightEntity): """Representation of a Flux light.""" + _attr_supported_features = SUPPORT_TRANSITION | SUPPORT_EFFECT + def __init__( self, coordinator: FluxLedUpdateCoordinator, @@ -304,20 +228,15 @@ class FluxLight(FluxEntity, CoordinatorEntity, LightEntity): ) -> None: """Initialize the light.""" super().__init__(coordinator, unique_id, name) - self._attr_supported_features = SUPPORT_FLUX_LED self._attr_min_mireds = ( color_temperature_kelvin_to_mired(self._device.max_temp) + 1 ) # for rounding self._attr_max_mireds = color_temperature_kelvin_to_mired(self._device.min_temp) - self._attr_supported_color_modes = { - _flux_color_mode_to_hass(mode, self._device.color_modes) - for mode in self._device.color_modes - } - if self._attr_supported_color_modes.intersection(EFFECT_SUPPORT_MODES): - self._attr_supported_features |= SUPPORT_EFFECT - self._attr_effect_list = FLUX_EFFECT_LIST - if custom_effect_colors: - self._attr_effect_list = [*FLUX_EFFECT_LIST, EFFECT_CUSTOM] + self._attr_supported_color_modes = _hass_color_modes(self._device) + custom_effects: list[str] = [] + if custom_effect_colors: + custom_effects.append(EFFECT_CUSTOM) + self._attr_effect_list = [*self._device.effect_list, *custom_effects] self._custom_effect_colors = custom_effect_colors self._custom_effect_speed_pct = custom_effect_speed_pct self._custom_effect_transition = custom_effect_transition @@ -325,7 +244,7 @@ class FluxLight(FluxEntity, CoordinatorEntity, LightEntity): @property def brightness(self) -> int: """Return the brightness of this light between 0..255.""" - return cast(int, self._device.brightness) + return self._device.brightness @property def color_temp(self) -> int: @@ -335,29 +254,17 @@ class FluxLight(FluxEntity, CoordinatorEntity, LightEntity): @property def rgb_color(self) -> tuple[int, int, int]: """Return the rgb color value.""" - # Note that we call color_RGB_to_hs and not color_RGB_to_hsv - # to get the unscaled value since this is what the frontend wants - # https://github.com/home-assistant/frontend/blob/e797c017614797bb11671496d6bd65863de22063/src/dialogs/more-info/controls/more-info-light.ts#L263 - rgb: tuple[int, int, int] = color_hs_to_RGB(*color_RGB_to_hs(*self._device.rgb)) - return rgb + return self._device.rgb_unscaled @property def rgbw_color(self) -> tuple[int, int, int, int]: """Return the rgbw color value.""" - rgbw: tuple[int, int, int, int] = self._device.rgbw - return rgbw + return self._device.rgbw @property def rgbww_color(self) -> tuple[int, int, int, int, int]: """Return the rgbww aka rgbcw color value.""" - rgbcw: tuple[int, int, int, int, int] = self._device.rgbcw - return rgbcw - - @property - def rgbwc_color(self) -> tuple[int, int, int, int, int]: - """Return the rgbwc color value.""" - rgbwc: tuple[int, int, int, int, int] = self._device.rgbww - return rgbwc + return self._device.rgbcw @property def color_mode(self) -> str: @@ -369,31 +276,60 @@ class FluxLight(FluxEntity, CoordinatorEntity, LightEntity): @property def effect(self) -> str | None: """Return the current effect.""" - if (current_mode := self._device.preset_pattern_num) == EFFECT_CUSTOM_CODE: - return EFFECT_CUSTOM - return EFFECT_ID_NAME.get(current_mode) + return self._device.effect async def _async_turn_on(self, **kwargs: Any) -> None: """Turn the specified or all lights on.""" - if (brightness := kwargs.get(ATTR_BRIGHTNESS)) is None: - brightness = self.brightness - - if not self.is_on: - await self._device.async_turn_on() + if self._device.requires_turn_on or not kwargs: + if not self.is_on: + await self._device.async_turn_on() if not kwargs: return + + if MODE_ATTRS.intersection(kwargs): + await self._async_set_mode(**kwargs) + return + await self._device.async_set_brightness(self._async_brightness(**kwargs)) + + async def _async_set_effect(self, effect: str, brightness: int) -> None: + """Set an effect.""" + # Custom effect + if effect == EFFECT_CUSTOM: + if self._custom_effect_colors: + await self._device.async_set_custom_pattern( + self._custom_effect_colors, + self._custom_effect_speed_pct, + self._custom_effect_transition, + ) + return + await self._device.async_set_effect( + effect, + self._device.speed or DEFAULT_EFFECT_SPEED, + _effect_brightness(brightness), + ) + + @callback + def _async_brightness(self, **kwargs: Any) -> int: + """Determine brightness from kwargs or current value.""" + if (brightness := kwargs.get(ATTR_BRIGHTNESS)) is None: + brightness = self.brightness + if not brightness: # If the brightness was previously 0, the light # will not turn on unless brightness is at least 1 - if not brightness: - brightness = 1 - elif not brightness: # If the device was on and brightness was not # set, it means it was masked by an effect - brightness = 255 + brightness = 255 if self.is_on else 1 + return brightness + async def _async_set_mode(self, **kwargs: Any) -> None: + """Set an effect or color mode.""" + brightness = self._async_brightness(**kwargs) + # Handle switch to Effect Mode + if effect := kwargs.get(ATTR_EFFECT): + await self._async_set_effect(effect, brightness) + return # Handle switch to CCT Color Mode - if ATTR_COLOR_TEMP in kwargs: - color_temp_mired = kwargs[ATTR_COLOR_TEMP] + if color_temp_mired := kwargs.get(ATTR_COLOR_TEMP): color_temp_kelvin = color_temperature_mired_to_kelvin(color_temp_mired) if self.color_mode != COLOR_MODE_RGBWW: await self._device.async_set_white_temp(color_temp_kelvin, brightness) @@ -405,84 +341,31 @@ class FluxLight(FluxEntity, CoordinatorEntity, LightEntity): brightness = kwargs.get( ATTR_BRIGHTNESS, self._device.getWhiteTemperature()[1] ) - cold, warm = color_temp_to_white_levels(color_temp_kelvin, brightness) + channels = color_temp_to_white_levels(color_temp_kelvin, brightness) + warm = channels.warm_white + cold = channels.cool_white await self._device.async_set_levels(r=0, b=0, g=0, w=warm, w2=cold) return # Handle switch to RGB Color Mode - if ATTR_RGB_COLOR in kwargs: - await self._device.async_set_levels( - *kwargs[ATTR_RGB_COLOR], brightness=brightness - ) + if rgb := kwargs.get(ATTR_RGB_COLOR): + red, green, blue = rgb + await self._device.async_set_levels(red, green, blue, brightness=brightness) return # Handle switch to RGBW Color Mode - if ATTR_RGBW_COLOR in kwargs: + if rgbw := kwargs.get(ATTR_RGBW_COLOR): if ATTR_BRIGHTNESS in kwargs: - rgbw = rgbw_brightness(kwargs[ATTR_RGBW_COLOR], brightness) - else: - rgbw = kwargs[ATTR_RGBW_COLOR] + rgbw = rgbw_brightness(rgbw, brightness) await self._device.async_set_levels(*rgbw) return # Handle switch to RGBWW Color Mode - if ATTR_RGBWW_COLOR in kwargs: + if rgbcw := kwargs.get(ATTR_RGBWW_COLOR): if ATTR_BRIGHTNESS in kwargs: rgbcw = rgbcw_brightness(kwargs[ATTR_RGBWW_COLOR], brightness) - else: - rgbcw = kwargs[ATTR_RGBWW_COLOR] await self._device.async_set_levels(*rgbcw_to_rgbwc(rgbcw)) return - if ATTR_WHITE in kwargs: - await self._device.async_set_levels(w=kwargs[ATTR_WHITE]) + if (white := kwargs.get(ATTR_WHITE)) is not None: + await self._device.async_set_levels(w=white) return - if ATTR_EFFECT in kwargs: - effect = kwargs[ATTR_EFFECT] - # Random color effect - if effect == EFFECT_RANDOM: - await self._device.async_set_levels( - random.randint(0, 255), - random.randint(0, 255), - random.randint(0, 255), - ) - return - # Custom effect - if effect == EFFECT_CUSTOM: - if self._custom_effect_colors: - await self._device.async_set_custom_pattern( - self._custom_effect_colors, - self._custom_effect_speed_pct, - self._custom_effect_transition, - ) - return - # Effect selection - if effect in EFFECT_MAP: - await self._device.async_set_preset_pattern( - EFFECT_MAP[effect], DEFAULT_EFFECT_SPEED - ) - return - raise ValueError(f"Unknown effect {effect}") - # Handle brightness adjustment in CCT Color Mode - if self.color_mode == COLOR_MODE_COLOR_TEMP: - await self._device.async_set_white_temp(self._device.color_temp, brightness) - return - # Handle brightness adjustment in RGB Color Mode - if self.color_mode == COLOR_MODE_RGB: - await self._device.async_set_levels(*self.rgb_color, brightness=brightness) - return - # Handle brightness adjustment in RGBW Color Mode - if self.color_mode == COLOR_MODE_RGBW: - await self._device.async_set_levels( - *rgbw_brightness(self.rgbw_color, brightness) - ) - return - # Handle brightness adjustment in RGBWW Color Mode - if self.color_mode == COLOR_MODE_RGBWW: - rgbwc = self.rgbwc_color - await self._device.async_set_levels(*rgbww_brightness(rgbwc, brightness)) - return - # Handle Brightness Only Color Mode - if self.color_mode in {COLOR_MODE_WHITE, COLOR_MODE_BRIGHTNESS}: - await self._device.async_set_levels(w=brightness) - return - raise ValueError(f"Unsupported color mode {self.color_mode}") async def async_set_custom_effect( self, colors: list[tuple[int, int, int]], speed_pct: int, transition: str diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index cd37897af67..191cdef7c38 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -3,7 +3,7 @@ "name": "Flux LED/MagicHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/flux_led", - "requirements": ["flux_led==0.24.24"], + "requirements": ["flux_led==0.26.7"], "quality_scale": "platinum", "codeowners": ["@icemanch"], "iot_class": "local_push", @@ -24,48 +24,16 @@ "macaddress": "B4E842*", "hostname": "[ba][lk]*" }, + { + "macaddress": "F0FE6B*", + "hostname": "[ba][lk]*" + }, { "macaddress": "8CCE4E*", "hostname": "lwip*" }, { - "hostname": "zengge_0[6789b]_*" - }, - { - "hostname": "zengge_1[06789abc]_*" - }, - { - "hostname": "zengge_2[15]_*" - }, - { - "hostname": "zengge_3[35]_*" - }, - { - "hostname": "zengge_4[14]_*" - }, - { - "hostname": "zengge_5[24]_*" - }, - { - "hostname": "zengge_62_*" - }, - { - "hostname": "zengge_81_*" - }, - { - "hostname": "zengge_0[0e]_*" - }, - { - "hostname": "zengge_9[34567]_*" - }, - { - "hostname": "zengge_a[123]_*" - }, - { - "hostname": "zengge_d1_*" - }, - { - "hostname": "zengge_e[12]_*" + "hostname": "zengge_[0-9a-f][0-9a-f]_*" }, { "macaddress": "C82E47*", diff --git a/homeassistant/components/flux_led/number.py b/homeassistant/components/flux_led/number.py new file mode 100644 index 00000000000..28007181e5c --- /dev/null +++ b/homeassistant/components/flux_led/number.py @@ -0,0 +1,80 @@ +"""Support for LED numbers.""" +from __future__ import annotations + +from typing import cast + +from homeassistant import config_entries +from homeassistant.components.number import NumberEntity, NumberMode +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import FluxLedUpdateCoordinator +from .const import DOMAIN, EFFECT_SPEED_SUPPORT_MODES +from .entity import FluxEntity +from .util import _effect_brightness, _hass_color_modes + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Flux lights.""" + coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + color_modes = _hass_color_modes(coordinator.device) + if not color_modes.intersection(EFFECT_SPEED_SUPPORT_MODES): + return + + async_add_entities( + [ + FluxNumber( + coordinator, + entry.unique_id, + entry.data[CONF_NAME], + ) + ] + ) + + +class FluxNumber(FluxEntity, CoordinatorEntity, NumberEntity): + """Defines a flux_led speed number.""" + + _attr_min_value = 1 + _attr_max_value = 100 + _attr_step = 1 + _attr_mode = NumberMode.SLIDER + _attr_icon = "mdi:speedometer" + + def __init__( + self, + coordinator: FluxLedUpdateCoordinator, + unique_id: str | None, + name: str, + ) -> None: + """Initialize the flux number.""" + super().__init__(coordinator, unique_id, name) + self._attr_name = f"{name} Effect Speed" + + @property + def value(self) -> float: + """Return the effect speed.""" + return cast(float, self._device.speed) + + async def async_set_value(self, value: float) -> None: + """Set the flux speed value.""" + current_effect = self._device.effect + new_speed = int(value) + if not current_effect: + raise HomeAssistantError( + "Speed can only be adjusted when an effect is active" + ) + if not self._device.speed_adjust_off and not self._device.is_on: + raise HomeAssistantError("Speed can only be adjusted when the light is on") + await self._device.async_set_effect( + current_effect, new_speed, _effect_brightness(self._device.brightness) + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/flux_led/switch.py b/homeassistant/components/flux_led/switch.py index 0ca7a771c78..d022acc1c74 100644 --- a/homeassistant/components/flux_led/switch.py +++ b/homeassistant/components/flux_led/switch.py @@ -12,7 +12,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import FluxLedUpdateCoordinator from .const import DOMAIN -from .entity import FluxEntity +from .entity import FluxOnOffEntity async def async_setup_entry( @@ -33,7 +33,7 @@ async def async_setup_entry( ) -class FluxSwitch(FluxEntity, CoordinatorEntity, SwitchEntity): +class FluxSwitch(FluxOnOffEntity, CoordinatorEntity, SwitchEntity): """Representation of a Flux switch.""" async def _async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/flux_led/translations/id.json b/homeassistant/components/flux_led/translations/id.json new file mode 100644 index 00000000000..84c993365ac --- /dev/null +++ b/homeassistant/components/flux_led/translations/id.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "already_in_progress": "Alur konfigurasi sedang berlangsung", + "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan" + }, + "error": { + "cannot_connect": "Gagal terhubung" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "Ingin menyiapkan {model} {id} ({ipaddr})?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Jika host dibiarkan kosong, proses penemuan akan digunakan untuk menemukan perangkat." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Efek Khusus: Daftar berisi 1 hingga 16 warna [R,G,B]. Contoh: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Efek Khusus: Kecepatan dalam persen untuk efek perubahan warna.", + "custom_effect_transition": "Efek Khusus: Jenis transisi antara warna.", + "mode": "Mode kecerahan yang dipilih." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/ja.json b/homeassistant/components/flux_led/translations/ja.json new file mode 100644 index 00000000000..3b6a34d7e5b --- /dev/null +++ b/homeassistant/components/flux_led/translations/ja.json @@ -0,0 +1,36 @@ +{ + "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" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{model} {id} ({ipaddr})", + "step": { + "discovery_confirm": { + "description": "{model} {id} ({ipaddr}) \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "\u30db\u30b9\u30c8\u3092\u7a7a\u306b\u3057\u3066\u304a\u304f\u3068\u3001\u30c7\u30a3\u30b9\u30ab\u30d0\u30ea\u30fc\u3092\u4f7f\u3063\u3066\u30c7\u30d0\u30a4\u30b9\u3092\u691c\u7d22\u3057\u307e\u3059\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "\u30ab\u30b9\u30bf\u30e0\u30a8\u30d5\u30a7\u30af\u30c8: 1\uff5e16\u8272[R,G,B]\u306e\u30ea\u30b9\u30c8\u3002\u4f8b: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "\u30ab\u30b9\u30bf\u30e0\u30a8\u30d5\u30a7\u30af\u30c8: \u8272\u3092\u5207\u308a\u66ff\u3048\u308b\u30a8\u30d5\u30a7\u30af\u30c8\u306e\u901f\u5ea6\u3092\u30d1\u30fc\u30bb\u30f3\u30c6\u30fc\u30b8\u3067\u8868\u793a\u3002", + "custom_effect_transition": "\u30ab\u30b9\u30bf\u30e0\u30a8\u30d5\u30a7\u30af\u30c8: \u8272\u3068\u8272\u306e\u9593\u3067\u306e\u9077\u79fb(\u30c8\u30e9\u30f3\u30b8\u30b7\u30e7\u30f3)\u306e\u7a2e\u985e\u3002", + "mode": "\u9078\u629e\u3057\u305f\u660e\u308b\u3055\u30e2\u30fc\u30c9\u3002" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/pl.json b/homeassistant/components/flux_led/translations/pl.json index 2e749564860..14cf6055a74 100644 --- a/homeassistant/components/flux_led/translations/pl.json +++ b/homeassistant/components/flux_led/translations/pl.json @@ -10,9 +10,25 @@ }, "flow_title": "{model} {id} ({ipaddr})", "step": { + "discovery_confirm": { + "description": "Czy chcesz skonfigurowa\u0107 {model} {id} ({ipaddr})?" + }, "user": { "data": { "host": "Nazwa hosta lub adres IP" + }, + "description": "Je\u015bli nie podasz IP lub nazwy hosta, zostanie u\u017cyte wykrywanie do odnalezienia urz\u0105dze\u0144." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "Efekt niestandardowy: Lista kolor\u00f3w od 1 do 16 [R,G,B]. Przyk\u0142ad: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "Efekt niestandardowy: szybko\u015b\u0107 efektu zmiany kolor\u00f3w (w procentach).", + "custom_effect_transition": "Efekt niestandardowy: rodzaj przej\u015bcia mi\u0119dzy kolorami.", + "mode": "Wybrany tryb jasno\u015bci." } } } diff --git a/homeassistant/components/flux_led/translations/sl.json b/homeassistant/components/flux_led/translations/sl.json new file mode 100644 index 00000000000..67c1d624911 --- /dev/null +++ b/homeassistant/components/flux_led/translations/sl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana", + "no_devices_found": "V omre\u017eju ni mogo\u010de najti nobene naprave" + }, + "error": { + "cannot_connect": "Povezava ni uspela" + }, + "step": { + "user": { + "data": { + "host": "Gostitelj" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_speed_pct": "U\u010dinek po meri: Hitrost v odstotkih za u\u010dinek, ki spreminja barve.", + "custom_effect_transition": "U\u010dinek po meri: Vrsta prehoda med barvami.", + "mode": "Izbrani na\u010din svetlosti." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux_led/translations/tr.json b/homeassistant/components/flux_led/translations/tr.json index 3be9b8e3c26..5ec66bfaec5 100644 --- a/homeassistant/components/flux_led/translations/tr.json +++ b/homeassistant/components/flux_led/translations/tr.json @@ -5,10 +5,30 @@ "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", "no_devices_found": "A\u011fda cihaz bulunamad\u0131" }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "flow_title": "{model} {id} ({ipaddr})", "step": { + "discovery_confirm": { + "description": "{model} {id} ( {ipaddr} ) kurulumu yapmak istiyor musunuz?" + }, "user": { "data": { - "host": "Ana Bilgisayar" + "host": "Ana bilgisayar" + }, + "description": "Ana bilgisayar\u0131 bo\u015f b\u0131rak\u0131rsan\u0131z, cihazlar\u0131 bulmak i\u00e7in ke\u015fif kullan\u0131lacakt\u0131r." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "custom_effect_colors": "\u00d6zel Efekt: 1 ila 16 [R,G,B] renk listesi. \u00d6rnek: [255,0,255],[60,128,0]", + "custom_effect_speed_pct": "\u00d6zel Efekt: Renkleri de\u011fi\u015ftiren efekt i\u00e7in y\u00fczde cinsinden h\u0131z.", + "custom_effect_transition": "\u00d6zel Efekt: Renkler aras\u0131ndaki ge\u00e7i\u015f t\u00fcr\u00fc.", + "mode": "Se\u00e7ilen parlakl\u0131k modu." } } } diff --git a/homeassistant/components/flux_led/util.py b/homeassistant/components/flux_led/util.py new file mode 100644 index 00000000000..774ae1aaa53 --- /dev/null +++ b/homeassistant/components/flux_led/util.py @@ -0,0 +1,36 @@ +"""Utils for FluxLED/MagicHome.""" +from __future__ import annotations + +from flux_led.aio import AIOWifiLedBulb +from flux_led.const import COLOR_MODE_DIM as FLUX_COLOR_MODE_DIM + +from homeassistant.components.light import ( + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_ONOFF, + COLOR_MODE_WHITE, +) + +from .const import FLUX_COLOR_MODE_TO_HASS + + +def _hass_color_modes(device: AIOWifiLedBulb) -> set[str]: + color_modes = device.color_modes + return {_flux_color_mode_to_hass(mode, color_modes) for mode in color_modes} + + +def _flux_color_mode_to_hass( + flux_color_mode: str | None, flux_color_modes: set[str] +) -> str: + """Map the flux color mode to Home Assistant color mode.""" + if flux_color_mode is None: + return COLOR_MODE_ONOFF + if flux_color_mode == FLUX_COLOR_MODE_DIM: + if len(flux_color_modes) > 1: + return COLOR_MODE_WHITE + return COLOR_MODE_BRIGHTNESS + return FLUX_COLOR_MODE_TO_HASS.get(flux_color_mode, COLOR_MODE_ONOFF) + + +def _effect_brightness(brightness: int) -> int: + """Convert hass brightness to effect brightness.""" + return round(brightness / 255 * 100) diff --git a/homeassistant/components/forecast_solar/const.py b/homeassistant/components/forecast_solar/const.py index 1ec8c3e4df1..97caba531e6 100644 --- a/homeassistant/components/forecast_solar/const.py +++ b/homeassistant/components/forecast_solar/const.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import timedelta -from typing import Final from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT from homeassistant.const import ( @@ -21,7 +20,6 @@ CONF_DECLINATION = "declination" CONF_AZIMUTH = "azimuth" CONF_MODULES_POWER = "modules power" CONF_DAMPING = "damping" -ENTRY_TYPE_SERVICE: Final = "service" SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( ForecastSolarSensorEntityDescription( diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index cd672311c52..6088da6e645 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -6,6 +6,7 @@ from datetime import datetime from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorEntity from homeassistant.config_entries import ConfigEntry 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.typing import StateType @@ -14,7 +15,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import DOMAIN, ENTRY_TYPE_SERVICE, SENSORS +from .const import DOMAIN, SENSORS from .models import ForecastSolarSensorEntityDescription @@ -53,15 +54,16 @@ class ForecastSolarSensorEntity(CoordinatorEntity, SensorEntity): self._attr_unique_id = f"{entry_id}_{entity_description.key}" self._attr_device_info = DeviceInfo( - entry_type=ENTRY_TYPE_SERVICE, + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry_id)}, manufacturer="Forecast.Solar", model=coordinator.data.account_type.value, name="Solar Production Forecast", + configuration_url="https://forecast.solar", ) @property - def native_value(self) -> StateType: + def native_value(self) -> datetime | StateType: """Return the state of the sensor.""" if self.entity_description.state is None: state: StateType | datetime = getattr( @@ -70,6 +72,4 @@ class ForecastSolarSensorEntity(CoordinatorEntity, SensorEntity): else: state = self.entity_description.state(self.coordinator.data) - if isinstance(state, datetime): - return state.isoformat() return state diff --git a/homeassistant/components/forecast_solar/translations/id.json b/homeassistant/components/forecast_solar/translations/id.json index 130f66db7f5..27ef16e0266 100644 --- a/homeassistant/components/forecast_solar/translations/id.json +++ b/homeassistant/components/forecast_solar/translations/id.json @@ -3,10 +3,28 @@ "step": { "user": { "data": { + "azimuth": "Azimuth (360 derajat, 0 = Utara, 90 = Timur, 180 = Selatan, 270 = Barat)", + "declination": "Deklinasi (0 = Horizontal, 90 = Vertikal)", "latitude": "Lintang", "longitude": "Bujur", + "modules power": "Total daya puncak modul surya Anda dalam Watt", "name": "Nama" - } + }, + "description": "Isi data panel surya Anda. Rujuk ke dokumentasi jika bidang isian tidak jelas." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Kunci API Forecast.Solar (opsional)", + "azimuth": "Azimuth (360 derajat, 0 = Utara, 90 = Timur, 180 = Selatan, 270 = Barat)", + "damping": "Faktor redaman: menyesuaikan hasil di pagi dan sore hari", + "declination": "Deklinasi (0 = Horizontal, 90 = Vertikal)", + "modules power": "Total daya puncak modul surya Anda dalam Watt" + }, + "description": "Nilai-nilai ini memungkinkan penyesuaian hasil Solar.Forecast. Rujuk ke dokumentasi jika bidang isian tidak jelas." } } } diff --git a/homeassistant/components/forecast_solar/translations/ja.json b/homeassistant/components/forecast_solar/translations/ja.json new file mode 100644 index 00000000000..62090376bed --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/ja.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "\u65b9\u4f4d\u89d2(360\u5ea6\u30010=\u5317\u300190=\u6771\u3001180=\u5357\u3001270=\u897f)", + "declination": "\u504f\u89d2(0\uff1d\u6c34\u5e73\u300190\uff1d\u5782\u76f4)", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "modules power": "\u30bd\u30fc\u30e9\u30fc\u30e2\u30b8\u30e5\u30fc\u30eb\u306e\u7dcf\u30ef\u30c3\u30c8\u30d4\u30fc\u30af\u96fb\u529b", + "name": "\u540d\u524d" + }, + "description": "\u30bd\u30fc\u30e9\u30fc\u30d1\u30cd\u30eb\u306e\u30c7\u30fc\u30bf\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u4e0d\u660e\u306a\u5834\u5408\u306f\u3001\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Forecast.Solar API\u30ad\u30fc(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "azimuth": "\u65b9\u4f4d\u89d2(360\u5ea6\u30010=\u5317\u300190=\u6771\u3001180=\u5357\u3001270=\u897f)", + "damping": "\u6e1b\u8870\u4fc2\u6570(\u30c0\u30f3\u30d4\u30f3\u30b0\u30d5\u30a1\u30af\u30bf\u30fc): \u671d\u3068\u5915\u65b9\u306e\u7d50\u679c\u3092\u8abf\u6574\u3059\u308b", + "declination": "\u504f\u89d2(0\uff1d\u6c34\u5e73\u300190\uff1d\u5782\u76f4)", + "modules power": "\u30bd\u30fc\u30e9\u30fc\u30e2\u30b8\u30e5\u30fc\u30eb\u306e\u7dcf\u30ef\u30c3\u30c8\u30d4\u30fc\u30af\u96fb\u529b" + }, + "description": "\u3053\u308c\u3089\u306e\u5024\u306b\u3088\u308a\u3001Solar.Forecast\u306e\u7d50\u679c\u3092\u5fae\u8abf\u6574\u3067\u304d\u307e\u3059\u3002\u30d5\u30a3\u30fc\u30eb\u30c9\u304c\u4e0d\u660e\u306a\u5834\u5408\u306f\u3001\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/pl.json b/homeassistant/components/forecast_solar/translations/pl.json index 8c1f96ea709..3fc782fe7c3 100644 --- a/homeassistant/components/forecast_solar/translations/pl.json +++ b/homeassistant/components/forecast_solar/translations/pl.json @@ -24,7 +24,7 @@ "declination": "Deklinacja (0 = Poziomo, 90 = Pionowo)", "modules power": "Ca\u0142kowita moc szczytowa modu\u0142\u00f3w fotowoltaicznych w watach" }, - "description": "Te warto\u015bci pozwalaj\u0105 dostosowa\u0107 wyniki dla Solar.Forecast. Prosz\u0119 zapozna\u0107 si\u0119 z dokumentacj\u0105, je\u015bli pole jest niejasne." + "description": "Te warto\u015bci pozwalaj\u0105 dostosowa\u0107 wyniki dla Solar.Forecast. Prosz\u0119 zapozna\u0107 si\u0119 z dokumentacj\u0105, je\u015bli pole jest niejasne." } } } diff --git a/homeassistant/components/forecast_solar/translations/ru.json b/homeassistant/components/forecast_solar/translations/ru.json index bd1e4ae70c0..9cf8e87a8e2 100644 --- a/homeassistant/components/forecast_solar/translations/ru.json +++ b/homeassistant/components/forecast_solar/translations/ru.json @@ -10,7 +10,7 @@ "modules power": "\u041e\u0431\u0449\u0430\u044f \u043f\u0438\u043a\u043e\u0432\u0430\u044f \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u044c \u0412\u0430\u0448\u0438\u0445 \u0441\u043e\u043b\u043d\u0435\u0447\u043d\u044b\u0445 \u043c\u043e\u0434\u0443\u043b\u0435\u0439 (\u0432 \u0412\u0430\u0442\u0442\u0430\u0445)", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Forecast.Solar." + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Forecast.Solar." } } }, diff --git a/homeassistant/components/forecast_solar/translations/tr.json b/homeassistant/components/forecast_solar/translations/tr.json new file mode 100644 index 00000000000..fecd8d7889a --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/tr.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimut (360 derece, 0 = Kuzey, 90 = Do\u011fu, 180 = G\u00fcney, 270 = Bat\u0131)", + "declination": "Sapma (0 = Yatay, 90 = Dikey)", + "latitude": "Enlem", + "longitude": "Boylam", + "modules power": "Solar mod\u00fcllerinizin toplam en y\u00fcksek Watt g\u00fcc\u00fc", + "name": "Ad" + }, + "description": "G\u00fcne\u015f panellerinizin verilerini doldurun. Bir alan net de\u011filse l\u00fctfen belgelere bak\u0131n." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Forecast.Solar API Anahtar\u0131 (iste\u011fe ba\u011fl\u0131)", + "azimuth": "Azimut (360 derece, 0 = Kuzey, 90 = Do\u011fu, 180 = G\u00fcney, 270 = Bat\u0131)", + "damping": "S\u00f6n\u00fcmleme fakt\u00f6r\u00fc: sonu\u00e7lar\u0131 sabah ve ak\u015fam ayarlar", + "declination": "Sapma (0 = Yatay, 90 = Dikey)", + "modules power": "Solar mod\u00fcllerinizin toplam en y\u00fcksek Watt g\u00fcc\u00fc" + }, + "description": "Bu de\u011ferler Solar.Forecast sonucunun ayarlanmas\u0131na izin verir. Bir alan net de\u011filse l\u00fctfen belgelere bak\u0131n." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/zh-Hans.json b/homeassistant/components/forecast_solar/translations/zh-Hans.json new file mode 100644 index 00000000000..8a667cf9260 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/zh-Hans.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "\u65b9\u4f4d\u89d2\uff08360 \u5ea6\uff0c\u4ee5 0 \u4e3a\u5317\uff0c90 \u4e3a\u4e1c\uff0c180 \u4e3a\u5357\uff0c270 \u4e3a\u897f\uff09", + "declination": "\u503e\u89d2\uff08\u4ee5 0 \u4e3a\u6c34\u5e73\uff0c90 \u4e3a\u5782\u76f4\uff09", + "latitude": "\u7eac\u5ea6", + "longitude": "\u7ecf\u5ea6", + "modules power": "\u5149\u4f0f\u53d1\u7535\u6a21\u7ec4\u7684\u603b\u5cf0\u503c\u529f\u7387(W)", + "name": "\u540d\u79f0" + }, + "description": "\u8bf7\u586b\u5199\u60a8\u7684\u592a\u9633\u80fd\u677f\u7684\u53c2\u6570\u3002\u5bf9\u4e8e\u4e0d\u6e05\u695a\u7684\u5b57\u6bb5\uff0c\u8bf7\u53c2\u9605\u6709\u5173\u6587\u6863\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Forecast.Solar API \u5bc6\u94a5\uff08\u53ef\u9009\uff09", + "azimuth": "\u65b9\u4f4d\u89d2\uff08360 \u5ea6\uff0c\u4ee5 0 \u4e3a\u5317\uff0c90 \u4e3a\u4e1c\uff0c180 \u4e3a\u5357\uff0c270 \u4e3a\u897f\uff09", + "damping": "\u963b\u5c3c\u7cfb\u6570\uff1a\u8c03\u8282\u65e9\u95f4\u548c\u665a\u95f4\u7684\u7ed3\u679c", + "declination": "\u503e\u89d2\uff08\u4ee5 0 \u4e3a\u6c34\u5e73\uff0c90 \u4e3a\u5782\u76f4\uff09", + "modules power": "\u5149\u4f0f\u53d1\u7535\u6a21\u7ec4\u7684\u603b\u5cf0\u503c\u529f\u7387(W)" + }, + "description": "\u8fd9\u4e9b\u503c\u7528\u4e8e\u8c03\u8282 Solar.Forecast \u7ed3\u679c\u3002\u5bf9\u4e8e\u4e0d\u6e05\u695a\u7684\u5b57\u6bb5\uff0c\u8bf7\u53c2\u9605\u6587\u6863\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forked_daapd/config_flow.py b/homeassistant/components/forked_daapd/config_flow.py index 16ebc1f82f7..e3cf6fc7c1d 100644 --- a/homeassistant/components/forked_daapd/config_flow.py +++ b/homeassistant/components/forked_daapd/config_flow.py @@ -6,8 +6,10 @@ from pyforked_daapd import ForkedDaapdAPI import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( @@ -153,35 +155,36 @@ class ForkedDaapdFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema(DATA_SCHEMA_DICT), errors={} ) - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Prepare configuration for a discovered forked-daapd device.""" version_num = 0 - if discovery_info.get("properties") and discovery_info["properties"].get( - "Machine Name" - ): + zeroconf_properties = discovery_info.properties + if zeroconf_properties.get("Machine Name"): with suppress(ValueError): version_num = int( - discovery_info["properties"].get("mtd-version", "0").split(".")[0] + zeroconf_properties.get("mtd-version", "0").split(".")[0] ) if version_num < 27: return self.async_abort(reason="not_forked_daapd") - await self.async_set_unique_id(discovery_info["properties"]["Machine Name"]) + await self.async_set_unique_id(zeroconf_properties["Machine Name"]) self._abort_if_unique_id_configured() # Update title and abort if we already have an entry for this host for entry in self._async_current_entries(): - if entry.data.get(CONF_HOST) != discovery_info["host"]: + if entry.data.get(CONF_HOST) != discovery_info.host: continue self.hass.config_entries.async_update_entry( entry, - title=discovery_info["properties"]["Machine Name"], + title=zeroconf_properties["Machine Name"], ) return self.async_abort(reason="already_configured") zeroconf_data = { - CONF_HOST: discovery_info["host"], - CONF_PORT: int(discovery_info["port"]), - CONF_NAME: discovery_info["properties"]["Machine Name"], + CONF_HOST: discovery_info.host, + CONF_PORT: discovery_info.port, + CONF_NAME: zeroconf_properties["Machine Name"], } self.discovery_schema = vol.Schema(fill_in_schema_dict(zeroconf_data)) self.context.update({"title_placeholders": zeroconf_data}) diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index aeb2350ce22..f19209a0b2d 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -797,8 +797,7 @@ class ForkedDaapdUpdater: if ( "queue" in update_types ): # update queue, queue before player for async_play_media - queue = await self._api.get_request("queue") - if queue: + if queue := await self._api.get_request("queue"): update_events["queue"] = asyncio.Event() async_dispatcher_send( self.hass, @@ -808,8 +807,7 @@ class ForkedDaapdUpdater: ) # order of below don't matter if not {"outputs", "volume"}.isdisjoint(update_types): # update outputs - outputs = await self._api.get_request("outputs") - if outputs: + if outputs := await self._api.get_request("outputs"): outputs = outputs["outputs"] update_events[ "outputs" @@ -838,8 +836,7 @@ class ForkedDaapdUpdater: if not {"player", "options", "volume"}.isdisjoint( update_types ): # update player - player = await self._api.get_request("player") - if player: + if player := await self._api.get_request("player"): update_events["player"] = asyncio.Event() if update_events.get("queue"): await update_events[ diff --git a/homeassistant/components/forked_daapd/translations/ja.json b/homeassistant/components/forked_daapd/translations/ja.json new file mode 100644 index 00000000000..692b7ca8346 --- /dev/null +++ b/homeassistant/components/forked_daapd/translations/ja.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "not_forked_daapd": "\u30c7\u30d0\u30a4\u30b9\u306f\u3001forked-daapd server\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002" + }, + "error": { + "forbidden": "\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093\u3002forked-daapd network\u306e\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30d1\u30fc\u30df\u30c3\u30b7\u30e7\u30f3\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "unknown_error": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc", + "websocket_not_enabled": "forked-daapd server\u306eWebSocket\u304c\u6709\u52b9\u306b\u306a\u3063\u3066\u3044\u307e\u305b\u3093\u3002", + "wrong_host_or_port": "\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093\u3002\u30db\u30b9\u30c8\u3068\u30dd\u30fc\u30c8\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "wrong_password": "\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093\u3002", + "wrong_server_type": "forked-daapd \u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306b\u306f\u3001\u30d0\u30fc\u30b8\u30e7\u30f3 >= 27.0 \u306eforked-daapd\u30b5\u30fc\u30d0\u30fc\u304c\u5fc5\u8981\u3067\u3059\u3002" + }, + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u5206\u304b\u308a\u3084\u3059\u3044\u540d\u524d(Friendly name)", + "password": "API\u30d1\u30b9\u30ef\u30fc\u30c9(\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u306a\u3044\u5834\u5408\u306f\u7a7a\u767d\u306e\u307e\u307e\u306b\u3057\u307e\u3059)", + "port": "API\u30dd\u30fc\u30c8" + }, + "title": "forked-daapd\u30c7\u30d0\u30a4\u30b9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "librespot_java_port": "librespot-java\u30d1\u30a4\u30d7\u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u7528\u30dd\u30fc\u30c8(\u4f7f\u7528\u3055\u308c\u3066\u3044\u308b\u5834\u5408)", + "max_playlists": "\u30bd\u30fc\u30b9\u3068\u3057\u3066\u4f7f\u7528\u3055\u308c\u308b\u30d7\u30ec\u30a4\u30ea\u30b9\u30c8\u306e\u6700\u5927\u6570", + "tts_pause_time": "TTS\u306e\u524d\u5f8c\u3067\u4e00\u6642\u505c\u6b62\u3059\u308b\u79d2\u6570", + "tts_volume": "TTS\u30dc\u30ea\u30e5\u30fc\u30e0(\u7bc4\u56f2\u306f\u3001[0,1]\u306e\u5c0f\u6570\u70b9)" + }, + "description": "forked-daapd\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u3055\u307e\u3056\u307e\u306a\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002", + "title": "forked-daapd\u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forked_daapd/translations/tr.json b/homeassistant/components/forked_daapd/translations/tr.json index 1ca2f9d4715..f34158852a5 100644 --- a/homeassistant/components/forked_daapd/translations/tr.json +++ b/homeassistant/components/forked_daapd/translations/tr.json @@ -1,20 +1,41 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "not_forked_daapd": "Cihaz, forked-daapd sunucusu de\u011fil." }, "error": { + "forbidden": "Ba\u011flan\u0131lam\u0131yor. L\u00fctfen forked-daapd a\u011f izinlerinizi kontrol edin.", "unknown_error": "Beklenmeyen hata", - "wrong_password": "Yanl\u0131\u015f parola." + "websocket_not_enabled": "forked-daapd sunucu websocket etkin de\u011fil.", + "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." }, + "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "name": "Kolay Ad\u0131", "password": "API parolas\u0131 (parola yoksa bo\u015f b\u0131rak\u0131n)", "port": "API Port" - } + }, + "title": "Forked-daapd cihaz\u0131n\u0131 kurun" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "librespot_java_port": "librespot-java boru kontrol\u00fc i\u00e7in ba\u011flant\u0131 noktas\u0131 (kullan\u0131l\u0131yorsa)", + "max_playlists": "Kaynak olarak kullan\u0131lan maksimum oynatma listesi say\u0131s\u0131", + "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" } } } diff --git a/homeassistant/components/foscam/translations/ja.json b/homeassistant/components/foscam/translations/ja.json new file mode 100644 index 00000000000..5a02ae5f446 --- /dev/null +++ b/homeassistant/components/foscam/translations/ja.json @@ -0,0 +1,26 @@ +{ + "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_response": "\u30c7\u30d0\u30a4\u30b9\u304b\u3089\u306e\u7121\u52b9\u306a\u5fdc\u7b54", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "rtsp_port": "RTSP\u30dd\u30fc\u30c8", + "stream": "\u30b9\u30c8\u30ea\u30fc\u30e0", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + }, + "title": "Foscam" +} \ No newline at end of file diff --git a/homeassistant/components/foscam/translations/tr.json b/homeassistant/components/foscam/translations/tr.json index b3e964ae08e..4b0f450d735 100644 --- a/homeassistant/components/foscam/translations/tr.json +++ b/homeassistant/components/foscam/translations/tr.json @@ -6,14 +6,16 @@ "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", - "unknown": "Beklenmeyen Hata" + "invalid_response": "Cihazdan ge\u00e7ersiz yan\u0131t", + "unknown": "Beklenmeyen hata" }, "step": { "user": { "data": { - "host": "Ana Bilgisayar", - "password": "\u015eifre", + "host": "Ana bilgisayar", + "password": "Parola", "port": "Port", + "rtsp_port": "RTSP port", "stream": "Ak\u0131\u015f", "username": "Kullan\u0131c\u0131 Ad\u0131" } diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index 0e10a8328c5..f0a7801823e 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -5,7 +5,9 @@ from freebox_api.exceptions import AuthorizationError, HttpRequestError import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN from .router import get_api @@ -105,8 +107,11 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Import a config entry.""" return await self.async_step_user(user_input) - async def async_step_zeroconf(self, discovery_info: dict): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Initialize flow from zeroconf.""" - host = discovery_info["properties"]["api_domain"] - port = discovery_info["properties"]["https_port"] + zeroconf_properties = discovery_info.properties + host = zeroconf_properties["api_domain"] + port = zeroconf_properties["https_port"] return await self.async_step_user({CONF_HOST: host, CONF_PORT: port}) diff --git a/homeassistant/components/freebox/translations/ja.json b/homeassistant/components/freebox/translations/ja.json new file mode 100644 index 00000000000..fa11e1c4822 --- /dev/null +++ b/homeassistant/components/freebox/translations/ja.json @@ -0,0 +1,25 @@ +{ + "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", + "register_failed": "\u767b\u9332\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3001\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "link": { + "description": "\u9001\u4fe1(submit) \u3092\u30af\u30ea\u30c3\u30af\u3057\u3001\u30eb\u30fc\u30bf\u30fc\u306e\u53f3\u77e2\u5370\u3092\u30bf\u30c3\u30c1\u3057\u3066\u3001Freebox\u3092Home Assistant\u306b\u767b\u9332\u3057\u307e\u3059\u3002 \n\n\uff01[\u30eb\u30fc\u30bf\u30fc\u306e\u30dc\u30bf\u30f3\u306e\u5834\u6240](/static/images/config_freebox.png)", + "title": "Freebox router\u306b\u30ea\u30f3\u30af" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "title": "Freebox" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/tr.json b/homeassistant/components/freebox/translations/tr.json index b675d38057d..7ebe2634020 100644 --- a/homeassistant/components/freebox/translations/tr.json +++ b/homeassistant/components/freebox/translations/tr.json @@ -5,12 +5,17 @@ }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", + "register_failed": "Kay\u0131t ba\u015far\u0131s\u0131z oldu, l\u00fctfen tekrar deneyin", "unknown": "Beklenmeyen hata" }, "step": { + "link": { + "description": "\"G\u00f6nder\"e t\u0131klay\u0131n, ard\u0131ndan Freebox'\u0131 Home Assistant ile kaydetmek i\u00e7in y\u00f6nlendiricideki sa\u011f oka dokunun. \n\n ![Y\u00f6nlendiricideki d\u00fc\u011fmenin konumu](/static/images/config_freebox.png)", + "title": "Freebox y\u00f6nlendiriciyi ba\u011fla" + }, "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "port": "Port" }, "title": "Freebox" diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py index 7aa34c8780e..754e6cb8818 100644 --- a/homeassistant/components/freedns/__init__.py +++ b/homeassistant/components/freedns/__init__.py @@ -72,7 +72,7 @@ async def _update_freedns(hass, session, url, auth_token): params[auth_token] = "" try: - with async_timeout.timeout(TIMEOUT): + async with async_timeout.timeout(TIMEOUT): resp = await session.get(url, params=params) body = await resp.text() diff --git a/homeassistant/components/freedompro/translations/id.json b/homeassistant/components/freedompro/translations/id.json index 9676af6d8f9..e05abfb7d14 100644 --- a/homeassistant/components/freedompro/translations/id.json +++ b/homeassistant/components/freedompro/translations/id.json @@ -11,7 +11,9 @@ "user": { "data": { "api_key": "Kunci API" - } + }, + "description": "Masukkan kunci API yang diperoleh dari https://home.freedompro.eu.", + "title": "Kunci API Freedompro" } } } diff --git a/homeassistant/components/freedompro/translations/ja.json b/homeassistant/components/freedompro/translations/ja.json new file mode 100644 index 00000000000..22e7047f496 --- /dev/null +++ b/homeassistant/components/freedompro/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": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc" + }, + "description": "https://home.freedompro.eu \u304b\u3089\u53d6\u5f97\u3057\u305fAPI\u30ad\u30fc\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "Freedompro API\u30ad\u30fc" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/tr.json b/homeassistant/components/freedompro/translations/tr.json new file mode 100644 index 00000000000..4d846a17117 --- /dev/null +++ b/homeassistant/components/freedompro/translations/tr.json @@ -0,0 +1,20 @@ +{ + "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" + }, + "step": { + "user": { + "data": { + "api_key": "API Anahtar\u0131" + }, + "description": "L\u00fctfen https://home.freedompro.eu adresinden al\u0131nan API anahtar\u0131n\u0131 girin", + "title": "Freedompro API anahtar\u0131" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index 6d0030685b2..193e11f49f3 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -2,6 +2,7 @@ import logging from fritzconnection.core.exceptions import FritzConnectionException, FritzSecurityError +from fritzconnection.core.logger import fritzlogger from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -20,6 +21,13 @@ from .services import async_setup_services, async_unload_services _LOGGER = logging.getLogger(__name__) +level = _LOGGER.getEffectiveLevel() +_LOGGER.info( + "Setting logging level of fritzconnection: %s", logging.getLevelName(level) +) +fritzlogger.set_level(level) +fritzlogger.enable() + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up fritzboxtools from config entry.""" diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 09926e5d9ac..77d21c269bd 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -442,11 +442,6 @@ class FritzDeviceBase(Entity): """No polling needed.""" return False - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False - async def async_process_update(self) -> None: """Update device.""" raise NotImplementedError() diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 55c60cc41a8..3a0cc2b1301 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -9,20 +9,15 @@ from urllib.parse import ParseResult, urlparse from fritzconnection.core.exceptions import FritzConnectionException, FritzSecurityError import voluptuous as vol +from homeassistant.components import ssdp from homeassistant.components.device_tracker.const import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) -from homeassistant.components.ssdp import ( - ATTR_SSDP_LOCATION, - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_UDN, -) from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow 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.typing import DiscoveryInfoType from .common import FritzBoxTools from .const import ( @@ -115,17 +110,18 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a flow initialized by discovery.""" - ssdp_location: ParseResult = urlparse(discovery_info[ATTR_SSDP_LOCATION]) + ssdp_location: ParseResult = urlparse(discovery_info.ssdp_location or "") self._host = ssdp_location.hostname self._port = ssdp_location.port self._name = ( - discovery_info.get(ATTR_UPNP_FRIENDLY_NAME) or self.fritz_tools.model + discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + or self.fritz_tools.model ) self.context[CONF_HOST] = self._host - if uuid := discovery_info.get(ATTR_UPNP_UDN): + if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): if uuid.startswith("uuid:"): uuid = uuid[5:] await self.async_set_unique_id(uuid) diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index e6ae95e3eb4..219e8f946ae 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -3,7 +3,7 @@ "name": "AVM FRITZ!Box Tools", "documentation": "https://www.home-assistant.io/integrations/fritz", "requirements": [ - "fritzconnection==1.7.0", + "fritzconnection==1.7.2", "xmltodict==0.12.0" ], "dependencies": ["network"], diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 809af534f0e..869e135c2c8 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -1,10 +1,11 @@ """AVM FRITZ!Box binary sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -import datetime +from datetime import datetime, timedelta import logging -from typing import Any, Callable, Literal +from typing import Any, Literal from fritzconnection.core.exceptions import ( FritzActionError, @@ -40,30 +41,29 @@ from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION _LOGGER = logging.getLogger(__name__) -def _uptime_calculation(seconds_uptime: float, last_value: str | None) -> str: +def _uptime_calculation(seconds_uptime: float, last_value: datetime | None) -> datetime: """Calculate uptime with deviation.""" - delta_uptime = utcnow() - datetime.timedelta(seconds=seconds_uptime) + delta_uptime = utcnow() - timedelta(seconds=seconds_uptime) if ( not last_value - or abs( - (delta_uptime - datetime.datetime.fromisoformat(last_value)).total_seconds() - ) - > UPTIME_DEVIATION + or abs((delta_uptime - last_value).total_seconds()) > UPTIME_DEVIATION ): - return delta_uptime.replace(microsecond=0).isoformat() + return delta_uptime return last_value -def _retrieve_device_uptime_state(status: FritzStatus, last_value: str) -> str: +def _retrieve_device_uptime_state( + status: FritzStatus, last_value: datetime +) -> datetime: """Return uptime from device.""" return _uptime_calculation(status.device_uptime, last_value) def _retrieve_connection_uptime_state( - status: FritzStatus, last_value: str | None -) -> str: + status: FritzStatus, last_value: datetime | None +) -> datetime: """Return uptime from connection.""" return _uptime_calculation(status.connection_uptime, last_value) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 969f8cf8f9e..4a740a26c33 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -246,6 +246,9 @@ def wifi_entities_list( """Get list of wifi entities.""" _LOGGER.debug("Setting up %s switches", SWITCH_TYPE_WIFINETWORK) std_table = {"ax": "Wifi6", "ac": "5Ghz", "n": "2.4Ghz"} + if fritzbox_tools.model == "FRITZ!Box 7390": + std_table = {"n": "5Ghz"} + networks: dict = {} for i in range(4): if not ("WLANConfiguration" + str(i)) in fritzbox_tools.connection.services: diff --git a/homeassistant/components/fritz/translations/bg.json b/homeassistant/components/fritz/translations/bg.json index b1ea395f077..3fca53d1013 100644 --- a/homeassistant/components/fritz/translations/bg.json +++ b/homeassistant/components/fritz/translations/bg.json @@ -1,7 +1,7 @@ { "config": { "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" }, "step": { "start_config": { diff --git a/homeassistant/components/fritz/translations/id.json b/homeassistant/components/fritz/translations/id.json index 1a3140da624..5aae1443d02 100644 --- a/homeassistant/components/fritz/translations/id.json +++ b/homeassistant/components/fritz/translations/id.json @@ -18,13 +18,17 @@ "data": { "password": "Kata Sandi", "username": "Nama Pengguna" - } + }, + "description": "FRITZ!Box ditemukan: {name} \n\nSiapkan FRITZ!Box Tools untuk mengontrol {name}", + "title": "Siapkan FRITZ!Box Tools." }, "reauth_confirm": { "data": { "password": "Kata Sandi", "username": "Nama Pengguna" - } + }, + "description": "Perbarui kredensial FRITZ!Box Tools untuk: {host} . \n\nFRITZ!Box Tools tidak dapat masuk ke FRITZ!Box Anda.", + "title": "Memperbarui FRITZ!Box Tools - kredensial" }, "start_config": { "data": { @@ -32,7 +36,9 @@ "password": "Kata Sandi", "port": "Port", "username": "Nama Pengguna" - } + }, + "description": "Siapkan FRITZ!Box Tools untuk mengontrol FRITZ!Box Anda.\nDiperlukan minimal: nama pengguna dan kata sandi.", + "title": "Siapkan FRITZ!Box Tools - wajib" }, "user": { "data": { @@ -40,6 +46,17 @@ "password": "Kata Sandi", "port": "Port", "username": "Nama Pengguna" + }, + "description": "Siapkan FRITZ!Box Tools untuk mengontrol FRITZ!Box Anda.\nDiperlukan minimal: nama pengguna dan kata sandi.", + "title": "Siapkan FRITZ!Box Tools." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Wakti dalam detik untuk mempertimbangkan perangkat sebagai 'di rumah'" } } } diff --git a/homeassistant/components/fritz/translations/ja.json b/homeassistant/components/fritz/translations/ja.json new file mode 100644 index 00000000000..77700224d19 --- /dev/null +++ b/homeassistant/components/fritz/translations/ja.json @@ -0,0 +1,64 @@ +{ + "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", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "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", + "connection_error": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "FRITZ!Box Tools\u3092\u767a\u898b\u3057\u307e\u3057\u305f: {name}\n\nFRITZ!Box Tools\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3001 {name} \u3092\u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u3059\u308b\u3002", + "title": "FRITZ!Box Tools\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + }, + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "FRITZ!Box Tools\u306e\u8a8d\u8a3c\u3092\u66f4\u65b0\u3057\u307e\u3059: {host}\n\nFRITZ!Box Tools\u304c\u3001FRITZ!Box\u306b\u30ed\u30b0\u30a4\u30f3\u3067\u304d\u307e\u305b\u3093\u3002", + "title": "FRITZ!Box Tools\u306e\u30a2\u30c3\u30d7\u30c7\u30fc\u30c8 - \u8cc7\u683c\u60c5\u5831" + }, + "start_config": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "FRITZ!Box Tools\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066FRITZ!Box\u3092\u5236\u5fa1\u3057\u307e\u3059\u3002\n\u6700\u4f4e\u9650\u5fc5\u8981\u306a\u3082\u306e: \u30e6\u30fc\u30b6\u30fc\u540d\u3001\u30d1\u30b9\u30ef\u30fc\u30c9\u3002", + "title": "FRITZ!Box Tools\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7 - \u5fc5\u9808" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "FRITZ!Box Tools\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066FRITZ!Box\u3092\u5236\u5fa1\u3057\u307e\u3059\u3002\n\u6700\u4f4e\u9650\u5fc5\u8981\u306a\u3082\u306e: \u30e6\u30fc\u30b6\u30fc\u540d\u3001\u30d1\u30b9\u30ef\u30fc\u30c9\u3002", + "title": "FRITZ!Box Tools\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "'\u30db\u30fc\u30e0' \u3067\u30c7\u30d0\u30a4\u30b9\u3092\u691c\u8a0e\u3059\u308b\u79d2\u6570" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/translations/tr.json b/homeassistant/components/fritz/translations/tr.json new file mode 100644 index 00000000000..febbd1999cc --- /dev/null +++ b/homeassistant/components/fritz/translations/tr.json @@ -0,0 +1,64 @@ +{ + "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", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "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", + "connection_error": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "Bulunan FRITZ!Box: {name} \n\n {name} kontrol etmek i\u00e7in FRITZ!Box Tools'u kurun", + "title": "FRITZ!Box Tools Kurulumu" + }, + "reauth_confirm": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "{host} i\u00e7in FRITZ!Box Tools kimlik bilgilerini g\u00fcncelleyin. \n\n FRITZ!Box Tools, FRITZ!Box'\u0131n\u0131zda oturum a\u00e7am\u0131yor.", + "title": "FRITZ!Box Tools - kimlik bilgilerinin g\u00fcncellenmesi" + }, + "start_config": { + "data": { + "host": "Ana bilgisayar", + "password": "Parola", + "port": "Port", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "FRITZ!Box'\u0131n\u0131z\u0131 kontrol etmek i\u00e7in FRITZ!Box Tools'u kurun.\n Minimum gerekli: kullan\u0131c\u0131 ad\u0131, \u015fifre.", + "title": "FRITZ!Box Tools Kurulumu - zorunlu" + }, + "user": { + "data": { + "host": "Ana bilgisayar", + "password": "Parola", + "port": "Port", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "FRITZ!Box'\u0131n\u0131z\u0131 kontrol etmek i\u00e7in FRITZ!Box Tools'u kurun.\n Minimum gerekli: kullan\u0131c\u0131 ad\u0131, \u015fifre.", + "title": "FRITZ!Box Tools Kurulumu" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Bir cihaz\u0131 'evde' varsaymak i\u00e7in saniye" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index bf2d857e30e..0f481773778 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -88,6 +88,8 @@ class FritzboxThermostat(FritzBoxEntity, ClimateEntity): @property def current_temperature(self) -> float: """Return the current temperature.""" + if self.device.has_temperature_sensor and self.device.temperature is not None: + return self.device.temperature # type: ignore [no-any-return] return self.device.actual_temperature # type: ignore [no-any-return] @property @@ -185,7 +187,7 @@ class FritzboxThermostat(FritzBoxEntity, ClimateEntity): attrs[ATTR_STATE_HOLIDAY_MODE] = self.device.holiday_active if self.device.summer_active is not None: attrs[ATTR_STATE_SUMMER_MODE] = self.device.summer_active - if ATTR_STATE_WINDOW_OPEN is not None: + if self.device.window_open is not None: attrs[ATTR_STATE_WINDOW_OPEN] = self.device.window_open return attrs diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index bcf17a1a958..0841757d147 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -8,15 +8,10 @@ from pyfritzhome import Fritzhome, LoginError from requests.exceptions import HTTPError import voluptuous as vol -from homeassistant.components.ssdp import ( - ATTR_SSDP_LOCATION, - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_UDN, -) +from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.typing import DiscoveryInfoType from .const import DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN @@ -119,13 +114,13 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors ) - async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a flow initialized by discovery.""" - host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname + host = urlparse(discovery_info.ssdp_location).hostname assert isinstance(host, str) self.context[CONF_HOST] = host - if uuid := discovery_info.get(ATTR_UPNP_UDN): + if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): if uuid.startswith("uuid:"): uuid = uuid[5:] await self.async_set_unique_id(uuid) @@ -143,7 +138,7 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_configured") self._host = host - self._name = str(discovery_info.get(ATTR_UPNP_FRIENDLY_NAME) or host) + self._name = str(discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) or host) self.context["title_placeholders"] = {"name": self._name} return await self.async_step_confirm() diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py index fa6da56caeb..133638c1fe8 100644 --- a/homeassistant/components/fritzbox/model.py +++ b/homeassistant/components/fritzbox/model.py @@ -18,8 +18,8 @@ class FritzExtraAttributes(TypedDict): class ClimateExtraAttributes(FritzExtraAttributes, total=False): """TypedDict for climates extra attributes.""" - battery_low: bool battery_level: int + battery_low: bool holiday_mode: bool summer_mode: bool window_open: bool diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 0745ddc8331..0ee7c3e8563 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -3,10 +3,12 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from typing import Final from pyfritzhome.fritzhomedevice import FritzhomeDevice +from homeassistant.components.climate.const import PRESET_COMFORT, PRESET_ECO from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, @@ -20,13 +22,17 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, ENERGY_KILO_WATT_HOUR, + ENTITY_CATEGORY_DIAGNOSTIC, PERCENTAGE, POWER_WATT, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utc_from_timestamp from . import FritzBoxEntity from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN @@ -37,7 +43,7 @@ from .model import FritzEntityDescriptionMixinBase class FritzEntityDescriptionMixinSensor(FritzEntityDescriptionMixinBase): """Sensor description mixin for Fritz!Smarthome entities.""" - native_value: Callable[[FritzhomeDevice], float | int | None] + native_value: Callable[[FritzhomeDevice], StateType | datetime] @dataclass @@ -54,6 +60,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, suitable=lambda device: ( device.has_temperature_sensor and not device.has_thermostat ), @@ -73,6 +80,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, suitable=lambda device: device.battery_level is not None, native_value=lambda device: device.battery_level, # type: ignore[no-any-return] ), @@ -94,6 +102,60 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] native_value=lambda device: device.energy / 1000 if device.energy else 0.0, ), + # Thermostat Sensors + FritzSensorEntityDescription( + key="comfort_temperature", + name="Comfort Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + suitable=lambda device: device.has_thermostat + and device.comfort_temperature is not None, + native_value=lambda device: device.comfort_temperature, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="eco_temperature", + name="Eco Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + suitable=lambda device: device.has_thermostat + and device.eco_temperature is not None, + native_value=lambda device: device.eco_temperature, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="nextchange_temperature", + name="Next Scheduled Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + suitable=lambda device: device.has_thermostat + and device.nextchange_temperature is not None, + native_value=lambda device: device.nextchange_temperature, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="nextchange_time", + name="Next Scheduled Change Time", + device_class=DEVICE_CLASS_TIMESTAMP, + suitable=lambda device: device.has_thermostat + and device.nextchange_endperiod is not None, + native_value=lambda device: utc_from_timestamp(device.nextchange_endperiod), + ), + FritzSensorEntityDescription( + key="nextchange_preset", + name="Next Scheduled Preset", + suitable=lambda device: device.has_thermostat + and device.nextchange_temperature is not None, + native_value=lambda device: PRESET_ECO + if device.nextchange_temperature == device.eco_temperature + else PRESET_COMFORT, + ), + FritzSensorEntityDescription( + key="scheduled_preset", + name="Current Scheduled Preset", + suitable=lambda device: device.has_thermostat + and device.nextchange_temperature is not None, + native_value=lambda device: PRESET_COMFORT + if device.nextchange_temperature == device.eco_temperature + else PRESET_ECO, + ), ) @@ -119,6 +181,6 @@ class FritzBoxSensor(FritzBoxEntity, SensorEntity): entity_description: FritzSensorEntityDescription @property - def native_value(self) -> float | int | None: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.native_value(self.device) diff --git a/homeassistant/components/fritzbox/translations/ja.json b/homeassistant/components/fritzbox/translations/ja.json new file mode 100644 index 00000000000..c246ea5fb0d --- /dev/null +++ b/homeassistant/components/fritzbox/translations/ja.json @@ -0,0 +1,39 @@ +{ + "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": "AVM FRITZ!Box\u306b\u63a5\u7d9a\u3057\u307e\u3057\u305f\u304c\u3001Smart Home devices\u3092\u5236\u5fa1\u3059\u308b\u3053\u3068\u304c\u3067\u304d\u307e\u305b\u3093\u3002", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "{name} \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "{name} \u30ed\u30b0\u30a4\u30f3\u60c5\u5831\u3092\u66f4\u65b0\u3057\u307e\u3059\u3002" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "AVM FRITZ!Box\u306e\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/tr.json b/homeassistant/components/fritzbox/translations/tr.json index 746fe594e19..53c8359f546 100644 --- a/homeassistant/components/fritzbox/translations/tr.json +++ b/homeassistant/components/fritzbox/translations/tr.json @@ -3,11 +3,14 @@ "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": "AVM FRITZ!Box'a ba\u011fl\u0131 ancak Ak\u0131ll\u0131 Ev cihazlar\u0131n\u0131 kontrol edemiyor.", "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" }, + "flow_title": "{name}", "step": { "confirm": { "data": { @@ -18,17 +21,18 @@ }, "reauth_confirm": { "data": { - "password": "\u015eifre", + "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" }, "description": "Giri\u015f bilgilerinizi {name} i\u00e7in g\u00fcncelleyin." }, "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "description": "AVM FRITZ!Box bilgilerinizi giriniz." } } } diff --git a/homeassistant/components/fritzbox_callmonitor/__init__.py b/homeassistant/components/fritzbox_callmonitor/__init__.py index 4c36ee3ddfb..edf463a84eb 100644 --- a/homeassistant/components/fritzbox_callmonitor/__init__.py +++ b/homeassistant/components/fritzbox_callmonitor/__init__.py @@ -2,6 +2,7 @@ import logging from fritzconnection.core.exceptions import FritzConnectionException, FritzSecurityError +from fritzconnection.core.logger import fritzlogger from requests.exceptions import ConnectionError as RequestsConnectionError from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME @@ -19,6 +20,13 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +level = _LOGGER.getEffectiveLevel() +_LOGGER.info( + "Setting logging level of fritzconnection: %s", logging.getLevelName(level) +) +fritzlogger.set_level(level) +fritzlogger.enable() + async def async_setup_entry(hass, config_entry): """Set up the fritzbox_callmonitor platforms.""" diff --git a/homeassistant/components/fritzbox_callmonitor/manifest.json b/homeassistant/components/fritzbox_callmonitor/manifest.json index 3d58f27e950..91bd73a6efd 100644 --- a/homeassistant/components/fritzbox_callmonitor/manifest.json +++ b/homeassistant/components/fritzbox_callmonitor/manifest.json @@ -3,7 +3,7 @@ "name": "AVM FRITZ!Box Call Monitor", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fritzbox_callmonitor", - "requirements": ["fritzconnection==1.7.0"], + "requirements": ["fritzconnection==1.7.2"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/fritzbox_callmonitor/translations/ja.json b/homeassistant/components/fritzbox_callmonitor/translations/ja.json new file mode 100644 index 00000000000..1c3294403ec --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/translations/ja.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "insufficient_permissions": "\u30e6\u30fc\u30b6\u30fc\u306b\u3001AVM FRITZ!Box\u306e\u8a2d\u5b9a\u3068\u96fb\u8a71\u5e33\u306b\u30a2\u30af\u30bb\u30b9\u3059\u308b\u6a29\u9650\u304c\u4e0d\u8db3\u3057\u3066\u3044\u307e\u3059\u3002", + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "flow_title": "{name}", + "step": { + "phonebook": { + "data": { + "phonebook": "\u96fb\u8a71\u5e33" + } + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + }, + "options": { + "error": { + "malformed_prefixes": "\u30d7\u30ec\u30d5\u30a3\u30c3\u30af\u30b9\u306e\u5f62\u5f0f\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093\u3002\u30d5\u30a9\u30fc\u30de\u30c3\u30c8\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "init": { + "data": { + "prefixes": "\u30d7\u30ec\u30d5\u30a3\u30c3\u30af\u30b9(\u30b3\u30f3\u30de\u533a\u5207\u308a\u30ea\u30b9\u30c8)" + }, + "title": "\u30d7\u30ec\u30d5\u30a3\u30c3\u30af\u30b9\u306e\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox_callmonitor/translations/tr.json b/homeassistant/components/fritzbox_callmonitor/translations/tr.json index 76799f24af8..e6b02a8176d 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/tr.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/tr.json @@ -17,8 +17,8 @@ }, "user": { "data": { - "host": "Ana Bilgisayar", - "password": "\u015eifre", + "host": "Ana bilgisayar", + "password": "Parola", "port": "Port", "username": "Kullan\u0131c\u0131 Ad\u0131" } diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index 2b4d968feca..cf648d3b613 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -1 +1,218 @@ -"""The Fronius component.""" +"""The Fronius integration.""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import logging +from typing import TypeVar + +from pyfronius import Fronius, FroniusError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_MODEL, ATTR_SW_VERSION, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo + +from .const import DOMAIN, SOLAR_NET_ID_SYSTEM, FroniusDeviceInfo +from .coordinator import ( + FroniusCoordinatorBase, + FroniusInverterUpdateCoordinator, + FroniusLoggerUpdateCoordinator, + FroniusMeterUpdateCoordinator, + FroniusOhmpilotUpdateCoordinator, + FroniusPowerFlowUpdateCoordinator, + FroniusStorageUpdateCoordinator, +) + +_LOGGER = logging.getLogger(__name__) +PLATFORMS: list[str] = ["sensor"] + +FroniusCoordinatorType = TypeVar("FroniusCoordinatorType", bound=FroniusCoordinatorBase) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up fronius from a config entry.""" + host = entry.data[CONF_HOST] + fronius = Fronius(async_get_clientsession(hass), host) + solar_net = FroniusSolarNet(hass, entry, fronius) + await solar_net.init_devices() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = solar_net + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + # reload on config_entry update + entry.async_on_unload(entry.add_update_listener(async_update_entry)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + solar_net = hass.data[DOMAIN].pop(entry.entry_id) + while solar_net.cleanup_callbacks: + solar_net.cleanup_callbacks.pop()() + + return unload_ok + + +async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update a given config entry.""" + await hass.config_entries.async_reload(entry.entry_id) + + +class FroniusSolarNet: + """The FroniusSolarNet class routes received values to sensor entities.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, fronius: Fronius + ) -> None: + """Initialize FroniusSolarNet class.""" + self.hass = hass + self.cleanup_callbacks: list[Callable[[], None]] = [] + self.config_entry = entry + self.coordinator_lock = asyncio.Lock() + self.fronius = fronius + self.host: str = entry.data[CONF_HOST] + # entry.unique_id is either logger uid or first inverter uid if no logger available + # prepended by "solar_net_" to have individual device for whole system (power_flow) + self.solar_net_device_id = f"solar_net_{entry.unique_id}" + self.system_device_info: DeviceInfo | None = None + + self.inverter_coordinators: list[FroniusInverterUpdateCoordinator] = [] + self.logger_coordinator: FroniusLoggerUpdateCoordinator | None = None + self.meter_coordinator: FroniusMeterUpdateCoordinator | None = None + self.ohmpilot_coordinator: FroniusOhmpilotUpdateCoordinator | None = None + self.power_flow_coordinator: FroniusPowerFlowUpdateCoordinator | None = None + self.storage_coordinator: FroniusStorageUpdateCoordinator | None = None + + async def init_devices(self) -> None: + """Initialize DataUpdateCoordinators for SolarNet devices.""" + if self.config_entry.data["is_logger"]: + self.logger_coordinator = FroniusLoggerUpdateCoordinator( + hass=self.hass, + solar_net=self, + logger=_LOGGER, + name=f"{DOMAIN}_logger_{self.host}", + ) + await self.logger_coordinator.async_config_entry_first_refresh() + + # _create_solar_net_device uses data from self.logger_coordinator when available + self.system_device_info = await self._create_solar_net_device() + + _inverter_infos = await self._get_inverter_infos() + for inverter_info in _inverter_infos: + coordinator = FroniusInverterUpdateCoordinator( + hass=self.hass, + solar_net=self, + logger=_LOGGER, + name=f"{DOMAIN}_inverter_{inverter_info.solar_net_id}_{self.host}", + inverter_info=inverter_info, + ) + await coordinator.async_config_entry_first_refresh() + self.inverter_coordinators.append(coordinator) + + self.meter_coordinator = await self._init_optional_coordinator( + FroniusMeterUpdateCoordinator( + hass=self.hass, + solar_net=self, + logger=_LOGGER, + name=f"{DOMAIN}_meters_{self.host}", + ) + ) + + self.ohmpilot_coordinator = await self._init_optional_coordinator( + FroniusOhmpilotUpdateCoordinator( + hass=self.hass, + solar_net=self, + logger=_LOGGER, + name=f"{DOMAIN}_ohmpilot_{self.host}", + ) + ) + + self.power_flow_coordinator = await self._init_optional_coordinator( + FroniusPowerFlowUpdateCoordinator( + hass=self.hass, + solar_net=self, + logger=_LOGGER, + name=f"{DOMAIN}_power_flow_{self.host}", + ) + ) + + self.storage_coordinator = await self._init_optional_coordinator( + FroniusStorageUpdateCoordinator( + hass=self.hass, + solar_net=self, + logger=_LOGGER, + name=f"{DOMAIN}_storages_{self.host}", + ) + ) + + async def _create_solar_net_device(self) -> DeviceInfo: + """Create a device for the Fronius SolarNet system.""" + solar_net_device: DeviceInfo = DeviceInfo( + configuration_url=self.fronius.url, + identifiers={(DOMAIN, self.solar_net_device_id)}, + manufacturer="Fronius", + name="SolarNet", + ) + if self.logger_coordinator: + _logger_info = self.logger_coordinator.data[SOLAR_NET_ID_SYSTEM] + solar_net_device[ATTR_MODEL] = _logger_info["product_type"]["value"] + solar_net_device[ATTR_SW_VERSION] = _logger_info["software_version"][ + "value" + ] + + device_registry = await dr.async_get_registry(self.hass) + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + **solar_net_device, + ) + return solar_net_device + + async def _get_inverter_infos(self) -> list[FroniusDeviceInfo]: + """Get information about the inverters in the SolarNet system.""" + try: + _inverter_info = await self.fronius.inverter_info() + except FroniusError as err: + raise ConfigEntryNotReady from err + + inverter_infos: list[FroniusDeviceInfo] = [] + for inverter in _inverter_info["inverters"]: + solar_net_id = inverter["device_id"]["value"] + unique_id = inverter["unique_id"]["value"] + device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=inverter["device_type"].get("manufacturer", "Fronius"), + model=inverter["device_type"].get( + "model", inverter["device_type"]["value"] + ), + name=inverter.get("custom_name", {}).get("value"), + via_device=(DOMAIN, self.solar_net_device_id), + ) + inverter_infos.append( + FroniusDeviceInfo( + device_info=device_info, + solar_net_id=solar_net_id, + unique_id=unique_id, + ) + ) + return inverter_infos + + @staticmethod + async def _init_optional_coordinator( + coordinator: FroniusCoordinatorType, + ) -> FroniusCoordinatorType | None: + """Initialize an update coordinator and return it if devices are found.""" + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady: + # ConfigEntryNotReady raised form FroniusError / KeyError in + # DataUpdateCoordinator if request not supported by the Fronius device + return None + # if no device for the request is installed an empty dict is returned + if not coordinator.data: + return None + return coordinator diff --git a/homeassistant/components/fronius/config_flow.py b/homeassistant/components/fronius/config_flow.py new file mode 100644 index 00000000000..86654f00c36 --- /dev/null +++ b/homeassistant/components/fronius/config_flow.py @@ -0,0 +1,160 @@ +"""Config flow for Fronius integration.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Final + +from pyfronius import Fronius, FroniusError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.dhcp import DhcpServiceInfo +from homeassistant.const import CONF_HOST, CONF_RESOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, FroniusConfigEntryData + +_LOGGER = logging.getLogger(__name__) + +DHCP_REQUEST_DELAY: Final = 60 + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +def create_title(info: FroniusConfigEntryData) -> str: + """Return the title of the config flow.""" + return ( + f"SolarNet {'Datalogger' if info['is_logger'] else 'Inverter'}" + f" at {info['host']}" + ) + + +async def validate_host( + hass: HomeAssistant, host: str +) -> tuple[str, FroniusConfigEntryData]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + fronius = Fronius(async_get_clientsession(hass), host) + + try: + datalogger_info: dict[str, Any] + datalogger_info = await fronius.current_logger_info() + except FroniusError as err: + _LOGGER.debug(err) + else: + logger_uid: str = datalogger_info["unique_identifier"]["value"] + return logger_uid, FroniusConfigEntryData( + host=host, + is_logger=True, + ) + # Gen24 devices don't provide GetLoggerInfo + try: + inverter_info = await fronius.inverter_info() + first_inverter = next(inverter for inverter in inverter_info["inverters"]) + except FroniusError as err: + _LOGGER.debug(err) + raise CannotConnect from err + except StopIteration as err: + raise CannotConnect("No supported Fronius SolarNet device found.") from err + first_inverter_uid: str = first_inverter["unique_id"]["value"] + return first_inverter_uid, FroniusConfigEntryData( + host=host, + is_logger=False, + ) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Fronius.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize flow.""" + self.info: FroniusConfigEntryData + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + unique_id, info = await validate_host(self.hass, user_input[CONF_HOST]) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(unique_id, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates=dict(info), reload_on_update=False + ) + return self.async_create_entry(title=create_title(info), data=info) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, conf: dict) -> FlowResult: + """Import a configuration from config.yaml.""" + return await self.async_step_user(user_input={CONF_HOST: conf[CONF_RESOURCE]}) + + async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: + """Handle a flow initiated by the DHCP client.""" + for entry in self._async_current_entries(include_ignore=False): + if entry.data[CONF_HOST].lstrip("http://").rstrip("/").lower() in ( + discovery_info.ip, + discovery_info.hostname, + ): + return self.async_abort(reason="already_configured") + # Symo Datalogger devices need up to 1 minute at boot from DHCP request + # to respond to API requests (connection refused until then) + await asyncio.sleep(DHCP_REQUEST_DELAY) + try: + unique_id, self.info = await validate_host(self.hass, discovery_info.ip) + except CannotConnect: + return self.async_abort(reason="invalid_host") + + await self.async_set_unique_id(unique_id, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates=dict(self.info), reload_on_update=False + ) + + return await self.async_step_confirm_discovery() + + async def async_step_confirm_discovery( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Attempt to confim.""" + title = create_title(self.info) + if user_input is not None: + return self.async_create_entry(title=title, data=self.info) + + self._set_confirm_only() + self.context.update({"title_placeholders": {"device": title}}) + return self.async_show_form( + step_id="confirm_discovery", + description_placeholders={ + "device": title, + }, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/fronius/const.py b/homeassistant/components/fronius/const.py new file mode 100644 index 00000000000..de3e0cc9563 --- /dev/null +++ b/homeassistant/components/fronius/const.py @@ -0,0 +1,25 @@ +"""Constants for the Fronius integration.""" +from typing import Final, NamedTuple, TypedDict + +from homeassistant.helpers.entity import DeviceInfo + +DOMAIN: Final = "fronius" + +SolarNetId = str +SOLAR_NET_ID_POWER_FLOW: SolarNetId = "power_flow" +SOLAR_NET_ID_SYSTEM: SolarNetId = "system" + + +class FroniusConfigEntryData(TypedDict): + """ConfigEntry for the Fronius integration.""" + + host: str + is_logger: bool + + +class FroniusDeviceInfo(NamedTuple): + """Information about a Fronius inverter device.""" + + device_info: DeviceInfo + solar_net_id: SolarNetId + unique_id: str diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py new file mode 100644 index 00000000000..e89f828f47d --- /dev/null +++ b/homeassistant/components/fronius/coordinator.py @@ -0,0 +1,198 @@ +"""DataUpdateCoordinators for the Fronius integration.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from datetime import timedelta +from typing import TYPE_CHECKING, Any, Dict, TypeVar + +from pyfronius import FroniusError + +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.core import callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + SOLAR_NET_ID_POWER_FLOW, + SOLAR_NET_ID_SYSTEM, + FroniusDeviceInfo, + SolarNetId, +) +from .sensor import ( + INVERTER_ENTITY_DESCRIPTIONS, + LOGGER_ENTITY_DESCRIPTIONS, + METER_ENTITY_DESCRIPTIONS, + OHMPILOT_ENTITY_DESCRIPTIONS, + POWER_FLOW_ENTITY_DESCRIPTIONS, + STORAGE_ENTITY_DESCRIPTIONS, +) + +if TYPE_CHECKING: + from . import FroniusSolarNet + from .sensor import _FroniusSensorEntity + + FroniusEntityType = TypeVar("FroniusEntityType", bound=_FroniusSensorEntity) + + +class FroniusCoordinatorBase( + ABC, DataUpdateCoordinator[Dict[SolarNetId, Dict[str, Any]]] +): + """Query Fronius endpoint and keep track of seen conditions.""" + + default_interval: timedelta + error_interval: timedelta + valid_descriptions: list[SensorEntityDescription] + + def __init__(self, *args: Any, solar_net: FroniusSolarNet, **kwargs: Any) -> None: + """Set up the FroniusCoordinatorBase class.""" + self._failed_update_count = 0 + self.solar_net = solar_net + # unregistered_keys are used to create entities in platform module + self.unregistered_keys: dict[SolarNetId, set[str]] = {} + super().__init__(*args, update_interval=self.default_interval, **kwargs) + + @abstractmethod + async def _update_method(self) -> dict[SolarNetId, Any]: + """Return data per solar net id from pyfronius.""" + + async def _async_update_data(self) -> dict[SolarNetId, Any]: + """Fetch the latest data from the source.""" + async with self.solar_net.coordinator_lock: + try: + data = await self._update_method() + except FroniusError as err: + self._failed_update_count += 1 + if self._failed_update_count == 3: + self.update_interval = self.error_interval + raise UpdateFailed(err) from err + + if self._failed_update_count != 0: + self._failed_update_count = 0 + self.update_interval = self.default_interval + + for solar_net_id in data: + if solar_net_id not in self.unregistered_keys: + # id seen for the first time + self.unregistered_keys[solar_net_id] = { + desc.key for desc in self.valid_descriptions + } + return data + + @callback + def add_entities_for_seen_keys( + self, + async_add_entities: AddEntitiesCallback, + entity_constructor: type[FroniusEntityType], + ) -> None: + """ + Add entities for received keys and registers listener for future seen keys. + + Called from a platforms `async_setup_entry`. + """ + + @callback + def _add_entities_for_unregistered_keys() -> None: + """Add entities for keys seen for the first time.""" + new_entities: list = [] + for solar_net_id, device_data in self.data.items(): + for key in self.unregistered_keys[solar_net_id].intersection( + device_data + ): + 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) + + _add_entities_for_unregistered_keys() + self.solar_net.cleanup_callbacks.append( + self.async_add_listener(_add_entities_for_unregistered_keys) + ) + + +class FroniusInverterUpdateCoordinator(FroniusCoordinatorBase): + """Query Fronius device inverter endpoint and keep track of seen conditions.""" + + default_interval = timedelta(minutes=1) + error_interval = timedelta(minutes=10) + valid_descriptions = INVERTER_ENTITY_DESCRIPTIONS + + def __init__( + self, *args: Any, inverter_info: FroniusDeviceInfo, **kwargs: Any + ) -> None: + """Set up a Fronius inverter device scope coordinator.""" + super().__init__(*args, **kwargs) + self.inverter_info = inverter_info + + async def _update_method(self) -> dict[SolarNetId, Any]: + """Return data per solar net id from pyfronius.""" + data = await self.solar_net.fronius.current_inverter_data( + self.inverter_info.solar_net_id + ) + # wrap a single devices data in a dict with solar_net_id key for + # FroniusCoordinatorBase _async_update_data and add_entities_for_seen_keys + return {self.inverter_info.solar_net_id: data} + + +class FroniusLoggerUpdateCoordinator(FroniusCoordinatorBase): + """Query Fronius logger info endpoint and keep track of seen conditions.""" + + default_interval = timedelta(hours=1) + error_interval = timedelta(hours=1) + valid_descriptions = LOGGER_ENTITY_DESCRIPTIONS + + async def _update_method(self) -> dict[SolarNetId, Any]: + """Return data per solar net id from pyfronius.""" + data = await self.solar_net.fronius.current_logger_info() + return {SOLAR_NET_ID_SYSTEM: data} + + +class FroniusMeterUpdateCoordinator(FroniusCoordinatorBase): + """Query Fronius system meter endpoint and keep track of seen conditions.""" + + default_interval = timedelta(minutes=1) + error_interval = timedelta(minutes=10) + valid_descriptions = METER_ENTITY_DESCRIPTIONS + + async def _update_method(self) -> dict[SolarNetId, Any]: + """Return data per solar net id from pyfronius.""" + data = await self.solar_net.fronius.current_system_meter_data() + return data["meters"] # type: ignore[no-any-return] + + +class FroniusOhmpilotUpdateCoordinator(FroniusCoordinatorBase): + """Query Fronius Ohmpilots and keep track of seen conditions.""" + + default_interval = timedelta(minutes=1) + error_interval = timedelta(minutes=10) + valid_descriptions = OHMPILOT_ENTITY_DESCRIPTIONS + + async def _update_method(self) -> dict[SolarNetId, Any]: + """Return data per solar net id from pyfronius.""" + data = await self.solar_net.fronius.current_system_ohmpilot_data() + return data["ohmpilots"] # type: ignore[no-any-return] + + +class FroniusPowerFlowUpdateCoordinator(FroniusCoordinatorBase): + """Query Fronius power flow endpoint and keep track of seen conditions.""" + + default_interval = timedelta(seconds=10) + error_interval = timedelta(minutes=3) + valid_descriptions = POWER_FLOW_ENTITY_DESCRIPTIONS + + async def _update_method(self) -> dict[SolarNetId, Any]: + """Return data per solar net id from pyfronius.""" + data = await self.solar_net.fronius.current_power_flow() + return {SOLAR_NET_ID_POWER_FLOW: data} + + +class FroniusStorageUpdateCoordinator(FroniusCoordinatorBase): + """Query Fronius system storage endpoint and keep track of seen conditions.""" + + default_interval = timedelta(minutes=1) + error_interval = timedelta(minutes=10) + valid_descriptions = STORAGE_ENTITY_DESCRIPTIONS + + async def _update_method(self) -> dict[SolarNetId, Any]: + """Return data per solar net id from pyfronius.""" + data = await self.solar_net.fronius.current_system_storage_data() + return data["storages"] # type: ignore[no-any-return] diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index e40b5303eca..d2f3fc2e0f3 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -1,8 +1,15 @@ { - "domain": "fronius", - "name": "Fronius", + "codeowners": ["@nielstron", "@farmio"], + "config_flow": true, + "dhcp": [ + { + "macaddress": "0003AC*" + } + ], "documentation": "https://www.home-assistant.io/integrations/fronius", - "requirements": ["pyfronius==0.7.0"], - "codeowners": ["@nielstron"], - "iot_class": "local_polling" + "domain": "fronius", + "iot_class": "local_polling", + "name": "Fronius", + "quality_scale": "platinum", + "requirements": ["pyfronius==0.7.1"] } diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 0ad172c2ab0..8a1348bed14 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -1,348 +1,876 @@ """Support for Fronius devices.""" from __future__ import annotations -import copy -from datetime import timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any -from pyfronius import Fronius, FroniusError import voluptuous as vol from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, SensorEntity, + SensorEntityDescription, + SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( - CONF_DEVICE, CONF_MONITORED_CONDITIONS, CONF_RESOURCE, - CONF_SCAN_INTERVAL, - CONF_SENSOR_TYPE, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_POWER_FACTOR, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, - DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_WATT_HOUR, + FREQUENCY_HERTZ, + PERCENTAGE, + POWER_VOLT_AMPERE, + POWER_WATT, + TEMP_CELSIUS, ) -from homeassistant.core import callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +if TYPE_CHECKING: + from . import FroniusSolarNet + from .coordinator import ( + FroniusCoordinatorBase, + FroniusInverterUpdateCoordinator, + FroniusLoggerUpdateCoordinator, + FroniusMeterUpdateCoordinator, + FroniusOhmpilotUpdateCoordinator, + FroniusPowerFlowUpdateCoordinator, + FroniusStorageUpdateCoordinator, + ) _LOGGER = logging.getLogger(__name__) -CONF_SCOPE = "scope" +ELECTRIC_CHARGE_AMPERE_HOURS = "Ah" +ENERGY_VOLT_AMPERE_REACTIVE_HOUR = "varh" +POWER_VOLT_AMPERE_REACTIVE = "var" -TYPE_INVERTER = "inverter" -TYPE_STORAGE = "storage" -TYPE_METER = "meter" -TYPE_POWER_FLOW = "power_flow" -TYPE_LOGGER_INFO = "logger_info" -SCOPE_DEVICE = "device" -SCOPE_SYSTEM = "system" - -DEFAULT_SCOPE = SCOPE_DEVICE -DEFAULT_DEVICE = 0 -DEFAULT_INVERTER = 1 -DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) - -SENSOR_TYPES = [ - TYPE_INVERTER, - TYPE_STORAGE, - TYPE_METER, - TYPE_POWER_FLOW, - TYPE_LOGGER_INFO, -] -SCOPE_TYPES = [SCOPE_DEVICE, SCOPE_SYSTEM] - -PREFIX_DEVICE_CLASS_MAPPING = [ - ("state_of_charge", DEVICE_CLASS_BATTERY), - ("temperature", DEVICE_CLASS_TEMPERATURE), - ("power_factor", DEVICE_CLASS_POWER_FACTOR), - ("power", DEVICE_CLASS_POWER), - ("energy", DEVICE_CLASS_ENERGY), - ("current", DEVICE_CLASS_CURRENT), - ("timestamp", DEVICE_CLASS_TIMESTAMP), - ("voltage", DEVICE_CLASS_VOLTAGE), -] - -PREFIX_STATE_CLASS_MAPPING = [ - ("state_of_charge", STATE_CLASS_MEASUREMENT), - ("temperature", STATE_CLASS_MEASUREMENT), - ("power_factor", STATE_CLASS_MEASUREMENT), - ("power", STATE_CLASS_MEASUREMENT), - ("energy", STATE_CLASS_TOTAL_INCREASING), - ("current", STATE_CLASS_MEASUREMENT), - ("timestamp", STATE_CLASS_MEASUREMENT), - ("voltage", STATE_CLASS_MEASUREMENT), -] - - -def _device_id_validator(config): - """Ensure that inverters have default id 1 and other devices 0.""" - config = copy.deepcopy(config) - for cond in config[CONF_MONITORED_CONDITIONS]: - if CONF_DEVICE not in cond: - if cond[CONF_SENSOR_TYPE] == TYPE_INVERTER: - cond[CONF_DEVICE] = DEFAULT_INVERTER - else: - cond[CONF_DEVICE] = DEFAULT_DEVICE - return config - - -PLATFORM_SCHEMA = vol.Schema( - vol.All( - PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_RESOURCE): cv.url, - vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, - [ - { - vol.Required(CONF_SENSOR_TYPE): vol.In(SENSOR_TYPES), - vol.Optional(CONF_SCOPE, default=DEFAULT_SCOPE): vol.In( - SCOPE_TYPES - ), - vol.Optional(CONF_DEVICE): cv.positive_int, - } - ], - ), - } - ), - _device_id_validator, - ) +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_RESOURCE): cv.url, + vol.Optional(CONF_MONITORED_CONDITIONS): object, + } + ), ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up of Fronius platform.""" - session = async_get_clientsession(hass) - fronius = Fronius(session, config[CONF_RESOURCE]) - - scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - adapters = [] - # Creates all adapters for monitored conditions - for condition in config[CONF_MONITORED_CONDITIONS]: - - device = condition[CONF_DEVICE] - sensor_type = condition[CONF_SENSOR_TYPE] - scope = condition[CONF_SCOPE] - name = f"Fronius {condition[CONF_SENSOR_TYPE].replace('_', ' ').capitalize()} {device if scope == SCOPE_DEVICE else SCOPE_SYSTEM} {config[CONF_RESOURCE]}" - if sensor_type == TYPE_INVERTER: - if scope == SCOPE_SYSTEM: - adapter_cls = FroniusInverterSystem - else: - adapter_cls = FroniusInverterDevice - elif sensor_type == TYPE_METER: - if scope == SCOPE_SYSTEM: - adapter_cls = FroniusMeterSystem - else: - adapter_cls = FroniusMeterDevice - elif sensor_type == TYPE_POWER_FLOW: - adapter_cls = FroniusPowerFlow - elif sensor_type == TYPE_LOGGER_INFO: - adapter_cls = FroniusLoggerInfo - else: - adapter_cls = FroniusStorage - - adapters.append(adapter_cls(fronius, name, device, async_add_entities)) - - # Creates a lamdba that fetches an update when called - def adapter_data_fetcher(data_adapter): - async def fetch_data(*_): - await data_adapter.async_update() - - return fetch_data - - # Set up the fetching in a fixed interval for each adapter - for adapter in adapters: - fetch = adapter_data_fetcher(adapter) - # fetch data once at set-up - await fetch() - async_track_time_interval(hass, fetch, scan_interval) +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: None = None, +) -> None: + """Import Fronius configuration from yaml.""" + _LOGGER.warning( + "Loading Fronius via platform setup is deprecated. Please remove it from your yaml configuration" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) -class FroniusAdapter: - """The Fronius sensor fetching component.""" +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Fronius sensor entities based on a config entry.""" + solar_net: FroniusSolarNet = hass.data[DOMAIN][config_entry.entry_id] + for inverter_coordinator in solar_net.inverter_coordinators: + inverter_coordinator.add_entities_for_seen_keys( + async_add_entities, InverterSensor + ) + if solar_net.logger_coordinator is not None: + solar_net.logger_coordinator.add_entities_for_seen_keys( + async_add_entities, LoggerSensor + ) + if solar_net.meter_coordinator is not None: + solar_net.meter_coordinator.add_entities_for_seen_keys( + async_add_entities, MeterSensor + ) + if solar_net.ohmpilot_coordinator is not None: + solar_net.ohmpilot_coordinator.add_entities_for_seen_keys( + async_add_entities, OhmpilotSensor + ) + if solar_net.power_flow_coordinator is not None: + solar_net.power_flow_coordinator.add_entities_for_seen_keys( + async_add_entities, PowerFlowSensor + ) + if solar_net.storage_coordinator is not None: + solar_net.storage_coordinator.add_entities_for_seen_keys( + async_add_entities, StorageSensor + ) + + +INVERTER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="energy_day", + name="Energy day", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_year", + name="Energy year", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_total", + name="Energy total", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="frequency_ac", + name="Frequency AC", + native_unit_of_measurement=FREQUENCY_HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="current_ac", + name="AC Current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="current_dc", + name="DC current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:current-dc", + ), + SensorEntityDescription( + key="current_dc_2", + name="DC Current 2", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:current-dc", + ), + SensorEntityDescription( + key="power_ac", + name="AC power", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_ac", + name="AC voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="voltage_dc", + name="DC voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:current-dc", + ), + SensorEntityDescription( + key="voltage_dc_2", + name="DC voltage 2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:current-dc", + ), + # device status entities + SensorEntityDescription( + key="inverter_state", + name="Inverter state", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="error_code", + name="Error code", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="status_code", + name="Status code", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="led_state", + name="LED state", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="led_color", + name="LED color", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), +] + +LOGGER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="co2_factor", + name="CO₂ factor", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:molecule-co2", + ), + SensorEntityDescription( + key="cash_factor", + name="Grid export tariff", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:cash-plus", + ), + SensorEntityDescription( + key="delivery_factor", + name="Grid import tariff", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:cash-minus", + ), +] + +METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="current_ac_phase_1", + name="Current AC phase 1", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="current_ac_phase_2", + name="Current AC phase 2", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="current_ac_phase_3", + name="Current AC phase 3", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_reactive_ac_consumed", + name="Energy reactive AC consumed", + native_unit_of_measurement=ENERGY_VOLT_AMPERE_REACTIVE_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + icon="mdi:lightning-bolt-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_reactive_ac_produced", + name="Energy reactive AC produced", + native_unit_of_measurement=ENERGY_VOLT_AMPERE_REACTIVE_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + icon="mdi:lightning-bolt-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_real_ac_minus", + name="Energy real AC minus", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_real_ac_plus", + name="Energy real AC plus", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_real_consumed", + name="Energy real consumed", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_real_produced", + name="Energy real produced", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="frequency_phase_average", + name="Frequency phase average", + native_unit_of_measurement=FREQUENCY_HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="meter_location", + name="Meter location", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="power_apparent_phase_1", + name="Power apparent phase 1", + native_unit_of_measurement=POWER_VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_apparent_phase_2", + name="Power apparent phase 2", + native_unit_of_measurement=POWER_VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_apparent_phase_3", + name="Power apparent phase 3", + native_unit_of_measurement=POWER_VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_apparent", + name="Power apparent", + native_unit_of_measurement=POWER_VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_factor_phase_1", + name="Power factor phase 1", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_factor_phase_2", + name="Power factor phase 2", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_factor_phase_3", + name="Power factor phase 3", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_factor", + name="Power factor", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="power_reactive_phase_1", + name="Power reactive phase 1", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_reactive_phase_2", + name="Power reactive phase 2", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_reactive_phase_3", + name="Power reactive phase 3", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_reactive", + name="Power reactive", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_real_phase_1", + name="Power real phase 1", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_real_phase_2", + name="Power real phase 2", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_real_phase_3", + name="Power real phase 3", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_real", + name="Power real", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_ac_phase_1", + name="Voltage AC phase 1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="voltage_ac_phase_2", + name="Voltage AC phase 2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="voltage_ac_phase_3", + name="Voltage AC phase 3", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="voltage_ac_phase_to_phase_12", + name="Voltage AC phase 1-2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="voltage_ac_phase_to_phase_23", + name="Voltage AC phase 2-3", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="voltage_ac_phase_to_phase_31", + name="Voltage AC phase 3-1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), +] + +OHMPILOT_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="energy_real_ac_consumed", + name="Energy consumed", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="power_real_ac", + name="Power", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="temperature_channel_1", + name="Temperature Channel 1", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="error_code", + name="Error code", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="state_code", + name="State code", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="state_message", + name="State message", + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + +POWER_FLOW_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="energy_day", + name="Energy day", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_year", + name="Energy year", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_total", + name="Energy total", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="meter_mode", + name="Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="power_battery", + name="Power battery", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="power_grid", + name="Power grid", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="power_load", + name="Power load", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="power_photovoltaics", + name="Power photovoltaics", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="relative_autonomy", + name="Relative autonomy", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:home-circle-outline", + ), + SensorEntityDescription( + key="relative_self_consumption", + name="Relative self consumption", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:solar-power", + ), +] + +STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="capacity_maximum", + name="Capacity maximum", + native_unit_of_measurement=ELECTRIC_CHARGE_AMPERE_HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="capacity_designed", + name="Capacity designed", + native_unit_of_measurement=ELECTRIC_CHARGE_AMPERE_HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="current_dc", + name="Current DC", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:current-dc", + ), + SensorEntityDescription( + key="voltage_dc", + name="Voltage DC", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:current-dc", + ), + SensorEntityDescription( + key="voltage_dc_maximum_cell", + name="Voltage DC maximum cell", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:current-dc", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="voltage_dc_minimum_cell", + name="Voltage DC minimum cell", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:current-dc", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="state_of_charge", + name="State of charge", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="temperature_cell", + name="Temperature cell", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), +] + + +class _FroniusSensorEntity(CoordinatorEntity, SensorEntity): + """Defines a Fronius coordinator entity.""" + + coordinator: FroniusCoordinatorBase + entity_descriptions: list[SensorEntityDescription] + _entity_id_prefix: str def __init__( - self, bridge: Fronius, name: str, device: int, add_entities: AddEntitiesCallback + self, + coordinator: FroniusCoordinatorBase, + key: str, + solar_net_id: str, ) -> None: - """Initialize the sensor.""" - self.bridge = bridge - self._name = name - self._device = device - self._fetched: dict[str, Any] = {} - self._available = True + """Set up an individual Fronius meter sensor.""" + super().__init__(coordinator) + self.entity_description = next( + desc for desc in self.entity_descriptions if desc.key == key + ) + # default entity_id added 2021.12 + # used for migration from non-unique_id entities of previous integration implementation + # when removed after migration period `_entity_id_prefix` will also no longer be needed + self.entity_id = f"{SENSOR_DOMAIN}.{key}_{DOMAIN}_{self._entity_id_prefix}_{coordinator.solar_net.host}" + self.solar_net_id = solar_net_id + self._attr_native_value = self._get_entity_value() - self.sensors: set[str] = set() - self._registered_sensors: set[SensorEntity] = set() - self._add_entities = add_entities + def _device_data(self) -> dict[str, Any]: + """Extract information for SolarNet device from coordinator data.""" + return self.coordinator.data[self.solar_net_id] - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def data(self): - """Return the state attributes.""" - return self._fetched - - @property - def available(self): - """Whether the fronius device is active.""" - return self._available - - async def async_update(self): - """Retrieve and update latest state.""" - try: - values = await self._update() - except FroniusError as err: - # fronius devices are often powered by self-produced solar energy - # and henced turned off at night. - # Therefore we will not print multiple errors when connection fails - if self._available: - self._available = False - _LOGGER.error("Failed to update: %s", err) - return - - self._available = True # reset connection failure - - attributes = self._fetched - # Copy data of current fronius device - for key, entry in values.items(): - # If the data is directly a sensor - if "value" in entry: - attributes[key] = entry - self._fetched = attributes - - # Add discovered value fields as sensors - # because some fields are only sent temporarily - new_sensors = [] - for key in attributes: - if key not in self.sensors: - self.sensors.add(key) - _LOGGER.info("Discovered %s, adding as sensor", key) - new_sensors.append(FroniusTemplateSensor(self, key)) - self._add_entities(new_sensors, True) - - # Schedule an update for all included sensors - for sensor in self._registered_sensors: - sensor.async_schedule_update_ha_state(True) - - async def _update(self) -> dict: - """Return values of interest.""" + def _get_entity_value(self) -> Any: + """Extract entity value from coordinator. Raises KeyError if not included in latest update.""" + new_value = self.coordinator.data[self.solar_net_id][ + self.entity_description.key + ]["value"] + return round(new_value, 4) if isinstance(new_value, float) else new_value @callback - def register(self, sensor): - """Register child sensor for update subscriptions.""" - self._registered_sensors.add(sensor) - return lambda: self._registered_sensors.remove(sensor) + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + try: + self._attr_native_value = self._get_entity_value() + except KeyError: + return + self.async_write_ha_state() -class FroniusInverterSystem(FroniusAdapter): - """Adapter for the fronius inverter with system scope.""" +class InverterSensor(_FroniusSensorEntity): + """Defines a Fronius inverter device sensor entity.""" - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_system_inverter_data() + entity_descriptions = INVERTER_ENTITY_DESCRIPTIONS + + def __init__( + self, + coordinator: FroniusInverterUpdateCoordinator, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius inverter sensor.""" + self._entity_id_prefix = f"inverter_{solar_net_id}" + super().__init__(coordinator, key, solar_net_id) + # device_info created in __init__ from a `GetInverterInfo` request + self._attr_device_info = coordinator.inverter_info.device_info + self._attr_unique_id = f"{coordinator.inverter_info.unique_id}-{key}" -class FroniusInverterDevice(FroniusAdapter): - """Adapter for the fronius inverter with device scope.""" +class LoggerSensor(_FroniusSensorEntity): + """Defines a Fronius logger device sensor entity.""" - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_inverter_data(self._device) + entity_descriptions = LOGGER_ENTITY_DESCRIPTIONS + _entity_id_prefix = "logger_info_0" + + def __init__( + self, + coordinator: FroniusLoggerUpdateCoordinator, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius meter sensor.""" + super().__init__(coordinator, key, solar_net_id) + logger_data = self._device_data() + # Logger device is already created in FroniusSolarNet._create_solar_net_device + self._attr_device_info = coordinator.solar_net.system_device_info + self._attr_native_unit_of_measurement = logger_data[key].get("unit") + self._attr_unique_id = f'{logger_data["unique_identifier"]["value"]}-{key}' -class FroniusStorage(FroniusAdapter): - """Adapter for the fronius battery storage.""" +class MeterSensor(_FroniusSensorEntity): + """Defines a Fronius meter device sensor entity.""" - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_storage_data(self._device) + entity_descriptions = METER_ENTITY_DESCRIPTIONS + + def __init__( + self, + coordinator: FroniusMeterUpdateCoordinator, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius meter sensor.""" + self._entity_id_prefix = f"meter_{solar_net_id}" + super().__init__(coordinator, key, solar_net_id) + meter_data = self._device_data() + # S0 meters connected directly to inverters respond "n.a." as serial number + # `model` contains the inverter id: "S0 Meter at inverter 1" + if (meter_uid := meter_data["serial"]["value"]) == "n.a.": + meter_uid = ( + f"{coordinator.solar_net.solar_net_device_id}:" + f'{meter_data["model"]["value"]}' + ) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, meter_uid)}, + manufacturer=meter_data["manufacturer"]["value"], + model=meter_data["model"]["value"], + name=meter_data["model"]["value"], + via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id), + ) + self._attr_unique_id = f"{meter_uid}-{key}" -class FroniusMeterSystem(FroniusAdapter): - """Adapter for the fronius meter with system scope.""" +class OhmpilotSensor(_FroniusSensorEntity): + """Defines a Fronius Ohmpilot sensor entity.""" - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_system_meter_data() + entity_descriptions = OHMPILOT_ENTITY_DESCRIPTIONS + + def __init__( + self, + coordinator: FroniusOhmpilotUpdateCoordinator, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius meter sensor.""" + self._entity_id_prefix = f"ohmpilot_{solar_net_id}" + super().__init__(coordinator, key, solar_net_id) + device_data = self._device_data() + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_data["serial"]["value"])}, + manufacturer=device_data["manufacturer"]["value"], + model=f"{device_data['model']['value']} {device_data['hardware']['value']}", + name=device_data["model"]["value"], + sw_version=device_data["software"]["value"], + via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id), + ) + self._attr_unique_id = f'{device_data["serial"]["value"]}-{key}' -class FroniusMeterDevice(FroniusAdapter): - """Adapter for the fronius meter with device scope.""" +class PowerFlowSensor(_FroniusSensorEntity): + """Defines a Fronius power flow sensor entity.""" - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_meter_data(self._device) + entity_descriptions = POWER_FLOW_ENTITY_DESCRIPTIONS + _entity_id_prefix = "power_flow_0" + + def __init__( + self, + coordinator: FroniusPowerFlowUpdateCoordinator, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius power flow sensor.""" + super().__init__(coordinator, key, solar_net_id) + # SolarNet device is already created in FroniusSolarNet._create_solar_net_device + self._attr_device_info = coordinator.solar_net.system_device_info + self._attr_unique_id = ( + f"{coordinator.solar_net.solar_net_device_id}-power_flow-{key}" + ) -class FroniusPowerFlow(FroniusAdapter): - """Adapter for the fronius power flow.""" +class StorageSensor(_FroniusSensorEntity): + """Defines a Fronius storage device sensor entity.""" - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_power_flow() + entity_descriptions = STORAGE_ENTITY_DESCRIPTIONS + def __init__( + self, + coordinator: FroniusStorageUpdateCoordinator, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius storage sensor.""" + self._entity_id_prefix = f"storage_{solar_net_id}" + super().__init__(coordinator, key, solar_net_id) + storage_data = self._device_data() -class FroniusLoggerInfo(FroniusAdapter): - """Adapter for the fronius power flow.""" - - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_logger_info() - - -class FroniusTemplateSensor(SensorEntity): - """Sensor for the single values (e.g. pv power, ac power).""" - - def __init__(self, parent: FroniusAdapter, key: str) -> None: - """Initialize a singular value sensor.""" - self._key = key - self._attr_name = f"{key.replace('_', ' ').capitalize()} {parent.name}" - self._parent = parent - for prefix, device_class in PREFIX_DEVICE_CLASS_MAPPING: - if self._key.startswith(prefix): - self._attr_device_class = device_class - break - for prefix, state_class in PREFIX_STATE_CLASS_MAPPING: - if self._key.startswith(prefix): - self._attr_state_class = state_class - break - - @property - def should_poll(self): - """Device should not be polled, returns False.""" - return False - - @property - def available(self): - """Whether the fronius device is active.""" - return self._parent.available - - async def async_update(self): - """Update the internal state.""" - state = self._parent.data.get(self._key) - self._attr_native_value = state.get("value") - if isinstance(self._attr_native_value, float): - self._attr_native_value = round(self._attr_native_value, 2) - self._attr_native_unit_of_measurement = state.get("unit") - - async def async_added_to_hass(self): - """Register at parent component for updates.""" - self.async_on_remove(self._parent.register(self)) - - def __hash__(self): - """Hash sensor by hashing its name.""" - return hash(self.name) + self._attr_unique_id = f'{storage_data["serial"]["value"]}-{key}' + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, storage_data["serial"]["value"])}, + manufacturer=storage_data["manufacturer"]["value"], + model=storage_data["model"]["value"], + name=storage_data["model"]["value"], + via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id), + ) diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json new file mode 100644 index 00000000000..711e363eeba --- /dev/null +++ b/homeassistant/components/fronius/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "flow_title": "{device}", + "step": { + "user": { + "title": "Fronius SolarNet", + "description": "Configure the IP address or local hostname of your Fronius device.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "confirm_discovery": { + "description": "Do you want to add {device} to Home Assistant?" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + } + } +} diff --git a/homeassistant/components/fronius/translations/bg.json b/homeassistant/components/fronius/translations/bg.json new file mode 100644 index 00000000000..cbf1e2ae7c9 --- /dev/null +++ b/homeassistant/components/fronius/translations/bg.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/ca.json b/homeassistant/components/fronius/translations/ca.json new file mode 100644 index 00000000000..3bc7a2cea27 --- /dev/null +++ b/homeassistant/components/fronius/translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3" + }, + "description": "Configura l'adre\u00e7a IP o el nom d'amfitri\u00f3 local del teu dispositiu Fronius.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/de.json b/homeassistant/components/fronius/translations/de.json new file mode 100644 index 00000000000..b67ed566326 --- /dev/null +++ b/homeassistant/components/fronius/translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Konfiguriere die IP-Adresse oder den lokalen Hostnamen deines Fronius-Ger\u00e4ts.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/en.json b/homeassistant/components/fronius/translations/en.json new file mode 100644 index 00000000000..244949935e9 --- /dev/null +++ b/homeassistant/components/fronius/translations/en.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "invalid_host": "Invalid hostname or IP address" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "flow_title": "{device}", + "step": { + "confirm_discovery": { + "description": "Do you want to add {device} to Home Assistant?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Configure the IP address or local hostname of your Fronius device.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/et.json b/homeassistant/components/fronius/translations/et.json new file mode 100644 index 00000000000..6ccf725ac6f --- /dev/null +++ b/homeassistant/components/fronius/translations/et.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Seadista Froniuse seadme IP-aadress v\u00f5i kohalik hostinimi.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/he.json b/homeassistant/components/fronius/translations/he.json new file mode 100644 index 00000000000..1699e0f8e19 --- /dev/null +++ b/homeassistant/components/fronius/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", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/hu.json b/homeassistant/components/fronius/translations/hu.json new file mode 100644 index 00000000000..fc461b66121 --- /dev/null +++ b/homeassistant/components/fronius/translations/hu.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "host": "C\u00edm" + }, + "description": "Adja meg a Fronius eszk\u00f6z\u00e9nek helyi c\u00edm\u00e9t (IP vagy hosztn\u00e9v).", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/id.json b/homeassistant/components/fronius/translations/id.json new file mode 100644 index 00000000000..538dcc33ee8 --- /dev/null +++ b/homeassistant/components/fronius/translations/id.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Konfigurasikan alamat IP atau nama host lokal perangkat Fronius Anda", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/ja.json b/homeassistant/components/fronius/translations/ja.json new file mode 100644 index 00000000000..f5d2a03874e --- /dev/null +++ b/homeassistant/components/fronius/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": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "Fronius device\u306eIP\u30a2\u30c9\u30ec\u30b9\u307e\u305f\u306f\u30ed\u30fc\u30ab\u30eb\u30db\u30b9\u30c8\u540d\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/nl.json b/homeassistant/components/fronius/translations/nl.json new file mode 100644 index 00000000000..a6aa710148b --- /dev/null +++ b/homeassistant/components/fronius/translations/nl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Configureer het IP-adres of de lokale hostnaam van uw Fronius-apparaat.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/no.json b/homeassistant/components/fronius/translations/no.json new file mode 100644 index 00000000000..8a44a9ff8af --- /dev/null +++ b/homeassistant/components/fronius/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "host": "Vert" + }, + "description": "Konfigurer IP-adressen eller det lokale vertsnavnet til Fronius-enheten.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/pl.json b/homeassistant/components/fronius/translations/pl.json new file mode 100644 index 00000000000..b98f78aa82f --- /dev/null +++ b/homeassistant/components/fronius/translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + }, + "description": "Skonfiguruj adres IP lub lokaln\u0105 nazw\u0119 hosta urz\u0105dzenia Fronius.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/ru.json b/homeassistant/components/fronius/translations/ru.json new file mode 100644 index 00000000000..02f67288518 --- /dev/null +++ b/homeassistant/components/fronius/translations/ru.json @@ -0,0 +1,20 @@ +{ + "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." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 IP-\u0430\u0434\u0440\u0435\u0441 \u0438\u043b\u0438 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0435 \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0445\u043e\u0441\u0442\u0430 \u0412\u0430\u0448\u0435\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Fronius.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/sl.json b/homeassistant/components/fronius/translations/sl.json new file mode 100644 index 00000000000..0eec93b817d --- /dev/null +++ b/homeassistant/components/fronius/translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "host": "Gostitelj" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/tr.json b/homeassistant/components/fronius/translations/tr.json new file mode 100644 index 00000000000..4f45dd9fa1e --- /dev/null +++ b/homeassistant/components/fronius/translations/tr.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "host": "Ana bilgisayar" + }, + "description": "Fronius cihaz\u0131n\u0131z\u0131n IP adresini veya yerel ana bilgisayar ad\u0131n\u0131 yap\u0131land\u0131r\u0131n.", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/translations/zh-Hant.json b/homeassistant/components/fronius/translations/zh-Hant.json new file mode 100644 index 00000000000..18134514d38 --- /dev/null +++ b/homeassistant/components/fronius/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "description": "\u8a2d\u5b9a Fronius \u88dd\u7f6e IP \u4f4d\u5740\u6216\u672c\u5730\u4e3b\u6a5f\u540d\u3002", + "title": "Fronius SolarNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d2db6138171..994ac596527 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,9 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": [ - "home-assistant-frontend==20211109.0" - ], + "requirements": ["home-assistant-frontend==20211211.0"], "dependencies": [ "api", "auth", @@ -17,8 +15,6 @@ "system_log", "websocket_api" ], - "codeowners": [ - "@home-assistant/frontend" - ], + "codeowners": ["@home-assistant/frontend"], "quality_scale": "internal" -} \ No newline at end of file +} diff --git a/homeassistant/components/garages_amsterdam/__init__.py b/homeassistant/components/garages_amsterdam/__init__.py index be228e2f3a0..2077dec741f 100644 --- a/homeassistant/components/garages_amsterdam/__init__.py +++ b/homeassistant/components/garages_amsterdam/__init__.py @@ -39,7 +39,7 @@ async def get_coordinator( return hass.data[DOMAIN] async def async_get_garages(): - with async_timeout.timeout(10): + async with async_timeout.timeout(10): return { garage.garage_name: garage for garage in await garages_amsterdam.get_garages( diff --git a/homeassistant/components/garages_amsterdam/translations/bg.json b/homeassistant/components/garages_amsterdam/translations/bg.json index 3348117ce6b..122ff7a6474 100644 --- a/homeassistant/components/garages_amsterdam/translations/bg.json +++ b/homeassistant/components/garages_amsterdam/translations/bg.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", - "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\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", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" } }, diff --git a/homeassistant/components/garages_amsterdam/translations/id.json b/homeassistant/components/garages_amsterdam/translations/id.json index 37a312250a1..f12cfd6fc80 100644 --- a/homeassistant/components/garages_amsterdam/translations/id.json +++ b/homeassistant/components/garages_amsterdam/translations/id.json @@ -4,6 +4,15 @@ "already_configured": "Perangkat sudah dikonfigurasi", "cannot_connect": "Gagal terhubung", "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "garage_name": "Nama garasi" + }, + "title": "Pilih garasi untuk dipantau" + } } - } + }, + "title": "Garasi Amsterdam" } \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/ja.json b/homeassistant/components/garages_amsterdam/translations/ja.json new file mode 100644 index 00000000000..778dd7077a1 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/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", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "garage_name": "\u30ac\u30ec\u30fc\u30b8\u540d" + }, + "title": "\u76e3\u8996\u3059\u308b\u30ac\u30ec\u30fc\u30b8\u3092\u9078\u629e" + } + } + }, + "title": "Garages Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/tr.json b/homeassistant/components/garages_amsterdam/translations/tr.json new file mode 100644 index 00000000000..49ddee0ef7b --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/tr.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "garage_name": "Garaj ad\u0131" + }, + "title": "\u0130zlemek i\u00e7in bir garaj se\u00e7in" + } + } + }, + "title": "Garages Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/bg.json b/homeassistant/components/gdacs/translations/bg.json new file mode 100644 index 00000000000..80a7cc489a9 --- /dev/null +++ b/homeassistant/components/gdacs/translations/bg.json @@ -0,0 +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" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/ja.json b/homeassistant/components/gdacs/translations/ja.json new file mode 100644 index 00000000000..de9079d249f --- /dev/null +++ b/homeassistant/components/gdacs/translations/ja.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "step": { + "user": { + "data": { + "radius": "\u534a\u5f84" + }, + "title": "\u30d5\u30a3\u30eb\u30bf\u30fc\u306e\u8a73\u7d30\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/tr.json b/homeassistant/components/gdacs/translations/tr.json index aeb6a5a345e..a5f849405ea 100644 --- a/homeassistant/components/gdacs/translations/tr.json +++ b/homeassistant/components/gdacs/translations/tr.json @@ -7,7 +7,8 @@ "user": { "data": { "radius": "Yar\u0131\u00e7ap" - } + }, + "title": "Filtre ayr\u0131nt\u0131lar\u0131n\u0131z\u0131 doldurun." } } } diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 726b6e654e7..383674a7f75 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -171,8 +171,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) - old_state = await self.async_get_last_state() - if old_state is not None: + if (old_state := await self.async_get_last_state()) is not None: if old_state.attributes.get(ATTR_MODE) == MODE_AWAY: self._is_away = True self._saved_target_humidity = self._target_humidity diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 4d52240535f..2c27d371c5e 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -240,8 +240,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) # Check If we have an old state - old_state = await self.async_get_last_state() - if old_state is not None: + if (old_state := await self.async_get_last_state()) is not None: # If we have no initial temperature, restore if self._target_temp is None: # If we have a previously saved temperature diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index b2d26dcb2a5..2904d1a1b1d 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -106,9 +106,7 @@ class GeofencyEntity(TrackerEntity, RestoreEntity): if self._attributes: return - state = await self.async_get_last_state() - - if state is None: + if (state := await self.async_get_last_state()) is None: self._gps = (None, None) return diff --git a/homeassistant/components/geofency/translations/ja.json b/homeassistant/components/geofency/translations/ja.json new file mode 100644 index 00000000000..ba80a197994 --- /dev/null +++ b/homeassistant/components/geofency/translations/ja.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "webhook_not_internet_accessible": "Webhook\u30e1\u30c3\u30bb\u30fc\u30b8\u3092\u53d7\u4fe1\u3059\u308b\u306b\u306f\u3001Home Assistant\u306e\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306b\u3001\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u304b\u3089\u30a2\u30af\u30bb\u30b9\u3067\u304d\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "create_entry": { + "default": "Home Assistant\u306b\u30a4\u30d9\u30f3\u30c8\u3092\u9001\u4fe1\u3059\u308b\u306b\u306f\u3001Geofency\u306ewebhook\u6a5f\u80fd\u3092\u8a2d\u5b9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\n\n\u6b21\u306e\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044:\n\n- URL: `{webhook_url}`\n- Method(\u65b9\u5f0f): POST\n\n\u8a73\u7d30\u306f[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8]({docs_url}) \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "user": { + "description": "Geofency Webhook\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3082\u3088\u308d\u3057\u3044\u3067\u3059\u304b\uff1f", + "title": "Geofency Webhook\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/translations/tr.json b/homeassistant/components/geofency/translations/tr.json index 84adcdf8225..4cd04c64d7b 100644 --- a/homeassistant/components/geofency/translations/tr.json +++ b/homeassistant/components/geofency/translations/tr.json @@ -3,6 +3,15 @@ "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr.", "webhook_not_internet_accessible": "Webhook mesajlar\u0131n\u0131 alabilmek i\u00e7in Home Assistant \u00f6rne\u011finize internetten eri\u015filebilmelidir." + }, + "create_entry": { + "default": "Etkinlikleri Home Assistant'a g\u00f6ndermek i\u00e7in Geofency'de webhook \u00f6zelli\u011fini ayarlaman\u0131z gerekir. \n\n A\u015fa\u011f\u0131daki bilgileri doldurun: \n\n - URL: ` {webhook_url} `\n - Y\u00f6ntem: POST \n\n Daha fazla ayr\u0131nt\u0131 i\u00e7in [belgelere]( {docs_url}" + }, + "step": { + "user": { + "description": "Geofency Webhook'u kurmak istedi\u011finizden emin misiniz?", + "title": "Geofency Webhook'u kurun" + } } } } \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/ja.json b/homeassistant/components/geonetnz_quakes/translations/ja.json new file mode 100644 index 00000000000..8948e9c4e4a --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/ja.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "\u534a\u5f84" + }, + "title": "\u30d5\u30a3\u30eb\u30bf\u30fc\u306e\u8a73\u7d30\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/tr.json b/homeassistant/components/geonetnz_quakes/translations/tr.json index 717f6d72b94..a7d80261b8b 100644 --- a/homeassistant/components/geonetnz_quakes/translations/tr.json +++ b/homeassistant/components/geonetnz_quakes/translations/tr.json @@ -2,6 +2,15 @@ "config": { "abort": { "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Yar\u0131\u00e7ap" + }, + "title": "Filtre ayr\u0131nt\u0131lar\u0131n\u0131z\u0131 doldurun." + } } } } \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/ja.json b/homeassistant/components/geonetnz_volcano/translations/ja.json new file mode 100644 index 00000000000..c674e4e43dd --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/ja.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "step": { + "user": { + "data": { + "radius": "\u534a\u5f84" + }, + "title": "\u30d5\u30a3\u30eb\u30bf\u30fc\u306e\u8a73\u7d30\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/tr.json b/homeassistant/components/geonetnz_volcano/translations/tr.json index 980be333568..5cb07962f5b 100644 --- a/homeassistant/components/geonetnz_volcano/translations/tr.json +++ b/homeassistant/components/geonetnz_volcano/translations/tr.json @@ -7,7 +7,8 @@ "user": { "data": { "radius": "Yar\u0131\u00e7ap" - } + }, + "title": "Filtre ayr\u0131nt\u0131lar\u0131n\u0131z\u0131 doldurun." } } } diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index c3227254075..8457f62fd3f 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -87,7 +87,7 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" try: - with timeout(API_TIMEOUT): + async with timeout(API_TIMEOUT): return cast(Dict[str, Any], await self.gios.async_update()) except ( ApiError, diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index ff3f33408a5..0fa5052e129 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -37,7 +37,7 @@ class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): websession = async_get_clientsession(self.hass) - with timeout(API_TIMEOUT): + async with timeout(API_TIMEOUT): gios = Gios(user_input[CONF_STATION_ID], websession) await gios.async_update() diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index f60a8e99d5a..ff589d34791 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -8,6 +8,7 @@ from homeassistant.components.sensor import DOMAIN as PLATFORM, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION, ATTR_NAME, CONF_NAME 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.entity_registry import async_get_registry @@ -83,7 +84,7 @@ class GiosSensor(CoordinatorEntity, SensorEntity): """Initialize.""" super().__init__(coordinator) self._attr_device_info = DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, str(coordinator.gios.station_id))}, manufacturer=MANUFACTURER, name=DEFAULT_NAME, diff --git a/homeassistant/components/gios/translations/bg.json b/homeassistant/components/gios/translations/bg.json index 35cfa0ad1d7..85c36ea1383 100644 --- a/homeassistant/components/gios/translations/bg.json +++ b/homeassistant/components/gios/translations/bg.json @@ -1,5 +1,11 @@ { "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": { + "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/gios/translations/ja.json b/homeassistant/components/gios/translations/ja.json new file mode 100644 index 00000000000..2bcc20414bb --- /dev/null +++ b/homeassistant/components/gios/translations/ja.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_sensors_data": "\u3053\u306e\u6e2c\u5b9a\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u306e\u30bb\u30f3\u30b5\u30fc\u306e\u30c7\u30fc\u30bf\u304c\u7121\u52b9\u3067\u3059\u3002", + "wrong_station_id": "\u6e2c\u5b9a\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u306eID\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093\u3002" + }, + "step": { + "user": { + "data": { + "name": "\u540d\u524d", + "station_id": "\u6e2c\u5b9a\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u306eID" + }, + "description": "GIO\u015a(Polish Chief Inspectorate Of Environmental Protection)\u306e\u5927\u6c17\u8cea\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u3002\u8a2d\u5b9a\u306b\u3064\u3044\u3066\u30d8\u30eb\u30d7\u304c\u5fc5\u8981\u306a\u5834\u5408\u306f\u3001https://www.home-assistant.io/integrations/gios \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "Reach GIO\u015a server" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/tr.json b/homeassistant/components/gios/translations/tr.json index 590aec1894c..c0444ed99ed 100644 --- a/homeassistant/components/gios/translations/tr.json +++ b/homeassistant/components/gios/translations/tr.json @@ -4,7 +4,24 @@ "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_sensors_data": "Bu \u00f6l\u00e7\u00fcm istasyonu i\u00e7in ge\u00e7ersiz sens\u00f6r verileri.", + "wrong_station_id": "\u00d6l\u00e7\u00fcm istasyonunun kimli\u011fi do\u011fru de\u011fil." + }, + "step": { + "user": { + "data": { + "name": "Ad", + "station_id": "\u00d6l\u00e7\u00fcm istasyonunun kimli\u011fi" + }, + "description": "GIO\u015a (Polonya \u00c7evre Koruma Ba\u015f M\u00fcfetti\u015fli\u011fi) hava kalitesi entegrasyonunu kurun. Yap\u0131land\u0131rmayla ilgili yard\u0131ma ihtiyac\u0131n\u0131z varsa buraya bak\u0131n: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Polonya \u00c7evre Koruma Ba\u015f M\u00fcfetti\u015fli\u011fi)" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "GIO\u015a sunucusuna ula\u015f\u0131n" } } } \ No newline at end of file diff --git a/homeassistant/components/github/manifest.json b/homeassistant/components/github/manifest.json index ce2ad4e047b..b6b575ef7ce 100644 --- a/homeassistant/components/github/manifest.json +++ b/homeassistant/components/github/manifest.json @@ -3,7 +3,7 @@ "name": "GitHub", "documentation": "https://www.home-assistant.io/integrations/github", "requirements": [ - "aiogithubapi==21.8.0" + "aiogithubapi==21.11.0" ], "codeowners": [ "@timmo001", diff --git a/homeassistant/components/glances/translations/ja.json b/homeassistant/components/glances/translations/ja.json new file mode 100644 index 00000000000..0267110e0d0 --- /dev/null +++ b/homeassistant/components/glances/translations/ja.json @@ -0,0 +1,36 @@ +{ + "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", + "wrong_version": "\u5bfe\u5fdc\u3057\u3066\u3044\u306a\u3044\u30d0\u30fc\u30b8\u30e7\u30f3(2\u307e\u305f\u306f3\u306e\u307f)" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "ssl": "SSL\u8a3c\u660e\u66f8\u3092\u4f7f\u7528\u3059\u308b", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b", + "version": "Glances API\u30d0\u30fc\u30b8\u30e7\u30f3(2\u307e\u305f\u306f3)" + }, + "title": "Glances\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u983b\u5ea6" + }, + "description": "Glances\u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/tr.json b/homeassistant/components/glances/translations/tr.json index 69f0cd7ceb1..3a8f4852f86 100644 --- a/homeassistant/components/glances/translations/tr.json +++ b/homeassistant/components/glances/translations/tr.json @@ -4,16 +4,22 @@ "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "wrong_version": "S\u00fcr\u00fcm desteklenmiyor (yaln\u0131zca 2 veya 3)" }, "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", + "name": "Ad", "password": "Parola", "port": "Port", - "username": "Kullan\u0131c\u0131 Ad\u0131" - } + "ssl": "SSL sertifikas\u0131 kullan\u0131r", + "username": "Kullan\u0131c\u0131 Ad\u0131", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n", + "version": "Glances API S\u00fcr\u00fcm\u00fc (2 veya 3)" + }, + "title": "Glances Kurulumu" } } }, @@ -22,7 +28,8 @@ "init": { "data": { "scan_interval": "G\u00fcncelleme s\u0131kl\u0131\u011f\u0131" - } + }, + "description": "Glances i\u00e7in se\u00e7enekleri yap\u0131land\u0131r\u0131n" } } } diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index 774f1fd0e21..e03aa25f8f9 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION, ATTR_MODEL, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( @@ -25,6 +26,7 @@ from .const import ( DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN, + MANUFACTURER, MIN_TIME_BETWEEN_UPDATES, ) @@ -101,8 +103,9 @@ class YetiEntity(CoordinatorEntity): def device_info(self) -> DeviceInfo: """Return the device information of the entity.""" return DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, self.api.sysdata["macAddress"])}, identifiers={(DOMAIN, self._server_unique_id)}, - manufacturer="Goal Zero", + manufacturer=MANUFACTURER, model=self.api.sysdata[ATTR_MODEL], name=self._name, sw_version=self.api.data["firmwareVersion"], diff --git a/homeassistant/components/goalzero/config_flow.py b/homeassistant/components/goalzero/config_flow.py index f192c71cbf8..2d8c0c848c9 100644 --- a/homeassistant/components/goalzero/config_flow.py +++ b/homeassistant/components/goalzero/config_flow.py @@ -8,14 +8,13 @@ from goalzero import Yeti, exceptions import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_NAME 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 homeassistant.helpers.typing import DiscoveryInfoType -from .const import DEFAULT_NAME, DOMAIN +from .const import DEFAULT_NAME, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) @@ -27,13 +26,13 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize a Goal Zero Yeti flow.""" - self.ip_address = None + self.ip_address: str | None = None - async def async_step_dhcp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" - self.ip_address = discovery_info[IP_ADDRESS] + self.ip_address = discovery_info.ip - await self.async_set_unique_id(discovery_info[MAC_ADDRESS]) + await self.async_set_unique_id(discovery_info.macaddress) self._abort_if_unique_id_configured(updates={CONF_HOST: self.ip_address}) self._async_abort_entries_match({CONF_HOST: self.ip_address}) @@ -48,7 +47,7 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Allow the user to confirm adding the device.""" if user_input is not None: return self.async_create_entry( - title="Goal Zero", + title=MANUFACTURER, data={ CONF_HOST: self.ip_address, CONF_NAME: DEFAULT_NAME, diff --git a/homeassistant/components/goalzero/const.py b/homeassistant/components/goalzero/const.py index d99cacb253e..fef1636005d 100644 --- a/homeassistant/components/goalzero/const.py +++ b/homeassistant/components/goalzero/const.py @@ -8,5 +8,5 @@ DATA_KEY_COORDINATOR = "coordinator" DOMAIN = "goalzero" DEFAULT_NAME = "Yeti" DATA_KEY_API = "api" - +MANUFACTURER = "Goal Zero" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) diff --git a/homeassistant/components/goalzero/manifest.json b/homeassistant/components/goalzero/manifest.json index b19cb884353..f46401d2a6b 100644 --- a/homeassistant/components/goalzero/manifest.json +++ b/homeassistant/components/goalzero/manifest.json @@ -3,7 +3,7 @@ "name": "Goal Zero Yeti", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/goalzero", - "requirements": ["goalzero==0.2.0"], + "requirements": ["goalzero==0.2.1"], "dhcp": [ {"hostname": "yeti*"} ], diff --git a/homeassistant/components/goalzero/translations/id.json b/homeassistant/components/goalzero/translations/id.json index 5bab8fa03a2..d5897a2d944 100644 --- a/homeassistant/components/goalzero/translations/id.json +++ b/homeassistant/components/goalzero/translations/id.json @@ -11,6 +11,10 @@ "unknown": "Kesalahan yang tidak diharapkan" }, "step": { + "confirm_discovery": { + "description": "Dianjurkan untuk menggunakan reservasi DHCP pada router Anda. Jika tidak diatur, perangkat mungkin tidak tersedia hingga Home Assistant mendeteksi alamat IP baru. Lihat panduan pengguna router Anda.", + "title": "Goal Zero Yeti" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/goalzero/translations/ja.json b/homeassistant/components/goalzero/translations/ja.json new file mode 100644 index 00000000000..3e2e33bc302 --- /dev/null +++ b/homeassistant/components/goalzero/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "confirm_discovery": { + "description": "\u30eb\u30fc\u30bf\u30fc\u306eDHCP\u4e88\u7d04(DHCP reservation)\u3092\u304a\u52e7\u3081\u3057\u307e\u3059\u3002\u3053\u306e\u8a2d\u5b9a\u3092\u884c\u3063\u3066\u3044\u306a\u3044\u5834\u5408\u306b\u306f\u3001Home Assistant\u304c\u65b0\u3057\u3044IP\u30a2\u30c9\u30ec\u30b9\u3092\u691c\u51fa\u3059\u308b\u307e\u3067\u3001\u30c7\u30d0\u30a4\u30b9\u304c\u4f7f\u7528\u3067\u304d\u306a\u304f\u306a\u308b\u3053\u3068\u304c\u3042\u308a\u307e\u3059\u3002\u30eb\u30fc\u30bf\u30fc\u306e\u30e6\u30fc\u30b6\u30fc\u30de\u30cb\u30e5\u30a2\u30eb\u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Goal Zero Yeti" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d" + }, + "description": "\u307e\u305a\u3001Goal Zero app\u3092\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059: https://www.goalzero.com/product-features/yeti-app/\n\n\u30eb\u30fc\u30bf\u30fc\u306eDHCP\u4e88\u7d04(DHCP reservation)\u3092\u304a\u52e7\u3081\u3057\u307e\u3059\u3002\u3053\u306e\u8a2d\u5b9a\u3092\u884c\u3063\u3066\u3044\u306a\u3044\u5834\u5408\u306b\u306f\u3001Home Assistant\u304c\u65b0\u3057\u3044IP\u30a2\u30c9\u30ec\u30b9\u3092\u691c\u51fa\u3059\u308b\u307e\u3067\u3001\u30c7\u30d0\u30a4\u30b9\u304c\u4f7f\u7528\u3067\u304d\u306a\u304f\u306a\u308b\u3053\u3068\u304c\u3042\u308a\u307e\u3059\u3002\u30eb\u30fc\u30bf\u30fc\u306e\u30e6\u30fc\u30b6\u30fc\u30de\u30cb\u30e5\u30a2\u30eb\u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Goal Zero Yeti" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/goalzero/translations/tr.json b/homeassistant/components/goalzero/translations/tr.json index ae77262b2b3..1101717545b 100644 --- a/homeassistant/components/goalzero/translations/tr.json +++ b/homeassistant/components/goalzero/translations/tr.json @@ -1,17 +1,27 @@ { "config": { "abort": { - "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi", + "unknown": "Beklenmeyen hata" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi", "unknown": "Beklenmeyen hata" }, "step": { + "confirm_discovery": { + "description": "Y\u00f6nlendiricinizde DHCP rezervasyonu yap\u0131lmas\u0131 \u00f6nerilir. Kurulmazsa, Home Assistant yeni ip adresini alg\u0131layana kadar cihaz kullan\u0131lamayabilir. Y\u00f6nlendiricinizin kullan\u0131m k\u0131lavuzuna bak\u0131n.", + "title": "Goal Zero Yeti" + }, "user": { "data": { - "host": "Ana Bilgisayar" - } + "host": "Ana bilgisayar", + "name": "Ad" + }, + "description": "\u00d6ncelikle Goal Zero uygulamas\u0131n\u0131 indirmeniz gerekiyor: https://www.goalzero.com/product-features/yeti-app/ \n\n Yeti'nizi Wi-fi a\u011f\u0131n\u0131za ba\u011flamak i\u00e7in talimatlar\u0131 izleyin. Y\u00f6nlendiricinizde DHCP rezervasyonu yap\u0131lmas\u0131 \u00f6nerilir. Kurulmazsa, Home Assistant yeni ip adresini alg\u0131layana kadar cihaz kullan\u0131lamayabilir. Y\u00f6nlendiricinizin kullan\u0131m k\u0131lavuzuna bak\u0131n.", + "title": "Goal Zero Yeti" } } } diff --git a/homeassistant/components/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index a70a1b6bf81..5d0392e6db2 100644 --- a/homeassistant/components/gogogate2/common.py +++ b/homeassistant/components/gogogate2/common.py @@ -80,18 +80,24 @@ class GoGoGate2Entity(CoordinatorEntity): super().__init__(data_update_coordinator) self._config_entry = config_entry self._door = door - self._unique_id = unique_id + self._door_id = door.door_id + self._api = data_update_coordinator.api + self._attr_unique_id = unique_id @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - return self._unique_id - - def _get_door(self) -> AbstractDoor: + def door(self) -> AbstractDoor: + """Return the door object.""" door = get_door_by_id(self._door.door_id, self.coordinator.data) self._door = door or self._door return self._door + @property + def door_status(self) -> AbstractDoor: + """Return the door with status.""" + data = self.coordinator.data + door_with_statuses = self._api.async_get_door_statuses_from_info(data) + return door_with_statuses[self._door_id] + @property def device_info(self) -> DeviceInfo: """Device info for the controller.""" @@ -108,6 +114,11 @@ class GoGoGate2Entity(CoordinatorEntity): sw_version=data.firmwareversion, ) + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return {"door_id": self._door_id} + def get_data_update_coordinator( hass: HomeAssistant, config_entry: ConfigEntry diff --git a/homeassistant/components/gogogate2/config_flow.py b/homeassistant/components/gogogate2/config_flow.py index 6fd61b79795..e97b62102c4 100644 --- a/homeassistant/components/gogogate2/config_flow.py +++ b/homeassistant/components/gogogate2/config_flow.py @@ -7,7 +7,7 @@ from ismartgate.const import GogoGate2ApiErrorCode, ISmartGateApiErrorCode import voluptuous as vol from homeassistant import data_entry_flow -from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.const import ( CONF_DEVICE, @@ -35,15 +35,21 @@ class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN): self._ip_address = None self._device_type = None - async def async_step_homekit(self, discovery_info): + async def async_step_homekit( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> data_entry_flow.FlowResult: """Handle homekit discovery.""" - await self.async_set_unique_id(discovery_info["properties"]["id"]) - return await self._async_discovery_handler(discovery_info["host"]) + await self.async_set_unique_id( + discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] + ) + return await self._async_discovery_handler(discovery_info.host) - async def async_step_dhcp(self, discovery_info): + async def async_step_dhcp( + self, discovery_info: dhcp.DhcpServiceInfo + ) -> data_entry_flow.FlowResult: """Handle dhcp discovery.""" - await self.async_set_unique_id(discovery_info[MAC_ADDRESS]) - return await self._async_discovery_handler(discovery_info[IP_ADDRESS]) + await self.async_set_unique_id(discovery_info.macaddress) + return await self._async_discovery_handler(discovery_info.ip) async def _async_discovery_handler(self, ip_address): """Start the user flow from any discovery.""" diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py index fb5871e8636..16191304bb9 100644 --- a/homeassistant/components/gogogate2/cover.py +++ b/homeassistant/components/gogogate2/cover.py @@ -55,64 +55,42 @@ class DeviceCover(GoGoGate2Entity, CoverEntity): """Initialize the object.""" unique_id = cover_unique_id(config_entry, door) super().__init__(config_entry, data_update_coordinator, door, unique_id) - self._api = data_update_coordinator.api - self._is_available = True + self._attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE + self._attr_device_class = ( + DEVICE_CLASS_GATE if self.door.gate else DEVICE_CLASS_GARAGE + ) @property def name(self): """Return the name of the door.""" - return self._get_door().name + return self.door.name @property def is_closed(self): """Return true if cover is closed, else False.""" - door_status = self._get_door_status() + door_status = self.door_status if door_status == DoorStatus.OPENED: return False if door_status == DoorStatus.CLOSED: return True - return None - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - if self._get_door().gate: - return DEVICE_CLASS_GATE - - return DEVICE_CLASS_GARAGE - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_OPEN | SUPPORT_CLOSE - @property def is_closing(self): """Return if the cover is closing or not.""" - return self._get_door_status() == TransitionDoorStatus.CLOSING + return self.door_status == TransitionDoorStatus.CLOSING @property def is_opening(self): """Return if the cover is opening or not.""" - return self._get_door_status() == TransitionDoorStatus.OPENING + return self.door_status == TransitionDoorStatus.OPENING async def async_open_cover(self, **kwargs): """Open the door.""" - await self._api.async_open_door(self._get_door().door_id) + await self._api.async_open_door(self._door_id) await self.coordinator.async_refresh() async def async_close_cover(self, **kwargs): """Close the door.""" - await self._api.async_close_door(self._get_door().door_id) + await self._api.async_close_door(self._door_id) await self.coordinator.async_refresh() - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {"door_id": self._get_door().door_id} - - def _get_door_status(self) -> AbstractDoor: - return self._api.async_get_door_statuses_from_info(self.coordinator.data)[ - self._door.door_id - ] diff --git a/homeassistant/components/gogogate2/sensor.py b/homeassistant/components/gogogate2/sensor.py index 6eb3d823c22..743c8e7efc7 100644 --- a/homeassistant/components/gogogate2/sensor.py +++ b/homeassistant/components/gogogate2/sensor.py @@ -11,6 +11,7 @@ from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_TEMPERATURE, ENTITY_CATEGORY_DIAGNOSTIC, + PERCENTAGE, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant @@ -49,7 +50,20 @@ async def async_setup_entry( async_add_entities(sensors) -class DoorSensorBattery(GoGoGate2Entity, SensorEntity): +class DoorSensorEntity(GoGoGate2Entity, SensorEntity): + """Base class for door sensor entities.""" + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + attrs = super().extra_state_attributes + door = self.door + if door.sensorid is not None: + attrs["sensor_id"] = door.sensorid + return attrs + + +class DoorSensorBattery(DoorSensorEntity): """Battery sensor entity for gogogate2 door sensor.""" _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC @@ -63,38 +77,22 @@ class DoorSensorBattery(GoGoGate2Entity, SensorEntity): """Initialize the object.""" unique_id = sensor_unique_id(config_entry, door, "battery") super().__init__(config_entry, data_update_coordinator, door, unique_id) + self._attr_device_class = DEVICE_CLASS_BATTERY + self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_native_unit_of_measurement = PERCENTAGE @property def name(self): """Return the name of the door.""" - return f"{self._get_door().name} battery" - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_BATTERY + return f"{self.door.name} battery" @property def native_value(self): """Return the state of the entity.""" - door = self._get_door() - return door.voltage # This is a percentage, not an absolute voltage - - @property - def state_class(self) -> str: - """Return the Measurement State Class.""" - return STATE_CLASS_MEASUREMENT - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - door = self._get_door() - if door.sensorid is not None: - return {"door_id": door.door_id, "sensor_id": door.sensorid} - return None + return self.door.voltage # This is a percentage, not an absolute voltage -class DoorSensorTemperature(GoGoGate2Entity, SensorEntity): +class DoorSensorTemperature(DoorSensorEntity): """Temperature sensor entity for gogogate2 door sensor.""" def __init__( @@ -106,37 +104,16 @@ class DoorSensorTemperature(GoGoGate2Entity, SensorEntity): """Initialize the object.""" unique_id = sensor_unique_id(config_entry, door, "temperature") super().__init__(config_entry, data_update_coordinator, door, unique_id) + self._attr_device_class = DEVICE_CLASS_TEMPERATURE + self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_native_unit_of_measurement = TEMP_CELSIUS @property def name(self): """Return the name of the door.""" - return f"{self._get_door().name} temperature" - - @property - def state_class(self) -> str: - """Return the Measurement State Class.""" - return STATE_CLASS_MEASUREMENT - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_TEMPERATURE + return f"{self.door.name} temperature" @property def native_value(self): """Return the state of the entity.""" - door = self._get_door() - return door.temperature - - @property - def native_unit_of_measurement(self): - """Return the unit_of_measurement.""" - return TEMP_CELSIUS - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - door = self._get_door() - if door.sensorid is not None: - return {"door_id": door.door_id, "sensor_id": door.sensorid} - return None + return self.door.temperature diff --git a/homeassistant/components/gogogate2/translations/bg.json b/homeassistant/components/gogogate2/translations/bg.json new file mode 100644 index 00000000000..48e0277066c --- /dev/null +++ b/homeassistant/components/gogogate2/translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\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": "{device} ({ip_address})", + "step": { + "user": { + "data": { + "ip_address": "IP \u0430\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gogogate2/translations/ja.json b/homeassistant/components/gogogate2/translations/ja.json new file mode 100644 index 00000000000..d1c4eb62b92 --- /dev/null +++ b/homeassistant/components/gogogate2/translations/ja.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "flow_title": "{device} ({ip_address})", + "step": { + "user": { + "data": { + "ip_address": "IP\u30a2\u30c9\u30ec\u30b9", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u4ee5\u4e0b\u306b\u5fc5\u8981\u306a\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Gogogate2\u307e\u305f\u306fismartgate\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gogogate2/translations/ru.json b/homeassistant/components/gogogate2/translations/ru.json index 3c5af51d94e..0ff1fbd7662 100644 --- a/homeassistant/components/gogogate2/translations/ru.json +++ b/homeassistant/components/gogogate2/translations/ru.json @@ -15,7 +15,7 @@ "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 GogoGate2.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 GogoGate2.", "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Gogogate2 \u0438\u043b\u0438 ismartgate" } } diff --git a/homeassistant/components/gogogate2/translations/tr.json b/homeassistant/components/gogogate2/translations/tr.json index e912e7f8012..bd16dbe72f2 100644 --- a/homeassistant/components/gogogate2/translations/tr.json +++ b/homeassistant/components/gogogate2/translations/tr.json @@ -7,13 +7,16 @@ "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { - "ip_address": "\u0130p Adresi", + "ip_address": "IP Adresi", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "description": "A\u015fa\u011f\u0131da gerekli bilgileri sa\u011flay\u0131n.", + "title": "Gogogate2 veya ismartgate'i kurun" } } } diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index d23560b85c1..269c0aafea1 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -2,6 +2,7 @@ from homeassistant.components import ( alarm_control_panel, binary_sensor, + button, camera, climate, cover, @@ -120,6 +121,7 @@ EVENT_SYNC_RECEIVED = "google_assistant_sync" DOMAIN_TO_GOOGLE_TYPES = { alarm_control_panel.DOMAIN: TYPE_ALARM, + button.DOMAIN: TYPE_SCENE, camera.DOMAIN: TYPE_CAMERA, climate.DOMAIN: TYPE_THERMOSTAT, cover.DOMAIN: TYPE_BLINDS, diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 14667dbb303..238ee8d9576 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -346,8 +346,7 @@ class GoogleConfigStore: async def async_load(self): """Store current configuration to disk.""" - data = await self._store.async_load() - if data: + if data := await self._store.async_load(): self._data = data @@ -522,8 +521,7 @@ class GoogleEntity: if area and area.name: device["roomHint"] = area.name - device_info = await _get_device_info(device_entry) - if device_info: + if device_info := await _get_device_info(device_entry): device["deviceInfo"] = device_info return device diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index ba7dc2597bc..e7a73351f60 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -11,11 +11,7 @@ import jwt # Typing imports from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ( - CLOUD_NEVER_EXPOSED_ENTITIES, - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, -) +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, ENTITY_CATEGORIES from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import dt as dt_util @@ -117,10 +113,7 @@ class GoogleConfig(AbstractConfig): entity_registry = er.async_get(self.hass) registry_entry = entity_registry.async_get(state.entity_id) if registry_entry: - auxiliary_entity = registry_entry.entity_category in ( - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, - ) + auxiliary_entity = registry_entry.entity_category in ENTITY_CATEGORIES else: auxiliary_entity = False diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index c9f6c20c7af..eb7b5e9c9eb 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -17,6 +17,8 @@ from .const import ( from .error import SmartHomeError from .helpers import GoogleEntity, RequestData, async_get_entities +EXECUTE_LIMIT = 2 # Wait 2 seconds for execute to finish + HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) @@ -207,16 +209,23 @@ async def handle_devices_execute(hass, data, payload): entities[entity_id] = GoogleEntity(hass, data.config, state) executions[entity_id] = [execution] - execute_results = await asyncio.gather( - *( - _entity_execute(entities[entity_id], data, execution) - for entity_id, execution in executions.items() + try: + execute_results = await asyncio.wait_for( + asyncio.shield( + asyncio.gather( + *( + _entity_execute(entities[entity_id], data, execution) + for entity_id, execution in executions.items() + ) + ) + ), + EXECUTE_LIMIT, ) - ) - - for entity_id, result in zip(executions, execute_results): - if result is not None: - results[entity_id] = result + for entity_id, result in zip(executions, execute_results): + if result is not None: + results[entity_id] = result + except asyncio.TimeoutError: + pass final_results = list(results.values()) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 9f79f0f7d9b..30ea244bac9 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -6,6 +6,7 @@ import logging from homeassistant.components import ( alarm_control_panel, binary_sensor, + button, camera, cover, fan, @@ -35,7 +36,7 @@ from homeassistant.const import ( ATTR_MODE, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, - CAST_APP_ID_HOMEASSISTANT, + CAST_APP_ID_HOMEASSISTANT_MEDIA, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_CUSTOM_BYPASS, SERVICE_ALARM_ARM_HOME, @@ -263,7 +264,7 @@ class BrightnessTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_BRIGHTNESS_PCT: params["brightness"], }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -307,7 +308,7 @@ class CameraStreamTrait(_Trait): ) self.stream_info = { "cameraStreamAccessUrl": f"{get_url(self.hass)}{url}", - "cameraStreamReceiverAppId": CAST_APP_ID_HOMEASSISTANT, + "cameraStreamReceiverAppId": CAST_APP_ID_HOMEASSISTANT_MEDIA, } @@ -358,7 +359,7 @@ class OnOffTrait(_Trait): service_domain, service, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -463,7 +464,7 @@ class ColorSettingTrait(_Trait): light.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_COLOR_TEMP: temp}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -478,7 +479,7 @@ class ColorSettingTrait(_Trait): light.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_HS_COLOR: color}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -495,7 +496,7 @@ class ColorSettingTrait(_Trait): light.ATTR_HS_COLOR: [color["hue"], saturation], light.ATTR_BRIGHTNESS: brightness, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -513,11 +514,11 @@ class SceneTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" - return domain in (scene.DOMAIN, script.DOMAIN) + return domain in (button.DOMAIN, scene.DOMAIN, script.DOMAIN) def sync_attributes(self): """Return scene attributes for a sync request.""" - # Neither supported domain can support sceneReversible + # None of the supported domains can support sceneReversible return {} def query_attributes(self): @@ -526,12 +527,17 @@ class SceneTrait(_Trait): async def execute(self, command, data, params, challenge): """Execute a scene command.""" - # Don't block for scripts as they can be slow. + service = SERVICE_TURN_ON + if self.state.domain == button.DOMAIN: + service = button.SERVICE_PRESS + + # Don't block for scripts or buttons, as they can be slow. await self.hass.services.async_call( self.state.domain, - SERVICE_TURN_ON, + service, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=self.state.domain != script.DOMAIN, + blocking=(not self.config.should_report_state) + and self.state.domain not in (button.DOMAIN, script.DOMAIN), context=data.context, ) @@ -565,7 +571,7 @@ class DockTrait(_Trait): self.state.domain, vacuum.SERVICE_RETURN_TO_BASE, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -605,7 +611,7 @@ class LocatorTrait(_Trait): self.state.domain, vacuum.SERVICE_LOCATE, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -726,7 +732,7 @@ class StartStopTrait(_Trait): self.state.domain, vacuum.SERVICE_START, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) else: @@ -734,7 +740,7 @@ class StartStopTrait(_Trait): self.state.domain, vacuum.SERVICE_STOP, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) elif command == COMMAND_PAUSEUNPAUSE: @@ -743,7 +749,7 @@ class StartStopTrait(_Trait): self.state.domain, vacuum.SERVICE_PAUSE, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) else: @@ -751,7 +757,7 @@ class StartStopTrait(_Trait): self.state.domain, vacuum.SERVICE_START, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -771,7 +777,7 @@ class StartStopTrait(_Trait): self.state.domain, cover.SERVICE_STOP_COVER, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) else: @@ -988,7 +994,7 @@ class TemperatureSettingTrait(_Trait): climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: self.state.entity_id, ATTR_TEMPERATURE: temp}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1036,7 +1042,7 @@ class TemperatureSettingTrait(_Trait): climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, svc_data, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1049,7 +1055,7 @@ class TemperatureSettingTrait(_Trait): climate.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) return @@ -1059,7 +1065,7 @@ class TemperatureSettingTrait(_Trait): climate.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) return @@ -1072,7 +1078,7 @@ class TemperatureSettingTrait(_Trait): climate.ATTR_PRESET_MODE: self.google_to_preset[target_mode], ATTR_ENTITY_ID: self.state.entity_id, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) return @@ -1084,7 +1090,7 @@ class TemperatureSettingTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, climate.ATTR_HVAC_MODE: self.google_to_hvac[target_mode], }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1165,7 +1171,7 @@ class HumiditySettingTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, humidifier.ATTR_HUMIDITY: params["humidity"], }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1214,7 +1220,7 @@ class LockUnlockTrait(_Trait): lock.DOMAIN, service, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1337,7 +1343,7 @@ class ArmDisArmTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, ATTR_CODE: data.config.secure_devices_pin, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1425,7 +1431,7 @@ class FanSpeedTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, climate.ATTR_FAN_MODE: params["fanSpeed"], }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1437,7 +1443,7 @@ class FanSpeedTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_PERCENTAGE: params["fanSpeedPercent"], }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1453,7 +1459,7 @@ class FanSpeedTrait(_Trait): fan.DOMAIN, fan.SERVICE_SET_DIRECTION, {ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_DIRECTION: direction}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1594,7 +1600,7 @@ class ModesTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_PRESET_MODE: preset_mode, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) return @@ -1608,7 +1614,7 @@ class ModesTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, input_select.ATTR_OPTION: option, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) return @@ -1622,7 +1628,7 @@ class ModesTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, select.ATTR_OPTION: option, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) return @@ -1636,7 +1642,7 @@ class ModesTrait(_Trait): ATTR_MODE: requested_mode, ATTR_ENTITY_ID: self.state.entity_id, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) return @@ -1650,7 +1656,7 @@ class ModesTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_EFFECT: requested_effect, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) return @@ -1665,7 +1671,7 @@ class ModesTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_SOUND_MODE: sound_mode, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1739,7 +1745,7 @@ class InputSelectorTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_INPUT_SOURCE: requested_source, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1883,7 +1889,11 @@ class OpenCloseTrait(_Trait): _verify_pin_challenge(data, self.state, challenge) await self.hass.services.async_call( - cover.DOMAIN, service, svc_params, blocking=True, context=data.context + cover.DOMAIN, + service, + svc_params, + blocking=not self.config.should_report_state, + context=data.context, ) @@ -1945,7 +1955,7 @@ class VolumeTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_MEDIA_VOLUME_LEVEL: level, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -1981,7 +1991,7 @@ class VolumeTrait(_Trait): media_player.DOMAIN, svc, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) else: @@ -2003,7 +2013,7 @@ class VolumeTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_MEDIA_VOLUME_MUTED: mute, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -2165,7 +2175,7 @@ class TransportControlTrait(_Trait): media_player.DOMAIN, service, service_attrs, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) @@ -2270,7 +2280,7 @@ class ChannelTrait(_Trait): media_player.ATTR_MEDIA_CONTENT_ID: channel_number, media_player.ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_CHANNEL, }, - blocking=True, + blocking=not self.config.should_report_state, context=data.context, ) diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index af4e6771795..3d65f4eb297 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -55,8 +55,11 @@ SUPPORTED_LANGUAGES = [ "ko-KR", "lv-LV", "ml-IN", + "ms-MY", "nb-NO", + "nl-BE", "nl-NL", + "pa-IN", "pl-PL", "pt-BR", "pt-PT", @@ -278,7 +281,7 @@ class GoogleCloudTTSProvider(Provider): ) # pylint: enable=no-member - with async_timeout.timeout(10, loop=self.hass.loop): + async with async_timeout.timeout(10): response = await self.hass.async_add_executor_job( self._client.synthesize_speech, synthesis_input, voice, audio_config ) diff --git a/homeassistant/components/google_domains/__init__.py b/homeassistant/components/google_domains/__init__.py index ae6cb5c70d5..59386eb378a 100644 --- a/homeassistant/components/google_domains/__init__.py +++ b/homeassistant/components/google_domains/__init__.py @@ -65,7 +65,7 @@ async def _update_google_domains(hass, session, domain, user, password, timeout) params = {"hostname": domain} try: - with async_timeout.timeout(timeout): + async with async_timeout.timeout(timeout): resp = await session.get(url, params=params) body = await resp.text() diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index 1b999edc8b7..2bd8e15795d 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -22,6 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, 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 import homeassistant.util.dt as dt_util @@ -213,7 +214,7 @@ class GoogleTravelTimeSensor(SensorEntity): def device_info(self) -> DeviceInfo: """Return device specific attributes.""" return DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self._api_key)}, name=DOMAIN, ) diff --git a/homeassistant/components/google_travel_time/translations/bg.json b/homeassistant/components/google_travel_time/translations/bg.json new file mode 100644 index 00000000000..d49807e49af --- /dev/null +++ b/homeassistant/components/google_travel_time/translations/bg.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447", + "name": "\u0418\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/google_travel_time/translations/ja.json b/homeassistant/components/google_travel_time/translations/ja.json new file mode 100644 index 00000000000..2fb8ae2883c --- /dev/null +++ b/homeassistant/components/google_travel_time/translations/ja.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\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": { + "api_key": "API\u30ad\u30fc", + "destination": "\u76ee\u7684\u5730", + "name": "\u540d\u524d", + "origin": "\u30aa\u30ea\u30b8\u30f3" + }, + "description": "\u51fa\u767a\u5730\u3068\u76ee\u7684\u5730\u3092\u6307\u5b9a\u3059\u308b\u5834\u5408\u3001\u4f4f\u6240\u3001\u7def\u5ea6/\u7d4c\u5ea6\u306e\u5ea7\u6a19\u3001\u307e\u305f\u306fGoogle place ID\u306e\u5f62\u5f0f\u3067\u3001\u30d1\u30a4\u30d7\u6587\u5b57\u3067\u533a\u5207\u3089\u308c\u305f1\u3064\u4ee5\u4e0a\u306e\u5834\u6240\u3092\u6307\u5b9a\u3067\u304d\u307e\u3059\u3002Google place ID\u3092\u4f7f\u7528\u3057\u3066\u5834\u6240\u3092\u6307\u5b9a\u3059\u308b\u5834\u5408\u3001ID\u306e\u524d\u306b\u3001`place_id:` \u3092\u4ed8\u3051\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "avoid": "\u907f\u3051\u308b", + "language": "\u8a00\u8a9e", + "mode": "\u30c8\u30e9\u30d9\u30eb\u30e2\u30fc\u30c9", + "time": "\u6642\u9593", + "time_type": "\u6642\u9593\u30bf\u30a4\u30d7", + "transit_mode": "\u30c8\u30e9\u30f3\u30b8\u30c3\u30c8\u30e2\u30fc\u30c9", + "transit_routing_preference": "\u30c8\u30e9\u30f3\u30b8\u30c3\u30c8\u30eb\u30fc\u30c6\u30a3\u30f3\u30b0\u306e\u8a2d\u5b9a", + "units": "\u5358\u4f4d" + }, + "description": "\u5fc5\u8981\u306b\u5fdc\u3058\u3066\u3001\u51fa\u767a\u6642\u523b\u307e\u305f\u306f\u5230\u7740\u6642\u523b\u306e\u3044\u305a\u308c\u304b\u3092\u6307\u5b9a\u3067\u304d\u307e\u3059\u3002\u51fa\u767a\u6642\u523b\u3092\u6307\u5b9a\u3059\u308b\u5834\u5408\u306f\u3001Unix \u30bf\u30a4\u30e0\u30b9\u30bf\u30f3\u30d7\u306e'now' \u3001\u307e\u305f\u306f '08:00:00' \u306e\u3088\u3046\u306a24\u6642\u9593\u306e\u6642\u523b\u6587\u5b57\u5217\u3092\u5165\u529b\u3067\u304d\u307e\u3059\u3002\u5230\u7740\u6642\u523b\u3092\u6307\u5b9a\u3059\u308b\u5834\u5408\u306f\u3001Unix\u30bf\u30a4\u30e0\u30b9\u30bf\u30f3\u30d7\u307e\u305f\u306f\u3001'08:00:00' \u306e\u3088\u3046\u306a24\u6642\u9593\u306e\u6642\u523b\u6587\u5b57\u5217\u3092\u4f7f\u7528\u3067\u304d\u307e\u3059\u3002" + } + } + }, + "title": "Google\u30de\u30c3\u30d7\u306e\u79fb\u52d5\u6642\u9593(Travel Time)" +} \ No newline at end of file diff --git a/homeassistant/components/google_travel_time/translations/tr.json b/homeassistant/components/google_travel_time/translations/tr.json new file mode 100644 index 00000000000..421179ff1a0 --- /dev/null +++ b/homeassistant/components/google_travel_time/translations/tr.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "step": { + "user": { + "data": { + "api_key": "API Anahtar\u0131", + "destination": "Hedef", + "name": "Ad", + "origin": "Kalk\u0131\u015f" + }, + "description": "Ba\u015flang\u0131\u00e7 ve var\u0131\u015f yerini belirtirken, bir adres, enlem/boylam koordinatlar\u0131 veya bir Google yer kimli\u011fi bi\u00e7iminde dikey \u00e7izgi karakteriyle ayr\u0131lm\u0131\u015f bir veya daha fazla konum sa\u011flayabilirsiniz. Bir Google yer kimli\u011fi kullanarak konumu belirtirken, kimli\u011fin \u00f6n\u00fcne 'place_id:' eklenmelidir." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "avoid": "Ka\u00e7\u0131nmak", + "language": "Dil", + "mode": "Seyahat Modu", + "time": "Zaman", + "time_type": "Zaman T\u00fcr\u00fc", + "transit_mode": "Transit Modu", + "transit_routing_preference": "Toplu Ta\u015f\u0131ma Tercihi", + "units": "Birimler" + }, + "description": "\u0130ste\u011fe ba\u011fl\u0131 olarak bir Kalk\u0131\u015f Saati veya Var\u0131\u015f Saati belirtebilirsiniz. Bir hareket saati belirtiyorsan\u0131z, \"\u015fimdi\", bir Unix zaman damgas\u0131 veya \"08:00:00\" gibi 24 saatlik bir zaman dizesi girebilirsiniz. Bir var\u0131\u015f saati belirtiyorsan\u0131z, bir Unix zaman damgas\u0131 veya \"08:00:00\" gibi 24 saatlik bir zaman dizesi kullanabilirsiniz." + } + } + }, + "title": "Google Haritalar Seyahat S\u00fcresi" +} \ No newline at end of file diff --git a/homeassistant/components/gpmdp/media_player.py b/homeassistant/components/gpmdp/media_player.py index 649e0283f5a..c6dbe41c996 100644 --- a/homeassistant/components/gpmdp/media_player.py +++ b/homeassistant/components/gpmdp/media_player.py @@ -211,8 +211,7 @@ class GPMDP(MediaPlayerEntity): """Send ws messages to GPMDP and verify request id in response.""" try: - websocket = self.get_ws() - if websocket is None: + if (websocket := self.get_ws()) is None: self._status = STATE_OFF return self._request_id += 1 @@ -342,8 +341,7 @@ class GPMDP(MediaPlayerEntity): def media_seek(self, position): """Send media_seek command to media player.""" - websocket = self.get_ws() - if websocket is None: + if (websocket := self.get_ws()) is None: return websocket.send( json.dumps( @@ -358,24 +356,21 @@ class GPMDP(MediaPlayerEntity): def volume_up(self): """Send volume_up command to media player.""" - websocket = self.get_ws() - if websocket is None: + if (websocket := self.get_ws()) is None: return websocket.send('{"namespace": "volume", "method": "increaseVolume"}') self.schedule_update_ha_state() def volume_down(self): """Send volume_down command to media player.""" - websocket = self.get_ws() - if websocket is None: + if (websocket := self.get_ws()) is None: return websocket.send('{"namespace": "volume", "method": "decreaseVolume"}') self.schedule_update_ha_state() def set_volume_level(self, volume): """Set volume on media player, range(0..1).""" - websocket = self.get_ws() - if websocket is None: + if (websocket := self.get_ws()) is None: return websocket.send( json.dumps( diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index 8b0965cc434..18b5b7fa585 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -132,8 +132,7 @@ class GPSLoggerEntity(TrackerEntity, RestoreEntity): if self._location is not None: return - state = await self.async_get_last_state() - if state is None: + if (state := await self.async_get_last_state()) is None: self._location = (None, None) self._accuracy = None self._attributes = { diff --git a/homeassistant/components/gpslogger/translations/ja.json b/homeassistant/components/gpslogger/translations/ja.json new file mode 100644 index 00000000000..c04d58020d6 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/ja.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "webhook_not_internet_accessible": "Webhook\u30e1\u30c3\u30bb\u30fc\u30b8\u3092\u53d7\u4fe1\u3059\u308b\u306b\u306f\u3001Home Assistant\u306e\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306b\u3001\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u304b\u3089\u30a2\u30af\u30bb\u30b9\u3067\u304d\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "create_entry": { + "default": "Home Assistant\u306b\u30a4\u30d9\u30f3\u30c8\u3092\u9001\u4fe1\u3059\u308b\u306b\u306f\u3001GPSLogger\u3067webhook\u6a5f\u80fd\u3092\u8a2d\u5b9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\n\n\u6b21\u306e\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044:\n\n- URL: `{webhook_url}`\n- Method(\u65b9\u5f0f): POST\n\n\u8a73\u7d30\u306f[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8]({docs_url}) \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "user": { + "description": "GPSLogger Webhook\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3082\u3088\u308d\u3057\u3044\u3067\u3059\u304b\uff1f", + "title": "GPSLogger Webhook\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/translations/tr.json b/homeassistant/components/gpslogger/translations/tr.json index 84adcdf8225..ef10b98c5df 100644 --- a/homeassistant/components/gpslogger/translations/tr.json +++ b/homeassistant/components/gpslogger/translations/tr.json @@ -3,6 +3,15 @@ "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr.", "webhook_not_internet_accessible": "Webhook mesajlar\u0131n\u0131 alabilmek i\u00e7in Home Assistant \u00f6rne\u011finize internetten eri\u015filebilmelidir." + }, + "create_entry": { + "default": "Olaylar\u0131 Home Assistant'a g\u00f6ndermek i\u00e7in GPSLogger'da webhook \u00f6zelli\u011fini ayarlaman\u0131z gerekir. \n\n A\u015fa\u011f\u0131daki bilgileri doldurun: \n\n - URL: ` {webhook_url} `\n - Y\u00f6ntem: POST \n\n Daha fazla ayr\u0131nt\u0131 i\u00e7in [belgelere]( {docs_url}" + }, + "step": { + "user": { + "description": "GPSLogger Webhook'u kurmak istedi\u011finizden emin misiniz?", + "title": "GPSLogger Webhook'u kurun" + } } } } \ No newline at end of file diff --git a/homeassistant/components/gree/bridge.py b/homeassistant/components/gree/bridge.py index 9a927d13d29..41ba4bd9842 100644 --- a/homeassistant/components/gree/bridge.py +++ b/homeassistant/components/gree/bridge.py @@ -103,3 +103,10 @@ class DiscoveryService(Listener): await coordo.async_refresh() async_dispatcher_send(self.hass, DISPATCH_DEVICE_DISCOVERED, coordo) + + async def device_update(self, device_info: DeviceInfo) -> None: + """Handle updates in device information, update if ip has changed.""" + for coordinator in self.hass.data[DOMAIN][COORDINATORS]: + if coordinator.device.device_info.mac == device_info.mac: + coordinator.device.device_info.ip = device_info.ip + await coordinator.async_refresh() diff --git a/homeassistant/components/gree/translations/ja.json b/homeassistant/components/gree/translations/ja.json new file mode 100644 index 00000000000..d1234b69652 --- /dev/null +++ b/homeassistant/components/gree/translations/ja.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "confirm": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gree/translations/tr.json b/homeassistant/components/gree/translations/tr.json index 8de4663957e..3df15466f03 100644 --- a/homeassistant/components/gree/translations/tr.json +++ b/homeassistant/components/gree/translations/tr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, "step": { diff --git a/homeassistant/components/greeneye_monitor/__init__.py b/homeassistant/components/greeneye_monitor/__init__.py index cc7b8955756..d2b0e7c307b 100644 --- a/homeassistant/components/greeneye_monitor/__init__.py +++ b/homeassistant/components/greeneye_monitor/__init__.py @@ -1,7 +1,9 @@ """Support for monitoring a GreenEye Monitor energy monitor.""" +from __future__ import annotations + import logging -from greeneye import Monitors +import greeneye import voluptuous as vol from homeassistant.const import ( @@ -15,8 +17,10 @@ from homeassistant.const import ( TIME_MINUTES, TIME_SECONDS, ) +from homeassistant.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -117,15 +121,15 @@ COMPONENT_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema({DOMAIN: COMPONENT_SCHEMA}, extra=vol.ALLOW_EXTRA) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the GreenEye Monitor component.""" - monitors = Monitors() + monitors = greeneye.Monitors() hass.data[DATA_GREENEYE_MONITOR] = monitors server_config = config[DOMAIN] server = await monitors.start_server(server_config[CONF_PORT]) - async def close_server(*args): + async def close_server(event: Event) -> None: """Close the monitoring server.""" await server.close() @@ -189,7 +193,7 @@ async def async_setup(hass, config): return False hass.async_create_task( - async_load_platform(hass, "sensor", DOMAIN, all_sensors, config) + async_load_platform(hass, "sensor", DOMAIN, {CONF_SENSORS: all_sensors}, config) ) return True diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py index 7fbfa717229..57c5c79891d 100644 --- a/homeassistant/components/greeneye_monitor/sensor.py +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -1,16 +1,28 @@ """Support for the sensors in a GreenEye Monitor.""" +from __future__ import annotations + +from typing import Any, Optional, Union, cast + +import greeneye + from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( CONF_NAME, CONF_SENSOR_TYPE, + CONF_SENSORS, CONF_TEMPERATURE_UNIT, + DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, ELECTRIC_POTENTIAL_VOLT, POWER_WATT, TIME_HOURS, TIME_MINUTES, TIME_SECONDS, ) +from homeassistant.core import Config, HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import DiscoveryInfoType from . import ( CONF_COUNTED_QUANTITY, @@ -32,18 +44,17 @@ DATA_WATT_SECONDS = "watt_seconds" UNIT_WATTS = POWER_WATT COUNTER_ICON = "mdi:counter" -CURRENT_SENSOR_ICON = "mdi:flash" -TEMPERATURE_ICON = "mdi:thermometer" -VOLTAGE_ICON = "mdi:current-ac" -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: Config, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType, +) -> None: """Set up a single GEM temperature sensor.""" - if not discovery_info: - return - - entities = [] - for sensor in discovery_info: + entities: list[GEMSensor] = [] + for sensor in discovery_info[CONF_SENSORS]: sensor_type = sensor[CONF_SENSOR_TYPE] if sensor_type == SENSOR_TYPE_CURRENT: entities.append( @@ -86,42 +97,45 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities(entities) +UnderlyingSensorType = Union[ + greeneye.monitor.Channel, + greeneye.monitor.Monitor, + greeneye.monitor.PulseCounter, + greeneye.monitor.TemperatureSensor, +] + + class GEMSensor(SensorEntity): """Base class for GreenEye Monitor sensors.""" _attr_should_poll = False - def __init__(self, monitor_serial_number, name, sensor_type, number): + def __init__( + self, monitor_serial_number: int, name: str, sensor_type: str, number: int + ) -> None: """Construct the entity.""" self._monitor_serial_number = monitor_serial_number - self._name = name - self._sensor = None + self._attr_name = name + self._monitor: greeneye.monitor.Monitor | None = None self._sensor_type = sensor_type self._number = number + self._attr_unique_id = ( + f"{self._monitor_serial_number}-{self._sensor_type}-{self._number}" + ) - @property - def unique_id(self): - """Return a unique ID for this sensor.""" - return f"{self._monitor_serial_number}-{self._sensor_type}-{self._number}" - - @property - def name(self): - """Return the name of the channel.""" - return self._name - - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Wait for and connect to the sensor.""" monitors = self.hass.data[DATA_GREENEYE_MONITOR] if not self._try_connect_to_monitor(monitors): monitors.add_listener(self._on_new_monitor) - def _on_new_monitor(self, *args): + def _on_new_monitor(self, monitor: greeneye.monitor.Monitor) -> None: monitors = self.hass.data[DATA_GREENEYE_MONITOR] if self._try_connect_to_monitor(monitors): monitors.remove_listener(self._on_new_monitor) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Remove listener from the sensor.""" if self._sensor: self._sensor.remove_listener(self.async_write_ha_state) @@ -129,44 +143,48 @@ class GEMSensor(SensorEntity): monitors = self.hass.data[DATA_GREENEYE_MONITOR] monitors.remove_listener(self._on_new_monitor) - def _try_connect_to_monitor(self, monitors): - monitor = monitors.monitors.get(self._monitor_serial_number) - if not monitor: + def _try_connect_to_monitor(self, monitors: greeneye.Monitors) -> bool: + self._monitor = monitors.monitors.get(self._monitor_serial_number) + if not self._sensor: return False - self._sensor = self._get_sensor(monitor) self._sensor.add_listener(self.async_write_ha_state) + self.async_write_ha_state() return True - def _get_sensor(self, monitor): + @property + def _sensor(self) -> UnderlyingSensorType | None: raise NotImplementedError() class CurrentSensor(GEMSensor): """Entity showing power usage on one channel of the monitor.""" - _attr_icon = CURRENT_SENSOR_ICON _attr_native_unit_of_measurement = UNIT_WATTS + _attr_device_class = DEVICE_CLASS_POWER - def __init__(self, monitor_serial_number, number, name, net_metering): + def __init__( + self, monitor_serial_number: int, number: int, name: str, net_metering: bool + ) -> None: """Construct the entity.""" super().__init__(monitor_serial_number, name, "current", number) self._net_metering = net_metering - def _get_sensor(self, monitor): - return monitor.channels[self._number - 1] + @property + def _sensor(self) -> greeneye.monitor.Channel | None: + return self._monitor.channels[self._number - 1] if self._monitor else None @property - def native_value(self): + def native_value(self) -> float | None: """Return the current number of watts being used by the channel.""" if not self._sensor: return None - return self._sensor.watts + return cast(Optional[float], self._sensor.watts) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return total wattseconds in the state dictionary.""" if not self._sensor: return None @@ -186,36 +204,38 @@ class PulseCounter(GEMSensor): def __init__( self, - monitor_serial_number, - number, - name, - counted_quantity, - time_unit, - counted_quantity_per_pulse, - ): + monitor_serial_number: int, + number: int, + name: str, + counted_quantity: str, + time_unit: str, + counted_quantity_per_pulse: float, + ) -> None: """Construct the entity.""" super().__init__(monitor_serial_number, name, "pulse", number) - self._counted_quantity = counted_quantity self._counted_quantity_per_pulse = counted_quantity_per_pulse self._time_unit = time_unit - - def _get_sensor(self, monitor): - return monitor.pulse_counters[self._number - 1] + self._attr_native_unit_of_measurement = f"{counted_quantity}/{self._time_unit}" @property - def native_value(self): + def _sensor(self) -> greeneye.monitor.PulseCounter | None: + return self._monitor.pulse_counters[self._number - 1] if self._monitor else None + + @property + def native_value(self) -> float | None: """Return the current rate of change for the given pulse counter.""" if not self._sensor or self._sensor.pulses_per_second is None: return None - return ( + result = ( self._sensor.pulses_per_second * self._counted_quantity_per_pulse * self._seconds_per_time_unit ) + return cast(float, result) @property - def _seconds_per_time_unit(self): + def _seconds_per_time_unit(self) -> int: """Return the number of seconds in the given display time unit.""" if self._time_unit == TIME_SECONDS: return 1 @@ -224,13 +244,13 @@ class PulseCounter(GEMSensor): if self._time_unit == TIME_HOURS: return 3600 - @property - def native_unit_of_measurement(self): - """Return the unit of measurement for this pulse counter.""" - return f"{self._counted_quantity}/{self._time_unit}" + # Config schema should have ensured it is one of the above values + raise Exception( + f"Invalid value for time unit: {self._time_unit}. Expected one of {TIME_SECONDS}, {TIME_MINUTES}, or {TIME_HOURS}" + ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return total pulses in the data dictionary.""" if not self._sensor: return None @@ -242,48 +262,50 @@ class TemperatureSensor(GEMSensor): """Entity showing temperature from one temperature sensor.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_icon = TEMPERATURE_ICON - def __init__(self, monitor_serial_number, number, name, unit): + def __init__( + self, monitor_serial_number: int, number: int, name: str, unit: str + ) -> None: """Construct the entity.""" super().__init__(monitor_serial_number, name, "temp", number) - self._unit = unit - - def _get_sensor(self, monitor): - return monitor.temperature_sensors[self._number - 1] + self._attr_native_unit_of_measurement = unit @property - def native_value(self): + def _sensor(self) -> greeneye.monitor.TemperatureSensor | None: + return ( + self._monitor.temperature_sensors[self._number - 1] + if self._monitor + else None + ) + + @property + def native_value(self) -> float | None: """Return the current temperature being reported by this sensor.""" if not self._sensor: return None - return self._sensor.temperature - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement for this sensor (user specified).""" - return self._unit + return cast(Optional[float], self._sensor.temperature) class VoltageSensor(GEMSensor): """Entity showing voltage.""" - _attr_icon = VOLTAGE_ICON _attr_native_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT + _attr_device_class = DEVICE_CLASS_VOLTAGE - def __init__(self, monitor_serial_number, number, name): + def __init__(self, monitor_serial_number: int, number: int, name: str) -> None: """Construct the entity.""" super().__init__(monitor_serial_number, name, "volts", number) - def _get_sensor(self, monitor): + @property + def _sensor(self) -> greeneye.monitor.Monitor | None: """Wire the updates to the monitor itself, since there is no voltage element in the API.""" - return monitor + return self._monitor @property - def native_value(self): + def native_value(self) -> float | None: """Return the current voltage being reported by this sensor.""" if not self._sensor: return None - return self._sensor.voltage + return cast(Optional[float], self._sensor.voltage) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 523b45a94f7..e3816d52d60 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -224,8 +224,7 @@ async def async_setup(hass, config): """Remove all user-defined groups and load new ones from config.""" auto = list(filter(lambda e: not e.user_defined, component.entities)) - conf = await component.async_prepare_reload() - if conf is None: + if (conf := await component.async_prepare_reload()) is None: return await _async_process_config(hass, conf, component) diff --git a/homeassistant/components/group/translations/ca.json b/homeassistant/components/group/translations/ca.json index 5cb406727e8..552a2c9677e 100644 --- a/homeassistant/components/group/translations/ca.json +++ b/homeassistant/components/group/translations/ca.json @@ -5,9 +5,9 @@ "home": "A casa", "locked": "Bloquejat", "not_home": "Fora", - "off": "off", + "off": "OFF", "ok": "OK", - "on": "on", + "on": "ON", "open": "Obert/a", "problem": "Problema", "unlocked": "Desbloquejat" diff --git a/homeassistant/components/group/translations/ja.json b/homeassistant/components/group/translations/ja.json index d6f283d5ef6..1faf42c68d4 100644 --- a/homeassistant/components/group/translations/ja.json +++ b/homeassistant/components/group/translations/ja.json @@ -1,13 +1,16 @@ { "state": { "_": { - "closed": "\u9589\u9396", + "closed": "\u30af\u30ed\u30fc\u30ba\u30c9", "home": "\u5728\u5b85", "locked": "\u30ed\u30c3\u30af\u3055\u308c\u307e\u3057\u305f", - "not_home": "\u5916\u51fa", + "not_home": "\u96e2\u5e2d(away)", "off": "\u30aa\u30d5", "ok": "OK", - "on": "\u30aa\u30f3" + "on": "\u30aa\u30f3", + "open": "\u30aa\u30fc\u30d7\u30f3", + "problem": "\u554f\u984c", + "unlocked": "\u30ed\u30c3\u30af\u89e3\u9664" } }, "title": "\u30b0\u30eb\u30fc\u30d7" diff --git a/homeassistant/components/group/translations/tr.json b/homeassistant/components/group/translations/tr.json index 5a596efdf01..a491fe6b244 100644 --- a/homeassistant/components/group/translations/tr.json +++ b/homeassistant/components/group/translations/tr.json @@ -9,7 +9,7 @@ "ok": "Tamam", "on": "A\u00e7\u0131k", "open": "A\u00e7\u0131k", - "problem": "Problem", + "problem": "Sorun", "unlocked": "Kilitli de\u011fil" } }, diff --git a/homeassistant/components/growatt_server/sensor_types/__init__.py b/homeassistant/components/growatt_server/sensor_types/__init__.py new file mode 100644 index 00000000000..3f5be3be7f5 --- /dev/null +++ b/homeassistant/components/growatt_server/sensor_types/__init__.py @@ -0,0 +1 @@ +"""Sensor types for supported Growatt systems.""" diff --git a/homeassistant/components/growatt_server/translations/bg.json b/homeassistant/components/growatt_server/translations/bg.json index 02c83a6e916..46573dc14b4 100644 --- a/homeassistant/components/growatt_server/translations/bg.json +++ b/homeassistant/components/growatt_server/translations/bg.json @@ -1,9 +1,15 @@ { "config": { + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { - "url": "URL" + "name": "\u0418\u043c\u0435", + "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/growatt_server/translations/id.json b/homeassistant/components/growatt_server/translations/id.json index 59975607fb7..dcc75c42fa0 100644 --- a/homeassistant/components/growatt_server/translations/id.json +++ b/homeassistant/components/growatt_server/translations/id.json @@ -1,17 +1,28 @@ { "config": { + "abort": { + "no_plants": "Tidak ada pembangkit yang ditemukan di akun ini" + }, "error": { "invalid_auth": "Autentikasi tidak valid" }, "step": { + "plant": { + "data": { + "plant_id": "Pembangkit" + }, + "title": "Pilih pembangkit Anda" + }, "user": { "data": { "name": "Nama", "password": "Kata Sandi", "url": "URL", "username": "Nama Pengguna" - } + }, + "title": "Masukkan informasi Growatt Anda" } } - } + }, + "title": "Server Growatt" } \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/ja.json b/homeassistant/components/growatt_server/translations/ja.json new file mode 100644 index 00000000000..1693f027c78 --- /dev/null +++ b/homeassistant/components/growatt_server/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "\u3053\u306e\u30a2\u30ab\u30a6\u30f3\u30c8\u3067\u690d\u7269(plants)\u306f\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "plant": { + "data": { + "plant_id": "\u30d7\u30e9\u30f3\u30c8" + }, + "title": "\u30d7\u30e9\u30f3\u30c8\u3092\u9078\u629e" + }, + "user": { + "data": { + "name": "\u540d\u524d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "url": "URL", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Growatt\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044" + } + } + }, + "title": "Growatt\u30b5\u30fc\u30d0\u30fc" +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/ru.json b/homeassistant/components/growatt_server/translations/ru.json index 0b98838dac8..c5eedf66ad3 100644 --- a/homeassistant/components/growatt_server/translations/ru.json +++ b/homeassistant/components/growatt_server/translations/ru.json @@ -20,7 +20,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": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Growatt." + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Growatt." } } }, diff --git a/homeassistant/components/growatt_server/translations/tr.json b/homeassistant/components/growatt_server/translations/tr.json new file mode 100644 index 00000000000..482ed6a427c --- /dev/null +++ b/homeassistant/components/growatt_server/translations/tr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "Bu hesapta bitki bulunamad\u0131" + }, + "error": { + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + }, + "step": { + "plant": { + "data": { + "plant_id": "Bitki" + }, + "title": "Tesisinizi se\u00e7in" + }, + "user": { + "data": { + "name": "Ad", + "password": "Parola", + "url": "URL", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "title": "Growatt bilgilerinizi girin" + } + } + }, + "title": "Growatt Sunucusu" +} \ No newline at end of file diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 9450c717148..367a45aa073 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -544,7 +544,7 @@ class GTFSDepartureSensor(SensorEntity): self._available = False self._icon = ICON self._name = "" - self._state: str | None = None + self._state: datetime.datetime | None = None self._attributes: dict[str, Any] = {} self._agency = None @@ -563,7 +563,7 @@ class GTFSDepartureSensor(SensorEntity): return self._name @property - def native_value(self) -> str | None: + def native_value(self) -> datetime.datetime | None: """Return the state of the sensor.""" return self._state @@ -619,9 +619,7 @@ class GTFSDepartureSensor(SensorEntity): if not self._departure: self._state = None else: - self._state = dt_util.as_utc( - self._departure["departure_time"] - ).isoformat() + self._state = self._departure["departure_time"] # Fetch trip and route details once, unless updated if not self._departure: diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 3610d3d3d80..fc7e2dd7be3 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -2,14 +2,29 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable -from typing import cast +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, cast from aioguardian import Client +from aioguardian.errors import GuardianError +import voluptuous as vol -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION, CONF_IP_ADDRESS, CONF_PORT -from homeassistant.core import HomeAssistant, callback +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import ( + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + CONF_DEVICE_ID, + CONF_FILENAME, + CONF_IP_ADDRESS, + CONF_PORT, + CONF_URL, +) +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import ( @@ -28,24 +43,97 @@ from .const import ( DATA_CLIENT, DATA_COORDINATOR, DATA_COORDINATOR_PAIRED_SENSOR, - DATA_PAIRED_SENSOR_MANAGER, DOMAIN, LOGGER, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) from .util import GuardianDataUpdateCoordinator +DATA_PAIRED_SENSOR_MANAGER = "paired_sensor_manager" + +SERVICE_NAME_DISABLE_AP = "disable_ap" +SERVICE_NAME_ENABLE_AP = "enable_ap" +SERVICE_NAME_PAIR_SENSOR = "pair_sensor" +SERVICE_NAME_REBOOT = "reboot" +SERVICE_NAME_RESET_VALVE_DIAGNOSTICS = "reset_valve_diagnostics" +SERVICE_NAME_UNPAIR_SENSOR = "unpair_sensor" +SERVICE_NAME_UPGRADE_FIRMWARE = "upgrade_firmware" + +SERVICES = ( + SERVICE_NAME_DISABLE_AP, + SERVICE_NAME_ENABLE_AP, + SERVICE_NAME_PAIR_SENSOR, + SERVICE_NAME_REBOOT, + SERVICE_NAME_RESET_VALVE_DIAGNOSTICS, + SERVICE_NAME_UNPAIR_SENSOR, + SERVICE_NAME_UPGRADE_FIRMWARE, +) + +SERVICE_BASE_SCHEMA = vol.All( + cv.deprecated(ATTR_ENTITY_ID), + vol.Schema( + { + vol.Optional(ATTR_DEVICE_ID): cv.string, + vol.Optional(ATTR_ENTITY_ID): cv.entity_id, + } + ), + cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), +) + +SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA = vol.All( + cv.deprecated(ATTR_ENTITY_ID), + vol.Schema( + { + vol.Optional(ATTR_DEVICE_ID): cv.string, + vol.Optional(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(CONF_UID): cv.string, + } + ), + cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), +) + +SERVICE_UPGRADE_FIRMWARE_SCHEMA = vol.All( + cv.deprecated(ATTR_ENTITY_ID), + vol.Schema( + { + vol.Optional(ATTR_DEVICE_ID): cv.string, + vol.Optional(ATTR_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_URL): cv.url, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_FILENAME): cv.string, + }, + ), + cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), +) + + PLATFORMS = ["binary_sensor", "sensor", "switch"] +@callback +def async_get_entry_id_for_service_call(hass: HomeAssistant, call: ServiceCall) -> str: + """Get the entry ID related to a service call (by device ID).""" + if ATTR_ENTITY_ID in call.data: + entity_registry = er.async_get(hass) + entity_registry_entry = entity_registry.async_get(call.data[ATTR_ENTITY_ID]) + if TYPE_CHECKING: + assert entity_registry_entry + assert entity_registry_entry.config_entry_id + return entity_registry_entry.config_entry_id + + device_id = call.data[CONF_DEVICE_ID] + device_registry = dr.async_get(hass) + + if device_entry := device_registry.async_get(device_id): + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.entry_id in device_entry.config_entries: + return entry.entry_id + + raise ValueError(f"No client for device ID: {device_id}") + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Elexa Guardian from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: {}, - DATA_COORDINATOR_PAIRED_SENSOR: {}, - } - client = Client(entry.data[CONF_IP_ADDRESS], port=entry.data[CONF_PORT]) # The valve controller's UDP-based API can't handle concurrent requests very well, @@ -53,6 +141,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_lock = asyncio.Lock() # Set up DataUpdateCoordinators for the valve controller: + coordinators: dict[str, GuardianDataUpdateCoordinator] = {} init_valve_controller_tasks = [] for api, api_coro in ( (API_SENSOR_PAIR_DUMP, client.sensor.pair_dump), @@ -61,9 +150,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: (API_VALVE_STATUS, client.valve.status), (API_WIFI_STATUS, client.wifi.status), ): - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR][ - api - ] = GuardianDataUpdateCoordinator( + coordinator = coordinators[api] = GuardianDataUpdateCoordinator( hass, client=client, api_name=api, @@ -74,15 +161,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: init_valve_controller_tasks.append(coordinator.async_refresh()) await asyncio.gather(*init_valve_controller_tasks) - hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] = client # Set up an object to evaluate each batch of paired sensor UIDs and add/remove # devices as appropriate: - paired_sensor_manager = hass.data[DOMAIN][entry.entry_id][ - DATA_PAIRED_SENSOR_MANAGER - ] = PairedSensorManager(hass, entry, client, api_lock) + paired_sensor_manager = PairedSensorManager(hass, entry, client, api_lock) await paired_sensor_manager.async_process_latest_paired_sensor_uids() + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + DATA_CLIENT: client, + DATA_COORDINATOR: coordinators, + DATA_COORDINATOR_PAIRED_SENSOR: {}, + DATA_PAIRED_SENSOR_MANAGER: paired_sensor_manager, + } + @callback def async_process_paired_sensor_uids() -> None: """Define a callback for when new paired sensor data is received.""" @@ -90,13 +182,108 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: paired_sensor_manager.async_process_latest_paired_sensor_uids() ) - hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR][ - API_SENSOR_PAIR_DUMP - ].async_add_listener(async_process_paired_sensor_uids) + coordinators[API_SENSOR_PAIR_DUMP].async_add_listener( + async_process_paired_sensor_uids + ) # Set up all of the Guardian entity platforms: hass.config_entries.async_setup_platforms(entry, PLATFORMS) + @callback + def extract_client(func: Callable) -> Callable: + """Define a decorator to get the correct client for a service call.""" + + async def wrapper(call: ServiceCall) -> None: + """Wrap the service function.""" + entry_id = async_get_entry_id_for_service_call(hass, call) + client = hass.data[DOMAIN][entry_id][DATA_CLIENT] + + try: + async with client: + await func(call, client) + except GuardianError as err: + LOGGER.error("Error while executing %s: %s", func.__name__, err) + + return wrapper + + @extract_client + async def async_disable_ap(call: ServiceCall, client: Client) -> None: + """Disable the onboard AP.""" + await client.wifi.disable_ap() + + @extract_client + async def async_enable_ap(call: ServiceCall, client: Client) -> None: + """Enable the onboard AP.""" + await client.wifi.enable_ap() + + @extract_client + async def async_pair_sensor(call: ServiceCall, client: Client) -> None: + """Add a new paired sensor.""" + entry_id = async_get_entry_id_for_service_call(hass, call) + paired_sensor_manager = hass.data[DOMAIN][entry_id][DATA_PAIRED_SENSOR_MANAGER] + uid = call.data[CONF_UID] + + await client.sensor.pair_sensor(uid) + await paired_sensor_manager.async_pair_sensor(uid) + + @extract_client + async def async_reboot(call: ServiceCall, client: Client) -> None: + """Reboot the valve controller.""" + await client.system.reboot() + + @extract_client + async def async_reset_valve_diagnostics(call: ServiceCall, client: Client) -> None: + """Fully reset system motor diagnostics.""" + await client.valve.reset() + + @extract_client + async def async_unpair_sensor(call: ServiceCall, client: Client) -> None: + """Remove a paired sensor.""" + entry_id = async_get_entry_id_for_service_call(hass, call) + paired_sensor_manager = hass.data[DOMAIN][entry_id][DATA_PAIRED_SENSOR_MANAGER] + uid = call.data[CONF_UID] + + await client.sensor.unpair_sensor(uid) + await paired_sensor_manager.async_unpair_sensor(uid) + + @extract_client + async def async_upgrade_firmware(call: ServiceCall, client: Client) -> None: + """Upgrade the device firmware.""" + await client.system.upgrade_firmware( + url=call.data[CONF_URL], + port=call.data[CONF_PORT], + filename=call.data[CONF_FILENAME], + ) + + for service_name, schema, method in ( + (SERVICE_NAME_DISABLE_AP, SERVICE_BASE_SCHEMA, async_disable_ap), + (SERVICE_NAME_ENABLE_AP, SERVICE_BASE_SCHEMA, async_enable_ap), + ( + SERVICE_NAME_PAIR_SENSOR, + SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA, + async_pair_sensor, + ), + (SERVICE_NAME_REBOOT, SERVICE_BASE_SCHEMA, async_reboot), + ( + SERVICE_NAME_RESET_VALVE_DIAGNOSTICS, + SERVICE_BASE_SCHEMA, + async_reset_valve_diagnostics, + ), + ( + SERVICE_NAME_UNPAIR_SENSOR, + SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA, + async_unpair_sensor, + ), + ( + SERVICE_NAME_UPGRADE_FIRMWARE, + SERVICE_UPGRADE_FIRMWARE_SCHEMA, + async_upgrade_firmware, + ), + ): + if hass.services.has_service(DOMAIN, service_name): + continue + hass.services.async_register(DOMAIN, service_name, method, schema=schema) + return True @@ -106,6 +293,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) + loaded_entries = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + if len(loaded_entries) == 1: + # If this is the last loaded instance of Guardian, deregister any services + # defined during integration setup: + for service_name in SERVICES: + hass.services.async_remove(DOMAIN, service_name) + return unload_ok @@ -204,7 +402,7 @@ class GuardianEntity(CoordinatorEntity): ) -> None: """Initialize.""" self._attr_device_info = DeviceInfo(manufacturer="Elexa") - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: "Data provided by Elexa"} + self._attr_extra_state_attributes = {} self._entry = entry self.entity_description = description diff --git a/homeassistant/components/guardian/config_flow.py b/homeassistant/components/guardian/config_flow.py index ccebeb99675..ea4589ddd42 100644 --- a/homeassistant/components/guardian/config_flow.py +++ b/homeassistant/components/guardian/config_flow.py @@ -8,11 +8,10 @@ from aioguardian.errors import GuardianError import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.dhcp import IP_ADDRESS +from homeassistant.components import dhcp, zeroconf from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.typing import DiscoveryInfoType from .const import CONF_UID, DOMAIN, LOGGER @@ -98,23 +97,23 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): title=info[CONF_UID], data={CONF_UID: info["uid"], **user_input} ) - async def async_step_dhcp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle the configuration via dhcp.""" self.discovery_info = { - CONF_IP_ADDRESS: discovery_info[IP_ADDRESS], + CONF_IP_ADDRESS: discovery_info.ip, CONF_PORT: DEFAULT_PORT, } return await self._async_handle_discovery() async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle the configuration via zeroconf.""" self.discovery_info = { - CONF_IP_ADDRESS: discovery_info["host"], - CONF_PORT: discovery_info["port"], + CONF_IP_ADDRESS: discovery_info.host, + CONF_PORT: discovery_info.port, } - pin = async_get_pin_from_discovery_hostname(discovery_info["hostname"]) + pin = async_get_pin_from_discovery_hostname(discovery_info.hostname) await self._async_set_unique_id(pin) return await self._async_handle_discovery() diff --git a/homeassistant/components/guardian/const.py b/homeassistant/components/guardian/const.py index 5e3779cc447..3499db24c03 100644 --- a/homeassistant/components/guardian/const.py +++ b/homeassistant/components/guardian/const.py @@ -17,6 +17,5 @@ CONF_UID = "uid" DATA_CLIENT = "client" DATA_COORDINATOR = "coordinator" DATA_COORDINATOR_PAIRED_SENSOR = "coordinator_paired_sensor" -DATA_PAIRED_SENSOR_MANAGER = "paired_sensor_manager" SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED = "guardian_paired_sensor_coordinator_added_{0}" diff --git a/homeassistant/components/guardian/manifest.json b/homeassistant/components/guardian/manifest.json index 44da7d01cc9..90e33a82452 100644 --- a/homeassistant/components/guardian/manifest.json +++ b/homeassistant/components/guardian/manifest.json @@ -12,6 +12,10 @@ "hostname": "gvc*", "macaddress": "30AEA4*" }, + { + "hostname": "gvc*", + "macaddress": "B4E62D*" + }, { "hostname": "guardian*", "macaddress": "30AEA4*" diff --git a/homeassistant/components/guardian/services.yaml b/homeassistant/components/guardian/services.yaml index cb2e7827657..4d48783c955 100644 --- a/homeassistant/components/guardian/services.yaml +++ b/homeassistant/components/guardian/services.yaml @@ -2,25 +2,36 @@ disable_ap: name: Disable AP description: Disable the device's onboard access point. - target: - entity: - integration: guardian - domain: switch + fields: + device_id: + name: Valve Controller + description: The valve controller whose AP should be disabled + required: true + selector: + device: + integration: guardian enable_ap: name: Enable AP description: Enable the device's onboard access point. - target: - entity: - integration: guardian - domain: switch -pair_sensor: - name: Pair sensor - description: Add a new paired sensor to the valve controller. - target: - entity: - integration: guardian - domain: switch fields: + device_id: + name: Valve Controller + description: The valve controller whose AP should be enabled + required: true + selector: + device: + integration: guardian +pair_sensor: + name: Pair Sensor + description: Add a new paired sensor to the valve controller. + fields: + device_id: + name: Valve Controller + description: The valve controller to add the sensor to + required: true + selector: + device: + integration: guardian uid: name: UID description: The UID of the paired sensor @@ -31,25 +42,36 @@ pair_sensor: reboot: name: Reboot description: Reboot the device. - target: - entity: - integration: guardian - domain: switch -reset_valve_diagnostics: - name: Reset valve diagnostics - description: Fully (and irrecoverably) reset all valve diagnostics. - target: - entity: - integration: guardian - domain: switch -unpair_sensor: - name: Unpair sensor - description: Remove a paired sensor from the valve controller. - target: - entity: - integration: guardian - domain: switch fields: + device_id: + name: Valve Controller + description: The valve controller to reboot + required: true + selector: + device: + integration: guardian +reset_valve_diagnostics: + name: Reset Valve Diagnostics + description: Fully (and irrecoverably) reset all valve diagnostics. + fields: + device_id: + name: Valve Controller + description: The valve controller whose diagnostics should be reset + required: true + selector: + device: + integration: guardian +unpair_sensor: + name: Unpair Sensor + description: Remove a paired sensor from the valve controller. + fields: + device_id: + name: Valve Controller + description: The valve controller to remove the sensor from + required: true + selector: + device: + integration: guardian uid: name: UID description: The UID of the paired sensor @@ -60,11 +82,14 @@ unpair_sensor: upgrade_firmware: name: Upgrade firmware description: Upgrade the device firmware. - target: - entity: - integration: guardian - domain: switch fields: + device_id: + name: Valve Controller + description: The valve controller whose firmware should be upgraded + required: true + selector: + device: + integration: guardian url: name: URL description: The URL of the server hosting the firmware file. @@ -76,7 +101,9 @@ upgrade_firmware: description: The port on which the firmware file is served. example: 443 selector: - text: + number: + min: 1 + max: 65535 filename: name: Filename description: The firmware filename. diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index 7417499e53a..17e28c1901a 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -5,40 +5,21 @@ from typing import Any from aioguardian import Client from aioguardian.errors import GuardianError -import voluptuous as vol from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_FILENAME, CONF_PORT, CONF_URL from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import ValveControllerEntity -from .const import ( - API_VALVE_STATUS, - CONF_UID, - DATA_CLIENT, - DATA_COORDINATOR, - DATA_PAIRED_SENSOR_MANAGER, - DOMAIN, - LOGGER, -) +from .const import API_VALVE_STATUS, DATA_CLIENT, DATA_COORDINATOR, DOMAIN, LOGGER ATTR_AVG_CURRENT = "average_current" ATTR_INST_CURRENT = "instantaneous_current" ATTR_INST_CURRENT_DDT = "instantaneous_current_ddt" ATTR_TRAVEL_COUNT = "travel_count" -SERVICE_DISABLE_AP = "disable_ap" -SERVICE_ENABLE_AP = "enable_ap" -SERVICE_PAIR_SENSOR = "pair_sensor" -SERVICE_REBOOT = "reboot" -SERVICE_RESET_VALVE_DIAGNOSTICS = "reset_valve_diagnostics" -SERVICE_UNPAIR_SENSOR = "unpair_sensor" -SERVICE_UPGRADE_FIRMWARE = "upgrade_firmware" - SWITCH_KIND_VALVE = "valve" SWITCH_DESCRIPTION_VALVE = SwitchEntityDescription( @@ -52,31 +33,6 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Guardian switches based on a config entry.""" - platform = entity_platform.async_get_current_platform() - - for service_name, schema, method in ( - (SERVICE_DISABLE_AP, {}, "async_disable_ap"), - (SERVICE_ENABLE_AP, {}, "async_enable_ap"), - (SERVICE_PAIR_SENSOR, {vol.Required(CONF_UID): cv.string}, "async_pair_sensor"), - (SERVICE_REBOOT, {}, "async_reboot"), - (SERVICE_RESET_VALVE_DIAGNOSTICS, {}, "async_reset_valve_diagnostics"), - ( - SERVICE_UPGRADE_FIRMWARE, - { - vol.Optional(CONF_URL): cv.url, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_FILENAME): cv.string, - }, - "async_upgrade_firmware", - ), - ( - SERVICE_UNPAIR_SENSOR, - {vol.Required(CONF_UID): cv.string}, - "async_unpair_sensor", - ), - ): - platform.async_register_entity_service(service_name, schema, method) - async_add_entities( [ ValveControllerSwitch( @@ -135,78 +91,6 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): } ) - async def async_disable_ap(self) -> None: - """Disable the device's onboard access point.""" - try: - async with self._client: - await self._client.wifi.disable_ap() - except GuardianError as err: - LOGGER.error("Error while disabling valve controller AP: %s", err) - - async def async_enable_ap(self) -> None: - """Enable the device's onboard access point.""" - try: - async with self._client: - await self._client.wifi.enable_ap() - except GuardianError as err: - LOGGER.error("Error while enabling valve controller AP: %s", err) - - async def async_pair_sensor(self, *, uid: str) -> None: - """Add a new paired sensor.""" - try: - async with self._client: - await self._client.sensor.pair_sensor(uid) - except GuardianError as err: - LOGGER.error("Error while adding paired sensor: %s", err) - return - - await self.hass.data[DOMAIN][self._entry.entry_id][ - DATA_PAIRED_SENSOR_MANAGER - ].async_pair_sensor(uid) - - async def async_reboot(self) -> None: - """Reboot the device.""" - try: - async with self._client: - await self._client.system.reboot() - except GuardianError as err: - LOGGER.error("Error while rebooting valve controller: %s", err) - - async def async_reset_valve_diagnostics(self) -> None: - """Fully reset system motor diagnostics.""" - try: - async with self._client: - await self._client.valve.reset() - except GuardianError as err: - LOGGER.error("Error while resetting valve diagnostics: %s", err) - - async def async_unpair_sensor(self, *, uid: str) -> None: - """Add a new paired sensor.""" - try: - async with self._client: - await self._client.sensor.unpair_sensor(uid) - except GuardianError as err: - LOGGER.error("Error while removing paired sensor: %s", err) - return - - await self.hass.data[DOMAIN][self._entry.entry_id][ - DATA_PAIRED_SENSOR_MANAGER - ].async_unpair_sensor(uid) - - async def async_upgrade_firmware( - self, *, url: str, port: int, filename: str - ) -> None: - """Upgrade the device firmware.""" - try: - async with self._client: - await self._client.system.upgrade_firmware( - url=url, - port=port, - filename=filename, - ) - except GuardianError as err: - LOGGER.error("Error while upgrading firmware: %s", err) - async def async_turn_off(self, **kwargs: Any) -> None: """Turn the valve off (closed).""" try: diff --git a/homeassistant/components/guardian/translations/bg.json b/homeassistant/components/guardian/translations/bg.json index 9c063cbbd0d..de9699e4a21 100644 --- a/homeassistant/components/guardian/translations/bg.json +++ b/homeassistant/components/guardian/translations/bg.json @@ -1,11 +1,13 @@ { "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\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" }, "step": { "user": { "data": { + "ip_address": "IP \u0430\u0434\u0440\u0435\u0441", "port": "\u041f\u043e\u0440\u0442" } } diff --git a/homeassistant/components/guardian/translations/id.json b/homeassistant/components/guardian/translations/id.json index b5b75321037..8193386fb62 100644 --- a/homeassistant/components/guardian/translations/id.json +++ b/homeassistant/components/guardian/translations/id.json @@ -6,6 +6,9 @@ "cannot_connect": "Gagal terhubung" }, "step": { + "discovery_confirm": { + "description": "Ingin menyiapkan perangkat Guardian ini?" + }, "user": { "data": { "ip_address": "Alamat IP", diff --git a/homeassistant/components/guardian/translations/ja.json b/homeassistant/components/guardian/translations/ja.json new file mode 100644 index 00000000000..0c282a324bd --- /dev/null +++ b/homeassistant/components/guardian/translations/ja.json @@ -0,0 +1,24 @@ +{ + "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" + }, + "step": { + "discovery_confirm": { + "description": "\u3053\u306eGuardian\u30c7\u30d0\u30a4\u30b9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "ip_address": "IP\u30a2\u30c9\u30ec\u30b9", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "\u30ed\u30fc\u30ab\u30eb\u306eElexa Guardian\u30c7\u30d0\u30a4\u30b9\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002" + }, + "zeroconf_confirm": { + "description": "\u3053\u306eGuardian\u30c7\u30d0\u30a4\u30b9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/guardian/translations/ru.json b/homeassistant/components/guardian/translations/ru.json index 1095a568d78..ca904d980af 100644 --- a/homeassistant/components/guardian/translations/ru.json +++ b/homeassistant/components/guardian/translations/ru.json @@ -14,7 +14,7 @@ "ip_address": "IP-\u0430\u0434\u0440\u0435\u0441", "port": "\u041f\u043e\u0440\u0442" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Elexa Guardian." + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Elexa Guardian." }, "zeroconf_confirm": { "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u044d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Elexa Guardian?" diff --git a/homeassistant/components/guardian/translations/tr.json b/homeassistant/components/guardian/translations/tr.json index 1e520a16995..9d3e903a2d6 100644 --- a/homeassistant/components/guardian/translations/tr.json +++ b/homeassistant/components/guardian/translations/tr.json @@ -6,11 +6,18 @@ "cannot_connect": "Ba\u011flanma hatas\u0131" }, "step": { + "discovery_confirm": { + "description": "Bu Guardian cihaz\u0131n\u0131 kurmak istiyor musunuz?" + }, "user": { "data": { - "ip_address": "\u0130p Adresi", + "ip_address": "IP Adresi", "port": "Port" - } + }, + "description": "Yerel bir Elexa Guardian cihaz\u0131 yap\u0131land\u0131r\u0131n." + }, + "zeroconf_confirm": { + "description": "Bu Guardian cihaz\u0131n\u0131 kurmak istiyor musunuz?" } } } diff --git a/homeassistant/components/habitica/translations/bg.json b/homeassistant/components/habitica/translations/bg.json index 02c83a6e916..ce37c7da82c 100644 --- a/homeassistant/components/habitica/translations/bg.json +++ b/homeassistant/components/habitica/translations/bg.json @@ -3,6 +3,7 @@ "step": { "user": { "data": { + "api_key": "API \u043a\u043b\u044e\u0447", "url": "URL" } } diff --git a/homeassistant/components/habitica/translations/ja.json b/homeassistant/components/habitica/translations/ja.json new file mode 100644 index 00000000000..28d877a4788 --- /dev/null +++ b/homeassistant/components/habitica/translations/ja.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "invalid_credentials": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "api_user": "Habitica API\u306e\u30e6\u30fc\u30b6\u30fcID", + "name": "Habitica\u2019s\u306e\u30e6\u30fc\u30b6\u30fc\u540d\u3092\u4e0a\u66f8\u304d\u3057\u307e\u3059\u3002\u30b5\u30fc\u30d3\u30b9\u30b3\u30fc\u30eb\u3067\u4f7f\u7528\u3055\u308c\u307e\u3059", + "url": "URL" + }, + "description": "Habitica profile\u306b\u63a5\u7d9a\u3057\u3066\u3001\u3042\u306a\u305f\u306e\u30e6\u30fc\u30b6\u30fc\u306e\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u3068\u30bf\u30b9\u30af\u3092\u76e3\u8996\u3067\u304d\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002 \u6ce8\u610f: api_id\u3068api_key\u306f\u3001https://habitica.com/user/settings/api \u304b\u3089\u53d6\u5f97\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + } + } + }, + "title": "Habitica" +} \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/tr.json b/homeassistant/components/habitica/translations/tr.json index f77cc77798c..32ad5aa4957 100644 --- a/homeassistant/components/habitica/translations/tr.json +++ b/homeassistant/components/habitica/translations/tr.json @@ -1,3 +1,20 @@ { + "config": { + "error": { + "invalid_credentials": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "api_key": "API Anahtar\u0131", + "api_user": "Habitica'n\u0131n API kullan\u0131c\u0131 kimli\u011fi", + "name": "Habitica'n\u0131n kullan\u0131c\u0131 ad\u0131n\u0131 ge\u00e7ersiz k\u0131l. Servis \u00e7a\u011fr\u0131lar\u0131 i\u00e7in kullan\u0131lacakt\u0131r", + "url": "URL" + }, + "description": "Kullan\u0131c\u0131n\u0131z\u0131n profilinin ve g\u00f6revlerinin izlenmesine izin vermek i\u00e7in Habitica profilinizi ba\u011flay\u0131n. api_id ve api_key'in https://habitica.com/user/settings/api adresinden al\u0131nmas\u0131 gerekti\u011fini unutmay\u0131n." + } + } + }, "title": "Habitica" } \ No newline at end of file diff --git a/homeassistant/components/hangouts/hangouts_bot.py b/homeassistant/components/hangouts/hangouts_bot.py index 16872079be3..5c0625411ae 100644 --- a/homeassistant/components/hangouts/hangouts_bot.py +++ b/homeassistant/components/hangouts/hangouts_bot.py @@ -182,9 +182,7 @@ class HangoutsBot: """Detect a matching intent.""" for intent_type, data in intents.items(): for matcher in data.get(CONF_MATCHERS, []): - match = matcher.match(text) - - if not match: + if not (match := matcher.match(text)): continue if intent_type == INTENT_HELP: return await self.hass.helpers.intent.async_handle( diff --git a/homeassistant/components/hangouts/translations/ja.json b/homeassistant/components/hangouts/translations/ja.json index 751e1ae41a1..72f5ce09313 100644 --- a/homeassistant/components/hangouts/translations/ja.json +++ b/homeassistant/components/hangouts/translations/ja.json @@ -1,6 +1,12 @@ { "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, "error": { + "invalid_2fa": "2\u8981\u7d20\u8a8d\u8a3c\u304c\u7121\u52b9\u3067\u3059\u3002\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", + "invalid_2fa_method": "2\u8981\u7d20\u8a8d\u8a3c\u304c\u7121\u52b9(\u96fb\u8a71\u3067\u78ba\u8a8d)", "invalid_login": "\u30ed\u30b0\u30a4\u30f3\u304c\u7121\u52b9\u3067\u3059\u3001\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002" }, "step": { @@ -13,7 +19,8 @@ }, "user": { "data": { - "email": "Email", + "authorization_code": "\u8a8d\u8a3c\u30b3\u30fc\u30c9(\u624b\u52d5\u8a8d\u8a3c\u6642\u306b\u5fc5\u8981)", + "email": "E\u30e1\u30fc\u30eb", "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" }, "description": "\u7a7a", diff --git a/homeassistant/components/hangouts/translations/tr.json b/homeassistant/components/hangouts/translations/tr.json index a204200a2d8..84fb80abaf5 100644 --- a/homeassistant/components/hangouts/translations/tr.json +++ b/homeassistant/components/hangouts/translations/tr.json @@ -4,12 +4,27 @@ "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "unknown": "Beklenmeyen hata" }, + "error": { + "invalid_2fa": "Ge\u00e7ersiz 2 Fakt\u00f6rl\u00fc Kimlik Do\u011frulama, l\u00fctfen tekrar deneyin.", + "invalid_2fa_method": "Ge\u00e7ersiz 2FA Y\u00f6ntemi (Telefonda do\u011frulay\u0131n).", + "invalid_login": "Ge\u00e7ersiz Giri\u015f, l\u00fctfen tekrar deneyin." + }, "step": { + "2fa": { + "data": { + "2fa": "2FA PIN'i" + }, + "description": "Bo\u015f", + "title": "2-Fakt\u00f6rl\u00fc Kimlik Do\u011frulama" + }, "user": { "data": { + "authorization_code": "Yetkilendirme Kodu (manuel kimlik do\u011frulama i\u00e7in gereklidir)", "email": "E-posta", "password": "Parola" - } + }, + "description": "Bo\u015f", + "title": "Google Hangouts Giri\u015fi" } } } diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py index 1c735b0747d..4dca2192c6b 100644 --- a/homeassistant/components/harmony/config_flow.py +++ b/homeassistant/components/harmony/config_flow.py @@ -16,6 +16,7 @@ from homeassistant.components.remote import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN, HARMONY_DATA, PREVIOUS_ACTIVE_ACTIVITY, UNIQUE_ID from .util import ( @@ -81,12 +82,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered Harmony device.""" _LOGGER.debug("SSDP discovery_info: %s", discovery_info) - parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) - friendly_name = discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME] + parsed_url = urlparse(discovery_info.ssdp_location) + friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] self._async_abort_entries_match({CONF_HOST: parsed_url.hostname}) diff --git a/homeassistant/components/harmony/data.py b/homeassistant/components/harmony/data.py index 706e06e881e..aa373d5813a 100644 --- a/homeassistant/components/harmony/data.py +++ b/homeassistant/components/harmony/data.py @@ -96,6 +96,7 @@ class HarmonyData(HarmonySubscriberMixin): sw_version=self._client.hub_config.info.get( "hubSwVersion", self._client.fw_version ), + configuration_url="https://www.logitech.com/en-us/my-account", ) async def connect(self) -> bool: diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index 3431eff7994..91022cf8d42 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -144,8 +144,7 @@ class HarmonyRemote(HarmonyEntity, remote.RemoteEntity, RestoreEntity): # Restore the last activity so we know # how what to turn on if nothing # is specified - last_state = await self.async_get_last_state() - if not last_state: + if not (last_state := await self.async_get_last_state()): return if ATTR_LAST_ACTIVITY not in last_state.attributes: return diff --git a/homeassistant/components/harmony/translations/ja.json b/homeassistant/components/harmony/translations/ja.json new file mode 100644 index 00000000000..21af2f3bb2a --- /dev/null +++ b/homeassistant/components/harmony/translations/ja.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "{name} ({host})\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b?", + "title": "Logitech Harmony Hub\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u30cf\u30d6\u540d" + }, + "title": "Logitech Harmony Hub\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "\u4f55\u3082\u6307\u5b9a\u3055\u308c\u3066\u3044\u306a\u3044\u5834\u5408\u306b\u5b9f\u884c\u3055\u308c\u308b\u30c7\u30d5\u30a9\u30eb\u30c8\u306e\u30a2\u30af\u30c6\u30a3\u30d3\u30c6\u30a3\u3002", + "delay_secs": "\u30b3\u30de\u30f3\u30c9\u3092\u9001\u4fe1\u3059\u308b\u969b\u306e\u9045\u5ef6\u6642\u9593\u3002" + }, + "description": "Harmony Hub\u306e\u8abf\u6574" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/tr.json b/homeassistant/components/harmony/translations/tr.json index c77f0f8e07e..cb3658d47fb 100644 --- a/homeassistant/components/harmony/translations/tr.json +++ b/homeassistant/components/harmony/translations/tr.json @@ -7,11 +7,29 @@ "cannot_connect": "Ba\u011flanma hatas\u0131", "unknown": "Beklenmeyen hata" }, + "flow_title": "{name}", "step": { + "link": { + "description": "{name} ( {host} ) kurulumu yapmak istiyor musunuz?", + "title": "Logitech Harmony Hub'\u0131 Kur" + }, "user": { "data": { - "host": "Ana Bilgisayar" - } + "host": "Ana bilgisayar", + "name": "Hub Ad\u0131" + }, + "title": "Logitech Harmony Hub'\u0131 Kur" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "Hi\u00e7biri belirtilmedi\u011finde y\u00fcr\u00fct\u00fclecek varsay\u0131lan etkinlik.", + "delay_secs": "Komut g\u00f6nderme aras\u0131ndaki gecikme." + }, + "description": "Harmony Hub Se\u00e7eneklerini Ayarlay\u0131n" } } } diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 6ad1c67f1f3..2c3d61ef584 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -19,15 +19,19 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_MANUFACTURER, ATTR_NAME, - ATTR_SERVICE, EVENT_CORE_CONFIG_UPDATE, + HASSIO_USER_NAME, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, ) from homeassistant.core import DOMAIN as HASS_DOMAIN, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, recorder -from homeassistant.helpers.device_registry import DeviceRegistry, async_get_registry +from homeassistant.helpers.device_registry import ( + DeviceEntryType, + DeviceRegistry, + async_get_registry, +) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -54,7 +58,7 @@ from .const import ( DOMAIN, SupervisorEntityModel, ) -from .discovery import async_setup_discovery_view +from .discovery import HassioServiceInfo, async_setup_discovery_view # noqa: F401 from .handler import HassIO, HassioAPIError, api_data from .http import HassIOView from .ingress import async_setup_ingress_view @@ -421,9 +425,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: _LOGGER.warning("Not connected with the supervisor / system too busy!") store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) - data = await store.async_load() - - if data is None: + if (data := await store.async_load()) is None: data = {} refresh_token = None @@ -438,10 +440,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: # Migrate old name if user.name == "Hass.io": - await hass.auth.async_update_user(user, name="Supervisor") + await hass.auth.async_update_user(user, name=HASSIO_USER_NAME) if refresh_token is None: - user = await hass.auth.async_create_system_user("Supervisor", [GROUP_ID_ADMIN]) + user = await hass.auth.async_create_system_user( + HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] + ) refresh_token = await hass.auth.async_create_refresh_token(user) data["hassio_user"] = user.id await store.async_save(data) @@ -458,8 +462,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: await hass.components.panel_custom.async_register_panel( frontend_url_path="hassio", webcomponent_name="hassio-main", - sidebar_title="Supervisor", - sidebar_icon="hass:home-assistant", js_url="/api/hassio/app/entrypoint.js", embed_iframe=True, require_admin=True, @@ -565,9 +567,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: async def async_handle_core_service(call): """Service handler for handling core services.""" - if ( - call.service in SHUTDOWN_SERVICES - and await recorder.async_migration_in_progress(hass) + if call.service in SHUTDOWN_SERVICES and recorder.async_migration_in_progress( + hass ): _LOGGER.error( "The system cannot %s while a database upgrade is in progress", @@ -663,7 +664,7 @@ def async_register_addons_in_dev_reg( model=SupervisorEntityModel.ADDON, sw_version=addon[ATTR_VERSION], name=addon[ATTR_NAME], - entry_type=ATTR_SERVICE, + entry_type=DeviceEntryType.SERVICE, configuration_url=f"homeassistant://hassio/addon/{addon[ATTR_SLUG]}", ) if manufacturer := addon.get(ATTR_REPOSITORY) or addon.get(ATTR_URL): @@ -682,7 +683,7 @@ def async_register_os_in_dev_reg( model=SupervisorEntityModel.OS, sw_version=os_dict[ATTR_VERSION], name="Home Assistant Operating System", - entry_type=ATTR_SERVICE, + entry_type=DeviceEntryType.SERVICE, ) dev_reg.async_get_or_create(config_entry_id=entry_id, **params) diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index d6240896c84..41107a6fa55 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -21,8 +21,7 @@ async def async_setup_addon_panel(hass: HomeAssistant, hassio): hass.http.register_view(hassio_addon_panel) # If panels are exists - panels = await hassio_addon_panel.get_panels() - if not panels: + if not (panels := await hassio_addon_panel.get_panels()): return # Register available panels diff --git a/homeassistant/components/hassio/binary_sensor.py b/homeassistant/components/hassio/binary_sensor.py index 5078e11c26e..b5525fe9ce4 100644 --- a/homeassistant/components/hassio/binary_sensor.py +++ b/homeassistant/components/hassio/binary_sensor.py @@ -4,8 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_RUNNING, - DEVICE_CLASS_UPDATE, + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -31,15 +30,18 @@ class HassioBinarySensorEntityDescription(BinarySensorEntityDescription): target: str | None = None -ENTITY_DESCRIPTIONS = ( +COMMON_ENTITY_DESCRIPTIONS = ( HassioBinarySensorEntityDescription( - device_class=DEVICE_CLASS_UPDATE, + device_class=BinarySensorDeviceClass.UPDATE, entity_registry_enabled_default=False, key=ATTR_UPDATE_AVAILABLE, name="Update Available", ), +) + +ADDON_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS + ( HassioBinarySensorEntityDescription( - device_class=DEVICE_CLASS_RUNNING, + device_class=BinarySensorDeviceClass.RUNNING, entity_registry_enabled_default=False, key=ATTR_STATE, name="Running", @@ -58,7 +60,7 @@ async def async_setup_entry( entities = [] - for entity_description in ENTITY_DESCRIPTIONS: + for entity_description in ADDON_ENTITY_DESCRIPTIONS: for addon in coordinator.data[DATA_KEY_ADDONS].values(): entities.append( HassioAddonBinarySensor( @@ -68,7 +70,8 @@ async def async_setup_entry( ) ) - if coordinator.is_hass_os: + if coordinator.is_hass_os: + for entity_description in COMMON_ENTITY_DESCRIPTIONS: entities.append( HassioOSBinarySensor( coordinator=coordinator, diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 9f15ff6e8b8..587457f2ca2 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -1,6 +1,10 @@ """Implement the services discovery feature from Hass.io for Add-ons.""" +from __future__ import annotations + import asyncio +from dataclasses import dataclass import logging +from typing import Any from aiohttp import web from aiohttp.web_exceptions import HTTPServiceUnavailable @@ -9,6 +13,7 @@ from homeassistant import config_entries from homeassistant.components.http import HomeAssistantView from homeassistant.const import ATTR_NAME, ATTR_SERVICE, EVENT_HOMEASSISTANT_START from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import BaseServiceInfo from .const import ATTR_ADDON, ATTR_CONFIG, ATTR_DISCOVERY, ATTR_UUID from .handler import HassioAPIError @@ -16,6 +21,13 @@ from .handler import HassioAPIError _LOGGER = logging.getLogger(__name__) +@dataclass +class HassioServiceInfo(BaseServiceInfo): + """Prepared info from hassio entries.""" + + config: dict[str, Any] + + @callback def async_setup_discovery_view(hass: HomeAssistant, hassio): """Discovery setup.""" @@ -88,7 +100,9 @@ class HassIODiscovery(HomeAssistantView): # Use config flow await self.hass.config_entries.flow.async_init( - service, context={"source": config_entries.SOURCE_HASSIO}, data=config_data + service, + context={"source": config_entries.SOURCE_HASSIO}, + data=HassioServiceInfo(config=config_data), ) async def async_process_del(self, data): diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 620c69f543d..6935bbdc7da 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -255,3 +255,5 @@ async def _websocket_forward(ws_from, ws_to): await ws_to.close(code=ws_to.close_code, message=msg.extra) except RuntimeError: _LOGGER.debug("Ingress Websocket runtime error") + except ConnectionResetError: + _LOGGER.debug("Ingress Websocket Connection Reset") diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index 27cc1eaf735..cc0bcc77265 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -2,8 +2,15 @@ "domain": "hassio", "name": "Home Assistant Supervisor", "documentation": "https://www.home-assistant.io/integrations/hassio", - "dependencies": ["http"], - "after_dependencies": ["panel_custom"], - "codeowners": ["@home-assistant/supervisor"], - "iot_class": "local_polling" -} + "dependencies": [ + "http" + ], + "after_dependencies": [ + "panel_custom" + ], + "codeowners": [ + "@home-assistant/supervisor" + ], + "iot_class": "local_polling", + "quality_scale": "internal" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/ja.json b/homeassistant/components/hassio/translations/ja.json new file mode 100644 index 00000000000..0f704ceba19 --- /dev/null +++ b/homeassistant/components/hassio/translations/ja.json @@ -0,0 +1,18 @@ +{ + "system_health": { + "info": { + "board": "\u30dc\u30fc\u30c9", + "disk_total": "\u30c7\u30a3\u30b9\u30af\u5408\u8a08", + "disk_used": "\u4f7f\u7528\u6e08\u307f\u30c7\u30a3\u30b9\u30af", + "docker_version": "Docker\u306e\u30d0\u30fc\u30b8\u30e7\u30f3", + "healthy": "\u5143\u6c17", + "host_os": "\u30db\u30b9\u30c8\u30aa\u30da\u30ec\u30fc\u30c6\u30a3\u30f3\u30b0\u30b7\u30b9\u30c6\u30e0", + "installed_addons": "\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u6e08\u307f\u306e\u30a2\u30c9\u30aa\u30f3", + "supervisor_api": "Supervisor API", + "supervisor_version": "Supervisor\u306e\u30d0\u30fc\u30b8\u30e7\u30f3", + "supported": "\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u3059", + "update_channel": "\u30a2\u30c3\u30d7\u30c7\u30fc\u30c8\u30c1\u30e3\u30f3\u30cd\u30eb", + "version_api": "\u30d0\u30fc\u30b8\u30e7\u30f3API" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index dfc2b7dc01d..e2ffff5e1e3 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -1,11 +1,13 @@ """Websocekt API handlers for the hassio integration.""" import logging +import re import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import Unauthorized import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -34,6 +36,11 @@ SCHEMA_WEBSOCKET_EVENT = vol.Schema( extra=vol.ALLOW_EXTRA, ) +# Endpoints needed for ingress can't require admin because addons can set `panel_admin: false` +WS_NO_ADMIN_ENDPOINTS = re.compile( + r"^(?:" r"|/ingress/(session|validate_session)" r"|/addons/[^/]+/info" r")$" +) + _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -79,7 +86,6 @@ async def websocket_supervisor_event( connection.send_result(msg[WS_ID]) -@websocket_api.require_admin @websocket_api.async_response @websocket_api.websocket_command( { @@ -94,6 +100,10 @@ async def websocket_supervisor_api( hass: HomeAssistant, connection: ActiveConnection, msg: dict ): """Websocket handler to call Supervisor API.""" + if not connection.user.is_admin and not WS_NO_ADMIN_ENDPOINTS.match( + msg[ATTR_ENDPOINT] + ): + raise Unauthorized() supervisor: HassIO = hass.data[DOMAIN] try: result = await supervisor.send_command( diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index a4cdb6cdddc..2dbab5e9409 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -11,9 +11,13 @@ import voluptuous as vol from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle @@ -23,8 +27,11 @@ from .const import ( COMMAND_RETRY_ATTEMPTS, COMMAND_RETRY_DELAY, DATA_CONTROLLER_MANAGER, + DATA_ENTITY_ID_MAP, + DATA_GROUP_MANAGER, DATA_SOURCE_MANAGER, DOMAIN, + SIGNAL_HEOS_PLAYER_ADDED, SIGNAL_HEOS_UPDATED, ) @@ -117,15 +124,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: source_manager = SourceManager(favorites, inputs) source_manager.connect_update(hass, controller) + group_manager = GroupManager(hass, controller) + hass.data[DOMAIN] = { DATA_CONTROLLER_MANAGER: controller_manager, + DATA_GROUP_MANAGER: group_manager, DATA_SOURCE_MANAGER: source_manager, MEDIA_PLAYER_DOMAIN: players, + # Maps player_id to entity_id. Populated by the individual HeosMediaPlayer entities. + DATA_ENTITY_ID_MAP: {}, } services.register(hass, controller) hass.config_entries.async_setup_platforms(entry, PLATFORMS) + group_manager.connect_update() + entry.async_on_unload(group_manager.disconnect_update) return True @@ -184,7 +198,7 @@ class ControllerManager: if event == heos_const.EVENT_PLAYERS_CHANGED: self.update_ids(data[heos_const.DATA_MAPPED_IDS]) # Update players - self._hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED) + async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) async def _heos_event(self, event): """Handle connection event.""" @@ -196,7 +210,7 @@ class ControllerManager: except HeosError as ex: _LOGGER.error("Unable to refresh players: %s", ex) # Update players - self._hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED) + async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) def update_ids(self, mapped_ids: dict[int, int]): """Update the IDs in the device and entity registry.""" @@ -223,6 +237,148 @@ class ControllerManager: _LOGGER.debug("Updated entity %s unique id to %s", entity_id, new_id) +class GroupManager: + """Class that manages HEOS groups.""" + + def __init__(self, hass, controller): + """Init group manager.""" + self._hass = hass + self._group_membership = {} + self._disconnect_player_added = None + self._initialized = False + self.controller = controller + + def _get_entity_id_to_player_id_map(self) -> dict: + """Return a dictionary which maps all HeosMediaPlayer entity_ids to player_ids.""" + return {v: k for k, v in self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP].items()} + + async def async_get_group_membership(self): + """Return a dictionary which contains all group members for each player as entity_ids.""" + group_info_by_entity_id = { + player_entity_id: [] + for player_entity_id in self._get_entity_id_to_player_id_map() + } + + try: + groups = await self.controller.get_groups(refresh=True) + except HeosError as err: + _LOGGER.error("Unable to get HEOS group info: %s", err) + return group_info_by_entity_id + + player_id_to_entity_id_map = self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP] + for group in groups.values(): + leader_entity_id = player_id_to_entity_id_map.get(group.leader.player_id) + member_entity_ids = [ + player_id_to_entity_id_map[member.player_id] + for member in group.members + if member.player_id in player_id_to_entity_id_map + ] + # Make sure the group leader is always the first element + group_info = [leader_entity_id, *member_entity_ids] + if leader_entity_id: + group_info_by_entity_id[leader_entity_id] = group_info + for member_entity_id in member_entity_ids: + group_info_by_entity_id[member_entity_id] = group_info + + return group_info_by_entity_id + + async def async_join_players( + self, leader_entity_id: str, member_entity_ids: list[str] + ) -> None: + """Create a group with `leader_entity_id` as group leader and `member_entity_ids` as member players.""" + entity_id_to_player_id_map = self._get_entity_id_to_player_id_map() + leader_id = entity_id_to_player_id_map.get(leader_entity_id) + if not leader_id: + raise HomeAssistantError( + f"The group leader {leader_entity_id} could not be resolved to a HEOS player." + ) + member_ids = [ + entity_id_to_player_id_map[member] + for member in member_entity_ids + if member in entity_id_to_player_id_map + ] + + try: + await self.controller.create_group(leader_id, member_ids) + except HeosError as err: + _LOGGER.error( + "Failed to group %s with %s: %s", + leader_entity_id, + member_entity_ids, + err, + ) + + async def async_unjoin_player(self, player_entity_id: str): + """Remove `player_entity_id` from any group.""" + player_id = self._get_entity_id_to_player_id_map().get(player_entity_id) + if not player_id: + raise HomeAssistantError( + f"The player {player_entity_id} could not be resolved to a HEOS player." + ) + + try: + await self.controller.create_group(player_id, []) + except HeosError as err: + _LOGGER.error( + "Failed to ungroup %s: %s", + player_entity_id, + err, + ) + + async def async_update_groups(self, event, data=None): + """Update the group membership from the controller.""" + if event in ( + heos_const.EVENT_GROUPS_CHANGED, + heos_const.EVENT_CONNECTED, + SIGNAL_HEOS_PLAYER_ADDED, + ): + groups = await self.async_get_group_membership() + if groups: + self._group_membership = groups + _LOGGER.debug("Groups updated due to change event") + # Let players know to update + async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) + else: + _LOGGER.debug("Groups empty") + + def connect_update(self): + """Connect listener for when groups change and signal player update.""" + self.controller.dispatcher.connect( + heos_const.SIGNAL_CONTROLLER_EVENT, self.async_update_groups + ) + self.controller.dispatcher.connect( + heos_const.SIGNAL_HEOS_EVENT, self.async_update_groups + ) + + # When adding a new HEOS player we need to update the groups. + async def _async_handle_player_added(): + # Avoid calling async_update_groups when `DATA_ENTITY_ID_MAP` has not been + # fully populated yet. This may only happen during early startup. + if ( + len(self._hass.data[DOMAIN][MEDIA_PLAYER_DOMAIN]) + <= len(self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP]) + and not self._initialized + ): + self._initialized = True + await self.async_update_groups(SIGNAL_HEOS_PLAYER_ADDED) + + self._disconnect_player_added = async_dispatcher_connect( + self._hass, SIGNAL_HEOS_PLAYER_ADDED, _async_handle_player_added + ) + + @callback + def disconnect_update(self): + """Disconnect the listeners.""" + if self._disconnect_player_added: + self._disconnect_player_added() + self._disconnect_player_added = None + + @property + def group_membership(self): + """Provide access to group members for player entities.""" + return self._group_membership + + class SourceManager: """Class that manages sources for players.""" @@ -335,14 +491,13 @@ class SourceManager: heos_const.EVENT_USER_CHANGED, heos_const.EVENT_CONNECTED, ): - sources = await get_sources() # If throttled, it will return None - if sources: + if sources := await get_sources(): self.favorites, self.inputs = sources self.source_list = self._build_source_list() _LOGGER.debug("Sources updated due to changed event") # Let players know to update - hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED) + async_dispatcher_send(hass, SIGNAL_HEOS_UPDATED) controller.dispatcher.connect( heos_const.SIGNAL_CONTROLLER_EVENT, update_sources diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index b84a3a23607..63a020c41d9 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -1,4 +1,5 @@ """Config flow to configure Heos.""" +from typing import TYPE_CHECKING from urllib.parse import urlparse from pyheos import Heos, HeosError @@ -7,6 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult from .const import DATA_DISCOVERED_HOSTS, DOMAIN @@ -21,11 +23,15 @@ class HeosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered Heos device.""" # Store discovered host - hostname = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname - friendly_name = f"{discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME]} ({hostname})" + if TYPE_CHECKING: + assert discovery_info.ssdp_location + hostname = urlparse(discovery_info.ssdp_location).hostname + friendly_name = ( + f"{discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]} ({hostname})" + ) self.hass.data.setdefault(DATA_DISCOVERED_HOSTS, {}) self.hass.data[DATA_DISCOVERED_HOSTS][friendly_name] = hostname # Abort if other flows in progress or an entry already exists diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index 503df40ccd4..636751d150b 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -5,9 +5,12 @@ ATTR_USERNAME = "username" COMMAND_RETRY_ATTEMPTS = 2 COMMAND_RETRY_DELAY = 1 DATA_CONTROLLER_MANAGER = "controller" +DATA_ENTITY_ID_MAP = "entity_id_map" +DATA_GROUP_MANAGER = "group_manager" DATA_SOURCE_MANAGER = "source_manager" DATA_DISCOVERED_HOSTS = "heos_discovered_hosts" DOMAIN = "heos" SERVICE_SIGN_IN = "sign_in" SERVICE_SIGN_OUT = "sign_out" +SIGNAL_HEOS_PLAYER_ADDED = "heos_player_added" SIGNAL_HEOS_UPDATED = "heos_updated" diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index a562d8e3d7a..27d172198e4 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -15,6 +15,7 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_PLAYLIST, MEDIA_TYPE_URL, SUPPORT_CLEAR_PLAYLIST, + SUPPORT_GROUPING, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -30,10 +31,21 @@ from homeassistant.components.media_player.const import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, STATE_PAUSED, STATE_PLAYING from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import DeviceInfo from homeassistant.util.dt import utcnow -from .const import DATA_SOURCE_MANAGER, DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_UPDATED +from .const import ( + DATA_ENTITY_ID_MAP, + DATA_GROUP_MANAGER, + DATA_SOURCE_MANAGER, + DOMAIN as HEOS_DOMAIN, + SIGNAL_HEOS_PLAYER_ADDED, + SIGNAL_HEOS_UPDATED, +) BASE_SUPPORTED_FEATURES = ( SUPPORT_VOLUME_MUTE @@ -43,6 +55,7 @@ BASE_SUPPORTED_FEATURES = ( | SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOURCE | SUPPORT_PLAY_MEDIA + | SUPPORT_GROUPING ) PLAY_STATE_TO_STATE = { @@ -97,6 +110,7 @@ class HeosMediaPlayer(MediaPlayerEntity): self._signals = [] self._supported_features = BASE_SUPPORTED_FEATURES self._source_manager = None + self._group_manager = None async def _player_update(self, player_id, event): """Handle player attribute updated.""" @@ -120,16 +134,24 @@ class HeosMediaPlayer(MediaPlayerEntity): ) # Update state when heos changes self._signals.append( - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_HEOS_UPDATED, self._heos_updated - ) + async_dispatcher_connect(self.hass, SIGNAL_HEOS_UPDATED, self._heos_updated) ) + # Register this player's entity_id so it can be resolved by the group manager + self.hass.data[HEOS_DOMAIN][DATA_ENTITY_ID_MAP][ + self._player.player_id + ] = self.entity_id + async_dispatcher_send(self.hass, SIGNAL_HEOS_PLAYER_ADDED) @log_command_error("clear playlist") async def async_clear_playlist(self): """Clear players playlist.""" await self._player.clear_queue() + @log_command_error("join_players") + async def async_join_players(self, group_members: list[str]) -> None: + """Join `group_members` as a player group with the current player.""" + await self._group_manager.async_join_players(self.entity_id, group_members) + @log_command_error("pause") async def async_media_pause(self): """Send pause command.""" @@ -238,9 +260,17 @@ class HeosMediaPlayer(MediaPlayerEntity): current_support = [CONTROL_TO_SUPPORT[control] for control in controls] self._supported_features = reduce(ior, current_support, BASE_SUPPORTED_FEATURES) + if self._group_manager is None: + self._group_manager = self.hass.data[HEOS_DOMAIN][DATA_GROUP_MANAGER] + if self._source_manager is None: self._source_manager = self.hass.data[HEOS_DOMAIN][DATA_SOURCE_MANAGER] + @log_command_error("unjoin_player") + async def async_unjoin_player(self): + """Remove this player from any group.""" + await self._group_manager.async_unjoin_player(self.entity_id) + async def async_will_remove_from_hass(self): """Disconnect the device when removed.""" for signal_remove in self._signals: @@ -274,6 +304,11 @@ class HeosMediaPlayer(MediaPlayerEntity): "media_type": self._player.now_playing_media.type, } + @property + def group_members(self) -> list[str]: + """List of players which are grouped together.""" + return self._group_manager.group_membership.get(self.entity_id, []) + @property def is_volume_muted(self) -> bool: """Boolean if volume is currently muted.""" diff --git a/homeassistant/components/heos/translations/ja.json b/homeassistant/components/heos/translations/ja.json new file mode 100644 index 00000000000..0464bf98979 --- /dev/null +++ b/homeassistant/components/heos/translations/ja.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "Heos\u30c7\u30d0\u30a4\u30b9(\u3067\u304d\u308c\u3070\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u306b\u6709\u7dda\u3067\u63a5\u7d9a\u3055\u308c\u3066\u3044\u308b\u30c7\u30d0\u30a4\u30b9)\u306e\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Heos\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/translations/tr.json b/homeassistant/components/heos/translations/tr.json index 4f1ad775905..afe563e9a96 100644 --- a/homeassistant/components/heos/translations/tr.json +++ b/homeassistant/components/heos/translations/tr.json @@ -9,8 +9,10 @@ "step": { "user": { "data": { - "host": "Ana Bilgisayar" - } + "host": "Ana bilgisayar" + }, + "description": "L\u00fctfen bir Heos cihaz\u0131n\u0131n ana bilgisayar ad\u0131n\u0131 veya IP adresini girin (tercihen a\u011fa kabloyla ba\u011fl\u0131 olan).", + "title": "Heos'a ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 1d47bbaf89f..e29c6682d8f 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -5,13 +5,12 @@ from datetime import datetime, timedelta import logging import herepy +from herepy.here_enum import RouteMode import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( ATTR_ATTRIBUTION, - ATTR_LATITUDE, - ATTR_LONGITUDE, ATTR_MODE, CONF_API_KEY, CONF_MODE, @@ -22,10 +21,10 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, TIME_MINUTES, ) -from homeassistant.core import HomeAssistant, State, callback -from homeassistant.helpers import location +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.location import find_coordinates from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.util import dt @@ -200,14 +199,17 @@ def _are_valid_client_credentials(here_client: herepy.RoutingApi) -> bool: known_working_origin = [38.9, -77.04833] known_working_destination = [39.0, -77.1] try: - here_client.car_route( + here_client.public_transport_timetable( known_working_origin, known_working_destination, + True, [ - herepy.RouteMode[ROUTE_MODE_FASTEST], - herepy.RouteMode[TRAVEL_MODE_CAR], - herepy.RouteMode[TRAFFIC_MODE_DISABLED], + RouteMode[ROUTE_MODE_FASTEST], + RouteMode[TRAVEL_MODE_CAR], + RouteMode[TRAFFIC_MODE_ENABLED], ], + arrival=None, + departure="now", ) except herepy.InvalidCredentialsError: return False @@ -313,63 +315,15 @@ class HERETravelTimeSensor(SensorEntity): """Update Sensor Information.""" # Convert device_trackers to HERE friendly location if self._origin_entity_id is not None: - self._here_data.origin = await self._get_location_from_entity( - self._origin_entity_id - ) + self._here_data.origin = find_coordinates(self.hass, self._origin_entity_id) if self._destination_entity_id is not None: - self._here_data.destination = await self._get_location_from_entity( - self._destination_entity_id + self._here_data.destination = find_coordinates( + self.hass, self._destination_entity_id ) await self.hass.async_add_executor_job(self._here_data.update) - async def _get_location_from_entity(self, entity_id: str) -> str | None: - """Get the location from the entity state or attributes.""" - if (entity := self.hass.states.get(entity_id)) is None: - _LOGGER.error("Unable to find entity %s", entity_id) - return None - - # Check if the entity has location attributes - if location.has_location(entity): - return self._get_location_from_attributes(entity) - - # Check if device is in a zone - zone_entity = self.hass.states.get(f"zone.{entity.state}") - if location.has_location(zone_entity): - _LOGGER.debug( - "%s is in %s, getting zone location", entity_id, zone_entity.entity_id - ) - return self._get_location_from_attributes(zone_entity) - - # Check if state is valid coordinate set - if self._entity_state_is_valid_coordinate_set(entity.state): - return entity.state - - _LOGGER.error( - "The state of %s is not a valid set of coordinates: %s", - entity_id, - entity.state, - ) - return None - - @staticmethod - def _entity_state_is_valid_coordinate_set(state: str) -> bool: - """Check that the given string is a valid set of coordinates.""" - schema = vol.Schema(cv.gps) - try: - coordinates = state.split(",") - schema(coordinates) - return True - except (vol.MultipleInvalid): - return False - - @staticmethod - def _get_location_from_attributes(entity: State) -> str: - """Get the lat/long string from an entities attributes.""" - attr = entity.attributes - return f"{attr.get(ATTR_LATITUDE)},{attr.get(ATTR_LONGITUDE)}" - class HERETravelTimeData: """HERETravelTime data object.""" diff --git a/homeassistant/components/hisense_aehw4a1/translations/ja.json b/homeassistant/components/hisense_aehw4a1/translations/ja.json new file mode 100644 index 00000000000..75107c4c0fc --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/ja.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "confirm": { + "description": "Hisense AEH-W4A1\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/tr.json b/homeassistant/components/hisense_aehw4a1/translations/tr.json index a893a653a78..e2404aaa686 100644 --- a/homeassistant/components/hisense_aehw4a1/translations/tr.json +++ b/homeassistant/components/hisense_aehw4a1/translations/tr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, "step": { diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 2ac9a77c025..72bcba104a4 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -119,7 +119,7 @@ class LazyState(history_models.LazyState): vol.Required("start_time"): str, vol.Optional("end_time"): str, vol.Optional("statistic_ids"): [str], - vol.Required("period"): vol.Any("hour", "5minute"), + vol.Required("period"): vol.Any("5minute", "hour", "day", "month"), } ) @websocket_api.async_response @@ -130,16 +130,14 @@ async def ws_get_statistics_during_period( start_time_str = msg["start_time"] end_time_str = msg.get("end_time") - start_time = dt_util.parse_datetime(start_time_str) - if 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: - end_time = dt_util.parse_datetime(end_time_str) - if 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") @@ -194,11 +192,8 @@ class HistoryPeriodView(HomeAssistantView): ) -> web.Response: """Return history over a period of time.""" datetime_ = None - if datetime: - datetime_ = dt_util.parse_datetime(datetime) - - if datetime_ is None: - return self.json_message("Invalid datetime", HTTPStatus.BAD_REQUEST) + if datetime and (datetime_ := dt_util.parse_datetime(datetime)) is None: + return self.json_message("Invalid datetime", HTTPStatus.BAD_REQUEST) now = dt_util.utcnow() @@ -212,8 +207,7 @@ class HistoryPeriodView(HomeAssistantView): return self.json([]) if end_time_str := request.query.get("end_time"): - end_time = dt_util.parse_datetime(end_time_str) - if end_time: + if end_time := dt_util.parse_datetime(end_time_str): end_time = dt_util.as_utc(end_time) else: return self.json_message("Invalid end_time", HTTPStatus.BAD_REQUEST) diff --git a/homeassistant/components/hive/alarm_control_panel.py b/homeassistant/components/hive/alarm_control_panel.py new file mode 100644 index 00000000000..a17d71f51ab --- /dev/null +++ b/homeassistant/components/hive/alarm_control_panel.py @@ -0,0 +1,100 @@ +"""Support for the Hive alarm.""" +from datetime import timedelta + +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_NIGHT, +) +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +from homeassistant.helpers.entity import DeviceInfo + +from . import HiveEntity +from .const import DOMAIN + +ICON = "mdi:security" +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=15) +HIVETOHA = { + "home": STATE_ALARM_DISARMED, + "asleep": STATE_ALARM_ARMED_NIGHT, + "away": STATE_ALARM_ARMED_AWAY, +} + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Hive thermostat based on a config entry.""" + + hive = hass.data[DOMAIN][entry.entry_id] + devices = hive.session.deviceList.get("alarm_control_panel") + if devices: + async_add_entities( + [HiveAlarmControlPanelEntity(hive, dev) for dev in devices], True + ) + + +class HiveAlarmControlPanelEntity(HiveEntity, AlarmControlPanelEntity): + """Representation of a Hive alarm.""" + + _attr_icon = ICON + + @property + def unique_id(self): + """Return unique ID of entity.""" + return self._unique_id + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this AdGuard Home instance.""" + return DeviceInfo( + identifiers={(DOMAIN, self.device["device_id"])}, + model=self.device["deviceData"]["model"], + manufacturer=self.device["deviceData"]["manufacturer"], + name=self.device["device_name"], + sw_version=self.device["deviceData"]["version"], + via_device=(DOMAIN, self.device["parentDevice"]), + ) + + @property + def name(self): + """Return the name of the alarm.""" + return self.device["haName"] + + @property + def available(self): + """Return if the device is available.""" + return self.device["deviceData"]["online"] + + @property + def state(self): + """Return state of alarm.""" + if self.device["status"]["state"]: + return STATE_ALARM_TRIGGERED + return HIVETOHA[self.device["status"]["mode"]] + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_NIGHT | SUPPORT_ALARM_ARM_AWAY + + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + await self.hive.alarm.setMode(self.device, "home") + + async def async_alarm_arm_night(self, code=None): + """Send arm night command.""" + await self.hive.alarm.setMode(self.device, "asleep") + + async def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + await self.hive.alarm.setMode(self.device, "away") + + async def async_update(self): + """Update all Node data from Hive.""" + await self.hive.session.updateData(self.device) + self.device = await self.hive.alarm.getAlarm(self.device) diff --git a/homeassistant/components/hive/const.py b/homeassistant/components/hive/const.py index 9e1d7fc1f80..f24ed0f7b24 100644 --- a/homeassistant/components/hive/const.py +++ b/homeassistant/components/hive/const.py @@ -6,8 +6,17 @@ CONF_CODE = "2fa" CONFIG_ENTRY_VERSION = 1 DEFAULT_NAME = "Hive" DOMAIN = "hive" -PLATFORMS = ["binary_sensor", "climate", "light", "sensor", "switch", "water_heater"] +PLATFORMS = [ + "alarm_control_panel", + "binary_sensor", + "climate", + "light", + "sensor", + "switch", + "water_heater", +] PLATFORM_LOOKUP = { + "alarm_control_panel": "alarm_control_panel", "binary_sensor": "binary_sensor", "climate": "climate", "light": "light", diff --git a/homeassistant/components/hive/translations/ja.json b/homeassistant/components/hive/translations/ja.json new file mode 100644 index 00000000000..55b18b13427 --- /dev/null +++ b/homeassistant/components/hive/translations/ja.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f", + "unknown_entry": "\u65e2\u5b58\u306e\u30a8\u30f3\u30c8\u30ea\u30fc\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3002" + }, + "error": { + "invalid_code": "Hive\u3078\u306e\u30b5\u30a4\u30f3\u30a4\u30f3\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u4e8c\u8981\u7d20\u8a8d\u8a3c\u30b3\u30fc\u30c9\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093\u3002", + "invalid_password": "Hive\u3078\u306e\u30b5\u30a4\u30f3\u30a4\u30f3\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093\u306e\u3067\u3001\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", + "invalid_username": "Hive\u3078\u306e\u30b5\u30a4\u30f3\u30a4\u30f3\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u3042\u306a\u305f\u306e\u30e1\u30fc\u30eb\u30a2\u30c9\u30ec\u30b9\u304c\u8a8d\u8b58\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002", + "no_internet_available": "Hive\u306b\u63a5\u7d9a\u3059\u308b\u306b\u306f\u3001\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u63a5\u7d9a\u304c\u5fc5\u8981\u3067\u3059\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "2fa": { + "data": { + "2fa": "2\u8981\u7d20\u30b3\u30fc\u30c9" + }, + "description": "Hive\u8a8d\u8a3c\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u307e\u3059\u3002 \n\n\u5225\u306e\u30b3\u30fc\u30c9\u3092\u30ea\u30af\u30a8\u30b9\u30c8\u3059\u308b\u306b\u306f\u3001\u30b3\u30fc\u30c9 0000 \u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Hive 2\u8981\u7d20\u8a8d\u8a3c\u3002" + }, + "reauth": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "Hive\u306e\u30ed\u30b0\u30a4\u30f3\u60c5\u5831\u3092\u518d\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "Hive\u30ed\u30b0\u30a4\u30f3" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "scan_interval": "\u30b9\u30ad\u30e3\u30f3\u30a4\u30f3\u30bf\u30fc\u30d0\u30eb(\u79d2)", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "Hive\u306e\u30ed\u30b0\u30a4\u30f3\u60c5\u5831\u3068\u8a2d\u5b9a\u3092\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "Hive\u30ed\u30b0\u30a4\u30f3" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "scan_interval": "\u30b9\u30ad\u30e3\u30f3\u30a4\u30f3\u30bf\u30fc\u30d0\u30eb(\u79d2)" + }, + "description": "\u30b9\u30ad\u30e3\u30f3\u9593\u9694\u3092\u66f4\u65b0\u3057\u3066\u3001\u30c7\u30fc\u30bf\u3092\u3088\u308a\u983b\u7e41\u306b\u30dd\u30fc\u30ea\u30f3\u30b0\u3057\u307e\u3059\u3002", + "title": "Hive\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hive/translations/tr.json b/homeassistant/components/hive/translations/tr.json new file mode 100644 index 00000000000..afc0ee66f03 --- /dev/null +++ b/homeassistant/components/hive/translations/tr.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu", + "unknown_entry": "Mevcut giri\u015f bulunamad\u0131." + }, + "error": { + "invalid_code": "Hive'da oturum a\u00e7\u0131lamad\u0131. \u0130ki fakt\u00f6rl\u00fc kimlik do\u011frulama kodunuz yanl\u0131\u015ft\u0131.", + "invalid_password": "Hive'da oturum a\u00e7\u0131lamad\u0131. Yanl\u0131\u015f \u015fifre. L\u00fctfen tekrar deneyin.", + "invalid_username": "Hive'da oturum a\u00e7\u0131lamad\u0131. E-posta adresiniz tan\u0131nm\u0131yor.", + "no_internet_available": "Hive'a ba\u011flanmak i\u00e7in internet ba\u011flant\u0131s\u0131 gereklidir.", + "unknown": "Beklenmeyen hata" + }, + "step": { + "2fa": { + "data": { + "2fa": "\u0130ki ad\u0131ml\u0131 kimlik do\u011frulama kodu" + }, + "description": "Hive kimlik do\u011frulama kodunuzu girin. \n\n Ba\u015fka bir kod istemek i\u00e7in l\u00fctfen 0000 kodunu girin.", + "title": "Hive \u0130ki Fakt\u00f6rl\u00fc Kimlik Do\u011frulama." + }, + "reauth": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "Hive giri\u015f bilgilerinizi tekrar girin.", + "title": "Hive Giri\u015f yap" + }, + "user": { + "data": { + "password": "Parola", + "scan_interval": "Tarama Aral\u0131\u011f\u0131 (saniye)", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "Hive oturum a\u00e7ma bilgilerinizi ve yap\u0131land\u0131rman\u0131z\u0131 girin.", + "title": "Hive Giri\u015f yap" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "scan_interval": "Tarama Aral\u0131\u011f\u0131 (saniye)" + }, + "description": "Verileri daha s\u0131k kontrol etmek i\u00e7in tarama aral\u0131\u011f\u0131n\u0131 g\u00fcncelleyin.", + "title": "Hive i\u00e7in Se\u00e7enekler" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/translations/bg.json b/homeassistant/components/hlk_sw16/translations/bg.json new file mode 100644 index 00000000000..d3c0c1a8e77 --- /dev/null +++ b/homeassistant/components/hlk_sw16/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" + }, + "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", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/translations/ja.json b/homeassistant/components/hlk_sw16/translations/ja.json new file mode 100644 index 00000000000..a9d2ddfd3ac --- /dev/null +++ b/homeassistant/components/hlk_sw16/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/translations/tr.json b/homeassistant/components/hlk_sw16/translations/tr.json index 40c9c39b967..3fdcebd112c 100644 --- a/homeassistant/components/hlk_sw16/translations/tr.json +++ b/homeassistant/components/hlk_sw16/translations/tr.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" } diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 373ad6be295..910bec3e6ab 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -63,16 +63,14 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): elif ( self._state is not None and self._sign == 1 - and dt_util.parse_datetime(self._state) < dt_util.utcnow() + and self._state < dt_util.utcnow() ): # if the date is supposed to be in the future but we're # already past it, set state to None. self._state = None else: seconds = self._sign * float(status[self._key][ATTR_VALUE]) - self._state = ( - dt_util.utcnow() + timedelta(seconds=seconds) - ).isoformat() + self._state = dt_util.utcnow() + timedelta(seconds=seconds) else: self._state = status[self._key].get(ATTR_VALUE) if self._key == BSH_OPERATION_STATE: diff --git a/homeassistant/components/home_connect/translations/ja.json b/homeassistant/components/home_connect/translations/ja.json new file mode 100644 index 00000000000..66b53ce718b --- /dev/null +++ b/homeassistant/components/home_connect/translations/ja.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "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})" + }, + "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/home_connect/translations/tr.json b/homeassistant/components/home_connect/translations/tr.json new file mode 100644 index 00000000000..58624199557 --- /dev/null +++ b/homeassistant/components/home_connect/translations/tr.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "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})" + }, + "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/home_plus_control/translations/ja.json b/homeassistant/components/home_plus_control/translations/ja.json new file mode 100644 index 00000000000..df5165fc3fa --- /dev/null +++ b/homeassistant/components/home_plus_control/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "authorize_url_timeout": "\u8a8d\u8a3cURL\u306e\u751f\u6210\u304c\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002", + "missing_configuration": "\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u306b\u5f93\u3063\u3066\u304f\u3060\u3055\u3044\u3002", + "no_url_available": "\u4f7f\u7528\u53ef\u80fd\u306aURL\u304c\u3042\u308a\u307e\u305b\u3093\u3002\u3053\u306e\u30a8\u30e9\u30fc\u306e\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001[\u30d8\u30eb\u30d7\u30bb\u30af\u30b7\u30e7\u30f3\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044]({docs_url})", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "create_entry": { + "default": "\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f" + }, + "step": { + "pick_implementation": { + "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" + } + } + }, + "title": "Legrand Home+ Control" +} \ No newline at end of file diff --git a/homeassistant/components/home_plus_control/translations/tr.json b/homeassistant/components/home_plus_control/translations/tr.json new file mode 100644 index 00000000000..0138716d548 --- /dev/null +++ b/homeassistant/components/home_plus_control/translations/tr.json @@ -0,0 +1,21 @@ +{ + "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", + "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" + } + } + }, + "title": "Legrand Home+ Kontrol" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 8c31859b8e0..d75358fe62e 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -138,9 +138,8 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no async def async_handle_core_service(call): """Service handler for handling core services.""" - if ( - call.service in SHUTDOWN_SERVICES - and await recorder.async_migration_in_progress(hass) + if call.service in SHUTDOWN_SERVICES and recorder.async_migration_in_progress( + hass ): _LOGGER.error( "The system cannot %s while a database upgrade is in progress", diff --git a/homeassistant/components/homeassistant/translations/id.json b/homeassistant/components/homeassistant/translations/id.json index 8f3d9484c27..f795a47ee20 100644 --- a/homeassistant/components/homeassistant/translations/id.json +++ b/homeassistant/components/homeassistant/translations/id.json @@ -10,6 +10,7 @@ "os_version": "Versi Sistem Operasi", "python_version": "Versi Python", "timezone": "Zona Waktu", + "user": "Pengguna", "version": "Versi", "virtualenv": "Lingkungan Virtual" } diff --git a/homeassistant/components/homeassistant/translations/ja.json b/homeassistant/components/homeassistant/translations/ja.json new file mode 100644 index 00000000000..453478aee37 --- /dev/null +++ b/homeassistant/components/homeassistant/translations/ja.json @@ -0,0 +1,18 @@ +{ + "system_health": { + "info": { + "arch": "CPU \u30a2\u30fc\u30ad\u30c6\u30af\u30c1\u30e3", + "dev": "\u958b\u767a", + "docker": "Docker", + "hassio": "Supervisor", + "installation_type": "\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u306e\u7a2e\u985e", + "os_name": "\u30aa\u30da\u30ec\u30fc\u30c6\u30a3\u30f3\u30b0\u30b7\u30b9\u30c6\u30e0\u30d5\u30a1\u30df\u30ea\u30fc", + "os_version": "\u30aa\u30da\u30ec\u30fc\u30c6\u30a3\u30f3\u30b0 \u30b7\u30b9\u30c6\u30e0\u306e\u30d0\u30fc\u30b8\u30e7\u30f3", + "python_version": "Python \u30d0\u30fc\u30b8\u30e7\u30f3", + "timezone": "\u30bf\u30a4\u30e0\u30be\u30fc\u30f3", + "user": "\u30e6\u30fc\u30b6\u30fc", + "version": "\u30d0\u30fc\u30b8\u30e7\u30f3", + "virtualenv": "\u4eee\u60f3\u74b0\u5883" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/tr.json b/homeassistant/components/homeassistant/translations/tr.json index 9c57c8f2cee..cffbee33e74 100644 --- a/homeassistant/components/homeassistant/translations/tr.json +++ b/homeassistant/components/homeassistant/translations/tr.json @@ -3,13 +3,14 @@ "info": { "arch": "CPU Mimarisi", "dev": "Geli\u015ftirme", - "docker": "Konteyner", + "docker": "Docker", "hassio": "S\u00fcperviz\u00f6r", "installation_type": "Kurulum T\u00fcr\u00fc", "os_name": "\u0130\u015fletim Sistemi Ailesi", "os_version": "\u0130\u015fletim Sistemi S\u00fcr\u00fcm\u00fc", "python_version": "Python S\u00fcr\u00fcm\u00fc", "timezone": "Saat dilimi", + "user": "Kullan\u0131c\u0131", "version": "S\u00fcr\u00fcm", "virtualenv": "Sanal Ortam" } diff --git a/homeassistant/components/homeassistant/triggers/numeric_state.py b/homeassistant/components/homeassistant/triggers/numeric_state.py index 3f280f581b3..823bb608b4d 100644 --- a/homeassistant/components/homeassistant/triggers/numeric_state.py +++ b/homeassistant/components/homeassistant/triggers/numeric_state.py @@ -13,12 +13,18 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import CALLBACK_TYPE, HassJob, callback -from homeassistant.helpers import condition, config_validation as cv, template +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, + template, +) from homeassistant.helpers.event import ( async_track_same_state, async_track_state_change_event, ) +from homeassistant.helpers.typing import ConfigType # mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs # mypy: no-check-untyped-defs @@ -43,11 +49,11 @@ def validate_above_below(value): return value -TRIGGER_SCHEMA = vol.All( +_TRIGGER_SCHEMA = vol.All( cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "numeric_state", - vol.Required(CONF_ENTITY_ID): cv.entity_ids, + vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids, vol.Optional(CONF_BELOW): cv.NUMERIC_STATE_THRESHOLD_SCHEMA, vol.Optional(CONF_ABOVE): cv.NUMERIC_STATE_THRESHOLD_SCHEMA, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, @@ -62,6 +68,18 @@ TRIGGER_SCHEMA = vol.All( _LOGGER = logging.getLogger(__name__) +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate trigger config.""" + config = _TRIGGER_SCHEMA(config) + registry = er.async_get(hass) + config[CONF_ENTITY_ID] = er.async_resolve_entity_ids( + registry, cv.entity_ids_or_uuids(config[CONF_ENTITY_ID]) + ) + return config + + async def async_attach_trigger( hass, config, action, automation_info, *, platform_type="numeric_state" ) -> CALLBACK_TYPE: diff --git a/homeassistant/components/homeassistant/triggers/state.py b/homeassistant/components/homeassistant/triggers/state.py index f60071d633c..e16416c2f13 100644 --- a/homeassistant/components/homeassistant/triggers/state.py +++ b/homeassistant/components/homeassistant/triggers/state.py @@ -3,20 +3,24 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any import voluptuous as vol from homeassistant import exceptions from homeassistant.const import CONF_ATTRIBUTE, CONF_FOR, CONF_PLATFORM, MATCH_ALL from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, State, callback -from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers import ( + config_validation as cv, + entity_registry as er, + template, +) from homeassistant.helpers.event import ( Event, async_track_same_state, async_track_state_change_event, process_state_match, ) +from homeassistant.helpers.typing import ConfigType # mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs # mypy: no-check-untyped-defs @@ -30,7 +34,7 @@ CONF_TO = "to" BASE_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "state", - vol.Required(CONF_ENTITY_ID): cv.entity_ids, + vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids, vol.Optional(CONF_FOR): cv.positive_time_period_template, vol.Optional(CONF_ATTRIBUTE): cv.match_all, } @@ -39,8 +43,8 @@ BASE_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( TRIGGER_STATE_SCHEMA = BASE_SCHEMA.extend( { # These are str on purpose. Want to catch YAML conversions - vol.Optional(CONF_FROM): vol.Any(str, [str]), - vol.Optional(CONF_TO): vol.Any(str, [str]), + vol.Optional(CONF_FROM): vol.Any(str, [str], None), + vol.Optional(CONF_TO): vol.Any(str, [str], None), } ) @@ -52,17 +56,26 @@ TRIGGER_ATTRIBUTE_SCHEMA = BASE_SCHEMA.extend( ) -def TRIGGER_SCHEMA(value: Any) -> dict: # pylint: disable=invalid-name - """Validate trigger.""" - if not isinstance(value, dict): +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate trigger config.""" + if not isinstance(config, dict): raise vol.Invalid("Expected a dictionary") # We use this approach instead of vol.Any because # this gives better error messages. - if CONF_ATTRIBUTE in value: - return TRIGGER_ATTRIBUTE_SCHEMA(value) + if CONF_ATTRIBUTE in config: + config = TRIGGER_ATTRIBUTE_SCHEMA(config) + else: + config = TRIGGER_STATE_SCHEMA(config) - return TRIGGER_STATE_SCHEMA(value) + registry = er.async_get(hass) + config[CONF_ENTITY_ID] = er.async_resolve_entity_ids( + registry, cv.entity_ids_or_uuids(config[CONF_ENTITY_ID]) + ) + + return config async def async_attach_trigger( @@ -74,12 +87,16 @@ async def async_attach_trigger( platform_type: str = "state", ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" - entity_id = config.get(CONF_ENTITY_ID) - from_state = config.get(CONF_FROM, MATCH_ALL) - to_state = config.get(CONF_TO, MATCH_ALL) + entity_ids = config[CONF_ENTITY_ID] + if (from_state := config.get(CONF_FROM)) is None: + from_state = MATCH_ALL + if (to_state := config.get(CONF_TO)) is None: + to_state = MATCH_ALL time_delta = config.get(CONF_FOR) template.attach(hass, time_delta) - match_all = from_state == MATCH_ALL and to_state == MATCH_ALL + # If neither CONF_FROM or CONF_TO are specified, + # fire on all changes to the state or an attribute + match_all = CONF_FROM not in config and CONF_TO not in config unsub_track_same = {} period: dict[str, timedelta] = {} match_from_state = process_state_match(from_state) @@ -192,7 +209,7 @@ async def async_attach_trigger( entity_ids=entity, ) - unsub = async_track_state_change_event(hass, entity_id, state_automation_listener) + unsub = async_track_state_change_event(hass, entity_ids, state_automation_listener) @callback def async_remove(): diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 90780489d7b..1bfdc0e4e58 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -69,8 +69,7 @@ async def async_attach_trigger(hass, config, action, automation_info): def update_entity_trigger(entity_id, new_state=None): """Update the entity trigger for the entity_id.""" # If a listener was already set up for entity, remove it. - remove = entities.pop(entity_id, None) - if remove: + if remove := entities.pop(entity_id, None): remove() remove = None diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 9d8c3d04302..cfa734559fc 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -1,4 +1,6 @@ """Support for Apple HomeKit.""" +from __future__ import annotations + import asyncio import ipaddress import logging @@ -18,6 +20,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN from homeassistant.components.http import HomeAssistantView from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN +from homeassistant.components.network.const import MDNS_TARGET_IP from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( @@ -90,6 +93,7 @@ from .const import ( HOMEKIT_PAIRING_QR, HOMEKIT_PAIRING_QR_SECRET, MANUFACTURER, + PERSIST_LOCK, SERVICE_HOMEKIT_RESET_ACCESSORY, SERVICE_HOMEKIT_START, SERVICE_HOMEKIT_UNPAIR, @@ -98,11 +102,11 @@ from .const import ( from .type_triggers import DeviceTriggerAccessory from .util import ( accessory_friendly_name, + async_dismiss_setup_message, async_port_is_available, - dismiss_setup_message, + async_show_setup_message, get_persist_fullpath_for_entry_id, remove_state_files_for_entry_id, - show_setup_message, state_needs_accessory_mode, validate_entity_config, ) @@ -119,8 +123,6 @@ STATUS_WAIT = 3 PORT_CLEANUP_CHECK_INTERVAL_SECS = 1 -MDNS_TARGET_IP = "224.0.0.251" - _HOMEKIT_CONFIG_UPDATE_TIME = ( 5 # number of seconds to wait for homekit to see the c# change ) @@ -172,6 +174,15 @@ UNPAIR_SERVICE_SCHEMA = vol.All( ) +def _async_all_homekit_instances(hass: HomeAssistant) -> list[HomeKit]: + """All active HomeKit instances.""" + return [ + data[HOMEKIT] + for data in hass.data[DOMAIN].values() + if isinstance(data, dict) and HOMEKIT in data + ] + + def _async_get_entries_by_name(current_entries): """Return a dict of the entries by name.""" @@ -182,7 +193,7 @@ def _async_get_entries_by_name(current_entries): async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HomeKit from yaml.""" - hass.data.setdefault(DOMAIN, {}) + hass.data.setdefault(DOMAIN, {})[PERSIST_LOCK] = asyncio.Lock() _async_register_events_and_services(hass) @@ -310,7 +321,7 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - dismiss_setup_message(hass, entry.entry_id) + async_dismiss_setup_message(hass, entry.entry_id) homekit = hass.data[DOMAIN][entry.entry_id][HOMEKIT] if homekit.status == STATUS_RUNNING: @@ -361,10 +372,7 @@ def _async_register_events_and_services(hass: HomeAssistant): async def async_handle_homekit_reset_accessory(service): """Handle reset accessory HomeKit service call.""" - for entry_id in hass.data[DOMAIN]: - if HOMEKIT not in hass.data[DOMAIN][entry_id]: - continue - homekit = hass.data[DOMAIN][entry_id][HOMEKIT] + for homekit in _async_all_homekit_instances(hass): if homekit.status != STATUS_RUNNING: _LOGGER.warning( "HomeKit is not running. Either it is waiting to be " @@ -394,16 +402,11 @@ def _async_register_events_and_services(hass: HomeAssistant): for ctype, cval in dev_reg_ent.connections if ctype == device_registry.CONNECTION_NETWORK_MAC ] - domain_data = hass.data[DOMAIN] matching_instances = [ - domain_data[entry_id][HOMEKIT] - for entry_id in domain_data - if HOMEKIT in domain_data[entry_id] - and domain_data[entry_id][HOMEKIT].driver - and device_registry.format_mac( - domain_data[entry_id][HOMEKIT].driver.state.mac - ) - in macs + homekit + for homekit in _async_all_homekit_instances(hass) + if homekit.driver + and device_registry.format_mac(homekit.driver.state.mac) in macs ] if not matching_instances: raise HomeAssistantError( @@ -422,10 +425,7 @@ def _async_register_events_and_services(hass: HomeAssistant): async def async_handle_homekit_service_start(service): """Handle start HomeKit service call.""" tasks = [] - for entry_id in hass.data[DOMAIN]: - if HOMEKIT not in hass.data[DOMAIN][entry_id]: - continue - homekit = hass.data[DOMAIN][entry_id][HOMEKIT] + for homekit in _async_all_homekit_instances(hass): if homekit.status == STATUS_RUNNING: _LOGGER.debug("HomeKit is already running") continue @@ -659,8 +659,7 @@ class HomeKit: async def async_remove_bridge_accessory(self, aid): """Try adding accessory to bridge if configured beforehand.""" - acc = self.bridge.accessories.pop(aid, None) - if acc: + if acc := self.bridge.accessories.pop(aid, None): await acc.stop() return acc @@ -709,7 +708,8 @@ class HomeKit: self._async_register_bridge() _LOGGER.debug("Driver start for %s", self._name) await self.driver.async_start() - self.driver.async_persist() + async with self.hass.data[DOMAIN][PERSIST_LOCK]: + await self.hass.async_add_executor_job(self.driver.persist) self.status = STATUS_RUNNING if self.driver.state.paired: @@ -719,7 +719,7 @@ class HomeKit: @callback def _async_show_setup_message(self): """Show the pairing setup message.""" - show_setup_message( + async_show_setup_message( self.hass, self._entry_id, accessory_friendly_name(self._entry_title, self.driver.accessory), @@ -766,7 +766,7 @@ class HomeKit: manufacturer=MANUFACTURER, name=accessory_friendly_name(self._entry_title, self.driver.accessory), model=f"HomeKit {hk_mode_name}", - entry_type="service", + entry_type=device_registry.DeviceEntryType.SERVICE, ) @callback @@ -859,7 +859,7 @@ class HomeKit: ent_reg_ent is None or ent_reg_ent.device_id is None or ent_reg_ent.device_id not in device_lookup - or ent_reg_ent.device_class + or (ent_reg_ent.device_class or ent_reg_ent.original_device_class) in (DEVICE_CLASS_BATTERY_CHARGING, DEVICE_CLASS_BATTERY) ): return diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 30ee2e72589..ca12daa33b3 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -4,6 +4,7 @@ import logging from pyhap.accessory import Accessory, Bridge from pyhap.accessory_driver import AccessoryDriver from pyhap.const import CATEGORY_OTHER +from pyhap.util import callback as pyhap_callback from homeassistant.components import cover from homeassistant.components.cover import ( @@ -80,10 +81,10 @@ from .const import ( ) from .util import ( accessory_friendly_name, + async_dismiss_setup_message, + async_show_setup_message, convert_to_float, - dismiss_setup_message, format_sw_version, - show_setup_message, validate_media_player_features, ) @@ -195,7 +196,14 @@ def get_accessory(hass, driver, state, aid, config): # noqa: C901 elif state.domain == "remote" and features & SUPPORT_ACTIVITY: a_type = "ActivityRemote" - elif state.domain in ("automation", "input_boolean", "remote", "scene", "script"): + elif state.domain in ( + "automation", + "button", + "input_boolean", + "remote", + "scene", + "script", + ): a_type = "Switch" elif state.domain in ("input_select", "select"): @@ -543,13 +551,15 @@ class HomeDriver(AccessoryDriver): self._bridge_name = bridge_name self._entry_title = entry_title + @pyhap_callback def pair(self, client_uuid, client_public, client_permissions): """Override super function to dismiss setup message if paired.""" success = super().pair(client_uuid, client_public, client_permissions) if success: - dismiss_setup_message(self.hass, self._entry_id) + async_dismiss_setup_message(self.hass, self._entry_id) return success + @pyhap_callback def unpair(self, client_uuid): """Override super function to show setup message if unpaired.""" super().unpair(client_uuid) @@ -557,7 +567,7 @@ class HomeDriver(AccessoryDriver): if self.state.paired: return - show_setup_message( + async_show_setup_message( self.hass, self._entry_id, accessory_friendly_name(self._entry_title, self.accessory), diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py index 0f5e29426a8..c7ddc29a788 100644 --- a/homeassistant/components/homekit/aidmanager.py +++ b/homeassistant/components/homekit/aidmanager.py @@ -82,8 +82,7 @@ class AccessoryAidStorage: aidstore = get_aid_storage_filename_for_entry_id(self._entry) self.store = Store(self.hass, AID_MANAGER_STORAGE_VERSION, aidstore) - raw_storage = await self.store.async_load() - if not raw_storage: + if not (raw_storage := await self.store.async_load()): # There is no data about aid allocations yet return diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 79a0e71f969..0d8bf967c5b 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -76,6 +76,7 @@ SUPPORTED_DOMAINS = [ "alarm_control_panel", "automation", "binary_sensor", + "button", CAMERA_DOMAIN, "climate", "cover", @@ -452,12 +453,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): data_schema = {} entity_schema = vol.In - # Strip out entities that no longer exist to prevent error in the UI - entities = [ - entity_id - for entity_id in entity_filter.get(CONF_INCLUDE_ENTITIES, []) - if entity_id in all_supported_entities - ] + entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) if self.hk_options[CONF_HOMEKIT_MODE] != HOMEKIT_MODE_ACCESSORY: include_exclude_mode = MODE_INCLUDE if not entities: @@ -468,9 +464,13 @@ class OptionsFlowHandler(config_entries.OptionsFlow): ] = vol.In(INCLUDE_EXCLUDE_MODES) entity_schema = cv.multi_select - data_schema[vol.Optional(CONF_ENTITIES, default=entities)] = entity_schema( - all_supported_entities - ) + # Strip out entities that no longer exist to prevent error in the UI + valid_entities = [ + entity_id for entity_id in entities if entity_id in all_supported_entities + ] + data_schema[ + vol.Optional(CONF_ENTITIES, default=valid_entities) + ] = entity_schema(all_supported_entities) return self.async_show_form( step_id="include_exclude", data_schema=vol.Schema(data_schema) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index c77efb705da..8494327bb68 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -12,6 +12,7 @@ HOMEKIT_PAIRING_QR_SECRET = "homekit-pairing-qr-secret" HOMEKIT = "homekit" SHUTDOWN_TIMEOUT = 30 CONF_ENTRY_INDEX = "index" +PERSIST_LOCK = "persist_lock" # ### Codecs #### VIDEO_CODEC_COPY = "copy" diff --git a/homeassistant/components/homekit/translations/id.json b/homeassistant/components/homekit/translations/id.json index 64ce23a5224..d849d2164cc 100644 --- a/homeassistant/components/homekit/translations/id.json +++ b/homeassistant/components/homekit/translations/id.json @@ -21,13 +21,15 @@ "step": { "advanced": { "data": { - "auto_start": "Mulai otomatis (nonaktifkan jika Anda memanggil layanan homekit.start secara manual)" + "auto_start": "Mulai otomatis (nonaktifkan jika Anda memanggil layanan homekit.start secara manual)", + "devices": "Perangkat (Pemicu)" }, - "description": "Sakelar yang dapat diprogram dibuat untuk setiap perangkat yang dipilih. Saat pemicu perangkat aktif, HomeKit dapat dikonfigurasi untuk menjalankan otomatisasi atau scene.", + "description": "Sakelar yang dapat diprogram dibuat untuk setiap perangkat yang dipilih. Saat pemicu perangkat aktif, HomeKit dapat dikonfigurasi untuk menjalankan otomatisasi atau skenario.", "title": "Konfigurasi Tingkat Lanjut" }, "cameras": { "data": { + "camera_audio": "Kamera yang mendukung audio", "camera_copy": "Kamera yang mendukung aliran H.264 asli" }, "description": "Periksa semua kamera yang mendukung streaming H.264 asli. Jika kamera tidak mengeluarkan aliran H.264, sistem akan mentranskode video ke H.264 untuk HomeKit. Proses transcoding membutuhkan CPU kinerja tinggi dan tidak mungkin bekerja pada komputer papan tunggal.", diff --git a/homeassistant/components/homekit/translations/ja.json b/homeassistant/components/homekit/translations/ja.json new file mode 100644 index 00000000000..06a7d6bc1d3 --- /dev/null +++ b/homeassistant/components/homekit/translations/ja.json @@ -0,0 +1,60 @@ +{ + "config": { + "abort": { + "port_name_in_use": "\u540c\u3058\u540d\u524d\u3084\u30dd\u30fc\u30c8\u3092\u6301\u3064\u30a2\u30af\u30bb\u30b5\u30ea\u30fc\u3084\u30d6\u30ea\u30c3\u30b8\u304c\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002" + }, + "step": { + "pairing": { + "description": "\u201cHomeKit Pairing\u201d\u306e\"\u901a\u77e5\"\u306e\u6307\u793a\u306b\u5f93\u3063\u3066\u30da\u30a2\u30ea\u30f3\u30b0\u3092\u5b8c\u4e86\u3057\u307e\u3059\u3002", + "title": "\u30da\u30a2 HomeKit" + }, + "user": { + "data": { + "include_domains": "\u542b\u3081\u308b\u30c9\u30e1\u30a4\u30f3" + }, + "description": "\u542b\u3081\u308b\u30c9\u30e1\u30a4\u30f3\u3092\u9078\u629e\u3057\u307e\u3059\u3002\u30c9\u30e1\u30a4\u30f3\u5185\u3067\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u308b\u3059\u3079\u3066\u306e\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u304c\u542b\u307e\u308c\u307e\u3059\u3002\u30a2\u30af\u30bb\u30b5\u30ea\u30fc \u30e2\u30fc\u30c9\u306e\u5225\u306e \u3001HomeKit\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306f\u3001\u5404 \u30c6\u30ec\u30d3\u30e1\u30c7\u30a3\u30a2 \u30d7\u30ec\u30fc\u30e4\u30fc\u3001\u30a2\u30af\u30c6\u30a3\u30d3\u30c6\u30a3 \u30d9\u30fc\u30b9\u306e\u30ea\u30e2\u30fc\u30c8\u3001\u30ed\u30c3\u30af\u3001\u304a\u3088\u3073\u30ab\u30e1\u30e9\u306b\u5bfe\u3057\u3066\u4f5c\u6210\u3055\u308c\u307e\u3059\u3002", + "title": "\u542b\u3081\u308b\u30c9\u30e1\u30a4\u30f3\u306e\u9078\u629e" + } + } + }, + "options": { + "step": { + "advanced": { + "data": { + "auto_start": "\u81ea\u52d5\u8d77\u52d5(homekit.start\u30b5\u30fc\u30d3\u30b9\u3092\u624b\u52d5\u3067\u547c\u3073\u51fa\u3059\u5834\u5408\u306f\u7121\u52b9\u306b\u3059\u308b)", + "devices": "\u30c7\u30d0\u30a4\u30b9(\u30c8\u30ea\u30ac\u30fc)" + }, + "description": "\u9078\u629e\u3057\u305f\u30c7\u30d0\u30a4\u30b9\u3054\u3068\u306b\u3001\u30d7\u30ed\u30b0\u30e9\u30e0\u53ef\u80fd\u306a\u30b9\u30a4\u30c3\u30c1\u304c\u4f5c\u6210\u3055\u308c\u307e\u3059\u3002\u30c7\u30d0\u30a4\u30b9\u306e\u30c8\u30ea\u30ac\u30fc\u304c\u767a\u751f\u3059\u308b\u3068\u3001HomeKit\u306f\u30aa\u30fc\u30c8\u30e1\u30fc\u30b7\u30e7\u30f3\u3084\u30b7\u30fc\u30f3\u3092\u5b9f\u884c\u3059\u308b\u3088\u3046\u306b\u69cb\u6210\u3067\u304d\u307e\u3059\u3002", + "title": "\u9ad8\u5ea6\u306a\u8a2d\u5b9a" + }, + "cameras": { + "data": { + "camera_audio": "\u97f3\u58f0\u306b\u5bfe\u5fdc\u3057\u305f\u30ab\u30e1\u30e9", + "camera_copy": "H.264\u306e\u30cd\u30a4\u30c6\u30a3\u30d6\u30b9\u30c8\u30ea\u30fc\u30e0\u3092\u30b5\u30dd\u30fc\u30c8\u3059\u308b\u30ab\u30e1\u30e9" + }, + "description": "\u3059\u3079\u3066\u306e\u30ab\u30e1\u30e9\u304c\u3001\u30cd\u30a4\u30c6\u30a3\u30d6\u3067H.264\u30b9\u30c8\u30ea\u30fc\u30e0\u3092\u30b5\u30dd\u30fc\u30c8\u3057\u3066\u3044\u308b\u304b\u3069\u3046\u304b\u3092\u78ba\u8a8d\u3057\u307e\u3059\u3002\u30ab\u30e1\u30e9\u304c\u3001H.264\u30b9\u30c8\u30ea\u30fc\u30e0\u51fa\u529b\u306b\u5bfe\u5fdc\u3057\u3066\u3044\u306a\u3044\u5834\u5408\u3001\u30b7\u30b9\u30c6\u30e0\u306f\u3001HomeKit\u306eH.264\u306b\u30d3\u30c7\u30aa\u3092\u30c8\u30e9\u30f3\u30b9\u30b3\u30fc\u30c9\u3057\u307e\u3059\u3002\u30c8\u30e9\u30f3\u30b9\u30b3\u30fc\u30c7\u30a3\u30f3\u30b0\u306b\u306f\u9ad8\u30d1\u30d5\u30a9\u30fc\u30de\u30f3\u30b9\u306aCPU\u304c\u5fc5\u8981\u306a\u306e\u3067\u3001\u30b7\u30f3\u30b0\u30eb\u30dc\u30fc\u30c9\u30b3\u30f3\u30d4\u30e5\u30fc\u30bf\u3067\u306f\u52d5\u4f5c\u3057\u306a\u3044\u3068\u601d\u308f\u308c\u307e\u3059\u3002", + "title": "\u30ab\u30e1\u30e9\u306e\u8a2d\u5b9a" + }, + "include_exclude": { + "data": { + "entities": "\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3", + "mode": "\u30e2\u30fc\u30c9" + }, + "description": "\u542b\u307e\u308c\u308b\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u3092\u9078\u629e\u3057\u307e\u3059\u3002\u30a2\u30af\u30bb\u30b5\u30ea\u30fc\u30e2\u30fc\u30c9\u3067\u306f\u30011\u3064\u306e\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u306e\u307f\u304c\u542b\u307e\u308c\u307e\u3059\u3002\u30d6\u30ea\u30c3\u30b8\u30a4\u30f3\u30af\u30eb\u30fc\u30c9\u30e2\u30fc\u30c9\u3067\u306f\u3001\u7279\u5b9a\u306e\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u304c\u9078\u629e\u3055\u308c\u3066\u3044\u306a\u3044\u9650\u308a\u3001\u30c9\u30e1\u30a4\u30f3\u5185\u306e\u3059\u3079\u3066\u306e\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u304c\u542b\u307e\u308c\u307e\u3059\u3002\u30d6\u30ea\u30c3\u30b8\u9664\u5916\u30e2\u30fc\u30c9\u3067\u306f\u3001\u9664\u5916\u3055\u308c\u305f\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u3092\u9664\u3044\u3066\u3001\u30c9\u30e1\u30a4\u30f3\u5185\u306e\u3059\u3079\u3066\u306e\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u304c\u542b\u307e\u308c\u307e\u3059\u3002\u6700\u9ad8\u306e\u30d1\u30d5\u30a9\u30fc\u30de\u30f3\u30b9\u3092\u5b9f\u73fe\u3059\u308b\u305f\u3081\u306b\u3001\u30c6\u30ec\u30d3\u30e1\u30c7\u30a3\u30a2\u30d7\u30ec\u30fc\u30e4\u30fc\u3001\u30a2\u30af\u30c6\u30a3\u30d3\u30c6\u30a3\u30d9\u30fc\u30b9\u306e\u30ea\u30e2\u30b3\u30f3(remote)\u3001\u30ed\u30c3\u30af\u3001\u30ab\u30e1\u30e9\u306b\u5bfe\u3057\u3066\u500b\u5225\u306b\u3001HomeKit\u30a2\u30af\u30bb\u30b5\u30ea\u30fc\u3092\u4f5c\u6210\u3057\u307e\u3059\u3002", + "title": "\u542b\u3081\u308b\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u3092\u9078\u629e" + }, + "init": { + "data": { + "include_domains": "\u542b\u3081\u308b\u30c9\u30e1\u30a4\u30f3", + "mode": "\u30e2\u30fc\u30c9" + }, + "description": "HomeKit \u306f\u3001\u30d6\u30ea\u30c3\u30b8\u307e\u305f\u306f\u5358\u4e00\u306e\u30a2\u30af\u30bb\u30b5\u30ea\u3092\u516c\u958b\u3059\u308b\u3088\u3046\u306b\u69cb\u6210\u3067\u304d\u307e\u3059\u3002\u30a2\u30af\u30bb\u30b5\u30ea\u30fc\u30e2\u30fc\u30c9\u3067\u306f\u30011\u3064\u306e\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u306e\u307f\u304c\u4f7f\u7528\u3067\u304d\u307e\u3059\u3002TV\u30c7\u30d0\u30a4\u30b9\u30af\u30e9\u30b9\u3092\u6301\u3064\u30e1\u30c7\u30a3\u30a2\u30d7\u30ec\u30fc\u30e4\u30fc\u304c\u6b63\u5e38\u306b\u6a5f\u80fd\u3059\u308b\u305f\u3081\u306b\u306f\u3001\u30a2\u30af\u30bb\u30b5\u30ea\u30fc\u30e2\u30fc\u30c9\u304c\u5fc5\u8981\u3067\u3059\u3002\"\u542b\u3081\u308b\u30c9\u30e1\u30a4\u30f3(Domains to include)\"\u306e\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u306f\u3001 HomeKit \u306b\u542b\u307e\u308c\u307e\u3059\u3002\u6b21\u306e\u753b\u9762\u3067\u3053\u306e\u30ea\u30b9\u30c8\u306b\u542b\u3081\u308b\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u3001\u307e\u305f\u306f\u9664\u5916\u3059\u308b\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u3092\u9078\u629e\u3067\u304d\u307e\u3059\u3002", + "title": "\u542b\u3081\u308b\u30c9\u30e1\u30a4\u30f3\u3092\u9078\u629e\u3057\u307e\u3059\u3002" + }, + "yaml": { + "description": "\u3053\u306e\u30a8\u30f3\u30c8\u30ea\u30fc\u306fYAML\u3092\u4ecb\u3057\u3066\u5236\u5fa1\u3055\u308c\u307e\u3059", + "title": "HomeKit\u30aa\u30d7\u30b7\u30e7\u30f3\u306e\u8abf\u6574" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/no.json b/homeassistant/components/homekit/translations/no.json index 86e5c8d95cb..868b3ff03fe 100644 --- a/homeassistant/components/homekit/translations/no.json +++ b/homeassistant/components/homekit/translations/no.json @@ -40,8 +40,8 @@ "entities": "Entiteter", "mode": "Modus" }, - "description": "Velg enhetene som skal inkluderes. I tilbeh\u00f8rsmodus er bare en enkelt enhet inkludert. I bridge-inkluderingsmodus vil alle enheter i domenet bli inkludert, med mindre spesifikke enheter er valgt. I bridge-ekskluderingsmodus vil alle enheter i domenet bli inkludert, bortsett fra de ekskluderte enhetene. For best ytelse opprettes et eget HomeKit-tilbeh\u00f8r for hver tv-mediaspiller, aktivitetsbasert fjernkontroll, l\u00e5s og kamera.", - "title": "Velg enheter som skal inkluderes" + "description": "Velg entitetene som skal inkluderes. I tilbeh\u00f8rsmodus er bare en enkelt entitet inkludert. I bridge-inkluderingsmodus vil alle entiteter i domenet bli inkludert, med mindre spesifikke entiteter er valgt. I bridge-ekskluderingsmodus vil alle entiteter i domenet bli inkludert, bortsett fra de ekskluderte entitetene. For best ytelse opprettes et eget HomeKit-tilbeh\u00f8r for hver tv-mediaspiller, aktivitetsbasert fjernkontroll, l\u00e5s og kamera.", + "title": "Velg entiteter som skal inkluderes" }, "init": { "data": { diff --git a/homeassistant/components/homekit/translations/pl.json b/homeassistant/components/homekit/translations/pl.json index 4b25a482cfd..5cc9ae00f0b 100644 --- a/homeassistant/components/homekit/translations/pl.json +++ b/homeassistant/components/homekit/translations/pl.json @@ -33,7 +33,7 @@ "camera_copy": "Kamery obs\u0142uguj\u0105ce kodek H.264" }, "description": "Sprawd\u017a, czy wszystkie kamery obs\u0142uguj\u0105 kodek H.264. Je\u015bli kamera nie wysy\u0142a strumienia skompresowanego kodekiem H.264, system b\u0119dzie transkodowa\u0142 wideo do H.264 dla HomeKit. Transkodowanie wymaga wydajnego procesora i jest ma\u0142o prawdopodobne, aby dzia\u0142a\u0142o na komputerach jednop\u0142ytkowych.", - "title": "Wyb\u00f3r kodeka wideo kamery" + "title": "Konfiguracja kamery" }, "include_exclude": { "data": { diff --git a/homeassistant/components/homekit/translations/tr.json b/homeassistant/components/homekit/translations/tr.json index 6b196c25da5..875d8a720a8 100644 --- a/homeassistant/components/homekit/translations/tr.json +++ b/homeassistant/components/homekit/translations/tr.json @@ -5,31 +5,55 @@ }, "step": { "pairing": { - "description": "{name} haz\u0131r olur olmaz e\u015fle\u015ftirme, \"Bildirimler\" i\u00e7inde \"HomeKit K\u00f6pr\u00fc Kurulumu\" olarak mevcut olacakt\u0131r.", + "description": "\u201cHomeKit E\u015fle\u015ftirme\u201d alt\u0131ndaki \u201cBildirimler\u201d b\u00f6l\u00fcm\u00fcndeki talimatlar\u0131 izleyerek e\u015fle\u015ftirmeyi tamamlamak i\u00e7in.", "title": "HomeKit'i E\u015fle\u015ftir" + }, + "user": { + "data": { + "include_domains": "\u0130\u00e7erecek etki alanlar\u0131" + }, + "description": "Dahil edilecek alanlar\u0131 se\u00e7in. Etki alan\u0131ndaki t\u00fcm desteklenen varl\u0131klar dahil edilecektir. Her TV medya oynat\u0131c\u0131, aktivite tabanl\u0131 uzaktan kumanda, kilit ve kamera i\u00e7in aksesuar modunda ayr\u0131 bir HomeKit \u00f6rne\u011fi olu\u015fturulacakt\u0131r.", + "title": "Dahil edilecek etki alanlar\u0131n\u0131 se\u00e7in" } } }, "options": { "step": { + "advanced": { + "data": { + "auto_start": "Otomatik ba\u015flatma (homekit.start hizmetini manuel olarak ar\u0131yorsan\u0131z devre d\u0131\u015f\u0131 b\u0131rak\u0131n)", + "devices": "Cihazlar (Tetikleyiciler)" + }, + "description": "Se\u00e7ilen her cihaz i\u00e7in programlanabilir anahtarlar olu\u015fturulur. Bir cihaz tetikleyicisi tetiklendi\u011finde, HomeKit bir otomasyon veya sahne \u00e7al\u0131\u015ft\u0131racak \u015fekilde yap\u0131land\u0131r\u0131labilir.", + "title": "Geli\u015fmi\u015f yap\u0131land\u0131rma" + }, "cameras": { "data": { + "camera_audio": "Sesi destekleyen kameralar", "camera_copy": "Yerel H.264 ak\u0131\u015flar\u0131n\u0131 destekleyen kameralar" }, "description": "Yerel H.264 ak\u0131\u015flar\u0131n\u0131 destekleyen t\u00fcm kameralar\u0131 kontrol edin. Kamera bir H.264 ak\u0131\u015f\u0131 vermezse, sistem videoyu HomeKit i\u00e7in H.264'e d\u00f6n\u00fc\u015ft\u00fcr\u00fcr. Kod d\u00f6n\u00fc\u015ft\u00fcrme, y\u00fcksek performansl\u0131 bir CPU gerektirir ve tek kartl\u0131 bilgisayarlarda \u00e7al\u0131\u015fma olas\u0131l\u0131\u011f\u0131 d\u00fc\u015f\u00fckt\u00fcr.", - "title": "Kamera video codec bile\u015fenini se\u00e7in." + "title": "Kamera Yap\u0131land\u0131rmas\u0131" }, "include_exclude": { "data": { "entities": "Varl\u0131klar", "mode": "Mod" }, + "description": "Dahil edilecek varl\u0131klar\u0131 se\u00e7in. Aksesuar modunda yaln\u0131zca tek bir varl\u0131k dahil edilir. K\u00f6pr\u00fc dahil modunda, belirli varl\u0131klar se\u00e7ilmedi\u011fi s\u00fcrece etki alan\u0131ndaki t\u00fcm varl\u0131klar dahil edilecektir. K\u00f6pr\u00fc hari\u00e7 tutma modunda, hari\u00e7 tutulan varl\u0131klar d\u0131\u015f\u0131nda etki alan\u0131ndaki t\u00fcm varl\u0131klar dahil edilecektir. En iyi performans i\u00e7in, her TV medya oynat\u0131c\u0131, aktivite tabanl\u0131 uzaktan kumanda, kilit ve kamera i\u00e7in ayr\u0131 bir HomeKit aksesuar\u0131 olu\u015fturulacakt\u0131r.", "title": "Dahil edilecek varl\u0131klar\u0131 se\u00e7in" }, "init": { "data": { + "include_domains": "\u0130\u00e7erecek etki alanlar\u0131", "mode": "Mod" - } + }, + "description": "HomeKit, bir k\u00f6pr\u00fcy\u00fc veya tek bir aksesuar\u0131 g\u00f6sterecek \u015fekilde yap\u0131land\u0131r\u0131labilir. Aksesuar modunda yaln\u0131zca tek bir varl\u0131k kullan\u0131labilir. TV cihaz s\u0131n\u0131f\u0131na sahip medya oynat\u0131c\u0131lar\u0131n d\u00fczg\u00fcn \u00e7al\u0131\u015fmas\u0131 i\u00e7in aksesuar modu gereklidir. \"Eklenecek alan adlar\u0131\"ndaki varl\u0131klar HomeKit'e dahil edilecektir. Bir sonraki ekranda bu listeye dahil edilecek veya hari\u00e7 tutulacak varl\u0131klar\u0131 se\u00e7ebileceksiniz.", + "title": "Dahil edilecek alanlar\u0131 se\u00e7in." + }, + "yaml": { + "description": "Bu giri\u015f YAML arac\u0131l\u0131\u011f\u0131yla kontrol edilir", + "title": "HomeKit Se\u00e7eneklerini Ayarlay\u0131n" } } } diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 6cf8735b075..14d065dc9bf 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -333,8 +333,7 @@ class Camera(HomeAccessory, PyhapCamera): session_info["id"], stream_config, ) - input_source = await self._async_get_stream_source() - if not input_source: + if not (input_source := await self._async_get_stream_source()): _LOGGER.error("Camera has no stream source") return False if "-i " not in input_source: diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index 41a76ca7fed..69267c733d2 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -52,6 +52,10 @@ from .const import ( SERV_TELEVISION, ) +MAXIMUM_SOURCES = ( + 90 # Maximum services per accessory is 100. The base acccessory uses 9 +) + _LOGGER = logging.getLogger(__name__) REMOTE_KEYS = { @@ -92,7 +96,14 @@ class RemoteInputSelectAccessory(HomeAccessory): self.sources = [] self.support_select_source = False if features & required_feature: - self.sources = state.attributes.get(source_list_key, []) + sources = state.attributes.get(source_list_key, []) + if len(sources) > MAXIMUM_SOURCES: + _LOGGER.warning( + "%s: Reached maximum number of sources (%s)", + self.entity_id, + MAXIMUM_SOURCES, + ) + self.sources = sources[:MAXIMUM_SOURCES] if self.sources: self.support_select_source = True @@ -159,13 +170,21 @@ class RemoteInputSelectAccessory(HomeAccessory): possible_sources = new_state.attributes.get(self.source_list_key, []) if source_name in possible_sources: - _LOGGER.debug( - "%s: Sources out of sync. Rebuilding Accessory", - self.entity_id, - ) - # Sources are out of sync, recreate the accessory - self.async_reset() - return + index = possible_sources.index(source_name) + if index >= MAXIMUM_SOURCES: + _LOGGER.debug( + "%s: Source %s and above are not supported", + self.entity_id, + MAXIMUM_SOURCES, + ) + else: + _LOGGER.debug( + "%s: Sources out of sync. Rebuilding Accessory", + self.entity_id, + ) + # Sources are out of sync, recreate the accessory + self.async_reset() + return _LOGGER.debug( "%s: Source %s does not exist the source list: %s", diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index c309e42a0f0..148b705b23f 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -1,8 +1,9 @@ """Class to hold all sensor accessories.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Callable, NamedTuple +from typing import NamedTuple from pyhap.const import CATEGORY_SENSOR @@ -114,8 +115,7 @@ class TemperatureSensor(HomeAccessory): def async_update_state(self, new_state): """Update temperature after state changed.""" unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) - temperature = convert_to_float(new_state.state) - if temperature: + if temperature := convert_to_float(new_state.state): temperature = temperature_to_homekit(temperature, unit) self.char_temp.set_value(temperature) _LOGGER.debug( @@ -142,8 +142,7 @@ class HumiditySensor(HomeAccessory): @callback def async_update_state(self, new_state): """Update accessory after state change.""" - humidity = convert_to_float(new_state.state) - if humidity: + if humidity := convert_to_float(new_state.state): self.char_humidity.set_value(humidity) _LOGGER.debug("%s: Percent set to %d%%", self.entity_id, humidity) @@ -170,8 +169,7 @@ class AirQualitySensor(HomeAccessory): @callback def async_update_state(self, new_state): """Update accessory after state change.""" - density = convert_to_float(new_state.state) - if density: + if density := convert_to_float(new_state.state): if self.char_density.value != density: self.char_density.set_value(density) _LOGGER.debug("%s: Set density to %d", self.entity_id, density) @@ -206,8 +204,7 @@ class CarbonMonoxideSensor(HomeAccessory): @callback def async_update_state(self, new_state): """Update accessory after state change.""" - value = convert_to_float(new_state.state) - if value: + if value := convert_to_float(new_state.state): self.char_level.set_value(value) if value > self.char_peak.value: self.char_peak.set_value(value) @@ -242,8 +239,7 @@ class CarbonDioxideSensor(HomeAccessory): @callback def async_update_state(self, new_state): """Update accessory after state change.""" - value = convert_to_float(new_state.state) - if value: + if value := convert_to_float(new_state.state): self.char_level.set_value(value) if value > self.char_peak.value: self.char_peak.set_value(value) @@ -271,8 +267,7 @@ class LightSensor(HomeAccessory): @callback def async_update_state(self, new_state): """Update accessory after state change.""" - luminance = convert_to_float(new_state.state) - if luminance: + if luminance := convert_to_float(new_state.state): self.char_light.set_value(luminance) _LOGGER.debug("%s: Set to %d", self.entity_id, luminance) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 9b9ff1f4df2..ec6813a82f1 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -12,6 +12,7 @@ from pyhap.const import ( CATEGORY_SWITCH, ) +from homeassistant.components import button from homeassistant.components.input_select import ATTR_OPTIONS, SERVICE_SELECT_OPTION from homeassistant.components.switch import DOMAIN from homeassistant.components.vacuum import ( @@ -69,7 +70,7 @@ VALVE_TYPE: dict[str, ValveInfo] = { } -ACTIVATE_ONLY_SWITCH_DOMAINS = {"scene", "script"} +ACTIVATE_ONLY_SWITCH_DOMAINS = {"button", "scene", "script"} ACTIVATE_ONLY_RESET_SECONDS = 10 @@ -149,6 +150,8 @@ class Switch(HomeAccessory): if self._domain == "script": service = self._object_id params = {} + elif self._domain == button.DOMAIN: + service = button.SERVICE_PRESS else: service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index a5c9f3937ea..11b0f6cf925 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -294,9 +294,7 @@ def get_media_player_features(state): def validate_media_player_features(state, feature_list): """Validate features for media players.""" - supported_modes = get_media_player_features(state) - - if not supported_modes: + if not (supported_modes := get_media_player_features(state)): _LOGGER.error("%s does not support any media_player features", state.entity_id) return False @@ -317,7 +315,7 @@ def validate_media_player_features(state, feature_list): return True -def show_setup_message(hass, entry_id, bridge_name, pincode, uri): +def async_show_setup_message(hass, entry_id, bridge_name, pincode, uri): """Display persistent notification with setup information.""" pin = pincode.decode() _LOGGER.info("Pincode: %s", pin) @@ -336,12 +334,14 @@ def show_setup_message(hass, entry_id, bridge_name, pincode, uri): f"### {pin}\n" f"![image](/api/homekit/pairingqr?{entry_id}-{pairing_secret})" ) - hass.components.persistent_notification.create(message, "HomeKit Pairing", entry_id) + hass.components.persistent_notification.async_create( + message, "HomeKit Pairing", entry_id + ) -def dismiss_setup_message(hass, entry_id): +def async_dismiss_setup_message(hass, entry_id): """Dismiss persistent notification and remove QR code.""" - hass.components.persistent_notification.dismiss(entry_id) + hass.components.persistent_notification.async_dismiss(entry_id) def convert_to_float(state): diff --git a/homeassistant/components/homekit_controller/air_quality.py b/homeassistant/components/homekit_controller/air_quality.py deleted file mode 100644 index df5a89f179e..00000000000 --- a/homeassistant/components/homekit_controller/air_quality.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Support for HomeKit Controller air quality sensors.""" -import logging - -from aiohomekit.model.characteristics import CharacteristicsTypes -from aiohomekit.model.services import ServicesTypes - -from homeassistant.components.air_quality import AirQualityEntity -from homeassistant.core import callback - -from . import KNOWN_DEVICES, HomeKitEntity - -_LOGGER = logging.getLogger(__name__) - -AIR_QUALITY_TEXT = { - 0: "unknown", - 1: "excellent", - 2: "good", - 3: "fair", - 4: "inferior", - 5: "poor", -} - - -class HomeAirQualitySensor(HomeKitEntity, AirQualityEntity): - """Representation of a HomeKit Controller Air Quality sensor.""" - - async def async_added_to_hass(self): - """Call when entity is added to hass.""" - _LOGGER.warning( - "The homekit_controller air_quality entity has been " - "deprecated and will be removed in 2021.12.0" - ) - await super().async_added_to_hass() - - @property - def entity_registry_enabled_default(self) -> bool: - """Whether or not to enable this entity by default.""" - # This entity is deprecated, so don't enable by default - return False - - def get_characteristic_types(self): - """Define the homekit characteristics the entity cares about.""" - return [ - CharacteristicsTypes.AIR_QUALITY, - CharacteristicsTypes.DENSITY_PM25, - CharacteristicsTypes.DENSITY_PM10, - CharacteristicsTypes.DENSITY_OZONE, - CharacteristicsTypes.DENSITY_NO2, - CharacteristicsTypes.DENSITY_SO2, - CharacteristicsTypes.DENSITY_VOC, - ] - - @property - def particulate_matter_2_5(self): - """Return the particulate matter 2.5 level.""" - return self.service.value(CharacteristicsTypes.DENSITY_PM25) - - @property - def particulate_matter_10(self): - """Return the particulate matter 10 level.""" - return self.service.value(CharacteristicsTypes.DENSITY_PM10) - - @property - def ozone(self): - """Return the O3 (ozone) level.""" - return self.service.value(CharacteristicsTypes.DENSITY_OZONE) - - @property - def sulphur_dioxide(self): - """Return the SO2 (sulphur dioxide) level.""" - return self.service.value(CharacteristicsTypes.DENSITY_SO2) - - @property - def nitrogen_dioxide(self): - """Return the NO2 (nitrogen dioxide) level.""" - return self.service.value(CharacteristicsTypes.DENSITY_NO2) - - @property - def air_quality_text(self): - """Return the Air Quality Index (AQI).""" - air_quality = self.service.value(CharacteristicsTypes.AIR_QUALITY) - return AIR_QUALITY_TEXT.get(air_quality, "unknown") - - @property - def volatile_organic_compounds(self): - """Return the volatile organic compounds (VOC) level.""" - return self.service.value(CharacteristicsTypes.DENSITY_VOC) - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - data = {"air_quality_text": self.air_quality_text} - - if voc := self.volatile_organic_compounds: - data["volatile_organic_compounds"] = voc - - return data - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up Homekit air quality sensor.""" - hkid = config_entry.data["AccessoryPairingID"] - conn = hass.data[KNOWN_DEVICES][hkid] - - @callback - def async_add_service(service): - if service.short_type != ServicesTypes.AIR_QUALITY_SENSOR: - return False - info = {"aid": service.accessory.aid, "iid": service.iid} - async_add_entities([HomeAirQualitySensor(conn, info)], True) - return True - - conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/button.py b/homeassistant/components/homekit_controller/button.py new file mode 100644 index 00000000000..b83cc351fd5 --- /dev/null +++ b/homeassistant/components/homekit_controller/button.py @@ -0,0 +1,96 @@ +""" +Support for Homekit buttons. + +These are mostly used where a HomeKit accessory exposes additional non-standard +characteristics that don't map to a Home Assistant feature. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.const import ENTITY_CATEGORY_CONFIG +from homeassistant.core import callback + +from . import KNOWN_DEVICES, CharacteristicEntity + + +@dataclass +class HomeKitButtonEntityDescription(ButtonEntityDescription): + """Describes Homekit button.""" + + write_value: int | str | None = None + + +BUTTON_ENTITIES: dict[str, HomeKitButtonEntityDescription] = { + CharacteristicsTypes.Vendor.HAA_SETUP: HomeKitButtonEntityDescription( + key=CharacteristicsTypes.Vendor.HAA_SETUP, + name="Setup", + icon="mdi:cog", + entity_category=ENTITY_CATEGORY_CONFIG, + write_value="#HAA@trcmd", + ), + CharacteristicsTypes.Vendor.HAA_UPDATE: HomeKitButtonEntityDescription( + key=CharacteristicsTypes.Vendor.HAA_UPDATE, + name="Update", + device_class=ButtonDeviceClass.UPDATE, + entity_category=ENTITY_CATEGORY_CONFIG, + write_value="#HAA@trcmd", + ), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit buttons.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + @callback + def async_add_characteristic(char: Characteristic): + if not (description := BUTTON_ENTITIES.get(char.type)): + return False + info = {"aid": char.service.accessory.aid, "iid": char.service.iid} + async_add_entities([HomeKitButton(conn, info, char, description)], True) + return True + + conn.add_char_factory(async_add_characteristic) + + +class HomeKitButton(CharacteristicEntity, ButtonEntity): + """Representation of a Button control on a homekit accessory.""" + + entity_description: HomeKitButtonEntityDescription + + def __init__( + self, + conn, + info, + char, + description: HomeKitButtonEntityDescription, + ): + """Initialise a HomeKit button control.""" + self.entity_description = description + super().__init__(conn, info, char) + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [self._char.type] + + @property + def name(self) -> str: + """Return the name of the device if any.""" + if name := super().name: + return f"{name} - {self.entity_description.name}" + return f"{self.entity_description.name}" + + async def async_press(self) -> None: + """Press the button.""" + key = self.entity_description.key + val = self.entity_description.write_value + return await self.async_put_characteristics({key: val}) diff --git a/homeassistant/components/homekit_controller/camera.py b/homeassistant/components/homekit_controller/camera.py index a0b15087356..820574e3ffd 100644 --- a/homeassistant/components/homekit_controller/camera.py +++ b/homeassistant/components/homekit_controller/camera.py @@ -18,11 +18,6 @@ class HomeKitCamera(AccessoryEntity, Camera): """Define the homekit characteristics the entity is tracking.""" return [] - @property - def state(self): - """Return the current state of the camera.""" - return "idle" - async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index cc4addfae4f..0728048ec84 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, async_get_registry as async_get_device_registry, @@ -75,8 +76,7 @@ def ensure_pin_format(pin, allow_insecure_setup_codes=None): If incorrect code is entered, an exception is raised. """ - match = PIN_FORMAT.search(pin.strip()) - if not match: + if not (match := PIN_FORMAT.search(pin.strip())): raise aiohomekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}") pin_without_dashes = "".join(match.groups()) if not allow_insecure_setup_codes and pin_without_dashes in INSECURE_CODES: @@ -158,16 +158,16 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): continue record = device.info return await self.async_step_zeroconf( - { - "host": record["address"], - "port": record["port"], - "hostname": record["name"], - "type": "_hap._tcp.local.", - "name": record["name"], - "properties": { + zeroconf.ZeroconfServiceInfo( + host=record["address"], + port=record["port"], + hostname=record["name"], + type="_hap._tcp.local.", + name=record["name"], + properties={ "md": record["md"], "pv": record["pv"], - "id": unique_id, + zeroconf.ATTR_PROPERTIES_ID: unique_id, "c#": record["c#"], "s#": record["s#"], "ff": record["ff"], @@ -175,7 +175,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): "sf": record["sf"], "sh": "", }, - } + ) ) return self.async_abort(reason="no_devices") @@ -197,7 +197,9 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return False - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle a discovered HomeKit accessory. This flow is triggered by the discovery component. @@ -206,10 +208,10 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # homekit_python has code to do this, but not in a form we can # easily use, so do the bare minimum ourselves here instead. properties = { - key.lower(): value for (key, value) in discovery_info["properties"].items() + key.lower(): value for (key, value) in discovery_info.properties.items() } - if "id" not in properties: + if zeroconf.ATTR_PROPERTIES_ID not in properties: # This can happen if the TXT record is received after the PTR record # we will wait for the next update in this case _LOGGER.debug( @@ -220,9 +222,9 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # The hkid is a unique random number that looks like a pairing code. # It changes if a device is factory reset. - hkid = properties["id"] + hkid = properties[zeroconf.ATTR_PROPERTIES_ID] model = properties["md"] - name = discovery_info["name"].replace("._hap._tcp.local.", "") + name = discovery_info.name.replace("._hap._tcp.local.", "") status_flags = int(properties["sf"]) paired = not status_flags & 0x01 @@ -240,8 +242,8 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Set unique-id and error out if it's already configured existing_entry = await self.async_set_unique_id(normalize_hkid(hkid)) updated_ip_port = { - "AccessoryIP": discovery_info["host"], - "AccessoryPort": discovery_info["port"], + "AccessoryIP": discovery_info.host, + "AccessoryPort": discovery_info.port, } # If the device is already paired and known to us we should monitor c# @@ -472,8 +474,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # available. Otherwise request a fresh copy from the API. # This removes the 'accessories' key from pairing_data at # the same time. - accessories = pairing_data.pop("accessories", None) - if not accessories: + if not (accessories := pairing_data.pop("accessories", None)): accessories = await pairing.list_accessories_and_characteristics() bridge_info = get_bridge_information(accessories) diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 3c9372f96db..eee244c8cf1 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -40,7 +40,6 @@ HOMEKIT_ACCESSORY_DISPATCH = { "leak": "binary_sensor", "fan": "fan", "fanv2": "fan", - "air-quality": "air_quality", "occupancy": "binary_sensor", "television": "media_player", "valve": "switch", @@ -51,6 +50,8 @@ CHARACTERISTIC_PLATFORMS = { CharacteristicsTypes.Vendor.EVE_ENERGY_WATT: "sensor", CharacteristicsTypes.Vendor.EVE_DEGREE_AIR_PRESSURE: "sensor", CharacteristicsTypes.Vendor.EVE_DEGREE_ELEVATION: "number", + CharacteristicsTypes.Vendor.HAA_SETUP: "button", + CharacteristicsTypes.Vendor.HAA_UPDATE: "button", CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY: "sensor", CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY_2: "sensor", CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL: "number", diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 3a07ae7ec8b..d9645d22a2d 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==0.6.3"], + "requirements": ["aiohomekit==0.6.4"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k", "@bdraco"], diff --git a/homeassistant/components/homekit_controller/storage.py b/homeassistant/components/homekit_controller/storage.py index ffc5bdc2381..4d512fbbc5d 100644 --- a/homeassistant/components/homekit_controller/storage.py +++ b/homeassistant/components/homekit_controller/storage.py @@ -34,8 +34,7 @@ class EntityMapStorage: async def async_initialize(self): """Get the pairing cache data.""" - raw_storage = await self.store.async_load() - if not raw_storage: + if not (raw_storage := await self.store.async_load()): # There is no cached data about HomeKit devices yet return diff --git a/homeassistant/components/homekit_controller/translations/id.json b/homeassistant/components/homekit_controller/translations/id.json index 839169fc6a9..57754bea50b 100644 --- a/homeassistant/components/homekit_controller/translations/id.json +++ b/homeassistant/components/homekit_controller/translations/id.json @@ -12,6 +12,7 @@ }, "error": { "authentication_error": "Kode HomeKit salah. Periksa dan coba lagi.", + "insecure_setup_code": "Kode penyiapan yang diminta tidak aman karena sifatnya yang sepele. Aksesori ini gagal memenuhi persyaratan keamanan dasar.", "max_peers_error": "Perangkat menolak untuk menambahkan pemasangan karena tidak memiliki penyimpanan pemasangan yang tersedia.", "pairing_failed": "Terjadi kesalahan yang tidak tertangani saat mencoba memasangkan dengan perangkat ini. Ini mungkin kegagalan sementara atau perangkat Anda mungkin tidak didukung saat ini.", "unable_to_pair": "Gagal memasangkan, coba lagi.", @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "Izinkan pemasangan dengan kode penyiapan yang tidak aman.", "pairing_code": "Kode Pemasangan" }, "description": "Pengontrol HomeKit berkomunikasi dengan {name} melalui jaringan area lokal menggunakan koneksi terenkripsi yang aman tanpa pengontrol HomeKit atau iCloud terpisah. Masukkan kode pemasangan HomeKit Anda (dalam format XXX-XX-XXX) untuk menggunakan aksesori ini. Kode ini biasanya ditemukan pada perangkat itu sendiri atau dalam kemasan.", diff --git a/homeassistant/components/homekit_controller/translations/ja.json b/homeassistant/components/homekit_controller/translations/ja.json new file mode 100644 index 00000000000..d02809b291a --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/ja.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u3089\u306a\u3044\u305f\u3081\u3001\u30da\u30a2\u30ea\u30f3\u30b0\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002", + "already_configured": "\u30a2\u30af\u30bb\u30b5\u30ea\u306f\u3001\u3053\u306e\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u3067\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "already_paired": "\u3053\u306e\u30a2\u30af\u30bb\u30b5\u30ea\u306f\u3001\u3059\u3067\u306b\u4ed6\u306e\u30c7\u30d0\u30a4\u30b9\u3068\u30da\u30a2\u30ea\u30f3\u30b0\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u30a2\u30af\u30bb\u30b5\u30ea\u3092\u30ea\u30bb\u30c3\u30c8\u3057\u3066\u3001\u3082\u3046\u4e00\u5ea6\u3084\u308a\u76f4\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "ignored_model": "\u3053\u306e\u30e2\u30c7\u30eb\u306eHomeKit\u3067\u306e\u5bfe\u5fdc\u306f\u3001\u3088\u308a\u5b8c\u5168\u3067\u30cd\u30a4\u30c6\u30a3\u30d6\u306a\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u4f7f\u7528\u53ef\u80fd\u306a\u305f\u3081\u3001\u30d6\u30ed\u30c3\u30af\u3055\u308c\u3066\u3044\u307e\u3059\u3002", + "invalid_config_entry": "\u3053\u306e\u30c7\u30d0\u30a4\u30b9\u306f\u30da\u30a2\u30ea\u30f3\u30b0\u306e\u6e96\u5099\u304c\u3067\u304d\u3066\u3044\u308b\u3068\u8868\u793a\u3055\u308c\u3066\u3044\u307e\u3059\u304c\u3001Home Assistant\u306b\u306f\u3059\u3067\u306b\u7af6\u5408\u3059\u308b\u69cb\u6210\u30a8\u30f3\u30c8\u30ea\u30fc\u304c\u3042\u308b\u305f\u3081\u3001\u5148\u306b\u3053\u308c\u3092\u524a\u9664\u3057\u3066\u304a\u304f\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "invalid_properties": "\u7121\u52b9\u306a\u30d7\u30ed\u30d1\u30c6\u30a3\u304c\u30c7\u30d0\u30a4\u30b9\u306b\u3088\u3063\u3066\u77e5\u3089\u3055\u308c\u307e\u3057\u305f\u3002", + "no_devices": "\u30da\u30a2\u30ea\u30f3\u30b0\u3055\u308c\u3066\u3044\u306a\u3044\u30c7\u30d0\u30a4\u30b9\u306f\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f" + }, + "error": { + "authentication_error": "HomeKit\u30b3\u30fc\u30c9\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093\u3002\u78ba\u8a8d\u3057\u3066\u3001\u3082\u3046\u4e00\u5ea6\u8a66\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "insecure_setup_code": "\u8981\u6c42\u3055\u308c\u305f\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u30b3\u30fc\u30c9\u306f\u3001\u5358\u7d14\u3059\u304e\u308b\u6027\u8cea\u306a\u305f\u3081\u5b89\u5168\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002\u3053\u306e\u30a2\u30af\u30bb\u30b5\u30ea\u306f\u3001\u57fa\u672c\u7684\u306a\u30bb\u30ad\u30e5\u30ea\u30c6\u30a3\u8981\u4ef6\u3092\u6e80\u305f\u3057\u3066\u3044\u307e\u305b\u3093\u3002", + "max_peers_error": "\u30c7\u30d0\u30a4\u30b9\u306b\u306f\u7121\u6599\u306e\u30da\u30a2\u30ea\u30f3\u30b0\u30b9\u30c8\u30ec\u30fc\u30b8\u304c\u306a\u3044\u305f\u3081\u3001\u30da\u30a2\u30ea\u30f3\u30b0\u306e\u8ffd\u52a0\u3092\u62d2\u5426\u3057\u307e\u3057\u305f\u3002", + "pairing_failed": "\u3053\u306e\u30c7\u30d0\u30a4\u30b9\u3068\u306e\u30da\u30a2\u30ea\u30f3\u30b0\u4e2d\u306b\u3001\u672a\u51e6\u7406\u306e\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002\u3053\u308c\u306f\u4e00\u6642\u7684\u306a\u969c\u5bb3\u304b\u3001\u30c7\u30d0\u30a4\u30b9\u304c\u73fe\u5728\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u306a\u3044\u53ef\u80fd\u6027\u304c\u3042\u308a\u307e\u3059\u3002", + "unable_to_pair": "\u30da\u30a2\u30ea\u30f3\u30b0\u3067\u304d\u307e\u305b\u3093\u3002\u3082\u3046\u4e00\u5ea6\u8a66\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "unknown_error": "\u30c7\u30d0\u30a4\u30b9\u304c\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u3092\u5831\u544a\u3057\u307e\u3057\u305f\u3002\u30da\u30a2\u30ea\u30f3\u30b0\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002" + }, + "flow_title": "{name}", + "step": { + "busy_error": { + "description": "\u3059\u3079\u3066\u306e\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u30fc\u3067\u30da\u30a2\u30ea\u30f3\u30b0\u3092\u4e2d\u6b62\u3059\u308b\u304b\u3001\u30c7\u30d0\u30a4\u30b9\u3092\u518d\u8d77\u52d5\u3057\u3066\u304b\u3089\u3001\u30da\u30a2\u30ea\u30f3\u30b0\u3092\u518d\u958b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u65e2\u306b\u4ed6\u306e\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u30fc\u3068\u30da\u30a2\u30ea\u30f3\u30b0\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "max_tries_error": { + "description": "\u30c7\u30d0\u30a4\u30b9\u306f\u3001100\u56de\u3092\u8d85\u3048\u308b\u8a8d\u8a3c\u8a66\u884c\u3092\u53d7\u4fe1\u3057\u307e\u3057\u305f\u3002\u30c7\u30d0\u30a4\u30b9\u3092\u518d\u8d77\u52d5\u3057\u3066\u304b\u3089\u3001\u30da\u30a2\u30ea\u30f3\u30b0\u3092\u518d\u958b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u8a8d\u8a3c\u306e\u6700\u5927\u8a66\u884c\u56de\u6570\u3092\u8d85\u3048\u307e\u3057\u305f" + }, + "pair": { + "data": { + "allow_insecure_setup_codes": "\u5b89\u5168\u3067\u306a\u3044\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u30b3\u30fc\u30c9\u3068\u306e\u30da\u30a2\u30ea\u30f3\u30b0\u3092\u8a31\u53ef\u3059\u308b\u3002", + "pairing_code": "\u30da\u30a2\u30ea\u30f3\u30b0\u30b3\u30fc\u30c9" + }, + "description": "HomeKit\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u306f\u3001\u5225\u306eHomeKit\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u3084iCloud\u3092\u4f7f\u7528\u305b\u305a\u306b\u3001\u30bb\u30ad\u30e5\u30a2\u306a\u6697\u53f7\u5316\u63a5\u7d9a\u3092\u4f7f\u7528\u3057\u3066\u30ed\u30fc\u30ab\u30eb\u30a8\u30ea\u30a2\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u3067 {name} \u3068\u901a\u4fe1\u3057\u307e\u3059\u3002\u3053\u306e\u30a2\u30af\u30bb\u30b5\u30ea\u30fc\u3092\u4f7f\u7528\u3059\u308b\u306b\u306f\u3001HomeKit \u306e\u30da\u30a2\u30ea\u30f3\u30b0\u30b3\u30fc\u30c9(XXX-XX-XXX \u306e\u5f62\u5f0f)\u5165\u529b\u3057\u307e\u3059\u3002\u3053\u306e\u30b3\u30fc\u30c9\u306f\u901a\u5e38\u3001\u30c7\u30d0\u30a4\u30b9\u672c\u4f53\u307e\u305f\u306f\u30d1\u30c3\u30b1\u30fc\u30b8\u306b\u8a18\u8f09\u3055\u308c\u3066\u3044\u307e\u3059\u3002", + "title": "HomeKit Accessory Protocol\u3092\u4ecb\u3057\u3066\u30c7\u30d0\u30a4\u30b9\u3068\u30da\u30a2\u30ea\u30f3\u30b0" + }, + "protocol_error": { + "description": "\u30c7\u30d0\u30a4\u30b9\u304c\u30da\u30a2\u30ea\u30f3\u30b0\u30e2\u30fc\u30c9\u306b\u306a\u3063\u3066\u3044\u306a\u3044\u53ef\u80fd\u6027\u304c\u3042\u308a\u307e\u3059\u306e\u3067\u3001\u7269\u7406\u307e\u305f\u306f\u4eee\u60f3\u7684\u306a\u30dc\u30bf\u30f3\u3092\u62bc\u3059\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\u30c7\u30d0\u30a4\u30b9\u304c\u30da\u30a2\u30ea\u30f3\u30b0\u30e2\u30fc\u30c9\u306b\u306a\u3063\u3066\u3044\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3059\u308b\u304b\u3001\u30c7\u30d0\u30a4\u30b9\u3092\u518d\u8d77\u52d5\u3057\u3066\u304b\u3089\u3001\u30da\u30a2\u30ea\u30f3\u30b0\u3092\u518d\u958b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30a2\u30af\u30bb\u30b5\u30ea\u30fc\u3068\u306e\u901a\u4fe1\u4e2d\u306b\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f" + }, + "user": { + "data": { + "device": "\u30c7\u30d0\u30a4\u30b9" + }, + "description": "HomeKit\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u306f\u3001\u5225\u306eHomeKit\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u3084iCloud\u3092\u4f7f\u7528\u305b\u305a\u306b\u3001\u30bb\u30ad\u30e5\u30a2\u306a\u6697\u53f7\u5316\u63a5\u7d9a\u3092\u4f7f\u7528\u3057\u3066\u30ed\u30fc\u30ab\u30eb\u30a8\u30ea\u30a2\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u7d4c\u7531\u3067\u901a\u4fe1\u3057\u307e\u3059\u3002\u30da\u30a2\u30ea\u30f3\u30b0\u3059\u308b\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044:", + "title": "\u30c7\u30d0\u30a4\u30b9\u306e\u9078\u629e" + } + } + }, + "device_automation": { + "trigger_subtype": { + "button1": "\u30dc\u30bf\u30f31", + "button10": "\u30dc\u30bf\u30f310", + "button2": "\u30dc\u30bf\u30f32", + "button3": "\u30dc\u30bf\u30f33", + "button4": "\u30dc\u30bf\u30f34", + "button5": "\u30dc\u30bf\u30f35", + "button6": "\u30dc\u30bf\u30f36", + "button7": "\u30dc\u30bf\u30f37", + "button8": "\u30dc\u30bf\u30f38", + "button9": "\u30dc\u30bf\u30f39", + "doorbell": "\u30c9\u30a2\u30d9\u30eb" + }, + "trigger_type": { + "double_press": "\"{subtype}\" \u30922\u56de\u62bc\u3059", + "long_press": "\"{subtype}\" \u304c\u3001\u62bc\u3055\u308c\u305f\u307e\u307e", + "single_press": "\"{subtype}\" \u304c\u3001\u62bc\u3055\u308c\u307e\u3057\u305f" + } + }, + "title": "HomeKit\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u30fc" +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/translations/tr.json b/homeassistant/components/homekit_controller/translations/tr.json index 9d72049ba21..7ddb32ade8e 100644 --- a/homeassistant/components/homekit_controller/translations/tr.json +++ b/homeassistant/components/homekit_controller/translations/tr.json @@ -1,19 +1,51 @@ { "config": { "abort": { + "accessory_not_found_error": "Cihaz art\u0131k bulunamad\u0131\u011f\u0131ndan e\u015fle\u015ftirme eklenemiyor.", "already_configured": "Aksesuar zaten bu denetleyici ile yap\u0131land\u0131r\u0131lm\u0131\u015ft\u0131r.", - "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor" + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "already_paired": "Bu aksesuar zaten ba\u015fka bir cihazla e\u015fle\u015ftirilmi\u015f. L\u00fctfen aksesuar\u0131 s\u0131f\u0131rlay\u0131n ve tekrar deneyin.", + "ignored_model": "Daha \u00f6zellikli tam yerel entegrasyon kullan\u0131labilir oldu\u011fundan, bu model i\u00e7in HomeKit deste\u011fi engellendi.", + "invalid_config_entry": "Bu ayg\u0131t e\u015fle\u015fmeye haz\u0131r olarak g\u00f6steriliyor, ancak ev asistan\u0131nda ilk olarak kald\u0131r\u0131lmas\u0131 gereken \u00e7ak\u0131\u015fan bir yap\u0131land\u0131rma girdisi zaten var.", + "invalid_properties": "Cihaz taraf\u0131ndan a\u00e7\u0131klanan ge\u00e7ersiz \u00f6zellikler.", + "no_devices": "E\u015flenmemi\u015f cihaz bulunamad\u0131" }, "error": { "authentication_error": "Yanl\u0131\u015f HomeKit kodu. L\u00fctfen kontrol edip tekrar deneyin.", + "insecure_setup_code": "\u0130stenen kurulum kodu, \u00f6nemsiz do\u011fas\u0131 nedeniyle g\u00fcvenli de\u011fil. Bu aksesuar, temel g\u00fcvenlik gereksinimlerini kar\u015f\u0131lam\u0131yor.", + "max_peers_error": "Cihaz, \u00fccretsiz e\u015fle\u015ftirme depolama alan\u0131 olmad\u0131\u011f\u0131 i\u00e7in e\u015fle\u015ftirme eklemeyi reddetti.", + "pairing_failed": "Bu cihazla e\u015fle\u015fmeye \u00e7al\u0131\u015f\u0131l\u0131rken i\u015flenmeyen bir hata olu\u015ftu. Bu ge\u00e7ici bir hata olabilir veya cihaz\u0131n\u0131z \u015fu anda desteklenmiyor olabilir.", + "unable_to_pair": "E\u015fle\u015ftirilemiyor, l\u00fctfen tekrar deneyin.", "unknown_error": "Cihaz bilinmeyen bir hata bildirdi. E\u015fle\u015ftirme ba\u015far\u0131s\u0131z oldu." }, + "flow_title": "{name}", "step": { "busy_error": { + "description": "T\u00fcm denetleyicilerde e\u015fle\u015ftirmeyi durdurun veya cihaz\u0131 yeniden ba\u015flatmay\u0131 deneyin, ard\u0131ndan e\u015fle\u015ftirmeye devam edin.", "title": "Cihaz zaten ba\u015fka bir oyun kumandas\u0131yla e\u015fle\u015fiyor" }, "max_tries_error": { + "description": "Cihaz, 100'den fazla ba\u015far\u0131s\u0131z kimlik do\u011frulama giri\u015fimi ald\u0131. Cihaz\u0131 yeniden ba\u015flatmay\u0131 deneyin, ard\u0131ndan e\u015fle\u015ftirmeye devam edin.", "title": "Maksimum kimlik do\u011frulama giri\u015fimi a\u015f\u0131ld\u0131" + }, + "pair": { + "data": { + "allow_insecure_setup_codes": "G\u00fcvenli olmayan kurulum kodlar\u0131yla e\u015fle\u015ftirmeye izin verin.", + "pairing_code": "E\u015fle\u015ftirme Kodu" + }, + "description": "HomeKit Denetleyici, ayr\u0131 bir HomeKit denetleyicisi veya iCloud olmadan g\u00fcvenli bir \u015fifreli ba\u011flant\u0131 kullanarak yerel alan a\u011f\u0131 \u00fczerinden {name} ile ileti\u015fim kurar. Bu aksesuar\u0131 kullanmak i\u00e7in HomeKit e\u015fle\u015ftirme kodunuzu (XXX-XX-XXX bi\u00e7iminde) girin. Bu kod genellikle cihaz\u0131n kendisinde veya ambalaj\u0131nda bulunur.", + "title": "HomeKit Aksesuar Protokol\u00fc arac\u0131l\u0131\u011f\u0131yla bir cihazla e\u015fle\u015ftirin" + }, + "protocol_error": { + "description": "Cihaz e\u015fle\u015ftirme modunda olmayabilir ve fiziksel veya sanal bir d\u00fc\u011fmeye bas\u0131lmas\u0131n\u0131 gerektirebilir. Cihaz\u0131n e\u015fle\u015ftirme modunda oldu\u011fundan emin olun veya cihaz\u0131 yeniden ba\u015flatmay\u0131 deneyin, ard\u0131ndan e\u015fle\u015ftirmeye devam edin.", + "title": "Aksesuarla ileti\u015fim kurma hatas\u0131" + }, + "user": { + "data": { + "device": "Cihaz" + }, + "description": "HomeKit Denetleyici, ayr\u0131 bir HomeKit denetleyicisi veya iCloud olmadan g\u00fcvenli bir \u015fifreli ba\u011flant\u0131 kullanarak yerel alan a\u011f\u0131 \u00fczerinden ileti\u015fim kurar. E\u015fle\u015ftirmek istedi\u011finiz cihaz\u0131 se\u00e7in:", + "title": "Cihaz se\u00e7imi" } } }, @@ -30,6 +62,12 @@ "button8": "D\u00fc\u011fme 8", "button9": "D\u00fc\u011fme 9", "doorbell": "Kap\u0131 zili" + }, + "trigger_type": { + "double_press": "\" {subtype} \" iki kez bas\u0131ld\u0131", + "long_press": "\" {subtype} \" bas\u0131l\u0131 tutuldu", + "single_press": "\" {subtype} \" bas\u0131ld\u0131" } - } + }, + "title": "HomeKit Denetleyicisi" } \ No newline at end of file diff --git a/homeassistant/components/homematic/binary_sensor.py b/homeassistant/components/homematic/binary_sensor.py index c57e4cd15c7..b25fb2949aa 100644 --- a/homeassistant/components/homematic/binary_sensor.py +++ b/homeassistant/components/homematic/binary_sensor.py @@ -26,7 +26,9 @@ SENSOR_TYPES_CLASS = { "TiltSensor": None, "WeatherSensor": None, "IPContact": DEVICE_CLASS_OPENING, + "MotionIP": DEVICE_CLASS_MOTION, "MotionIPV2": DEVICE_CLASS_MOTION, + "MotionIPContactSabotage": DEVICE_CLASS_MOTION, "IPRemoteMotionV2": DEVICE_CLASS_MOTION, } diff --git a/homeassistant/components/homematic/const.py b/homeassistant/components/homematic/const.py index 8aaa3ea21ac..427a4ccb7aa 100644 --- a/homeassistant/components/homematic/const.py +++ b/homeassistant/components/homematic/const.py @@ -47,6 +47,7 @@ HM_DEVICE_TYPES = { "IOSwitch", "IOSwitchNoInhibit", "IPSwitch", + "IPSwitchRssiDevice", "RFSiren", "IPSwitchPowermeter", "HMWIOSwitch", @@ -80,6 +81,8 @@ HM_DEVICE_TYPES = { "SwitchPowermeter", "Motion", "MotionV2", + "MotionIPV2", + "MotionIPContactSabotage", "RemoteMotion", "MotionIP", "ThermostatWall", @@ -114,7 +117,6 @@ HM_DEVICE_TYPES = { "IPBrightnessSensor", "IPGarage", "UniversalSensor", - "MotionIPV2", "IPMultiIO", "IPThermostatWall2", "IPRemoteMotionV2", @@ -129,6 +131,7 @@ HM_DEVICE_TYPES = { "IPMultiIOPCB", "ValveBoxW", "CO2SensorIP", + "IPLockDLD", ], DISCOVER_CLIMATE: [ "Thermostat", @@ -151,6 +154,7 @@ HM_DEVICE_TYPES = { "Motion", "MotionV2", "MotionIP", + "MotionIPV2", "MotionIPContactSabotage", "RemoteMotion", "WeatherSensor", @@ -165,7 +169,6 @@ HM_DEVICE_TYPES = { "IPPassageSensor", "SmartwareMotion", "IPWeatherSensorPlus", - "MotionIPV2", "WaterIP", "IPMultiIO", "TiltIP", @@ -178,7 +181,6 @@ HM_DEVICE_TYPES = { "IPRainSensor", "IPLanRouter", "IPMultiIOPCB", - "IPLockDLD", "IPWHS2", ], DISCOVER_COVER: [ diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index 2fb23f707e3..8e83484505b 100644 --- a/homeassistant/components/homematic/entity.py +++ b/homeassistant/components/homematic/entity.py @@ -1,11 +1,16 @@ """Homematic base entity.""" +from __future__ import annotations + from abc import abstractmethod from datetime import timedelta import logging +from pyhomematic import HMConnection +from pyhomematic.devicetypes.generic import HMGeneric + from homeassistant.const import ATTR_NAME import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from .const import ( ATTR_ADDRESS, @@ -27,7 +32,14 @@ SCAN_INTERVAL_VARIABLES = timedelta(seconds=30) class HMDevice(Entity): """The HomeMatic device base object.""" - def __init__(self, config): + _homematic: HMConnection + _hmdevice: HMGeneric + + def __init__( + self, + config: dict[str, str], + entity_description: EntityDescription | None = None, + ) -> None: """Initialize a generic HomeMatic device.""" self._name = config.get(ATTR_NAME) self._address = config.get(ATTR_ADDRESS) @@ -35,12 +47,13 @@ class HMDevice(Entity): self._channel = config.get(ATTR_CHANNEL) self._state = config.get(ATTR_PARAM) self._unique_id = config.get(ATTR_UNIQUE_ID) - self._data = {} - self._homematic = None - self._hmdevice = None + self._data: dict[str, str] = {} self._connected = False self._available = False - self._channel_map = set() + self._channel_map: set[str] = set() + + if entity_description is not None: + self.entity_description = entity_description # Set parameter to uppercase if self._state: diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index 84bb7b4d5a3..19b24fbb3f8 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -1,15 +1,29 @@ """Support for HomeMatic sensors.""" +from __future__ import annotations + +from copy import copy import logging -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( + ATTR_NAME, CONCENTRATION_PARTS_PER_MILLION, DEGREE, DEVICE_CLASS_CO2, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, + DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, ELECTRIC_CURRENT_MILLIAMPERE, ELECTRIC_POTENTIAL_VOLT, ENERGY_WATT_HOUR, @@ -24,7 +38,7 @@ from homeassistant.const import ( VOLUME_CUBIC_METERS, ) -from .const import ATTR_DISCOVER_DEVICES +from .const import ATTR_DISCOVER_DEVICES, ATTR_PARAM from .entity import HMDevice _LOGGER = logging.getLogger(__name__) @@ -45,54 +59,174 @@ HM_STATE_HA_CAST = { "IPLockDLD": {0: None, 1: "locked", 2: "unlocked"}, } -HM_UNIT_HA_CAST = { - "HUMIDITY": PERCENTAGE, - "TEMPERATURE": TEMP_CELSIUS, - "ACTUAL_TEMPERATURE": TEMP_CELSIUS, - "BRIGHTNESS": "#", - "POWER": POWER_WATT, - "CURRENT": ELECTRIC_CURRENT_MILLIAMPERE, - "VOLTAGE": ELECTRIC_POTENTIAL_VOLT, - "ENERGY_COUNTER": ENERGY_WATT_HOUR, - "GAS_POWER": VOLUME_CUBIC_METERS, - "GAS_ENERGY_COUNTER": VOLUME_CUBIC_METERS, - "IEC_POWER": POWER_WATT, - "IEC_ENERGY_COUNTER": ENERGY_WATT_HOUR, - "LUX": LIGHT_LUX, - "ILLUMINATION": LIGHT_LUX, - "CURRENT_ILLUMINATION": LIGHT_LUX, - "AVERAGE_ILLUMINATION": LIGHT_LUX, - "LOWEST_ILLUMINATION": LIGHT_LUX, - "HIGHEST_ILLUMINATION": LIGHT_LUX, - "RAIN_COUNTER": LENGTH_MILLIMETERS, - "WIND_SPEED": SPEED_KILOMETERS_PER_HOUR, - "WIND_DIRECTION": DEGREE, - "WIND_DIRECTION_RANGE": DEGREE, - "SUNSHINEDURATION": "#", - "AIR_PRESSURE": PRESSURE_HPA, - "FREQUENCY": FREQUENCY_HERTZ, - "VALUE": "#", - "VALVE_STATE": PERCENTAGE, - "CARRIER_SENSE_LEVEL": PERCENTAGE, - "DUTY_CYCLE_LEVEL": PERCENTAGE, - "CONCENTRATION": CONCENTRATION_PARTS_PER_MILLION, + +SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = { + "HUMIDITY": SensorEntityDescription( + key="HUMIDITY", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + "ACTUAL_TEMPERATURE": SensorEntityDescription( + key="ACTUAL_TEMPERATURE", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "TEMPERATURE": SensorEntityDescription( + key="TEMPERATURE", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "LUX": SensorEntityDescription( + key="LUX", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "CURRENT_ILLUMINATION": SensorEntityDescription( + key="CURRENT_ILLUMINATION", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "ILLUMINATION": SensorEntityDescription( + key="ILLUMINATION", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "AVERAGE_ILLUMINATION": SensorEntityDescription( + key="AVERAGE_ILLUMINATION", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "LOWEST_ILLUMINATION": SensorEntityDescription( + key="LOWEST_ILLUMINATION", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "HIGHEST_ILLUMINATION": SensorEntityDescription( + key="HIGHEST_ILLUMINATION", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "POWER": SensorEntityDescription( + key="POWER", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + "IEC_POWER": SensorEntityDescription( + key="IEC_POWER", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + "CURRENT": SensorEntityDescription( + key="CURRENT", + native_unit_of_measurement=ELECTRIC_CURRENT_MILLIAMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + "CONCENTRATION": SensorEntityDescription( + key="CONCENTRATION", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=DEVICE_CLASS_CO2, + state_class=STATE_CLASS_MEASUREMENT, + ), + "ENERGY_COUNTER": SensorEntityDescription( + key="ENERGY_COUNTER", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + "IEC_ENERGY_COUNTER": SensorEntityDescription( + key="IEC_ENERGY_COUNTER", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + "VOLTAGE": SensorEntityDescription( + key="VOLTAGE", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "GAS_POWER": SensorEntityDescription( + key="GAS_POWER", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + device_class=DEVICE_CLASS_GAS, + state_class=STATE_CLASS_MEASUREMENT, + ), + "GAS_ENERGY_COUNTER": SensorEntityDescription( + key="GAS_ENERGY_COUNTER", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + device_class=DEVICE_CLASS_GAS, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + "RAIN_COUNTER": SensorEntityDescription( + key="RAIN_COUNTER", + native_unit_of_measurement=LENGTH_MILLIMETERS, + ), + "WIND_SPEED": SensorEntityDescription( + key="WIND_SPEED", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + "WIND_DIRECTION": SensorEntityDescription( + key="WIND_DIRECTION", + native_unit_of_measurement=DEGREE, + ), + "WIND_DIRECTION_RANGE": SensorEntityDescription( + key="WIND_DIRECTION_RANGE", + native_unit_of_measurement=DEGREE, + ), + "SUNSHINEDURATION": SensorEntityDescription( + key="SUNSHINEDURATION", + native_unit_of_measurement="#", + ), + "AIR_PRESSURE": SensorEntityDescription( + key="AIR_PRESSURE", + native_unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "FREQUENCY": SensorEntityDescription( + key="FREQUENCY", + native_unit_of_measurement=FREQUENCY_HERTZ, + ), + "VALUE": SensorEntityDescription( + key="VALUE", + native_unit_of_measurement="#", + ), + "VALVE_STATE": SensorEntityDescription( + key="VALVE_STATE", + native_unit_of_measurement=PERCENTAGE, + ), + "CARRIER_SENSE_LEVEL": SensorEntityDescription( + key="CARRIER_SENSE_LEVEL", + native_unit_of_measurement=PERCENTAGE, + ), + "DUTY_CYCLE_LEVEL": SensorEntityDescription( + key="DUTY_CYCLE_LEVEL", + native_unit_of_measurement=PERCENTAGE, + ), + "BRIGHTNESS": SensorEntityDescription( + key="BRIGHTNESS", + native_unit_of_measurement="#", + icon="mdi:invert-colors", + ), } -HM_DEVICE_CLASS_HA_CAST = { - "HUMIDITY": DEVICE_CLASS_HUMIDITY, - "TEMPERATURE": DEVICE_CLASS_TEMPERATURE, - "ACTUAL_TEMPERATURE": DEVICE_CLASS_TEMPERATURE, - "LUX": DEVICE_CLASS_ILLUMINANCE, - "CURRENT_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, - "AVERAGE_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, - "LOWEST_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, - "HIGHEST_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, - "POWER": DEVICE_CLASS_POWER, - "CURRENT": DEVICE_CLASS_POWER, - "CONCENTRATION": DEVICE_CLASS_CO2, -} - -HM_ICON_HA_CAST = {"WIND_SPEED": "mdi:weather-windy", "BRIGHTNESS": "mdi:invert-colors"} +DEFAULT_SENSOR_DESCRIPTION = SensorEntityDescription( + key="", + entity_registry_enabled_default=True, +) def setup_platform(hass, config, add_entities, discovery_info=None): @@ -102,7 +236,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMSensor(conf) + state = conf.get(ATTR_PARAM) + entity_desc = SENSOR_DESCRIPTIONS.get(state) + if entity_desc is None: + name = conf.get(ATTR_NAME) + _LOGGER.warning( + "Sensor (%s) entity description is missing. Sensor state (%s) needs to be maintained", + name, + state, + ) + entity_desc = copy(DEFAULT_SENSOR_DESCRIPTION) + + new_device = HMSensor(conf, entity_desc) devices.append(new_device) add_entities(devices, True) @@ -122,21 +267,6 @@ class HMSensor(HMDevice, SensorEntity): # No cast, return original value return self._hm_get_state() - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return HM_UNIT_HA_CAST.get(self._state) - - @property - def device_class(self): - """Return the device class to use in the frontend, if any.""" - return HM_DEVICE_CLASS_HA_CAST.get(self._state) - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return HM_ICON_HA_CAST.get(self._state) - def _init_data_struct(self): """Generate a data dictionary (self._data) from metadata.""" if self._state: diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 14c80f56b1a..2f7d8d86012 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -4,7 +4,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_registry import async_entries_for_config_entry @@ -85,7 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False await async_setup_services(hass) - await async_remove_obsolete_entities(hass, entry, hap) + _async_remove_obsolete_entities(hass, entry, hap) # Register on HA stop event to gracefully shutdown HomematicIP Cloud connection hap.reset_connection_listener = hass.bus.async_listen_once( @@ -93,7 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Register hap as device in registry. - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) home = hap.home hapname = home.label if home.label != entry.unique_id else f"Home-{home.label}" @@ -118,7 +118,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hap.async_reset() -async def async_remove_obsolete_entities( +@callback +def _async_remove_obsolete_entities( hass: HomeAssistant, entry: ConfigEntry, hap: HomematicipHAP ): """Remove obsolete entities from entity registry.""" @@ -126,7 +127,7 @@ async def async_remove_obsolete_entities( if hap.home.currentAPVersion < "2.2.12": return - entity_registry = await er.async_get_registry(hass) + entity_registry = er.async_get(hass) er_entries = async_entries_for_config_entry(entity_registry, entry.entry_id) for er_entry in er_entries: if er_entry.unique_id.startswith("HomematicipAccesspointStatus"): diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index b1261258bf4..0cc462ac1f4 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -218,8 +218,7 @@ class HomematicipBaseActionSensor(HomematicipGenericEntity, BinarySensorEntity): state_attr = super().extra_state_attributes for attr, attr_key in SAM_DEVICE_ATTRIBUTES.items(): - attr_value = getattr(self._device, attr, None) - if attr_value: + if attr_value := getattr(self._device, attr, None): state_attr[attr_key] = attr_value return state_attr @@ -490,8 +489,7 @@ class HomematicipSecurityZoneSensorGroup(HomematicipGenericEntity, BinarySensorE state_attr = super().extra_state_attributes for attr, attr_key in GROUP_ATTRIBUTES.items(): - attr_value = getattr(self._device, attr, None) - if attr_value: + if attr_value := getattr(self._device, attr, None): state_attr[attr_key] = attr_value window_state = getattr(self._device, "windowState", None) diff --git a/homeassistant/components/homematicip_cloud/generic_entity.py b/homeassistant/components/homematicip_cloud/generic_entity.py index ecf0549d8b8..8bcea5d1435 100644 --- a/homeassistant/components/homematicip_cloud/generic_entity.py +++ b/homeassistant/components/homematicip_cloud/generic_entity.py @@ -139,13 +139,13 @@ class HomematicipGenericEntity(Entity): if self.hmip_device_removed: try: del self._hap.hmip_device_by_entity_id[self.entity_id] - await self.async_remove_from_registries() + self.async_remove_from_registries() except KeyError as err: _LOGGER.debug("Error removing HMIP device from registry: %s", err) - async def async_remove_from_registries(self) -> None: + @callback + def async_remove_from_registries(self) -> None: """Remove entity/device from registry.""" - # Remove callback from device. self._device.remove_callback(self._async_device_changed) self._device.remove_callback(self._async_device_removed) @@ -155,7 +155,7 @@ class HomematicipGenericEntity(Entity): if device_id := self.registry_entry.device_id: # Remove from device registry. - device_registry = await dr.async_get_registry(self.hass) + device_registry = dr.async_get(self.hass) if device_id in device_registry.devices: # This will also remove associated entities from entity registry. device_registry.async_remove_device(device_id) @@ -163,7 +163,7 @@ class HomematicipGenericEntity(Entity): # Remove from entity registry. # Only relevant for entities that do not belong to a device. if entity_id := self.registry_entry.entity_id: - entity_registry = await er.async_get_registry(self.hass) + entity_registry = er.async_get(self.hass) if entity_id in entity_registry.entities: entity_registry.async_remove(entity_id) @@ -238,16 +238,14 @@ class HomematicipGenericEntity(Entity): if isinstance(self._device, AsyncDevice): for attr, attr_key in DEVICE_ATTRIBUTES.items(): - attr_value = getattr(self._device, attr, None) - if attr_value: + if attr_value := getattr(self._device, attr, None): state_attr[attr_key] = attr_value state_attr[ATTR_IS_GROUP] = False if isinstance(self._device, AsyncGroup): for attr, attr_key in GROUP_ATTRIBUTES.items(): - attr_value = getattr(self._device, attr, None) - if attr_value: + if attr_value := getattr(self._device, attr, None): state_attr[attr_key] = attr_value state_attr[ATTR_IS_GROUP] = True diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index ae866bb42e2..d1c3f71a83f 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -282,8 +282,7 @@ class HomematicipIlluminanceSensor(HomematicipGenericEntity, SensorEntity): state_attr = super().extra_state_attributes for attr, attr_key in ILLUMINATION_DEVICE_ATTRIBUTES.items(): - attr_value = getattr(self._device, attr, None) - if attr_value: + if attr_value := getattr(self._device, attr, None): state_attr[attr_key] = attr_value return state_attr diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index 45b47b40efa..88c14c648d8 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -210,8 +210,7 @@ async def _async_activate_eco_mode_with_duration( duration = service.data[ATTR_DURATION] if hapid := service.data.get(ATTR_ACCESSPOINT_ID): - home = _get_home(hass, hapid) - if home: + if home := _get_home(hass, hapid): await home.activate_absence_with_duration(duration) else: for hap in hass.data[HMIPC_DOMAIN].values(): @@ -225,8 +224,7 @@ async def _async_activate_eco_mode_with_period( endtime = service.data[ATTR_ENDTIME] if hapid := service.data.get(ATTR_ACCESSPOINT_ID): - home = _get_home(hass, hapid) - if home: + if home := _get_home(hass, hapid): await home.activate_absence_with_period(endtime) else: for hap in hass.data[HMIPC_DOMAIN].values(): @@ -239,8 +237,7 @@ async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) -> temperature = service.data[ATTR_TEMPERATURE] if hapid := service.data.get(ATTR_ACCESSPOINT_ID): - home = _get_home(hass, hapid) - if home: + if home := _get_home(hass, hapid): await home.activate_vacation(endtime, temperature) else: for hap in hass.data[HMIPC_DOMAIN].values(): @@ -250,8 +247,7 @@ async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) -> async def _async_deactivate_eco_mode(hass: HomeAssistant, service: ServiceCall) -> None: """Service to deactivate eco mode.""" if hapid := service.data.get(ATTR_ACCESSPOINT_ID): - home = _get_home(hass, hapid) - if home: + if home := _get_home(hass, hapid): await home.deactivate_absence() else: for hap in hass.data[HMIPC_DOMAIN].values(): @@ -261,8 +257,7 @@ async def _async_deactivate_eco_mode(hass: HomeAssistant, service: ServiceCall) async def _async_deactivate_vacation(hass: HomeAssistant, service: ServiceCall) -> None: """Service to deactivate vacation.""" if hapid := service.data.get(ATTR_ACCESSPOINT_ID): - home = _get_home(hass, hapid) - if home: + if home := _get_home(hass, hapid): await home.deactivate_vacation() else: for hap in hass.data[HMIPC_DOMAIN].values(): diff --git a/homeassistant/components/homematicip_cloud/translations/ja.json b/homeassistant/components/homematicip_cloud/translations/ja.json index 5b5d0d62ab9..f68e51c7893 100644 --- a/homeassistant/components/homematicip_cloud/translations/ja.json +++ b/homeassistant/components/homematicip_cloud/translations/ja.json @@ -1,11 +1,12 @@ { "config": { "abort": { - "already_configured": "\u30a2\u30af\u30bb\u30b9\u30dd\u30a4\u30f3\u30c8\u306f\u65e2\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", - "unknown": "\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002" + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "connection_aborted": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" }, "error": { - "invalid_sgtin_or_pin": "PIN\u304c\u7121\u52b9\u3067\u3059\u3001\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", + "invalid_sgtin_or_pin": "SGTIN\u3001\u307e\u305f\u306fPIN\u30b3\u30fc\u30c9\u304c\u7121\u52b9\u3067\u3059\u3001\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", "press_the_button": "\u9752\u3044\u30dc\u30bf\u30f3\u3092\u62bc\u3057\u3066\u304f\u3060\u3055\u3044\u3002", "register_failed": "\u767b\u9332\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3001\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", "timeout_button": "\u9752\u3044\u30dc\u30bf\u30f3\u3092\u62bc\u3059\u3068\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3059\u3002\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002" @@ -14,8 +15,14 @@ "init": { "data": { "hapid": "\u30a2\u30af\u30bb\u30b9\u30dd\u30a4\u30f3\u30c8ID (SGTIN)", + "name": "\u540d\u524d(\u30aa\u30d7\u30b7\u30e7\u30f3\u3002\u5168\u30c7\u30d0\u30a4\u30b9\u306e\u540d\u524d\u306e\u30d7\u30ec\u30d5\u30a3\u30c3\u30af\u30b9\u3068\u3057\u3066\u4f7f\u7528)", "pin": "PIN\u30b3\u30fc\u30c9" - } + }, + "title": "HomematicIP Access point\u3092\u9078\u629e" + }, + "link": { + "description": "\u30a2\u30af\u30bb\u30b9\u30dd\u30a4\u30f3\u30c8\u306e\u9752\u3044\u30dc\u30bf\u30f3\u3092\u62bc\u3057\u3066\u3001\u9001\u4fe1(submit)\u30dc\u30bf\u30f3\u3092\u62bc\u3059\u3068\u3001Home Assistant\u306bHomematicIP\u304c\u767b\u9332\u3055\u308c\u307e\u3059\u3002\n\n![\u30d6\u30ea\u30c3\u30b8\u306e\u30dc\u30bf\u30f3\u306e\u4f4d\u7f6e](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "\u30ea\u30f3\u30af \u30a2\u30af\u30bb\u30b9\u30dd\u30a4\u30f3\u30c8" } } } diff --git a/homeassistant/components/homematicip_cloud/translations/pl.json b/homeassistant/components/homematicip_cloud/translations/pl.json index 7b7cdc05cab..9446fe2687b 100644 --- a/homeassistant/components/homematicip_cloud/translations/pl.json +++ b/homeassistant/components/homematicip_cloud/translations/pl.json @@ -21,7 +21,7 @@ "title": "Wybierz punkt dost\u0119pu HomematicIP" }, "link": { - "description": "Naci\u015bnij niebieski przycisk na punkcie dost\u0119pu i przycisk przesy\u0142ania, aby zarejestrowa\u0107 HomematicIP w Home Assistant. \n\n![Umiejscowienie przycisku na mostku](/static/images/config_flows/config_homematicip_cloud.png)", + "description": "Naci\u015bnij niebieski przycisk na punkcie dost\u0119pu i przycisk \"Zatwierd\u017a\", aby zarejestrowa\u0107 HomematicIP w Home Assistant. \n\n![Umiejscowienie przycisku na mostku](/static/images/config_flows/config_homematicip_cloud.png)", "title": "Po\u0142\u0105czenie z punktem dost\u0119pu" } } diff --git a/homeassistant/components/homematicip_cloud/translations/tr.json b/homeassistant/components/homematicip_cloud/translations/tr.json index 72f139217ca..3654357064e 100644 --- a/homeassistant/components/homematicip_cloud/translations/tr.json +++ b/homeassistant/components/homematicip_cloud/translations/tr.json @@ -4,6 +4,26 @@ "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "connection_aborted": "Ba\u011flanma hatas\u0131", "unknown": "Beklenmeyen hata" + }, + "error": { + "invalid_sgtin_or_pin": "Ge\u00e7ersiz SGTIN veya PIN Kodu , l\u00fctfen tekrar deneyin.", + "press_the_button": "L\u00fctfen mavi d\u00fc\u011fmeye bas\u0131n.", + "register_failed": "Kay\u0131t ba\u015far\u0131s\u0131z oldu, l\u00fctfen tekrar deneyin.", + "timeout_button": "Mavi d\u00fc\u011fmeye basma zaman a\u015f\u0131m\u0131, l\u00fctfen tekrar deneyin." + }, + "step": { + "init": { + "data": { + "hapid": "Eri\u015fim noktas\u0131 kimli\u011fi (SGTIN)", + "name": "Ad (iste\u011fe ba\u011fl\u0131, t\u00fcm cihazlar i\u00e7in ad \u00f6neki olarak kullan\u0131l\u0131r)", + "pin": "PIN Kodu" + }, + "title": "HomematicIP Eri\u015fim noktas\u0131 se\u00e7in" + }, + "link": { + "description": "HomematicIP'i Home Assistant ile kaydetmek i\u00e7in eri\u015fim noktas\u0131ndaki mavi d\u00fc\u011fmeye ve g\u00f6nder d\u00fc\u011fmesine bas\u0131n. \n\n ![K\u00f6pr\u00fcdeki d\u00fc\u011fmenin konumu](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Ba\u011flant\u0131 Eri\u015fim noktas\u0131" + } } } } \ No newline at end of file diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 7c40a0bb684..d2766515595 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -57,6 +57,8 @@ ATTR_FAN_ACTION = "fan_action" ATTR_PERMANENT_HOLD = "permanent_hold" +PRESET_HOLD = "Hold" + PLATFORM_SCHEMA = vol.All( cv.deprecated(CONF_REGION), PLATFORM_SCHEMA.extend( @@ -161,7 +163,7 @@ class HoneywellUSThermostat(ClimateEntity): self._attr_temperature_unit = ( TEMP_CELSIUS if device.temperature_unit == "C" else TEMP_FAHRENHEIT ) - self._attr_preset_modes = [PRESET_NONE, PRESET_AWAY] + self._attr_preset_modes = [PRESET_NONE, PRESET_AWAY, PRESET_HOLD] self._attr_is_aux_heat = device.system_mode == "emheat" # not all honeywell HVACs support all modes @@ -268,7 +270,12 @@ class HoneywellUSThermostat(ClimateEntity): @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - return PRESET_AWAY if self._away else None + if self._away: + return PRESET_AWAY + if self._is_permanent_hold(): + return PRESET_HOLD + + return None @property def fan_mode(self) -> str | None: @@ -353,8 +360,26 @@ class HoneywellUSThermostat(ClimateEntity): "Temperature %.1f out of range", getattr(self, f"_{mode}_away_temp") ) + def _turn_hold_mode_on(self) -> None: + """Turn permanent hold on.""" + try: + # Get current mode + mode = self._device.system_mode + except somecomfort.SomeComfortError: + _LOGGER.error("Can not get system mode") + return + # Check that we got a valid mode back + if mode in HW_MODE_TO_HVAC_MODE: + try: + # Set permanent hold + setattr(self._device, f"hold_{mode}", True) + except somecomfort.SomeComfortError: + _LOGGER.error("Couldn't set permanent hold") + else: + _LOGGER.error("Invalid system mode returned: %s", mode) + def _turn_away_mode_off(self) -> None: - """Turn away off.""" + """Turn away/hold off.""" self._away = False try: # Disabling all hold modes @@ -367,6 +392,9 @@ class HoneywellUSThermostat(ClimateEntity): """Set new preset mode.""" if preset_mode == PRESET_AWAY: self._turn_away_mode_on() + elif preset_mode == PRESET_HOLD: + self._away = False + self._turn_hold_mode_on() else: self._turn_away_mode_off() diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index a308a704c74..9bf4932a953 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -3,7 +3,7 @@ "name": "Honeywell Total Connect Comfort (US)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/honeywell", - "requirements": ["somecomfort==0.7.0"], + "requirements": ["somecomfort==0.8.0"], "codeowners": ["@rdfurman"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/honeywell/translations/id.json b/homeassistant/components/honeywell/translations/id.json index ee1540cc787..62151da1481 100644 --- a/homeassistant/components/honeywell/translations/id.json +++ b/homeassistant/components/honeywell/translations/id.json @@ -8,7 +8,9 @@ "data": { "password": "Kata Sandi", "username": "Nama Pengguna" - } + }, + "description": "Masukkan kredensial yang digunakan untuk masuk ke mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (AS)" } } } diff --git a/homeassistant/components/honeywell/translations/ja.json b/homeassistant/components/honeywell/translations/ja.json new file mode 100644 index 00000000000..1d3e19750c6 --- /dev/null +++ b/homeassistant/components/honeywell/translations/ja.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "mytotalconnectcomfort.com \u306b\u30ed\u30b0\u30a4\u30f3\u3059\u308b\u305f\u3081\u306b\u4f7f\u7528\u3059\u308b\u8a8d\u8a3c\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Honeywell Total Connect Comfort (US)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/tr.json b/homeassistant/components/honeywell/translations/tr.json new file mode 100644 index 00000000000..e6eb57aca1f --- /dev/null +++ b/homeassistant/components/honeywell/translations/tr.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + }, + "step": { + "user": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "L\u00fctfen mytotalconnectcomfort.com'da oturum a\u00e7mak i\u00e7in kullan\u0131lan kimlik bilgilerini girin.", + "title": "Honeywell Toplam Ba\u011flant\u0131 Konforu (ABD)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 19e3437b79c..b12b6f83e3a 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -1,7 +1,6 @@ """Support to serve the Home Assistant API as WSGI application.""" from __future__ import annotations -from contextvars import ContextVar from ipaddress import ip_network import logging import os @@ -13,6 +12,7 @@ from aiohttp.typedefs import StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection import voluptuous as vol +from homeassistant.components.network import async_get_source_ip from homeassistant.const import EVENT_HOMEASSISTANT_STOP, SERVER_PORT from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import storage @@ -20,7 +20,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.setup import async_start_setup, async_when_setup_or_start -import homeassistant.util as hass_util from homeassistant.util import ssl as ssl_util from .auth import setup_auth @@ -28,7 +27,7 @@ from .ban import setup_bans from .const import KEY_AUTHENTICATED, KEY_HASS, KEY_HASS_USER # noqa: F401 from .cors import setup_cors from .forwarded import async_setup_forwarded -from .request_context import setup_request_context +from .request_context import current_request, setup_request_context from .security_filter import setup_security_filter from .static import CACHE_HEADERS, CachingStaticResource from .view import HomeAssistantView @@ -190,7 +189,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.http = server - local_ip = await hass.async_add_executor_job(hass_util.get_local_ip) + local_ip = await async_get_source_ip(hass) host = local_ip if server_host is not None: @@ -298,21 +297,24 @@ class HomeAssistantHTTP: # Should be instance of aiohttp.web_exceptions._HTTPMove. raise redirect_exc(redirect_to) # type: ignore[arg-type,misc] - self.app.router.add_route("GET", url, redirect) + self.app["allow_configured_cors"]( + self.app.router.add_route("GET", url, redirect) + ) def register_static_path( self, url_path: str, path: str, cache_headers: bool = True - ) -> web.FileResponse | None: + ) -> None: """Register a folder or file to serve as a static path.""" if os.path.isdir(path): if cache_headers: - resource: type[ - CachingStaticResource | web.StaticResource - ] = CachingStaticResource + resource: CachingStaticResource | web.StaticResource = ( + CachingStaticResource(url_path, path) + ) else: - resource = web.StaticResource - self.app.router.register_resource(resource(url_path, path)) - return None + resource = web.StaticResource(url_path, path) + self.app.router.register_resource(resource) + self.app["allow_configured_cors"](resource) + return async def serve_file(request: web.Request) -> web.FileResponse: """Serve file from disk.""" @@ -320,8 +322,9 @@ class HomeAssistantHTTP: return web.FileResponse(path, headers=CACHE_HEADERS) return web.FileResponse(path) - self.app.router.add_route("GET", url_path, serve_file) - return None + self.app["allow_configured_cors"]( + self.app.router.add_route("GET", url_path, serve_file) + ) async def start(self) -> None: """Start the aiohttp server.""" @@ -397,8 +400,3 @@ async def start_http_server_and_save_config( ] store.async_delay_save(lambda: conf, SAVE_DELAY) - - -current_request: ContextVar[web.Request | None] = ContextVar( - "current_request", default=None -) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index e4d7da6ac9b..19f7c429a1e 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from datetime import timedelta +from ipaddress import ip_address import logging import secrets from typing import Final @@ -12,10 +13,13 @@ from aiohttp import hdrs from aiohttp.web import Application, Request, StreamResponse, middleware import jwt +from homeassistant.auth.models import User from homeassistant.core import HomeAssistant, callback from homeassistant.util import dt as dt_util +from homeassistant.util.network import is_local from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER +from .request_context import current_request _LOGGER = logging.getLogger(__name__) @@ -46,6 +50,42 @@ def async_sign_path( return f"{path}?{SIGN_QUERY_PARAM}={encoded}" +@callback +def async_user_not_allowed_do_auth( + hass: HomeAssistant, user: User, request: Request | None = None +) -> str | None: + """Validate that user is not allowed to do auth things.""" + if not user.is_active: + return "User is not active" + + if not user.local_only: + return None + + # User is marked as local only, check if they are allowed to do auth + if request is None: + request = current_request.get() + + if not request: + return "No request available to validate local access" + + if "cloud" in hass.config.components: + # pylint: disable=import-outside-toplevel + from hass_nabucasa import remote + + if remote.is_cloud_request.get(): + return "User is local only" + + try: + remote = ip_address(request.remote) + except ValueError: + return "Invalid remote IP" + + if is_local(remote): + return None + + return "User cannot authenticate remotely" + + @callback def setup_auth(hass: HomeAssistant, app: Application) -> None: """Create auth middleware for the app.""" @@ -72,6 +112,9 @@ def setup_auth(hass: HomeAssistant, app: Application) -> None: if refresh_token is None: return False + if async_user_not_allowed_do_auth(hass, refresh_token.user, request): + return False + request[KEY_HASS_USER] = refresh_token.user request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id return True diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py index d9310c8937f..97a0530b703 100644 --- a/homeassistant/components/http/cors.py +++ b/homeassistant/components/http/cors.py @@ -70,7 +70,7 @@ def setup_cors(app: Application, origins: list[str]) -> None: cors.add(route, config) cors_added.add(path_str) - app["allow_cors"] = lambda route: _allow_cors( + app["allow_all_cors"] = lambda route: _allow_cors( route, { "*": aiohttp_cors.ResourceOptions( @@ -79,12 +79,7 @@ def setup_cors(app: Application, origins: list[str]) -> None: }, ) - if not origins: - return - - async def cors_startup(app: Application) -> None: - """Initialize CORS when app starts up.""" - for resource in list(app.router.resources()): - _allow_cors(resource) - - app.on_startup.append(cors_startup) + if origins: + app["allow_configured_cors"] = _allow_cors + else: + app["allow_configured_cors"] = lambda _: None diff --git a/homeassistant/components/http/request_context.py b/homeassistant/components/http/request_context.py index 032f3bfd49e..6e036b9cdc8 100644 --- a/homeassistant/components/http/request_context.py +++ b/homeassistant/components/http/request_context.py @@ -8,6 +8,10 @@ from aiohttp.web import Application, Request, StreamResponse, middleware from homeassistant.core import callback +current_request: ContextVar[Request | None] = ContextVar( + "current_request", default=None +) + @callback def setup_request_context( diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index bf8dc4b432b..192d2d5d57b 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -86,9 +86,7 @@ class HomeAssistantView: routes: list[AbstractRoute] = [] for method in ("get", "post", "delete", "put", "patch", "head", "options"): - handler = getattr(self, method, None) - - if not handler: + if not (handler := getattr(self, method, None)): continue handler = request_handler_factory(self, handler) @@ -96,11 +94,15 @@ class HomeAssistantView: for url in urls: routes.append(router.add_route(method, url, handler)) - if not self.cors_allowed: - return + # Use `get` because CORS middleware is not be loaded in emulated_hue + if self.cors_allowed: + allow_cors = app.get("allow_all_cors") + else: + allow_cors = app.get("allow_configured_cors") - for route in routes: - app["allow_cors"](route) + if allow_cors: + for route in routes: + allow_cors(route) def request_handler_factory( diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 4b33f3e5a71..f63b84254fc 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -448,7 +448,7 @@ async def async_setup_entry( # noqa: C901 ) if sw_version: device_info[ATTR_SW_VERSION] = sw_version - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, **device_info, diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index a3e7390802f..be2a149b4d5 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from huawei_lte_api.AuthorizedConnection import AuthorizedConnection @@ -31,7 +31,6 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.typing import DiscoveryInfoType from .const import ( CONF_TRACK_WIRED_CLIENTS, @@ -202,24 +201,29 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=title, data=user_input) - async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle SSDP initiated config flow.""" - await self.async_set_unique_id(discovery_info[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) self._abort_if_unique_id_configured() # Attempt to distinguish from other non-LTE Huawei router devices, at least # some ones we are interested in have "Mobile Wi-Fi" friendlyName. - if "mobile" not in discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "").lower(): + if ( + "mobile" + not in discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "").lower() + ): return self.async_abort(reason="not_huawei_lte") + if TYPE_CHECKING: + assert discovery_info.ssdp_location url = url_normalize( - discovery_info.get( + discovery_info.upnp.get( ssdp.ATTR_UPNP_PRESENTATION_URL, - f"http://{urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname}/", + f"http://{urlparse(discovery_info.ssdp_location).hostname}/", ) ) - if serial_number := discovery_info.get(ssdp.ATTR_UPNP_SERIAL): + if serial_number := discovery_info.upnp.get(ssdp.ATTR_UPNP_SERIAL): await self.async_set_unique_id(serial_number) self._abort_if_unique_id_configured() else: @@ -228,7 +232,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): user_input = {CONF_URL: url} self.context["title_placeholders"] = { - CONF_NAME: discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + CONF_NAME: discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) } return await self._async_show_user_form(user_input) diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 5c451f71545..7c3f3d16c92 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -133,8 +133,7 @@ def async_add_new_entities( tracked: set[str], ) -> None: """Add new entities that are not already being tracked.""" - hosts = _get_hosts(router) - if not hosts: + if not (hosts := _get_hosts(router)): return track_wired_clients = router.config_entry.options.get( @@ -225,8 +224,7 @@ class HuaweiLteScannerEntity(HuaweiLteBaseEntity, ScannerEntity): async def async_update(self) -> None: """Update state.""" - hosts = _get_hosts(self.router) - if hosts is None: + if (hosts := _get_hosts(self.router)) is None: self._available = False return self._available = True diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 4479f383524..57963046cbc 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -21,6 +21,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DATA_BYTES, DATA_RATE_BYTES_PER_SECOND, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, FREQUENCY_MEGAHERTZ, PERCENTAGE, STATE_UNKNOWN, @@ -58,6 +60,7 @@ class SensorMeta(NamedTuple): unit: str | None = None state_class: str | None = None enabled_default: bool = False + entity_category: str | None = None include: re.Pattern[str] | None = None exclude: re.Pattern[str] | None = None formatter: Callable[[str], tuple[StateType, str | None]] | None = None @@ -65,19 +68,38 @@ class SensorMeta(NamedTuple): SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { KEY_DEVICE_INFORMATION: SensorMeta( - include=re.compile(r"^WanIP.*Address$", re.IGNORECASE) + include=re.compile(r"^(WanIP.*Address|uptime)$", re.IGNORECASE) ), (KEY_DEVICE_INFORMATION, "WanIPAddress"): SensorMeta( - name="WAN IP address", icon="mdi:ip", enabled_default=True + name="WAN IP address", + icon="mdi:ip", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + enabled_default=True, ), (KEY_DEVICE_INFORMATION, "WanIPv6Address"): SensorMeta( - name="WAN IPv6 address", icon="mdi:ip" + name="WAN IPv6 address", + icon="mdi:ip", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + (KEY_DEVICE_INFORMATION, "uptime"): SensorMeta( + name="Uptime", + icon="mdi:timer-outline", + unit=TIME_SECONDS, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "band"): SensorMeta( + name="Band", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), - (KEY_DEVICE_SIGNAL, "band"): SensorMeta(name="Band"), (KEY_DEVICE_SIGNAL, "cell_id"): SensorMeta( - name="Cell ID", icon="mdi:transmission-tower" + name="Cell ID", + icon="mdi:transmission-tower", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "dl_mcs"): SensorMeta( + name="Downlink MCS", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), - (KEY_DEVICE_SIGNAL, "dl_mcs"): SensorMeta(name="Downlink MCS"), (KEY_DEVICE_SIGNAL, "dlbandwidth"): SensorMeta( name="Downlink bandwidth", icon=lambda x: ( @@ -85,19 +107,48 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:speedometer-medium", "mdi:speedometer", )[bisect((8, 15), x if x is not None else -1000)], + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "earfcn"): SensorMeta( + name="EARFCN", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "lac"): SensorMeta( + name="LAC", + icon="mdi:map-marker", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "plmn"): SensorMeta( + name="PLMN", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "rac"): SensorMeta( + name="RAC", + icon="mdi:map-marker", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "rrc_status"): SensorMeta( + name="RRC status", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "tac"): SensorMeta( + name="TAC", + icon="mdi:map-marker", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "tdd"): SensorMeta( + name="TDD", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), - (KEY_DEVICE_SIGNAL, "earfcn"): SensorMeta(name="EARFCN"), - (KEY_DEVICE_SIGNAL, "lac"): SensorMeta(name="LAC", icon="mdi:map-marker"), - (KEY_DEVICE_SIGNAL, "plmn"): SensorMeta(name="PLMN"), - (KEY_DEVICE_SIGNAL, "rac"): SensorMeta(name="RAC", icon="mdi:map-marker"), - (KEY_DEVICE_SIGNAL, "rrc_status"): SensorMeta(name="RRC status"), - (KEY_DEVICE_SIGNAL, "tac"): SensorMeta(name="TAC", icon="mdi:map-marker"), - (KEY_DEVICE_SIGNAL, "tdd"): SensorMeta(name="TDD"), (KEY_DEVICE_SIGNAL, "txpower"): SensorMeta( name="Transmit power", device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "ul_mcs"): SensorMeta( + name="Uplink MCS", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), - (KEY_DEVICE_SIGNAL, "ul_mcs"): SensorMeta(name="Uplink MCS"), (KEY_DEVICE_SIGNAL, "ulbandwidth"): SensorMeta( name="Uplink bandwidth", icon=lambda x: ( @@ -105,6 +156,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:speedometer-medium", "mdi:speedometer", )[bisect((8, 15), x if x is not None else -1000)], + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), (KEY_DEVICE_SIGNAL, "mode"): SensorMeta( name="Mode", @@ -114,8 +166,13 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { str(x), "mdi:signal" ) ), + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "pci"): SensorMeta( + name="PCI", + icon="mdi:transmission-tower", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), - (KEY_DEVICE_SIGNAL, "pci"): SensorMeta(name="PCI", icon="mdi:transmission-tower"), (KEY_DEVICE_SIGNAL, "rsrq"): SensorMeta( name="RSRQ", device_class=DEVICE_CLASS_SIGNAL_STRENGTH, @@ -127,6 +184,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-3", )[bisect((-11, -8, -5), x if x is not None else -1000)], state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, enabled_default=True, ), (KEY_DEVICE_SIGNAL, "rsrp"): SensorMeta( @@ -140,6 +198,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-3", )[bisect((-110, -95, -80), x if x is not None else -1000)], state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, enabled_default=True, ), (KEY_DEVICE_SIGNAL, "rssi"): SensorMeta( @@ -153,6 +212,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-3", )[bisect((-80, -70, -60), x if x is not None else -1000)], state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, enabled_default=True, ), (KEY_DEVICE_SIGNAL, "sinr"): SensorMeta( @@ -166,6 +226,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-3", )[bisect((0, 5, 10), x if x is not None else -1000)], state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, enabled_default=True, ), (KEY_DEVICE_SIGNAL, "rscp"): SensorMeta( @@ -179,6 +240,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-3", )[bisect((-95, -85, -75), x if x is not None else -1000)], state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), (KEY_DEVICE_SIGNAL, "ecio"): SensorMeta( name="EC/IO", @@ -191,22 +253,32 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { "mdi:signal-cellular-3", )[bisect((-20, -10, -6), x if x is not None else -1000)], state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + (KEY_DEVICE_SIGNAL, "transmode"): SensorMeta( + name="Transmission mode", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), - (KEY_DEVICE_SIGNAL, "transmode"): SensorMeta(name="Transmission mode"), (KEY_DEVICE_SIGNAL, "cqi0"): SensorMeta( name="CQI 0", icon="mdi:speedometer", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), (KEY_DEVICE_SIGNAL, "cqi1"): SensorMeta( name="CQI 1", icon="mdi:speedometer", ), + (KEY_DEVICE_SIGNAL, "enodeb_id"): SensorMeta( + name="eNodeB ID", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), (KEY_DEVICE_SIGNAL, "ltedlfreq"): SensorMeta( name="Downlink frequency", formatter=lambda x: ( round(int(x) / 10) if x is not None else None, FREQUENCY_MEGAHERTZ, ), + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), (KEY_DEVICE_SIGNAL, "lteulfreq"): SensorMeta( name="Uplink frequency", @@ -214,6 +286,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { round(int(x) / 10) if x is not None else None, FREQUENCY_MEGAHERTZ, ), + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), KEY_MONITORING_CHECK_NOTIFICATIONS: SensorMeta( exclude=re.compile( @@ -250,23 +323,33 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { device_class=DEVICE_CLASS_BATTERY, unit=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), (KEY_MONITORING_STATUS, "CurrentWifiUser"): SensorMeta( name="WiFi clients connected", icon="mdi:wifi", state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), (KEY_MONITORING_STATUS, "PrimaryDns"): SensorMeta( - name="Primary DNS server", icon="mdi:ip" + name="Primary DNS server", + icon="mdi:ip", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), (KEY_MONITORING_STATUS, "SecondaryDns"): SensorMeta( - name="Secondary DNS server", icon="mdi:ip" + name="Secondary DNS server", + icon="mdi:ip", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), (KEY_MONITORING_STATUS, "PrimaryIPv6Dns"): SensorMeta( - name="Primary IPv6 DNS server", icon="mdi:ip" + name="Primary IPv6 DNS server", + icon="mdi:ip", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), (KEY_MONITORING_STATUS, "SecondaryIPv6Dns"): SensorMeta( - name="Secondary IPv6 DNS server", icon="mdi:ip" + name="Secondary IPv6 DNS server", + icon="mdi:ip", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), KEY_MONITORING_TRAFFIC_STATISTICS: SensorMeta( exclude=re.compile(r"^showtraffic$", re.IGNORECASE) @@ -322,12 +405,15 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { (KEY_NET_CURRENT_PLMN, "State"): SensorMeta( name="Operator search mode", formatter=lambda x: ({"0": "Auto", "1": "Manual"}.get(x, "Unknown"), None), + entity_category=ENTITY_CATEGORY_CONFIG, ), (KEY_NET_CURRENT_PLMN, "FullName"): SensorMeta( name="Operator name", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), (KEY_NET_CURRENT_PLMN, "Numeric"): SensorMeta( name="Operator code", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), KEY_NET_NET_MODE: SensorMeta(include=re.compile(r"^NetworkMode$", re.IGNORECASE)), (KEY_NET_NET_MODE, "NetworkMode"): SensorMeta( @@ -344,6 +430,7 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { }.get(x, "Unknown"), None, ), + entity_category=ENTITY_CATEGORY_CONFIG, ), (KEY_SMS_SMS_COUNT, "LocalDeleted"): SensorMeta( name="SMS deleted (device)", @@ -514,3 +601,8 @@ class HuaweiLteSensor(HuaweiLteBaseEntity, SensorEntity): self._state, self._unit = formatter(value) self._available = value is not None + + @property + def entity_category(self) -> str | None: + """Return category of entity, if any.""" + return self.meta.entity_category diff --git a/homeassistant/components/huawei_lte/switch.py b/homeassistant/components/huawei_lte/switch.py index a4fd393346c..af2a382c4db 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -7,8 +7,8 @@ from typing import Any import attr from homeassistant.components.switch import ( - DEVICE_CLASS_SWITCH, DOMAIN as SWITCH_DOMAIN, + SwitchDeviceClass, SwitchEntity, ) from homeassistant.config_entries import ConfigEntry @@ -43,6 +43,7 @@ class HuaweiLteBaseSwitch(HuaweiLteBaseEntity, SwitchEntity): key: str item: str + _attr_device_class = SwitchDeviceClass.SWITCH _raw_state: str | None = attr.ib(init=False, default=None) def _turn(self, state: bool) -> None: @@ -56,11 +57,6 @@ class HuaweiLteBaseSwitch(HuaweiLteBaseEntity, SwitchEntity): """Turn switch off.""" self._turn(state=False) - @property - def device_class(self) -> str: - """Return device class.""" - return DEVICE_CLASS_SWITCH - async def async_added_to_hass(self) -> None: """Subscribe to needed data on add.""" await super().async_added_to_hass() diff --git a/homeassistant/components/huawei_lte/translations/bg.json b/homeassistant/components/huawei_lte/translations/bg.json index 997c3bc1456..741b8ec7d47 100644 --- a/homeassistant/components/huawei_lte/translations/bg.json +++ b/homeassistant/components/huawei_lte/translations/bg.json @@ -13,10 +13,12 @@ "login_attempts_exceeded": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u043d\u0438\u0442\u0435 \u043e\u043f\u0438\u0442\u0438 \u0437\u0430 \u0432\u043b\u0438\u0437\u0430\u043d\u0435 \u0441\u0430 \u043d\u0430\u0434\u0432\u0438\u0448\u0435\u043d\u0438. \u041c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u043f\u043e-\u043a\u044a\u0441\u043d\u043e", "response_error": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430 \u043e\u0442 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e" }, + "flow_title": "{name}", "step": { "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" }, "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u0434\u0430\u043d\u043d\u0438 \u0437\u0430 \u0434\u043e\u0441\u0442\u044a\u043f \u0434\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e. \u041f\u043e\u0441\u043e\u0447\u0432\u0430\u043d\u0435\u0442\u043e \u043d\u0430 \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435 \u0438 \u043f\u0430\u0440\u043e\u043b\u0430 \u043d\u0435 \u0435 \u0437\u0430\u0434\u044a\u043b\u0436\u0438\u0442\u0435\u043b\u043d\u043e, \u043d\u043e \u0434\u0430\u0432\u0430 \u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442 \u0437\u0430 \u043f\u043e\u0432\u0435\u0447\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u0438 \u0437\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0438\u0440\u0430\u043d\u0435. \u041e\u0442 \u0434\u0440\u0443\u0433\u0430 \u0441\u0442\u0440\u0430\u043d\u0430, \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u043d\u0435\u0442\u043e \u043d\u0430 \u043e\u0442\u043e\u0440\u0438\u0437\u0438\u0440\u0430\u043d\u0430 \u0432\u0440\u044a\u0437\u043a\u0430 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0434\u043e\u0432\u0435\u0434\u0435 \u0434\u043e \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0438 \u0441 \u0434\u043e\u0441\u0442\u044a\u043f\u0430 \u0434\u043e \u0443\u0435\u0431 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043e\u0442\u0432\u044a\u043d Home Assistant, \u0434\u043e\u043a\u0430\u0442\u043e \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u0435 \u0430\u043a\u0442\u0438\u0432\u043d\u0430, \u0438 \u043e\u0431\u0440\u0430\u0442\u043d\u043e\u0442\u043e.", diff --git a/homeassistant/components/huawei_lte/translations/id.json b/homeassistant/components/huawei_lte/translations/id.json index de784fd3e94..fa586718cac 100644 --- a/homeassistant/components/huawei_lte/translations/id.json +++ b/homeassistant/components/huawei_lte/translations/id.json @@ -34,7 +34,9 @@ "data": { "name": "Nama layanan notifikasi (perubahan harus dimulai ulang)", "recipient": "Penerima notifikasi SMS", - "track_new_devices": "Lacak perangkat baru" + "track_new_devices": "Lacak perangkat baru", + "track_wired_clients": "Lacak klien jaringan kabel", + "unauthenticated_mode": "Mode tidak diautentikasi (perubahan memerlukan pemuatan ulang)" } } } diff --git a/homeassistant/components/huawei_lte/translations/ja.json b/homeassistant/components/huawei_lte/translations/ja.json new file mode 100644 index 00000000000..6c74f5a7918 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/ja.json @@ -0,0 +1,44 @@ +{ + "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", + "not_huawei_lte": "Huawei LTE\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u3042\u308a\u307e\u305b\u3093" + }, + "error": { + "connection_timeout": "\u63a5\u7d9a\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8", + "incorrect_password": "\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093", + "incorrect_username": "\u30e6\u30fc\u30b6\u30fc\u540d\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_url": "\u7121\u52b9\u306aURL", + "login_attempts_exceeded": "\u30ed\u30b0\u30a4\u30f3\u8a66\u884c\u56de\u6570\u304c\u6700\u5927\u5024\u3092\u8d85\u3048\u307e\u3057\u305f\u3001\u5f8c\u3067\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044", + "response_error": "\u30c7\u30d0\u30a4\u30b9\u304b\u3089\u306e\u4e0d\u660e\u306a\u30a8\u30e9\u30fc", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "url": "URL", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u30c7\u30d0\u30a4\u30b9\u30a2\u30af\u30bb\u30b9\u306e\u8a73\u7d30\u3092\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "Huawei LTE\u306e\u8a2d\u5b9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "\u901a\u77e5\u30b5\u30fc\u30d3\u30b9\u540d(\u5909\u66f4\u306b\u306f\u518d\u8d77\u52d5\u304c\u5fc5\u8981)", + "recipient": "SMS\u901a\u77e5\u306e\u53d7\u4fe1\u8005", + "track_new_devices": "\u65b0\u3057\u3044\u30c7\u30d0\u30a4\u30b9\u306e\u8ffd\u8de1", + "track_wired_clients": "\u6709\u7dda\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u3092\u8ffd\u8de1\u3059\u308b", + "unauthenticated_mode": "\u8a8d\u8a3c\u306a\u3057\u306e\u30e2\u30fc\u30c9(\u5909\u66f4\u306b\u306f\u30ea\u30ed\u30fc\u30c9\u304c\u5fc5\u8981)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/tr.json b/homeassistant/components/huawei_lte/translations/tr.json index ba934acc39b..92d40ca810b 100644 --- a/homeassistant/components/huawei_lte/translations/tr.json +++ b/homeassistant/components/huawei_lte/translations/tr.json @@ -2,7 +2,8 @@ "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" + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "not_huawei_lte": "Huawei LTE cihaz\u0131 de\u011fil" }, "error": { "connection_timeout": "Ba\u011flant\u0131 zamana\u015f\u0131m\u0131", @@ -14,6 +15,7 @@ "response_error": "Cihazdan bilinmeyen hata", "unknown": "Beklenmeyen hata" }, + "flow_title": "{name}", "step": { "user": { "data": { @@ -21,7 +23,8 @@ "url": "URL", "username": "Kullan\u0131c\u0131 Ad\u0131" }, - "description": "Cihaz eri\u015fim ayr\u0131nt\u0131lar\u0131n\u0131 girin. Kullan\u0131c\u0131 ad\u0131 ve parolan\u0131n belirtilmesi iste\u011fe ba\u011fl\u0131d\u0131r, ancak daha fazla entegrasyon \u00f6zelli\u011fi i\u00e7in destek sa\u011flar. \u00d6te yandan, yetkili bir ba\u011flant\u0131n\u0131n kullan\u0131lmas\u0131, entegrasyon aktifken Ev Asistan\u0131 d\u0131\u015f\u0131ndan cihaz web aray\u00fcz\u00fcne eri\u015fimde sorunlara neden olabilir ve tam tersi." + "description": "Cihaz eri\u015fim ayr\u0131nt\u0131lar\u0131n\u0131 girin.", + "title": "Huawei LTE'yi yap\u0131land\u0131r\u0131n" } } }, @@ -29,8 +32,11 @@ "step": { "init": { "data": { + "name": "Bildirim hizmeti ad\u0131 (de\u011fi\u015fiklik yeniden ba\u015flatmay\u0131 gerektirir)", "recipient": "SMS bildirimi al\u0131c\u0131lar\u0131", - "track_new_devices": "Yeni cihazlar\u0131 izle" + "track_new_devices": "Yeni cihazlar\u0131 izle", + "track_wired_clients": "Kablolu a\u011f istemcilerini izleyin", + "unauthenticated_mode": "Kimli\u011fi do\u011frulanmam\u0131\u015f mod (de\u011fi\u015fiklik yeniden y\u00fckleme gerektirir)" } } } diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index 71b62e22d33..794283e09f5 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -1,79 +1,41 @@ """Support for the Philips Hue system.""" -import asyncio -import logging from aiohue.util import normalize_bridge_id -import voluptuous as vol from homeassistant import config_entries, core from homeassistant.components import persistent_notification -from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.service import verify_domain_control +from homeassistant.helpers import device_registry as dr from .bridge import HueBridge -from .const import ( - ATTR_GROUP_NAME, - ATTR_SCENE_NAME, - ATTR_TRANSITION, - CONF_ALLOW_HUE_GROUPS, - CONF_ALLOW_UNREACHABLE, - DEFAULT_ALLOW_HUE_GROUPS, - DEFAULT_ALLOW_UNREACHABLE, - DOMAIN, -) - -_LOGGER = logging.getLogger(__name__) -SERVICE_HUE_SCENE = "hue_activate_scene" +from .const import DOMAIN, SERVICE_HUE_ACTIVATE_SCENE +from .migration import check_migration +from .services import async_register_services async def async_setup_entry( hass: core.HomeAssistant, entry: config_entries.ConfigEntry -): +) -> bool: """Set up a bridge from a config entry.""" + # check (and run) migrations if needed + await check_migration(hass, entry) - # Migrate allow_unreachable from config entry data to config entry options - if ( - CONF_ALLOW_UNREACHABLE not in entry.options - and CONF_ALLOW_UNREACHABLE in entry.data - and entry.data[CONF_ALLOW_UNREACHABLE] != DEFAULT_ALLOW_UNREACHABLE - ): - options = { - **entry.options, - CONF_ALLOW_UNREACHABLE: entry.data[CONF_ALLOW_UNREACHABLE], - } - data = entry.data.copy() - data.pop(CONF_ALLOW_UNREACHABLE) - hass.config_entries.async_update_entry(entry, data=data, options=options) - - # Migrate allow_hue_groups from config entry data to config entry options - if ( - CONF_ALLOW_HUE_GROUPS not in entry.options - and CONF_ALLOW_HUE_GROUPS in entry.data - and entry.data[CONF_ALLOW_HUE_GROUPS] != DEFAULT_ALLOW_HUE_GROUPS - ): - options = { - **entry.options, - CONF_ALLOW_HUE_GROUPS: entry.data[CONF_ALLOW_HUE_GROUPS], - } - data = entry.data.copy() - data.pop(CONF_ALLOW_HUE_GROUPS) - hass.config_entries.async_update_entry(entry, data=data, options=options) - + # setup the bridge instance bridge = HueBridge(hass, entry) - - if not await bridge.async_setup(): + if not await bridge.async_initialize_bridge(): return False - _register_services(hass) + # register Hue domain services + async_register_services(hass) - config = bridge.api.config + api = bridge.api # For backwards compat - unique_id = normalize_bridge_id(config.bridgeid) + unique_id = normalize_bridge_id(api.config.bridge_id) if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=unique_id) # For recovering from bug where we incorrectly assumed homekit ID = bridge ID + # Remove this logic after Home Assistant 2022.4 elif entry.unique_id != unique_id: # Find entries with this unique ID other_entry = next( @@ -84,7 +46,6 @@ async def async_setup_entry( ), None, ) - if other_entry is None: # If no other entry, update unique ID of this entry ID. hass.config_entries.async_update_entry(entry, unique_id=unique_id) @@ -100,88 +61,54 @@ async def async_setup_entry( hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) return False - device_registry = await dr.async_get_registry(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, config.mac)}, - identifiers={(DOMAIN, config.bridgeid)}, - manufacturer="Signify", - name=config.name, - model=config.modelid, - sw_version=config.swversion, - ) - - if config.modelid == "BSB002" and config.swversion < "1935144040": - persistent_notification.async_create( - hass, - "Your Hue hub has a known security vulnerability ([CVE-2020-6007](https://cve.circl.lu/cve/CVE-2020-6007)). Go to the Hue app and check for software updates.", - "Signify Hue", - "hue_hub_firmware", + # add bridge device to device registry + device_registry = dr.async_get(hass) + if bridge.api_version == 1: + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, api.config.mac_address)}, + identifiers={(DOMAIN, api.config.bridge_id)}, + manufacturer="Signify", + name=api.config.name, + model=api.config.model_id, + sw_version=api.config.software_version, ) - - elif config.swupdate2_bridge_state == "readytoinstall": - err = ( - "Please check for software updates of the bridge in the Philips Hue App.", - "Signify Hue", - "hue_hub_firmware", + # create persistent notification if we found a bridge version with security vulnerability + if ( + api.config.model_id == "BSB002" + and api.config.software_version < "1935144040" + ): + persistent_notification.async_create( + hass, + "Your Hue hub has a known security vulnerability ([CVE-2020-6007] " + "(https://cve.circl.lu/cve/CVE-2020-6007)). " + "Go to the Hue app and check for software updates.", + "Signify Hue", + "hue_hub_firmware", + ) + else: + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, api.config.mac_address)}, + identifiers={ + (DOMAIN, api.config.bridge_id), + (DOMAIN, api.config.bridge_device.id), + }, + manufacturer=api.config.bridge_device.product_data.manufacturer_name, + name=api.config.name, + model=api.config.model_id, + sw_version=api.config.software_version, ) - _LOGGER.warning(err) return True -async def async_unload_entry(hass, entry): +async def async_unload_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +): """Unload a config entry.""" unload_success = await hass.data[DOMAIN][entry.entry_id].async_reset() if len(hass.data[DOMAIN]) == 0: hass.data.pop(DOMAIN) - hass.services.async_remove(DOMAIN, SERVICE_HUE_SCENE) + hass.services.async_remove(DOMAIN, SERVICE_HUE_ACTIVATE_SCENE) return unload_success - - -@core.callback -def _register_services(hass): - """Register Hue services.""" - - async def hue_activate_scene(call, skip_reload=True): - """Handle activation of Hue scene.""" - # Get parameters - group_name = call.data[ATTR_GROUP_NAME] - scene_name = call.data[ATTR_SCENE_NAME] - - # Call the set scene function on each bridge - tasks = [ - bridge.hue_activate_scene( - call.data, skip_reload=skip_reload, hide_warnings=skip_reload - ) - for bridge in hass.data[DOMAIN].values() - if isinstance(bridge, HueBridge) - ] - results = await asyncio.gather(*tasks) - - # Did *any* bridge succeed? If not, refresh / retry - # Note that we'll get a "None" value for a successful call - if None not in results: - if skip_reload: - await hue_activate_scene(call, skip_reload=False) - return - _LOGGER.warning( - "No bridge was able to activate " "scene %s in group %s", - scene_name, - group_name, - ) - - if not hass.services.has_service(DOMAIN, SERVICE_HUE_SCENE): - # Register a local handler for scene activation - hass.services.async_register( - DOMAIN, - SERVICE_HUE_SCENE, - verify_domain_control(hass, DOMAIN)(hue_activate_scene), - schema=vol.Schema( - { - vol.Required(ATTR_GROUP_NAME): cv.string, - vol.Required(ATTR_SCENE_NAME): cv.string, - vol.Optional(ATTR_TRANSITION): cv.positive_int, - } - ), - ) diff --git a/homeassistant/components/hue/binary_sensor.py b/homeassistant/components/hue/binary_sensor.py index c675544503c..b66b85a4844 100644 --- a/homeassistant/components/hue/binary_sensor.py +++ b/homeassistant/components/hue/binary_sensor.py @@ -1,56 +1,24 @@ -"""Hue binary sensor entities.""" -from aiohue.sensors import TYPE_ZLL_PRESENCE +"""Support for Hue binary sensors.""" +from __future__ import annotations -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOTION, - BinarySensorEntity, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN as HUE_DOMAIN -from .sensor_base import SENSOR_CONFIG_MAP, GenericZLLSensor - -PRESENCE_NAME_FORMAT = "{} motion" +from .bridge import HueBridge +from .const import DOMAIN +from .v1.binary_sensor import async_setup_entry as setup_entry_v1 +from .v2.binary_sensor import async_setup_entry as setup_entry_v2 -async def async_setup_entry(hass, config_entry, async_add_entities): - """Defer binary sensor setup to the shared sensor module.""" - bridge = hass.data[HUE_DOMAIN][config_entry.entry_id] - - if not bridge.sensor_manager: - return - - await bridge.sensor_manager.async_register_component( - "binary_sensor", async_add_entities - ) - - -class HuePresence(GenericZLLSensor, BinarySensorEntity): - """The presence sensor entity for a Hue motion sensor device.""" - - _attr_device_class = DEVICE_CLASS_MOTION - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self.sensor.presence - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - attributes = super().extra_state_attributes - if "sensitivity" in self.sensor.config: - attributes["sensitivity"] = self.sensor.config["sensitivity"] - if "sensitivitymax" in self.sensor.config: - attributes["sensitivity_max"] = self.sensor.config["sensitivitymax"] - return attributes - - -SENSOR_CONFIG_MAP.update( - { - TYPE_ZLL_PRESENCE: { - "platform": "binary_sensor", - "name_format": PRESENCE_NAME_FORMAT, - "class": HuePresence, - } - } -) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensor entities.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + if bridge.api_version == 1: + await setup_entry_v1(hass, config_entry, async_add_entities) + else: + await setup_entry_v2(hass, config_entry, async_add_entities) diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 19ab2128d62..5005f858a58 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -2,126 +2,119 @@ from __future__ import annotations import asyncio -from functools import partial +from collections.abc import Callable from http import HTTPStatus import logging +from typing import Any from aiohttp import client_exceptions -import aiohue +from aiohue import HueBridgeV1, HueBridgeV2, LinkButtonNotPressed, Unauthorized +from aiohue.errors import AiohueException import async_timeout -import slugify as unicode_slug from homeassistant import core +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from .const import ( - ATTR_GROUP_NAME, - ATTR_SCENE_NAME, - ATTR_TRANSITION, - CONF_ALLOW_HUE_GROUPS, - CONF_ALLOW_UNREACHABLE, - DEFAULT_ALLOW_HUE_GROUPS, - DEFAULT_ALLOW_UNREACHABLE, - DOMAIN, - LOGGER, -) -from .errors import AuthenticationRequired, CannotConnect -from .helpers import create_config_flow -from .sensor_base import SensorManager +from .const import CONF_API_VERSION, DOMAIN +from .v1.sensor_base import SensorManager +from .v2.device import async_setup_devices +from .v2.hue_event import async_setup_hue_events # How long should we sleep if the hub is busy HUB_BUSY_SLEEP = 0.5 -PLATFORMS = ["light", "binary_sensor", "sensor"] - -_LOGGER = logging.getLogger(__name__) +PLATFORMS_v1 = ["light", "binary_sensor", "sensor"] +PLATFORMS_v2 = ["light", "binary_sensor", "sensor", "scene", "switch"] class HueBridge: """Manages a single Hue bridge.""" - def __init__(self, hass, config_entry): + def __init__(self, hass: core.HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the system.""" self.config_entry = config_entry self.hass = hass - self.available = True self.authorized = False - self.api = None - self.parallel_updates_semaphore = None + self.parallel_updates_semaphore = asyncio.Semaphore( + 3 if self.api_version == 1 else 10 + ) # Jobs to be executed when API is reset. - self.reset_jobs = [] - self.sensor_manager = None - self._update_callbacks = {} + self.reset_jobs: list[core.CALLBACK_TYPE] = [] + self.sensor_manager: SensorManager | None = None + self.logger = logging.getLogger(__name__) + # store actual api connection to bridge as api + app_key: str = self.config_entry.data[CONF_API_KEY] + websession = aiohttp_client.async_get_clientsession(hass) + if self.api_version == 1: + self.api = HueBridgeV1(self.host, app_key, websession) + else: + self.api = HueBridgeV2(self.host, app_key, websession) + # store (this) bridge object in hass data + hass.data.setdefault(DOMAIN, {})[self.config_entry.entry_id] = self @property - def host(self): + def host(self) -> str: """Return the host of this bridge.""" - return self.config_entry.data["host"] + return self.config_entry.data[CONF_HOST] @property - def allow_unreachable(self): - """Allow unreachable light bulbs.""" - return self.config_entry.options.get( - CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE - ) - - @property - def allow_groups(self): - """Allow groups defined in the Hue bridge.""" - return self.config_entry.options.get( - CONF_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_HUE_GROUPS - ) - - async def async_setup(self, tries=0): - """Set up a phue bridge based on host parameter.""" - host = self.host - hass = self.hass - - bridge = aiohue.Bridge( - host, - username=self.config_entry.data["username"], - websession=aiohttp_client.async_get_clientsession(hass), - ) + def api_version(self) -> int: + """Return api version we're set-up for.""" + return self.config_entry.data[CONF_API_VERSION] + async def async_initialize_bridge(self) -> bool: + """Initialize Connection with the Hue API.""" try: - await authenticate_bridge(hass, bridge) + with async_timeout.timeout(10): + await self.api.initialize() - except AuthenticationRequired: + except (LinkButtonNotPressed, Unauthorized): # Usernames can become invalid if hub is reset or user removed. # We are going to fail the config entry setup and initiate a new # linking procedure. When linking succeeds, it will remove the # old config entry. - create_config_flow(hass, host) + create_config_flow(self.hass, self.host) return False - - except CannotConnect as err: + except ( + asyncio.TimeoutError, + client_exceptions.ClientOSError, + client_exceptions.ServerDisconnectedError, + client_exceptions.ContentTypeError, + ) as err: raise ConfigEntryNotReady( - f"Error connecting to the Hue bridge at {host}" + f"Error connecting to the Hue bridge at {self.host}" ) from err - except Exception: # pylint: disable=broad-except - LOGGER.exception("Unknown error connecting with Hue bridge at %s", host) + self.logger.exception("Unknown error connecting to Hue bridge") return False - self.api = bridge - if bridge.sensors is not None: - self.sensor_manager = SensorManager(self) + # v1 specific initialization/setup code here + if self.api_version == 1: + if self.api.sensors is not None: + self.sensor_manager = SensorManager(self) + self.hass.config_entries.async_setup_platforms( + self.config_entry, PLATFORMS_v1 + ) - hass.data.setdefault(DOMAIN, {})[self.config_entry.entry_id] = self - hass.config_entries.async_setup_platforms(self.config_entry, PLATFORMS) - - self.parallel_updates_semaphore = asyncio.Semaphore( - 3 if self.api.config.modelid == "BSB001" else 10 - ) + # v2 specific initialization/setup code here + else: + await async_setup_devices(self) + await async_setup_hue_events(self) + self.hass.config_entries.async_setup_platforms( + self.config_entry, PLATFORMS_v2 + ) + # add listener for config entry updates. self.reset_jobs.append(self.config_entry.add_update_listener(_update_listener)) - self.reset_jobs.append(asyncio.create_task(self._subscribe_events()).cancel) - self.authorized = True return True - async def async_request_call(self, task): + async def async_request_call( + self, task: Callable, *args, allowed_errors: list[str] | None = None, **kwargs + ) -> Any: """Limit parallel requests to Hue hub. The Hue hub can only handle a certain amount of parallel requests, total. @@ -132,17 +125,30 @@ class HueBridge: ContentResponseError means hub raised an error. Since we don't make bad requests, this is on them. """ + max_tries = 5 async with self.parallel_updates_semaphore: - for tries in range(4): + for tries in range(max_tries): try: - return await task() + return await task(*args, **kwargs) + except AiohueException as err: + # The new V2 api is a bit more fanatic with throwing errors + # some of which we accept in certain conditions + # handle that here. Note that these errors are strings and do not have + # an identifier or something. + if allowed_errors is not None and str(err) in allowed_errors: + # log only + self.logger.debug( + "Ignored error/warning from Hue API: %s", str(err) + ) + return None + raise err except ( client_exceptions.ClientOSError, client_exceptions.ClientResponseError, client_exceptions.ServerDisconnectedError, ) as err: - if tries == 3: - _LOGGER.error("Request failed %s times, giving up", tries) + if tries == max_tries: + self.logger.error("Request failed %s times, giving up", tries) raise # We only retry if it's a server error. So raise on all 4XX errors. @@ -154,7 +160,7 @@ class HueBridge: await asyncio.sleep(HUB_BUSY_SLEEP * tries) - async def async_reset(self): + async def async_reset(self) -> bool: """Reset this bridge to default state. Will cancel any scheduled setup retry and will unload @@ -171,12 +177,9 @@ class HueBridge: while self.reset_jobs: self.reset_jobs.pop()() - self._update_callbacks = {} - - # If setup was successful, we set api variable, forwarded entry and - # register service + # Unload platforms unload_success = await self.hass.config_entries.async_unload_platforms( - self.config_entry, PLATFORMS + self.config_entry, PLATFORMS_v1 if self.api_version == 1 else PLATFORMS_v2 ) if unload_success: @@ -184,127 +187,29 @@ class HueBridge: return unload_success - async def hue_activate_scene(self, data, skip_reload=False, hide_warnings=False): - """Service to call directly into bridge to set scenes.""" - if self.api.scenes is None: - _LOGGER.warning("Hub %s does not support scenes", self.api.host) - return - - group_name = data[ATTR_GROUP_NAME] - scene_name = data[ATTR_SCENE_NAME] - transition = data.get(ATTR_TRANSITION) - - group = next( - (group for group in self.api.groups.values() if group.name == group_name), - None, - ) - - # Additional scene logic to handle duplicate scene names across groups - scene = next( - ( - scene - for scene in self.api.scenes.values() - if scene.name == scene_name - and group is not None - and sorted(scene.lights) == sorted(group.lights) - ), - None, - ) - - # If we can't find it, fetch latest info. - if not skip_reload and (group is None or scene is None): - await self.async_request_call(self.api.groups.update) - await self.async_request_call(self.api.scenes.update) - return await self.hue_activate_scene(data, skip_reload=True) - - if group is None: - if not hide_warnings: - LOGGER.warning( - "Unable to find group %s" " on bridge %s", group_name, self.host - ) - return False - - if scene is None: - LOGGER.warning("Unable to find scene %s", scene_name) - return False - - return await self.async_request_call( - partial(group.set_action, scene=scene.id, transitiontime=transition) - ) - - async def handle_unauthorized_error(self): + async def handle_unauthorized_error(self) -> None: """Create a new config flow when the authorization is no longer valid.""" if not self.authorized: # we already created a new config flow, no need to do it again return - LOGGER.error( + self.logger.error( "Unable to authorize to bridge %s, setup the linking again", self.host ) self.authorized = False create_config_flow(self.hass, self.host) - async def _subscribe_events(self): - """Subscribe to Hue events.""" - try: - async for updated_object in self.api.listen_events(): - key = (updated_object.ITEM_TYPE, updated_object.id) - if key in self._update_callbacks: - for callback in self._update_callbacks[key]: - callback() - - except GeneratorExit: - pass - - @core.callback - def listen_updates(self, item_type, item_id, update_callback): - """Listen to updates.""" - key = (item_type, item_id) - callbacks: list[core.CALLBACK_TYPE] | None = self._update_callbacks.get(key) - - if callbacks is None: - callbacks = self._update_callbacks[key] = [] - - callbacks.append(update_callback) - - @core.callback - def unsub(): - try: - callbacks.remove(update_callback) - except ValueError: - pass - - return unsub - - -async def authenticate_bridge(hass: core.HomeAssistant, bridge: aiohue.Bridge): - """Create a bridge object and verify authentication.""" - try: - with async_timeout.timeout(10): - # Create username if we don't have one - if not bridge.username: - device_name = unicode_slug.slugify( - hass.config.location_name, max_length=19 - ) - await bridge.create_user(f"home-assistant#{device_name}") - - # Initialize bridge (and validate our username) - await bridge.initialize() - - except (aiohue.LinkButtonNotPressed, aiohue.Unauthorized) as err: - raise AuthenticationRequired from err - except ( - asyncio.TimeoutError, - client_exceptions.ClientOSError, - client_exceptions.ServerDisconnectedError, - client_exceptions.ContentTypeError, - ) as err: - raise CannotConnect from err - except aiohue.AiohueException as err: - LOGGER.exception("Unknown Hue linking error occurred") - raise AuthenticationRequired from err - - -async def _update_listener(hass, entry): - """Handle options update.""" +async def _update_listener(hass: core.HomeAssistant, entry: ConfigEntry) -> None: + """Handle ConfigEntry options update.""" await hass.config_entries.async_reload(entry.entry_id) + + +def create_config_flow(hass: core.HomeAssistant, host: str) -> None: + """Start a config flow.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={"host": host}, + ) + ) diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 72938ebfe0a..49fca2158d5 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -2,31 +2,35 @@ from __future__ import annotations import asyncio -from typing import Any +import logging from urllib.parse import urlparse -import aiohue -from aiohue.discovery import discover_nupnp, normalize_bridge_id +from aiohue import LinkButtonNotPressed, create_app_key +from aiohue.discovery import DiscoveredHueBridge, discover_bridge, discover_nupnp +from aiohue.util import normalize_bridge_id import async_timeout +import slugify as unicode_slug import voluptuous as vol -from homeassistant import config_entries, core -from homeassistant.components import ssdp -from homeassistant.const import CONF_HOST, CONF_USERNAME +from homeassistant import config_entries +from homeassistant.components import ssdp, zeroconf +from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.typing import ConfigType -from .bridge import authenticate_bridge from .const import ( CONF_ALLOW_HUE_GROUPS, CONF_ALLOW_UNREACHABLE, + CONF_API_VERSION, DEFAULT_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_UNREACHABLE, DOMAIN, - LOGGER, ) -from .errors import AuthenticationRequired, CannotConnect +from .errors import CannotConnect + +LOGGER = logging.getLogger(__name__) HUE_MANUFACTURERURL = ("http://www.philips.com", "http://www.philips-hue.com") HUE_IGNORED_BRIDGE_NAMES = ["Home Assistant Bridge", "Espalexa"] @@ -40,33 +44,43 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> HueOptionsFlowHandler: """Get the options flow for this handler.""" return HueOptionsFlowHandler(config_entry) - def __init__(self): - """Initialize the Hue flow.""" - self.bridge: aiohue.Bridge | None = None - self.discovered_bridges: dict[str, aiohue.Bridge] | None = None + @classmethod + @callback + def async_supports_options_flow( + cls, config_entry: config_entries.ConfigEntry + ) -> bool: + """Return options flow support for this handler.""" + return config_entry.data.get(CONF_API_VERSION, 1) == 1 - async def async_step_user(self, user_input=None): + def __init__(self) -> None: + """Initialize the Hue flow.""" + self.bridge: DiscoveredHueBridge | None = None + self.discovered_bridges: dict[str, DiscoveredHueBridge] | None = None + + async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: """Handle a flow initialized by the user.""" # This is for backwards compatibility. return await self.async_step_init(user_input) - @core.callback - def _async_get_bridge(self, host: str, bridge_id: str | None = None): - """Return a bridge object.""" + async def _get_bridge( + self, host: str, bridge_id: str | None = None + ) -> DiscoveredHueBridge: + """Return a DiscoveredHueBridge object.""" + bridge = await discover_bridge( + host, websession=aiohttp_client.async_get_clientsession(self.hass) + ) if bridge_id is not None: bridge_id = normalize_bridge_id(bridge_id) + assert bridge_id == bridge.id + return bridge - return aiohue.Bridge( - host, - websession=aiohttp_client.async_get_clientsession(self.hass), - bridge_id=bridge_id, - ) - - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input: ConfigType | None = None) -> FlowResult: """Handle a flow start.""" # Check if user chooses manual entry if user_input is not None and user_input["id"] == HUE_MANUAL_BRIDGE_ID: @@ -83,7 +97,7 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Find / discover bridges try: - with async_timeout.timeout(5): + async with async_timeout.timeout(5): bridges = await discover_nupnp( websession=aiohttp_client.async_get_clientsession(self.hass) ) @@ -116,7 +130,7 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) async def async_step_manual( - self, user_input: dict[str, Any] | None = None + self, user_input: ConfigType | None = None ) -> FlowResult: """Handle manual bridge setup.""" if user_input is None: @@ -126,10 +140,10 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) self._async_abort_entries_match({"host": user_input["host"]}) - self.bridge = self._async_get_bridge(user_input[CONF_HOST]) + self.bridge = await self._get_bridge(user_input[CONF_HOST]) return await self.async_step_link() - async def async_step_link(self, user_input=None): + async def async_step_link(self, user_input: ConfigType | None = None) -> FlowResult: """Attempt to link with the Hue bridge. Given a configured host, will ask the user to press the link button @@ -141,10 +155,17 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): bridge = self.bridge assert bridge is not None errors = {} + device_name = unicode_slug.slugify( + self.hass.config.location_name, max_length=19 + ) try: - await authenticate_bridge(self.hass, bridge) - except AuthenticationRequired: + app_key = await create_app_key( + bridge.host, + f"home-assistant#{device_name}", + websession=aiohttp_client.async_get_clientsession(self.hass), + ) + except LinkButtonNotPressed: errors["base"] = "register_failed" except CannotConnect: LOGGER.error("Error connecting to the Hue bridge at %s", bridge.host) @@ -165,11 +186,15 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_create_entry( - title=bridge.config.name, - data={CONF_HOST: bridge.host, CONF_USERNAME: bridge.username}, + title=f"Hue Bridge {bridge.id}", + data={ + CONF_HOST: bridge.host, + CONF_API_KEY: app_key, + CONF_API_VERSION: 2 if bridge.supports_v2 else 1, + }, ) - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered Hue bridge. This flow is triggered by the SSDP component. It will check if the @@ -177,66 +202,77 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """ # Filter out non-Hue bridges #1 if ( - discovery_info.get(ssdp.ATTR_UPNP_MANUFACTURER_URL) + discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER_URL) not in HUE_MANUFACTURERURL ): return self.async_abort(reason="not_hue_bridge") # Filter out non-Hue bridges #2 if any( - name in discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "") + name in discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "") for name in HUE_IGNORED_BRIDGE_NAMES ): return self.async_abort(reason="not_hue_bridge") if ( - ssdp.ATTR_SSDP_LOCATION not in discovery_info - or ssdp.ATTR_UPNP_SERIAL not in discovery_info + not discovery_info.ssdp_location + or ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp ): return self.async_abort(reason="not_hue_bridge") - host = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname + url = urlparse(discovery_info.ssdp_location) + if not url.hostname: + return self.async_abort(reason="not_hue_bridge") - bridge = self._async_get_bridge(host, discovery_info[ssdp.ATTR_UPNP_SERIAL]) - - await self.async_set_unique_id(bridge.id) + # abort if we already have exactly this bridge id/host + # reload the integration if the host got updated + bridge_id = normalize_bridge_id(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) + await self.async_set_unique_id(bridge_id) self._abort_if_unique_id_configured( - updates={CONF_HOST: bridge.host}, reload_on_update=False + updates={CONF_HOST: url.hostname}, reload_on_update=True ) - self.bridge = bridge + self.bridge = await self._get_bridge( + url.hostname, discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + ) return await self.async_step_link() - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle a discovered Hue bridge. This flow is triggered by the Zeroconf component. It will check if the host is already configured and delegate to the import step if not. """ - bridge = self._async_get_bridge( - discovery_info["host"], discovery_info["properties"]["bridgeid"] - ) - - await self.async_set_unique_id(bridge.id) + # abort if we already have exactly this bridge id/host + # reload the integration if the host got updated + bridge_id = normalize_bridge_id(discovery_info.properties["bridgeid"]) + await self.async_set_unique_id(bridge_id) self._abort_if_unique_id_configured( - updates={CONF_HOST: bridge.host}, reload_on_update=False + updates={CONF_HOST: discovery_info.host}, reload_on_update=True ) - self.bridge = bridge + # we need to query the other capabilities too + self.bridge = await self._get_bridge( + discovery_info.host, discovery_info.properties["bridgeid"] + ) return await self.async_step_link() - async def async_step_homekit(self, discovery_info): + async def async_step_homekit( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle a discovered Hue bridge on HomeKit. The bridge ID communicated over HomeKit differs, so we cannot use that as the unique identifier. Therefore, this method uses discovery without a unique ID. """ - self.bridge = self._async_get_bridge(discovery_info[CONF_HOST]) + self.bridge = await self._get_bridge(discovery_info.host) await self._async_handle_discovery_without_unique_id() return await self.async_step_link() - async def async_step_import(self, import_info): + async def async_step_import(self, import_info: ConfigType) -> FlowResult: """Import a new bridge as a config entry. This flow is triggered by `async_setup` for both configured and @@ -248,20 +284,18 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Check if host exists, abort if so. self._async_abort_entries_match({"host": import_info["host"]}) - self.bridge = self._async_get_bridge(import_info["host"]) + self.bridge = await self._get_bridge(import_info["host"]) return await self.async_step_link() class HueOptionsFlowHandler(config_entries.OptionsFlow): """Handle Hue options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Hue options flow.""" self.config_entry = config_entry - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_init(self, user_input: ConfigType | None = None) -> FlowResult: """Manage Hue options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/hue/const.py b/homeassistant/components/hue/const.py index 5313584659d..eef453fb83d 100644 --- a/homeassistant/components/hue/const.py +++ b/homeassistant/components/hue/const.py @@ -1,24 +1,34 @@ """Constants for the Hue component.""" -import logging -LOGGER = logging.getLogger(__package__) DOMAIN = "hue" -# How long to wait to actually do the refresh after requesting it. -# We wait some time so if we control multiple lights, we batch requests. -REQUEST_REFRESH_DELAY = 0.3 +CONF_API_VERSION = "api_version" -CONF_ALLOW_UNREACHABLE = "allow_unreachable" -DEFAULT_ALLOW_UNREACHABLE = False +CONF_SUBTYPE = "subtype" -CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" -DEFAULT_ALLOW_HUE_GROUPS = False +ATTR_HUE_EVENT = "hue_event" +SERVICE_HUE_ACTIVATE_SCENE = "hue_activate_scene" +ATTR_GROUP_NAME = "group_name" +ATTR_SCENE_NAME = "scene_name" +ATTR_TRANSITION = "transition" +ATTR_DYNAMIC = "dynamic" + + +# V1 API SPECIFIC CONSTANTS ################## GROUP_TYPE_LIGHT_GROUP = "LightGroup" GROUP_TYPE_ROOM = "Room" GROUP_TYPE_LUMINAIRE = "Luminaire" GROUP_TYPE_LIGHT_SOURCE = "LightSource" +GROUP_TYPE_ZONE = "Zone" +GROUP_TYPE_ENTERTAINMENT = "Entertainment" -ATTR_GROUP_NAME = "group_name" -ATTR_SCENE_NAME = "scene_name" -ATTR_TRANSITION = "transition" +CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" +DEFAULT_ALLOW_HUE_GROUPS = False + +CONF_ALLOW_UNREACHABLE = "allow_unreachable" +DEFAULT_ALLOW_UNREACHABLE = False + +# How long to wait to actually do the refresh after requesting it. +# We wait some time so if we control multiple lights, we batch requests. +REQUEST_REFRESH_DELAY = 0.3 diff --git a/homeassistant/components/hue/device_trigger.py b/homeassistant/components/hue/device_trigger.py index 5af68b9d769..76fb8cd6c96 100644 --- a/homeassistant/components/hue/device_trigger.py +++ b/homeassistant/components/hue/device_trigger.py @@ -1,189 +1,105 @@ """Provides device automations for Philips Hue events.""" -import voluptuous as vol -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from typing import TYPE_CHECKING + from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) -from homeassistant.components.homeassistant.triggers import event as event_trigger -from homeassistant.const import ( - CONF_DEVICE_ID, - CONF_DOMAIN, - CONF_EVENT, - CONF_PLATFORM, - CONF_TYPE, - CONF_UNIQUE_ID, +from homeassistant.const import CONF_DEVICE_ID +from homeassistant.core import CALLBACK_TYPE +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN +from .v1.device_trigger import ( + async_attach_trigger as async_attach_trigger_v1, + async_get_triggers as async_get_triggers_v1, + async_validate_trigger_config as async_validate_trigger_config_v1, +) +from .v2.device_trigger import ( + async_attach_trigger as async_attach_trigger_v2, + async_get_triggers as async_get_triggers_v2, + async_validate_trigger_config as async_validate_trigger_config_v2, ) -from . import DOMAIN -from .hue_event import CONF_HUE_EVENT +if TYPE_CHECKING: + from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, + ) + from homeassistant.core import HomeAssistant -CONF_SUBTYPE = "subtype" - -CONF_SHORT_PRESS = "remote_button_short_press" -CONF_SHORT_RELEASE = "remote_button_short_release" -CONF_LONG_RELEASE = "remote_button_long_release" -CONF_DOUBLE_SHORT_RELEASE = "remote_double_button_short_press" -CONF_DOUBLE_LONG_RELEASE = "remote_double_button_long_press" - -CONF_TURN_ON = "turn_on" -CONF_TURN_OFF = "turn_off" -CONF_DIM_UP = "dim_up" -CONF_DIM_DOWN = "dim_down" -CONF_BUTTON_1 = "button_1" -CONF_BUTTON_2 = "button_2" -CONF_BUTTON_3 = "button_3" -CONF_BUTTON_4 = "button_4" -CONF_DOUBLE_BUTTON_1 = "double_buttons_1_3" -CONF_DOUBLE_BUTTON_2 = "double_buttons_2_4" - -HUE_DIMMER_REMOTE_MODEL = "Hue dimmer switch" # RWL020/021 -HUE_DIMMER_REMOTE = { - (CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002}, - (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, - (CONF_SHORT_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2002}, - (CONF_LONG_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2003}, - (CONF_SHORT_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3002}, - (CONF_LONG_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3003}, - (CONF_SHORT_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 4002}, - (CONF_LONG_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 4003}, -} - -HUE_BUTTON_REMOTE_MODEL = "Hue Smart button" # ZLLSWITCH/ROM001 -HUE_BUTTON_REMOTE = { - (CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002}, - (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, -} - -HUE_WALL_REMOTE_MODEL = "Hue wall switch module" # ZLLSWITCH/RDM001 -HUE_WALL_REMOTE = { - (CONF_SHORT_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 1002}, - (CONF_SHORT_RELEASE, CONF_BUTTON_2): {CONF_EVENT: 2002}, -} - -HUE_TAP_REMOTE_MODEL = "Hue tap switch" # ZGPSWITCH -HUE_TAP_REMOTE = { - (CONF_SHORT_PRESS, CONF_BUTTON_1): {CONF_EVENT: 34}, - (CONF_SHORT_PRESS, CONF_BUTTON_2): {CONF_EVENT: 16}, - (CONF_SHORT_PRESS, CONF_BUTTON_3): {CONF_EVENT: 17}, - (CONF_SHORT_PRESS, CONF_BUTTON_4): {CONF_EVENT: 18}, -} - -HUE_FOHSWITCH_REMOTE_MODEL = "Friends of Hue Switch" # ZGPSWITCH -HUE_FOHSWITCH_REMOTE = { - (CONF_SHORT_PRESS, CONF_BUTTON_1): {CONF_EVENT: 20}, - (CONF_LONG_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 16}, - (CONF_SHORT_PRESS, CONF_BUTTON_2): {CONF_EVENT: 21}, - (CONF_LONG_RELEASE, CONF_BUTTON_2): {CONF_EVENT: 17}, - (CONF_SHORT_PRESS, CONF_BUTTON_3): {CONF_EVENT: 23}, - (CONF_LONG_RELEASE, CONF_BUTTON_3): {CONF_EVENT: 19}, - (CONF_SHORT_PRESS, CONF_BUTTON_4): {CONF_EVENT: 22}, - (CONF_LONG_RELEASE, CONF_BUTTON_4): {CONF_EVENT: 18}, - (CONF_DOUBLE_SHORT_RELEASE, CONF_DOUBLE_BUTTON_1): {CONF_EVENT: 101}, - (CONF_DOUBLE_LONG_RELEASE, CONF_DOUBLE_BUTTON_1): {CONF_EVENT: 100}, - (CONF_DOUBLE_SHORT_RELEASE, CONF_DOUBLE_BUTTON_2): {CONF_EVENT: 99}, - (CONF_DOUBLE_LONG_RELEASE, CONF_DOUBLE_BUTTON_2): {CONF_EVENT: 98}, -} + from .bridge import HueBridge -REMOTES = { - HUE_DIMMER_REMOTE_MODEL: HUE_DIMMER_REMOTE, - HUE_TAP_REMOTE_MODEL: HUE_TAP_REMOTE, - HUE_BUTTON_REMOTE_MODEL: HUE_BUTTON_REMOTE, - HUE_WALL_REMOTE_MODEL: HUE_WALL_REMOTE, - HUE_FOHSWITCH_REMOTE_MODEL: HUE_FOHSWITCH_REMOTE, -} - -TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( - {vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str} -) - - -def _get_hue_event_from_device_id(hass, device_id): - """Resolve hue event from device id.""" - for bridge in hass.data.get(DOMAIN, {}).values(): - for hue_event in bridge.sensor_manager.current_events.values(): - if device_id == hue_event.device_registry_id: - return hue_event - - return None - - -async def async_validate_trigger_config(hass, config): +async def async_validate_trigger_config(hass: "HomeAssistant", config: ConfigType): """Validate config.""" - config = TRIGGER_SCHEMA(config) + if DOMAIN not in hass.data: + # happens at startup + return config + device_id = config[CONF_DEVICE_ID] + # lookup device in HASS DeviceRegistry + dev_reg: dr.DeviceRegistry = dr.async_get(hass) + device_entry = dev_reg.async_get(device_id) + if device_entry is None: + raise InvalidDeviceAutomationConfig(f"Device ID {device_id} is not valid") - device_registry = await hass.helpers.device_registry.async_get_registry() - device = device_registry.async_get(config[CONF_DEVICE_ID]) - - trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) - - if not device: - raise InvalidDeviceAutomationConfig( - f"Device {config[CONF_DEVICE_ID]} not found" - ) - - if device.model not in REMOTES: - raise InvalidDeviceAutomationConfig( - f"Device model {device.model} is not a remote" - ) - - if trigger not in REMOTES[device.model]: - raise InvalidDeviceAutomationConfig( - f"Device does not support trigger {trigger}" - ) - - return config + for conf_entry_id in device_entry.config_entries: + if conf_entry_id not in hass.data[DOMAIN]: + continue + bridge: "HueBridge" = hass.data[DOMAIN][conf_entry_id] + if bridge.api_version == 1: + return await async_validate_trigger_config_v1(bridge, device_entry, config) + return await async_validate_trigger_config_v2(bridge, device_entry, config) -async def async_attach_trigger(hass, config, action, automation_info): +async def async_attach_trigger( + hass: "HomeAssistant", + config: ConfigType, + action: "AutomationActionType", + automation_info: "AutomationTriggerInfo", +) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" - device_registry = await hass.helpers.device_registry.async_get_registry() - device = device_registry.async_get(config[CONF_DEVICE_ID]) + device_id = config[CONF_DEVICE_ID] + # lookup device in HASS DeviceRegistry + dev_reg: dr.DeviceRegistry = dr.async_get(hass) + device_entry = dev_reg.async_get(device_id) + if device_entry is None: + raise InvalidDeviceAutomationConfig(f"Device ID {device_id} is not valid") - hue_event = _get_hue_event_from_device_id(hass, device.id) - if hue_event is None: - raise InvalidDeviceAutomationConfig - - trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) - - trigger = REMOTES[device.model][trigger] - - event_config = { - event_trigger.CONF_PLATFORM: "event", - event_trigger.CONF_EVENT_TYPE: CONF_HUE_EVENT, - event_trigger.CONF_EVENT_DATA: {CONF_UNIQUE_ID: hue_event.unique_id, **trigger}, - } - - event_config = event_trigger.TRIGGER_SCHEMA(event_config) - return await event_trigger.async_attach_trigger( - hass, event_config, action, automation_info, platform_type="device" + for conf_entry_id in device_entry.config_entries: + if conf_entry_id not in hass.data[DOMAIN]: + continue + bridge: "HueBridge" = hass.data[DOMAIN][conf_entry_id] + if bridge.api_version == 1: + return await async_attach_trigger_v1( + bridge, device_entry, config, action, automation_info + ) + return await async_attach_trigger_v2( + bridge, device_entry, config, action, automation_info + ) + raise InvalidDeviceAutomationConfig( + f"Device ID {device_id} is not found on any Hue bridge" ) -async def async_get_triggers(hass, device_id): - """List device triggers. +async def async_get_triggers(hass: "HomeAssistant", device_id: str): + """Get device triggers for given (hass) device id.""" + if DOMAIN not in hass.data: + return [] + # lookup device in HASS DeviceRegistry + dev_reg: dr.DeviceRegistry = dr.async_get(hass) + device_entry = dev_reg.async_get(device_id) + if device_entry is None: + raise ValueError(f"Device ID {device_id} is not valid") - Make sure device is a supported remote model. - Retrieve the hue event object matching device entry. - Generate device trigger list. - """ - device_registry = await hass.helpers.device_registry.async_get_registry() - device = device_registry.async_get(device_id) + # Iterate all config entries for this device + # and work out the bridge version + for conf_entry_id in device_entry.config_entries: + if conf_entry_id not in hass.data[DOMAIN]: + continue + bridge: "HueBridge" = hass.data[DOMAIN][conf_entry_id] - if device.model not in REMOTES: - return - - triggers = [] - for trigger, subtype in REMOTES[device.model]: - triggers.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_PLATFORM: "device", - CONF_TYPE: trigger, - CONF_SUBTYPE: subtype, - } - ) - - return triggers + if bridge.api_version == 1: + return await async_get_triggers_v1(bridge, device_entry) + return await async_get_triggers_v2(bridge, device_entry) diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index 13a3a70ae53..2bd9652f9b0 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -1,563 +1,28 @@ -"""Support for the Philips Hue lights.""" +"""Support for Hue lights.""" from __future__ import annotations -from datetime import timedelta -from functools import partial -import logging -import random +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback -import aiohue -import async_timeout - -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, - ATTR_EFFECT, - ATTR_FLASH, - ATTR_HS_COLOR, - ATTR_TRANSITION, - EFFECT_COLORLOOP, - EFFECT_RANDOM, - FLASH_LONG, - FLASH_SHORT, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, - SUPPORT_EFFECT, - SUPPORT_FLASH, - SUPPORT_TRANSITION, - LightEntity, -) -from homeassistant.core import callback -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) -from homeassistant.util import color - -from .const import ( - DOMAIN as HUE_DOMAIN, - GROUP_TYPE_LIGHT_GROUP, - GROUP_TYPE_LIGHT_SOURCE, - GROUP_TYPE_LUMINAIRE, - GROUP_TYPE_ROOM, - REQUEST_REFRESH_DELAY, -) -from .helpers import remove_devices - -SCAN_INTERVAL = timedelta(seconds=5) - -_LOGGER = logging.getLogger(__name__) - -SUPPORT_HUE_ON_OFF = SUPPORT_FLASH | SUPPORT_TRANSITION -SUPPORT_HUE_DIMMABLE = SUPPORT_HUE_ON_OFF | SUPPORT_BRIGHTNESS -SUPPORT_HUE_COLOR_TEMP = SUPPORT_HUE_DIMMABLE | SUPPORT_COLOR_TEMP -SUPPORT_HUE_COLOR = SUPPORT_HUE_DIMMABLE | SUPPORT_EFFECT | SUPPORT_COLOR -SUPPORT_HUE_EXTENDED = SUPPORT_HUE_COLOR_TEMP | SUPPORT_HUE_COLOR - -SUPPORT_HUE = { - "Extended color light": SUPPORT_HUE_EXTENDED, - "Color light": SUPPORT_HUE_COLOR, - "Dimmable light": SUPPORT_HUE_DIMMABLE, - "On/Off plug-in unit": SUPPORT_HUE_ON_OFF, - "Color temperature light": SUPPORT_HUE_COLOR_TEMP, -} - -ATTR_IS_HUE_GROUP = "is_hue_group" -GAMUT_TYPE_UNAVAILABLE = "None" -# Minimum Hue Bridge API version to support groups -# 1.4.0 introduced extended group info -# 1.12 introduced the state object for groups -# 1.13 introduced "any_on" to group state objects -GROUP_MIN_API_VERSION = (1, 13, 0) +from .bridge import HueBridge +from .const import DOMAIN +from .v1.light import async_setup_entry as setup_entry_v1 +from .v2.group import async_setup_entry as setup_groups_entry_v2 +from .v2.light import async_setup_entry as setup_entry_v2 -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Old way of setting up Hue lights. +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up light entities.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] - Can only be called when a user accidentally mentions hue platform in their - config. But even in that case it would have been ignored. - """ - - -def create_light(item_class, coordinator, bridge, is_group, rooms, api, item_id): - """Create the light.""" - api_item = api[item_id] - - if is_group: - supported_features = 0 - for light_id in api_item.lights: - if light_id not in bridge.api.lights: - continue - light = bridge.api.lights[light_id] - supported_features |= SUPPORT_HUE.get(light.type, SUPPORT_HUE_EXTENDED) - supported_features = supported_features or SUPPORT_HUE_EXTENDED - else: - supported_features = SUPPORT_HUE.get(api_item.type, SUPPORT_HUE_EXTENDED) - return item_class( - coordinator, bridge, is_group, api_item, supported_features, rooms - ) - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the Hue lights from a config entry.""" - bridge = hass.data[HUE_DOMAIN][config_entry.entry_id] - api_version = tuple(int(v) for v in bridge.api.config.apiversion.split(".")) - rooms = {} - - allow_groups = bridge.allow_groups - supports_groups = api_version >= GROUP_MIN_API_VERSION - if allow_groups and not supports_groups: - _LOGGER.warning("Please update your Hue bridge to support groups") - - light_coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="light", - update_method=partial(async_safe_fetch, bridge, bridge.api.lights.update), - update_interval=SCAN_INTERVAL, - request_refresh_debouncer=Debouncer( - bridge.hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True - ), - ) - - # First do a refresh to see if we can reach the hub. - # Otherwise we will declare not ready. - await light_coordinator.async_refresh() - - if not light_coordinator.last_update_success: - raise PlatformNotReady - - if not supports_groups: - update_lights_without_group_support = partial( - async_update_items, - bridge, - bridge.api.lights, - {}, - async_add_entities, - partial(create_light, HueLight, light_coordinator, bridge, False, rooms), - None, - ) - # We add a listener after fetching the data, so manually trigger listener - bridge.reset_jobs.append( - light_coordinator.async_add_listener(update_lights_without_group_support) - ) + if bridge.api_version == 1: + await setup_entry_v1(hass, config_entry, async_add_entities) return - - group_coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="group", - update_method=partial(async_safe_fetch, bridge, bridge.api.groups.update), - update_interval=SCAN_INTERVAL, - request_refresh_debouncer=Debouncer( - bridge.hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True - ), - ) - - if allow_groups: - update_groups = partial( - async_update_items, - bridge, - bridge.api.groups, - {}, - async_add_entities, - partial(create_light, HueLight, group_coordinator, bridge, True, None), - None, - ) - - bridge.reset_jobs.append(group_coordinator.async_add_listener(update_groups)) - - cancel_update_rooms_listener = None - - @callback - def _async_update_rooms(): - """Update rooms.""" - nonlocal cancel_update_rooms_listener - rooms.clear() - for item_id in bridge.api.groups: - group = bridge.api.groups[item_id] - if group.type != GROUP_TYPE_ROOM: - continue - for light_id in group.lights: - rooms[light_id] = group.name - - # Once we do a rooms update, we cancel the listener - # until the next time lights are added - bridge.reset_jobs.remove(cancel_update_rooms_listener) - cancel_update_rooms_listener() # pylint: disable=not-callable - cancel_update_rooms_listener = None - - @callback - def _setup_rooms_listener(): - nonlocal cancel_update_rooms_listener - if cancel_update_rooms_listener is not None: - # If there are new lights added before _async_update_rooms - # is called we should not add another listener - return - - cancel_update_rooms_listener = group_coordinator.async_add_listener( - _async_update_rooms - ) - bridge.reset_jobs.append(cancel_update_rooms_listener) - - _setup_rooms_listener() - await group_coordinator.async_refresh() - - update_lights_with_group_support = partial( - async_update_items, - bridge, - bridge.api.lights, - {}, - async_add_entities, - partial(create_light, HueLight, light_coordinator, bridge, False, rooms), - _setup_rooms_listener, - ) - # We add a listener after fetching the data, so manually trigger listener - bridge.reset_jobs.append( - light_coordinator.async_add_listener(update_lights_with_group_support) - ) - update_lights_with_group_support() - - -async def async_safe_fetch(bridge, fetch_method): - """Safely fetch data.""" - try: - with async_timeout.timeout(4): - return await bridge.async_request_call(fetch_method) - except aiohue.Unauthorized as err: - await bridge.handle_unauthorized_error() - raise UpdateFailed("Unauthorized") from err - except aiohue.AiohueException as err: - raise UpdateFailed(f"Hue error: {err}") from err - - -@callback -def async_update_items( - bridge, api, current, async_add_entities, create_item, new_items_callback -): - """Update items.""" - new_items = [] - - for item_id in api: - if item_id in current: - continue - - current[item_id] = create_item(api, item_id) - new_items.append(current[item_id]) - - bridge.hass.async_create_task(remove_devices(bridge, api, current)) - - if new_items: - # This is currently used to setup the listener to update rooms - if new_items_callback: - new_items_callback() - async_add_entities(new_items) - - -def hue_brightness_to_hass(value): - """Convert hue brightness 1..254 to hass format 0..255.""" - return min(255, round((value / 254) * 255)) - - -def hass_to_hue_brightness(value): - """Convert hass brightness 0..255 to hue 1..254 scale.""" - return max(1, round((value / 255) * 254)) - - -class HueLight(CoordinatorEntity, LightEntity): - """Representation of a Hue light.""" - - def __init__(self, coordinator, bridge, is_group, light, supported_features, rooms): - """Initialize the light.""" - super().__init__(coordinator) - self.light = light - self.bridge = bridge - self.is_group = is_group - self._supported_features = supported_features - self._rooms = rooms - - if is_group: - self.is_osram = False - self.is_philips = False - self.is_innr = False - self.is_ewelink = False - self.is_livarno = False - self.gamut_typ = GAMUT_TYPE_UNAVAILABLE - self.gamut = None - else: - self.is_osram = light.manufacturername == "OSRAM" - self.is_philips = light.manufacturername == "Philips" - self.is_innr = light.manufacturername == "innr" - self.is_ewelink = light.manufacturername == "eWeLink" - self.is_livarno = light.manufacturername.startswith("_TZ3000_") - self.gamut_typ = self.light.colorgamuttype - self.gamut = self.light.colorgamut - _LOGGER.debug("Color gamut of %s: %s", self.name, str(self.gamut)) - if self.light.swupdatestate == "readytoinstall": - err = ( - "Please check for software updates of the %s " - "bulb in the Philips Hue App." - ) - _LOGGER.warning(err, self.name) - if self.gamut and not color.check_valid_gamut(self.gamut): - err = "Color gamut of %s: %s, not valid, setting gamut to None." - _LOGGER.debug(err, self.name, str(self.gamut)) - self.gamut_typ = GAMUT_TYPE_UNAVAILABLE - self.gamut = None - - @property - def unique_id(self): - """Return the unique ID of this Hue light.""" - unique_id = self.light.uniqueid - if not unique_id and self.is_group and self.light.room: - unique_id = self.light.room["id"] - - return unique_id - - @property - def device_id(self): - """Return the ID of this Hue light.""" - return self.unique_id - - @property - def name(self): - """Return the name of the Hue light.""" - return self.light.name - - @property - def brightness(self): - """Return the brightness of this light between 0..255.""" - if self.is_group: - bri = self.light.action.get("bri") - else: - bri = self.light.state.get("bri") - - if bri is None: - return bri - - return hue_brightness_to_hass(bri) - - @property - def _color_mode(self): - """Return the hue color mode.""" - if self.is_group: - return self.light.action.get("colormode") - return self.light.state.get("colormode") - - @property - def hs_color(self): - """Return the hs color value.""" - mode = self._color_mode - source = self.light.action if self.is_group else self.light.state - - if mode in ("xy", "hs") and "xy" in source: - return color.color_xy_to_hs(*source["xy"], self.gamut) - - return None - - @property - def color_temp(self): - """Return the CT color value.""" - # Don't return color temperature unless in color temperature mode - if self._color_mode != "ct": - return None - - if self.is_group: - return self.light.action.get("ct") - return self.light.state.get("ct") - - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - if self.is_group: - return super().min_mireds - - min_mireds = self.light.controlcapabilities.get("ct", {}).get("min") - - # We filter out '0' too, which can be incorrectly reported by 3rd party buls - if not min_mireds: - return super().min_mireds - - return min_mireds - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - if self.is_group: - return super().max_mireds - if self.is_livarno: - return 500 - - max_mireds = self.light.controlcapabilities.get("ct", {}).get("max") - - if not max_mireds: - return super().max_mireds - - return max_mireds - - @property - def is_on(self): - """Return true if device is on.""" - if self.is_group: - return self.light.state["any_on"] - return self.light.state["on"] - - @property - def available(self): - """Return if light is available.""" - return self.coordinator.last_update_success and ( - self.is_group - or self.bridge.allow_unreachable - or self.light.state["reachable"] - ) - - @property - def supported_features(self): - """Flag supported features.""" - return self._supported_features - - @property - def effect(self): - """Return the current effect.""" - return self.light.state.get("effect", None) - - @property - def effect_list(self): - """Return the list of supported effects.""" - if self.is_osram: - return [EFFECT_RANDOM] - return [EFFECT_COLORLOOP, EFFECT_RANDOM] - - @property - def device_info(self) -> DeviceInfo | None: - """Return the device info.""" - if self.light.type in ( - GROUP_TYPE_LIGHT_GROUP, - GROUP_TYPE_ROOM, - GROUP_TYPE_LUMINAIRE, - GROUP_TYPE_LIGHT_SOURCE, - ): - return None - - suggested_area = None - if self.light.id in self._rooms: - suggested_area = self._rooms[self.light.id] - - return DeviceInfo( - identifiers={(HUE_DOMAIN, self.device_id)}, - manufacturer=self.light.manufacturername, - # productname added in Hue Bridge API 1.24 - # (published 03/05/2018) - model=self.light.productname or self.light.modelid, - name=self.name, - # Not yet exposed as properties in aiohue - suggested_area=suggested_area, - sw_version=self.light.raw["swversion"], - via_device=(HUE_DOMAIN, self.bridge.api.config.bridgeid), - ) - - async def async_added_to_hass(self) -> None: - """Handle entity being added to Home Assistant.""" - self.async_on_remove( - self.bridge.listen_updates( - self.light.ITEM_TYPE, self.light.id, self.async_write_ha_state - ) - ) - await super().async_added_to_hass() - - async def async_turn_on(self, **kwargs): - """Turn the specified or all lights on.""" - command = {"on": True} - - if ATTR_TRANSITION in kwargs: - command["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10) - - if ATTR_HS_COLOR in kwargs: - if self.is_osram: - command["hue"] = int(kwargs[ATTR_HS_COLOR][0] / 360 * 65535) - command["sat"] = int(kwargs[ATTR_HS_COLOR][1] / 100 * 255) - else: - # Philips hue bulb models respond differently to hue/sat - # requests, so we convert to XY first to ensure a consistent - # color. - xy_color = color.color_hs_to_xy(*kwargs[ATTR_HS_COLOR], self.gamut) - command["xy"] = xy_color - elif ATTR_COLOR_TEMP in kwargs: - temp = kwargs[ATTR_COLOR_TEMP] - command["ct"] = max(self.min_mireds, min(temp, self.max_mireds)) - - if ATTR_BRIGHTNESS in kwargs: - command["bri"] = hass_to_hue_brightness(kwargs[ATTR_BRIGHTNESS]) - - flash = kwargs.get(ATTR_FLASH) - - if flash == FLASH_LONG: - command["alert"] = "lselect" - del command["on"] - elif flash == FLASH_SHORT: - command["alert"] = "select" - del command["on"] - elif not self.is_innr and not self.is_ewelink and not self.is_livarno: - command["alert"] = "none" - - if ATTR_EFFECT in kwargs: - effect = kwargs[ATTR_EFFECT] - if effect == EFFECT_COLORLOOP: - command["effect"] = "colorloop" - elif effect == EFFECT_RANDOM: - command["hue"] = random.randrange(0, 65535) - command["sat"] = random.randrange(150, 254) - else: - command["effect"] = "none" - - if self.is_group: - await self.bridge.async_request_call( - partial(self.light.set_action, **command) - ) - else: - await self.bridge.async_request_call( - partial(self.light.set_state, **command) - ) - - await self.coordinator.async_request_refresh() - - async def async_turn_off(self, **kwargs): - """Turn the specified or all lights off.""" - command = {"on": False} - - if ATTR_TRANSITION in kwargs: - command["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10) - - flash = kwargs.get(ATTR_FLASH) - - if flash == FLASH_LONG: - command["alert"] = "lselect" - del command["on"] - elif flash == FLASH_SHORT: - command["alert"] = "select" - del command["on"] - elif not self.is_innr and not self.is_livarno: - command["alert"] = "none" - - if self.is_group: - await self.bridge.async_request_call( - partial(self.light.set_action, **command) - ) - else: - await self.bridge.async_request_call( - partial(self.light.set_state, **command) - ) - - await self.coordinator.async_request_refresh() - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - if not self.is_group: - return {} - return {ATTR_IS_HUE_GROUP: self.is_group} + # v2 setup logic here + await setup_entry_v2(hass, config_entry, async_add_entities) + await setup_groups_entry_v2(hass, config_entry, async_add_entities) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 6640ffc9fae..c789755c9a3 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -3,7 +3,7 @@ "name": "Philips Hue", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hue", - "requirements": ["aiohue==2.6.3"], + "requirements": ["aiohue==3.0.2"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", @@ -22,7 +22,7 @@ "models": ["BSB002"] }, "zeroconf": ["_hue._tcp.local."], - "codeowners": ["@balloob", "@frenck"], + "codeowners": ["@balloob", "@marcelveldt"], "quality_scale": "platinum", "iot_class": "local_push" } diff --git a/homeassistant/components/hue/migration.py b/homeassistant/components/hue/migration.py new file mode 100644 index 00000000000..9891cc65b0c --- /dev/null +++ b/homeassistant/components/hue/migration.py @@ -0,0 +1,222 @@ +"""Various helpers to handle config entry and api schema migrations.""" + +import logging + +from aiohue import HueBridgeV2 +from aiohue.discovery import is_v2_bridge +from aiohue.v2.models.device import DeviceArchetypes +from aiohue.v2.models.resource import ResourceTypes + +from homeassistant import core +from homeassistant.components.binary_sensor import DEVICE_CLASS_MOTION +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_USERNAME, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, +) +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.device_registry import ( + async_entries_for_config_entry as devices_for_config_entries, + async_get as async_get_device_registry, +) +from homeassistant.helpers.entity_registry import ( + async_entries_for_config_entry as entities_for_config_entry, + async_entries_for_device, + async_get as async_get_entity_registry, +) + +from .const import CONF_API_VERSION, DOMAIN + +LOGGER = logging.getLogger(__name__) + + +async def check_migration(hass: core.HomeAssistant, entry: ConfigEntry) -> None: + """Check if config entry needs any migration actions.""" + host = entry.data[CONF_HOST] + + # migrate CONF_USERNAME --> CONF_API_KEY + if CONF_USERNAME in entry.data: + LOGGER.info("Migrate %s to %s in schema", CONF_USERNAME, CONF_API_KEY) + data = dict(entry.data) + data[CONF_API_KEY] = data.pop(CONF_USERNAME) + hass.config_entries.async_update_entry(entry, data=data) + + conf_api_version = entry.data.get(CONF_API_VERSION, 1) + if conf_api_version == 1: + # a bridge might have upgraded firmware since last run so + # we discover its capabilities at every startup + websession = aiohttp_client.async_get_clientsession(hass) + if await is_v2_bridge(host, websession): + supported_api_version = 2 + else: + supported_api_version = 1 + LOGGER.debug( + "Configured api version is %s and supported api version %s for bridge %s", + conf_api_version, + supported_api_version, + host, + ) + + # the call to `is_v2_bridge` returns (silently) False even on connection error + # so if a migration is needed it will be done on next startup + + if conf_api_version == 1 and supported_api_version == 2: + # run entity/device schema migration for v2 + await handle_v2_migration(hass, entry) + + # store api version in entry data + if ( + CONF_API_VERSION not in entry.data + or conf_api_version != supported_api_version + ): + data = dict(entry.data) + data[CONF_API_VERSION] = supported_api_version + hass.config_entries.async_update_entry(entry, data=data) + + +async def handle_v2_migration(hass: core.HomeAssistant, entry: ConfigEntry) -> None: + """Perform migration of devices and entities to V2 Id's.""" + host = entry.data[CONF_HOST] + api_key = entry.data[CONF_API_KEY] + websession = aiohttp_client.async_get_clientsession(hass) + dev_reg = async_get_device_registry(hass) + ent_reg = async_get_entity_registry(hass) + LOGGER.info("Start of migration of devices and entities to support API schema 2") + + # Create mapping of mac address to HA device id's. + # Identifier in dev reg should be mac-address, + # but in some cases it has a postfix like `-0b` or `-01`. + dev_ids = {} + for hass_dev in devices_for_config_entries(dev_reg, entry.entry_id): + for domain, mac in hass_dev.identifiers: + if domain != DOMAIN: + continue + normalized_mac = mac.split("-")[0] + dev_ids[normalized_mac] = hass_dev.id + + # initialize bridge connection just for the migration + async with HueBridgeV2(host, api_key, websession) as api: + + sensor_class_mapping = { + DEVICE_CLASS_BATTERY: ResourceTypes.DEVICE_POWER, + DEVICE_CLASS_MOTION: ResourceTypes.MOTION, + DEVICE_CLASS_ILLUMINANCE: ResourceTypes.LIGHT_LEVEL, + DEVICE_CLASS_TEMPERATURE: ResourceTypes.TEMPERATURE, + } + + # migrate entities attached to a device + for hue_dev in api.devices: + zigbee = api.devices.get_zigbee_connectivity(hue_dev.id) + if not zigbee or not zigbee.mac_address: + # not a zigbee device or invalid mac + continue + + # get existing device by V1 identifier (mac address) + if hue_dev.product_data.product_archetype == DeviceArchetypes.BRIDGE_V2: + hass_dev_id = dev_ids.get(api.config.bridge_id.upper()) + else: + hass_dev_id = dev_ids.get(zigbee.mac_address) + if hass_dev_id is None: + # can be safely ignored, this device does not exist in current config + LOGGER.debug( + "Ignoring device %s (%s) as it does not (yet) exist in the device registry", + hue_dev.metadata.name, + hue_dev.id, + ) + continue + dev_reg.async_update_device( + hass_dev_id, new_identifiers={(DOMAIN, hue_dev.id)} + ) + LOGGER.info("Migrated device %s (%s)", hue_dev.metadata.name, hass_dev_id) + + # loop through all entities for device and find match + for ent in async_entries_for_device(ent_reg, hass_dev_id, True): + + if ent.entity_id.startswith("light"): + # migrate light + # should always return one lightid here + new_unique_id = next(iter(hue_dev.lights), None) + else: + # migrate sensors + matched_dev_class = sensor_class_mapping.get( + ent.original_device_class or "unknown" + ) + new_unique_id = next( + ( + sensor.id + for sensor in api.devices.get_sensors(hue_dev.id) + if sensor.type == matched_dev_class + ), + None, + ) + + if new_unique_id is None: + # this may happen if we're looking at orphaned or unsupported entity + LOGGER.warning( + "Skip migration of %s because it no longer exists on the bridge", + ent.entity_id, + ) + continue + + try: + ent_reg.async_update_entity( + ent.entity_id, new_unique_id=new_unique_id + ) + except ValueError: + # assume edge case where the entity was already migrated in a previous run + # which got aborted somehow and we do not want + # to crash the entire integration init + LOGGER.warning( + "Skip migration of %s because it already exists", + ent.entity_id, + ) + else: + LOGGER.info( + "Migrated entity %s from unique id %s to %s", + ent.entity_id, + ent.unique_id, + new_unique_id, + ) + + # migrate entities that are not connected to a device (groups) + for ent in entities_for_config_entry(ent_reg, entry.entry_id): + if ent.device_id is not None: + continue + if "-" in ent.unique_id: + # handle case where unique id is v2-id of group/zone + hue_group = api.groups.get(ent.unique_id) + else: + # handle case where the unique id is just the v1 id + v1_id = f"/groups/{ent.unique_id}" + hue_group = api.groups.room.get_by_v1_id( + v1_id + ) or api.groups.zone.get_by_v1_id(v1_id) + if hue_group is None or hue_group.grouped_light is None: + # this may happen if we're looking at some orphaned entity + LOGGER.warning( + "Skip migration of %s because it no longer exist on the bridge", + ent.entity_id, + ) + continue + new_unique_id = hue_group.grouped_light + LOGGER.info( + "Migrating %s from unique id %s to %s ", + ent.entity_id, + ent.unique_id, + new_unique_id, + ) + try: + ent_reg.async_update_entity(ent.entity_id, new_unique_id=new_unique_id) + except ValueError: + # assume edge case where the entity was already migrated in a previous run + # which got aborted somehow and we do not want + # to crash the entire integration init + LOGGER.warning( + "Skip migration of %s because it already exists", + ent.entity_id, + ) + LOGGER.info("Migration of devices and entities to support API schema 2 finished") diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py new file mode 100644 index 00000000000..7335d2a048e --- /dev/null +++ b/homeassistant/components/hue/scene.py @@ -0,0 +1,129 @@ +"""Support for scene platform for Hue scenes (V2 only).""" +from __future__ import annotations + +from typing import Any + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.controllers.scenes import ScenesController +from aiohue.v2.models.scene import Scene as HueScene + +from homeassistant.components.scene import Scene as SceneEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .bridge import HueBridge +from .const import DOMAIN +from .v2.entity import HueBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up scene platform from Hue group scenes.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + api: HueBridgeV2 = bridge.api + + if bridge.api_version == 1: + # should not happen, but just in case + raise NotImplementedError("Scene support is only available for V2 bridges") + + # add entities for all scenes + @callback + def async_add_entity(event_type: EventType, resource: HueScene) -> None: + """Add entity from Hue resource.""" + async_add_entities([HueSceneEntity(bridge, api.scenes, resource)]) + + # add all current items in controller + for item in api.scenes: + async_add_entity(EventType.RESOURCE_ADDED, item) + + # register listener for new items only + config_entry.async_on_unload( + api.scenes.subscribe(async_add_entity, event_filter=EventType.RESOURCE_ADDED) + ) + + +class HueSceneEntity(HueBaseEntity, SceneEntity): + """Representation of a Scene entity from Hue Scenes.""" + + def __init__( + self, + bridge: HueBridge, + controller: ScenesController, + resource: HueScene, + ) -> None: + """Initialize the entity.""" + super().__init__(bridge, controller, resource) + self.resource = resource + self.controller = controller + self.group = self.controller.get_group(self.resource.id) + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + await super().async_added_to_hass() + # Add value_changed callback for group to catch name changes. + self.async_on_remove( + self.bridge.api.groups.subscribe( + self._handle_event, + self.group.id, + (EventType.RESOURCE_UPDATED), + ) + ) + + @property + def name(self) -> str: + """Return default entity name.""" + group = self.controller.get_group(self.resource.id) + return f"{group.metadata.name} - {self.resource.metadata.name}" + + @property + def is_dynamic(self) -> bool: + """Return if this scene has a dynamic color palette.""" + if self.resource.palette.color and len(self.resource.palette.color) > 1: + return True + if ( + self.resource.palette.color_temperature + and len(self.resource.palette.color_temperature) > 1 + ): + return True + return False + + async def async_activate(self, **kwargs: Any) -> None: + """Activate Hue scene.""" + transition = kwargs.get("transition") + if transition is not None: + # hue transition duration is in steps of 100 ms + transition = int(transition * 100) + dynamic = kwargs.get("dynamic", self.is_dynamic) + await self.bridge.async_request_call( + self.controller.recall, + self.resource.id, + dynamic=dynamic, + duration=transition, + ) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the optional state attributes.""" + brightness = None + if palette := self.resource.palette: + if palette.dimming: + brightness = palette.dimming[0].brightness + if brightness is None: + # get brightness from actions + for action in self.resource.actions: + if action.action.dimming: + brightness = action.action.dimming.brightness + break + return { + "group_name": self.group.metadata.name, + "group_type": self.group.type.value, + "name": self.resource.metadata.name, + "speed": self.resource.speed, + "brightness": brightness, + "is_dynamic": self.is_dynamic, + } diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py index 9bd701fe526..7218831abe2 100644 --- a/homeassistant/components/hue/sensor.py +++ b/homeassistant/components/hue/sensor.py @@ -1,135 +1,24 @@ -"""Hue sensor entities.""" -from aiohue.sensors import ( - TYPE_ZLL_LIGHTLEVEL, - TYPE_ZLL_ROTARY, - TYPE_ZLL_SWITCH, - TYPE_ZLL_TEMPERATURE, -) +"""Support for Hue sensors.""" +from __future__ import annotations -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity -from homeassistant.const import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_TEMPERATURE, - ENTITY_CATEGORY_DIAGNOSTIC, - LIGHT_LUX, - PERCENTAGE, - TEMP_CELSIUS, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN as HUE_DOMAIN -from .sensor_base import SENSOR_CONFIG_MAP, GenericHueSensor, GenericZLLSensor - -LIGHT_LEVEL_NAME_FORMAT = "{} light level" -REMOTE_NAME_FORMAT = "{} battery level" -TEMPERATURE_NAME_FORMAT = "{} temperature" +from .bridge import HueBridge +from .const import DOMAIN +from .v1.sensor import async_setup_entry as setup_entry_v1 +from .v2.sensor import async_setup_entry as setup_entry_v2 -async def async_setup_entry(hass, config_entry, async_add_entities): - """Defer sensor setup to the shared sensor module.""" - bridge = hass.data[HUE_DOMAIN][config_entry.entry_id] - - if not bridge.sensor_manager: +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensor entities.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + if bridge.api_version == 1: + await setup_entry_v1(hass, config_entry, async_add_entities) return - - await bridge.sensor_manager.async_register_component("sensor", async_add_entities) - - -class GenericHueGaugeSensorEntity(GenericZLLSensor, SensorEntity): - """Parent class for all 'gauge' Hue device sensors.""" - - -class HueLightLevel(GenericHueGaugeSensorEntity): - """The light level sensor entity for a Hue motion sensor device.""" - - _attr_device_class = DEVICE_CLASS_ILLUMINANCE - _attr_native_unit_of_measurement = LIGHT_LUX - - @property - def native_value(self): - """Return the state of the device.""" - if self.sensor.lightlevel is None: - return None - - # https://developers.meethue.com/develop/hue-api/supported-devices/#clip_zll_lightlevel - # Light level in 10000 log10 (lux) +1 measured by sensor. Logarithm - # scale used because the human eye adjusts to light levels and small - # changes at low lux levels are more noticeable than at high lux - # levels. - return round(float(10 ** ((self.sensor.lightlevel - 1) / 10000)), 2) - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - attributes = super().extra_state_attributes - attributes.update( - { - "lightlevel": self.sensor.lightlevel, - "daylight": self.sensor.daylight, - "dark": self.sensor.dark, - "threshold_dark": self.sensor.tholddark, - "threshold_offset": self.sensor.tholdoffset, - } - ) - return attributes - - -class HueTemperature(GenericHueGaugeSensorEntity): - """The temperature sensor entity for a Hue motion sensor device.""" - - _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_native_unit_of_measurement = TEMP_CELSIUS - - @property - def native_value(self): - """Return the state of the device.""" - if self.sensor.temperature is None: - return None - - return self.sensor.temperature / 100 - - -class HueBattery(GenericHueSensor, SensorEntity): - """Battery class for when a batt-powered device is only represented as an event.""" - - _attr_device_class = DEVICE_CLASS_BATTERY - _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_native_unit_of_measurement = PERCENTAGE - _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC - - @property - def unique_id(self): - """Return a unique identifier for this device.""" - return f"{self.sensor.uniqueid}-battery" - - @property - def native_value(self): - """Return the state of the battery.""" - return self.sensor.battery - - -SENSOR_CONFIG_MAP.update( - { - TYPE_ZLL_LIGHTLEVEL: { - "platform": "sensor", - "name_format": LIGHT_LEVEL_NAME_FORMAT, - "class": HueLightLevel, - }, - TYPE_ZLL_TEMPERATURE: { - "platform": "sensor", - "name_format": TEMPERATURE_NAME_FORMAT, - "class": HueTemperature, - }, - TYPE_ZLL_SWITCH: { - "platform": "sensor", - "name_format": REMOTE_NAME_FORMAT, - "class": HueBattery, - }, - TYPE_ZLL_ROTARY: { - "platform": "sensor", - "name_format": REMOTE_NAME_FORMAT, - "class": HueBattery, - }, - } -) + await setup_entry_v2(hass, config_entry, async_add_entities) diff --git a/homeassistant/components/hue/services.py b/homeassistant/components/hue/services.py new file mode 100644 index 00000000000..72e88f0d956 --- /dev/null +++ b/homeassistant/components/hue/services.py @@ -0,0 +1,158 @@ +"""Handle Hue Service calls.""" +from __future__ import annotations + +import asyncio +import logging + +from aiohue import HueBridgeV1, HueBridgeV2 +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service import verify_domain_control + +from .bridge import HueBridge +from .const import ( + ATTR_DYNAMIC, + ATTR_GROUP_NAME, + ATTR_SCENE_NAME, + ATTR_TRANSITION, + DOMAIN, + SERVICE_HUE_ACTIVATE_SCENE, +) + +LOGGER = logging.getLogger(__name__) + + +def async_register_services(hass: HomeAssistant) -> None: + """Register services for Hue integration.""" + + async def hue_activate_scene(call: ServiceCall, skip_reload=True): + """Handle activation of Hue scene.""" + # Get parameters + group_name = call.data[ATTR_GROUP_NAME] + scene_name = call.data[ATTR_SCENE_NAME] + transition = call.data.get(ATTR_TRANSITION) + dynamic = call.data.get(ATTR_DYNAMIC, False) + + # Call the set scene function on each bridge + tasks = [ + hue_activate_scene_v1(bridge, group_name, scene_name, transition) + if bridge.api_version == 1 + else hue_activate_scene_v2( + bridge, group_name, scene_name, transition, dynamic + ) + for bridge in hass.data[DOMAIN].values() + if isinstance(bridge, HueBridge) + ] + results = await asyncio.gather(*tasks) + + # Did *any* bridge succeed? + # Note that we'll get a "True" value for a successful call + if True not in results: + LOGGER.warning( + "No bridge was able to activate scene %s in group %s", + scene_name, + group_name, + ) + + if not hass.services.has_service(DOMAIN, SERVICE_HUE_ACTIVATE_SCENE): + # Register a local handler for scene activation + hass.services.async_register( + DOMAIN, + SERVICE_HUE_ACTIVATE_SCENE, + verify_domain_control(hass, DOMAIN)(hue_activate_scene), + schema=vol.Schema( + { + vol.Required(ATTR_GROUP_NAME): cv.string, + vol.Required(ATTR_SCENE_NAME): cv.string, + vol.Optional(ATTR_TRANSITION): cv.positive_int, + vol.Optional(ATTR_DYNAMIC): cv.boolean, + } + ), + ) + + +async def hue_activate_scene_v1( + bridge: HueBridge, + group_name: str, + scene_name: str, + transition: int | None = None, + is_retry: bool = False, +) -> bool: + """Service for V1 bridge to call directly into bridge to set scenes.""" + api: HueBridgeV1 = bridge.api + if api.scenes is None: + LOGGER.warning("Hub %s does not support scenes", api.host) + return False + + group = next( + (group for group in api.groups.values() if group.name == group_name), + None, + ) + # Additional scene logic to handle duplicate scene names across groups + scene = next( + ( + scene + for scene in api.scenes.values() + if scene.name == scene_name + and group is not None + and sorted(scene.lights) == sorted(group.lights) + ), + None, + ) + # If we can't find it, fetch latest info and try again + if not is_retry and (group is None or scene is None): + await bridge.async_request_call(api.groups.update) + await bridge.async_request_call(api.scenes.update) + return await hue_activate_scene_v1( + bridge, group_name, scene_name, transition, is_retry=True + ) + + if group is None or scene is None: + LOGGER.debug( + "Unable to find scene %s for group %s on bridge %s", + scene_name, + group_name, + bridge.host, + ) + return False + + await bridge.async_request_call( + group.set_action, scene=scene.id, transitiontime=transition + ) + return True + + +async def hue_activate_scene_v2( + bridge: HueBridge, + group_name: str, + scene_name: str, + transition: int | None = None, + dynamic: bool = True, +) -> bool: + """Service for V2 bridge to call scene by name.""" + LOGGER.warning( + "Use of service_call '%s' is deprecated and will be removed " + "in a future release. Please use scene entities instead", + SERVICE_HUE_ACTIVATE_SCENE, + ) + api: HueBridgeV2 = bridge.api + for scene in api.scenes: + if scene.metadata.name.lower() != scene_name.lower(): + continue + group = api.scenes.get_group(scene.id) + if group.metadata.name.lower() != group_name.lower(): + continue + # found match! + if transition: + transition = transition * 100 # in steps of 100ms + await api.scenes.recall(scene.id, dynamic=dynamic, duration=transition) + return True + LOGGER.debug( + "Unable to find scene %s for group %s on bridge %s", + scene_name, + group_name, + bridge.host, + ) + return False diff --git a/homeassistant/components/hue/services.yaml b/homeassistant/components/hue/services.yaml index 07eeca6fa0f..4e6d1ad6998 100644 --- a/homeassistant/components/hue/services.yaml +++ b/homeassistant/components/hue/services.yaml @@ -16,3 +16,8 @@ hue_activate_scene: example: "Energize" selector: text: + dynamic: + name: Dynamic + description: Enable dynamic mode of the scene (V2 bridges and supported scenes only). + selector: + boolean: diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 678b7c2cad2..458e21419ab 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -44,14 +44,24 @@ "dim_down": "Dim down", "dim_up": "Dim up", "turn_off": "Turn off", - "turn_on": "Turn on" + "turn_on": "Turn on", + "1": "First button", + "2": "Second button", + "3": "Third button", + "4": "Fourth button" }, "trigger_type": { "remote_button_long_release": "\"{subtype}\" button released after long press", "remote_button_short_press": "\"{subtype}\" button pressed", "remote_button_short_release": "\"{subtype}\" button released", "remote_double_button_long_press": "Both \"{subtype}\" released after long press", - "remote_double_button_short_press": "Both \"{subtype}\" released" + "remote_double_button_short_press": "Both \"{subtype}\" released", + + "initial_press": "Button \"{subtype}\" pressed initially", + "repeat": "Button \"{subtype}\" held down", + "short_release": "Button \"{subtype}\" released after short press", + "long_release": "Button \"{subtype}\" released after long press", + "double_short_release": "Both \"{subtype}\" released" } }, "options": { @@ -59,6 +69,7 @@ "init": { "data": { "allow_hue_groups": "Allow Hue groups", + "allow_hue_scenes": "Allow Hue scenes", "allow_unreachable": "Allow unreachable bulbs to report their state correctly" } } diff --git a/homeassistant/components/hue/switch.py b/homeassistant/components/hue/switch.py new file mode 100644 index 00000000000..3de96b45842 --- /dev/null +++ b/homeassistant/components/hue/switch.py @@ -0,0 +1,94 @@ +"""Support for switch platform for Hue resources (V2 only).""" +from __future__ import annotations + +from typing import Any, Union + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.controllers.sensors import LightLevelController, MotionController +from aiohue.v2.models.resource import SensingService + +from homeassistant.components.switch import DEVICE_CLASS_SWITCH, SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_CONFIG +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .bridge import HueBridge +from .const import DOMAIN +from .v2.entity import HueBaseEntity + +ControllerType = Union[LightLevelController, MotionController] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Hue switch platform from Hue resources.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + api: HueBridgeV2 = bridge.api + + if bridge.api_version == 1: + # should not happen, but just in case + raise NotImplementedError("Switch support is only available for V2 bridges") + + @callback + def register_items(controller: ControllerType): + @callback + def async_add_entity(event_type: EventType, resource: SensingService) -> None: + """Add entity from Hue resource.""" + async_add_entities( + [HueSensingServiceEnabledEntity(bridge, controller, resource)] + ) + + # add all current items in controller + for item in controller: + async_add_entity(EventType.RESOURCE_ADDED, item) + + # register listener for new items only + config_entry.async_on_unload( + controller.subscribe( + async_add_entity, event_filter=EventType.RESOURCE_ADDED + ) + ) + + # setup for each switch-type hue resource + register_items(api.sensors.motion) + register_items(api.sensors.light_level) + + +class HueSensingServiceEnabledEntity(HueBaseEntity, SwitchEntity): + """Representation of a Switch entity from Hue SensingService.""" + + _attr_entity_category = ENTITY_CATEGORY_CONFIG + _attr_device_class = DEVICE_CLASS_SWITCH + + def __init__( + self, + bridge: HueBridge, + controller: LightLevelController | MotionController, + resource: SensingService, + ) -> None: + """Initialize the entity.""" + super().__init__(bridge, controller, resource) + self.resource = resource + self.controller = controller + + @property + def is_on(self) -> bool: + """Return true if the switch is on.""" + return self.resource.enabled + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.bridge.async_request_call( + self.controller.set_enabled, self.resource.id, enabled=True + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.bridge.async_request_call( + self.controller.set_enabled, self.resource.id, enabled=False + ) diff --git a/homeassistant/components/hue/translations/bg.json b/homeassistant/components/hue/translations/bg.json index 864963b3da5..062aa233562 100644 --- a/homeassistant/components/hue/translations/bg.json +++ b/homeassistant/components/hue/translations/bg.json @@ -24,11 +24,20 @@ "link": { "description": "\u041d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0431\u0443\u0442\u043e\u043d\u0430 \u043d\u0430 \u0431\u0430\u0437\u043e\u0432\u0430\u0442\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f, \u0437\u0430 \u0434\u0430 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u0442\u0435 Philips Hue \u0441 Home Assistant. \n\n![\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043d\u0430 \u0431\u0443\u0442\u043e\u043d\u0430 \u043d\u0430 \u0431\u0430\u0437\u043e\u0432\u0430\u0442\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f](/static/images/config_philips_hue.jpg)", "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 \u0445\u044a\u0431" + }, + "manual": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } } } }, "device_automation": { "trigger_subtype": { + "1": "\u041f\u044a\u0440\u0432\u0438 \u0431\u0443\u0442\u043e\u043d", + "2": "\u0412\u0442\u043e\u0440\u0438 \u0431\u0443\u0442\u043e\u043d", + "3": "\u0422\u0440\u0435\u0442\u0438 \u0431\u0443\u0442\u043e\u043d", + "4": "\u0427\u0435\u0442\u0432\u044a\u0440\u0442\u0438 \u0431\u0443\u0442\u043e\u043d", "double_buttons_1_3": "\u041f\u044a\u0440\u0432\u0438 \u0438 \u0442\u0440\u0435\u0442\u0438 \u0431\u0443\u0442\u043e\u043d\u0438", "double_buttons_2_4": "\u0412\u0442\u043e\u0440\u0438 \u0438 \u0447\u0435\u0442\u0432\u044a\u0440\u0442\u0438 \u0431\u0443\u0442\u043e\u043d\u0438" } diff --git a/homeassistant/components/hue/translations/ca.json b/homeassistant/components/hue/translations/ca.json index 47bb10b2abb..2e177b7ee76 100644 --- a/homeassistant/components/hue/translations/ca.json +++ b/homeassistant/components/hue/translations/ca.json @@ -35,6 +35,10 @@ }, "device_automation": { "trigger_subtype": { + "1": "Primer bot\u00f3", + "2": "Segon bot\u00f3", + "3": "Tercer bot\u00f3", + "4": "Quart bot\u00f3", "button_1": "Primer bot\u00f3", "button_2": "Segon bot\u00f3", "button_3": "Tercer bot\u00f3", @@ -47,11 +51,16 @@ "turn_on": "Enc\u00e9n" }, "trigger_type": { + "double_short_release": "Ambd\u00f3s \"{subtype}\" alliberats", + "initial_press": "Bot\u00f3 \"{subtype}\" premut inicialment", + "long_release": "Bot\u00f3 \"{subtype}\" alliberat despr\u00e9s d'una estona premut", "remote_button_long_release": "Bot\u00f3 \"{subtype}\" alliberat despr\u00e9s d'una estona premut", "remote_button_short_press": "Bot\u00f3 \"{subtype}\" premut", "remote_button_short_release": "Bot\u00f3 \"{subtype}\" alliberat", "remote_double_button_long_press": "Ambd\u00f3s \"{subtype}\" alliberats despr\u00e9s d'una estona premuts", - "remote_double_button_short_press": "Ambd\u00f3s \"{subtype}\" alliberats" + "remote_double_button_short_press": "Ambd\u00f3s \"{subtype}\" alliberats", + "repeat": "Bot\u00f3 \"{subtype}\" mantingut premut", + "short_release": "Bot\u00f3 \"{subtype}\" alliberat despr\u00e9s de pr\u00e9mer breument" } }, "options": { @@ -59,6 +68,7 @@ "init": { "data": { "allow_hue_groups": "Permet grups Hue", + "allow_hue_scenes": "Permet escenes Hue", "allow_unreachable": "Permet que bombetes no accessibles puguin informar del seu estat correctament" } } diff --git a/homeassistant/components/hue/translations/de.json b/homeassistant/components/hue/translations/de.json index bf0d2a7c756..1c9269bcfc2 100644 --- a/homeassistant/components/hue/translations/de.json +++ b/homeassistant/components/hue/translations/de.json @@ -35,6 +35,10 @@ }, "device_automation": { "trigger_subtype": { + "1": "Erste Taste", + "2": "Zweite Taste", + "3": "Dritte Taste", + "4": "Vierte Taste", "button_1": "Erste Taste", "button_2": "Zweite Taste", "button_3": "Dritte Taste", @@ -47,11 +51,16 @@ "turn_on": "Einschalten" }, "trigger_type": { + "double_short_release": "Beide \"{subtype}\" losgelassen", + "initial_press": "Taste \"{subtype}\" anfangs gedr\u00fcckt", + "long_release": "Taste \"{subtype}\" nach langem Dr\u00fccken losgelassen", "remote_button_long_release": "\"{subtype}\" Taste nach langem Dr\u00fccken losgelassen", "remote_button_short_press": "\"{subtype}\" Taste gedr\u00fcckt", "remote_button_short_release": "\"{subtype}\" Taste losgelassen", "remote_double_button_long_press": "Beide \"{subtype}\" nach langem Dr\u00fccken losgelassen", - "remote_double_button_short_press": "Beide \"{subtype}\" losgelassen" + "remote_double_button_short_press": "Beide \"{subtype}\" losgelassen", + "repeat": "Taste \"{subtype}\" gedr\u00fcckt gehalten", + "short_release": "Taste \"{subtype}\" nach kurzem Dr\u00fccken losgelassen" } }, "options": { @@ -59,6 +68,7 @@ "init": { "data": { "allow_hue_groups": "Hue-Gruppen erlauben", + "allow_hue_scenes": "Hue-Szenen zulassen", "allow_unreachable": "Erlaube nicht erreichbaren Gl\u00fchlampen, ihren Zustand korrekt zu melden" } } diff --git a/homeassistant/components/hue/translations/en.json b/homeassistant/components/hue/translations/en.json index e03eabd3d23..f0b8e560729 100644 --- a/homeassistant/components/hue/translations/en.json +++ b/homeassistant/components/hue/translations/en.json @@ -35,6 +35,10 @@ }, "device_automation": { "trigger_subtype": { + "1": "First button", + "2": "Second button", + "3": "Third button", + "4": "Fourth button", "button_1": "First button", "button_2": "Second button", "button_3": "Third button", @@ -47,11 +51,16 @@ "turn_on": "Turn on" }, "trigger_type": { + "double_short_release": "Both \"{subtype}\" released", + "initial_press": "Button \"{subtype}\" pressed initially", + "long_release": "Button \"{subtype}\" released after long press", "remote_button_long_release": "\"{subtype}\" button released after long press", "remote_button_short_press": "\"{subtype}\" button pressed", "remote_button_short_release": "\"{subtype}\" button released", "remote_double_button_long_press": "Both \"{subtype}\" released after long press", - "remote_double_button_short_press": "Both \"{subtype}\" released" + "remote_double_button_short_press": "Both \"{subtype}\" released", + "repeat": "Button \"{subtype}\" held down", + "short_release": "Button \"{subtype}\" released after short press" } }, "options": { @@ -59,6 +68,7 @@ "init": { "data": { "allow_hue_groups": "Allow Hue groups", + "allow_hue_scenes": "Allow Hue scenes", "allow_unreachable": "Allow unreachable bulbs to report their state correctly" } } diff --git a/homeassistant/components/hue/translations/et.json b/homeassistant/components/hue/translations/et.json index afde880690e..76ae73b7121 100644 --- a/homeassistant/components/hue/translations/et.json +++ b/homeassistant/components/hue/translations/et.json @@ -35,6 +35,10 @@ }, "device_automation": { "trigger_subtype": { + "1": "Esimene nupp", + "2": "Teine nupp", + "3": "Kolmas nupp", + "4": "Neljas nupp", "button_1": "Esimene nupp", "button_2": "Teine nupp", "button_3": "Kolmas nupp", @@ -47,11 +51,16 @@ "turn_on": "L\u00fclita sisse" }, "trigger_type": { + "double_short_release": "\"{subtype}\" nupp vabastatati", + "initial_press": "Nuppu \"{subtype}\" on vajutatud", + "long_release": "Nupp \"{subtype}\" vabastati p\u00e4rast pikka vajutust", "remote_button_long_release": "\"{subtype}\" nupp vabastatati p\u00e4rast pikka vajutust", "remote_button_short_press": "\"{subtype}\" nupp on vajutatud", "remote_button_short_release": "\"{subtype}\" nupp vabastati", "remote_double_button_long_press": "M\u00f5lemad \"{subtype}\" nupud vabastatati p\u00e4rast pikka vajutust", - "remote_double_button_short_press": "M\u00f5lemad \"{subtype}\" nupud vabastatati" + "remote_double_button_short_press": "M\u00f5lemad \"{subtype}\" nupud vabastatati", + "repeat": "Nuppu \" {subtype} \" hoitakse all", + "short_release": "Nupp \" {subtype} \" vabastati p\u00e4rast l\u00fchikest vajutust" } }, "options": { @@ -59,6 +68,7 @@ "init": { "data": { "allow_hue_groups": "Luba Hue r\u00fchmad", + "allow_hue_scenes": "Luba Hue stseenid", "allow_unreachable": "Luba k\u00e4ttesaamatutel pirnidel oma olekust \u00f5igesti teatada" } } diff --git a/homeassistant/components/hue/translations/he.json b/homeassistant/components/hue/translations/he.json index ece439b376b..f0a4c2fd484 100644 --- a/homeassistant/components/hue/translations/he.json +++ b/homeassistant/components/hue/translations/he.json @@ -33,6 +33,7 @@ }, "device_automation": { "trigger_subtype": { + "2": "\u05dc\u05d7\u05e6\u05df \u05e9\u05e0\u05d9", "turn_off": "\u05db\u05d1\u05d4", "turn_on": "\u05d4\u05e4\u05e2\u05dc" } diff --git a/homeassistant/components/hue/translations/hu.json b/homeassistant/components/hue/translations/hu.json index 917ec094ced..5c825d0bb67 100644 --- a/homeassistant/components/hue/translations/hu.json +++ b/homeassistant/components/hue/translations/hu.json @@ -35,6 +35,10 @@ }, "device_automation": { "trigger_subtype": { + "1": "1. gomb", + "2": "2. gomb", + "3": "3. gomb", + "4": "4. gomb", "button_1": "Els\u0151 gomb", "button_2": "M\u00e1sodik gomb", "button_3": "Harmadik gomb", @@ -47,11 +51,16 @@ "turn_on": "Bekapcsol\u00e1s" }, "trigger_type": { + "double_short_release": "Mindk\u00e9t \"{subtype}\" felengedve", + "initial_press": "\"{subtype}\" lenyomva el\u0151sz\u00f6r", + "long_release": "\"{subtype}\" felengedve hossz\u00fa nyomva tart\u00e1s ut\u00e1n", "remote_button_long_release": "A \"{subtype}\" gomb hossz\u00fa megnyom\u00e1s ut\u00e1n elengedve", "remote_button_short_press": "\"{subtype}\" gomb lenyomva", "remote_button_short_release": "\"{subtype}\" gomb elengedve", "remote_double_button_long_press": "Mindk\u00e9t \"{subtype}\" hossz\u00fa megnyom\u00e1st k\u00f6vet\u0151en megjelent", - "remote_double_button_short_press": "Mindk\u00e9t \"{subtype}\" megjelent" + "remote_double_button_short_press": "Mindk\u00e9t \"{subtype}\" megjelent", + "repeat": "\"{subtype}\"gomb lenyomva tartava", + "short_release": "\"{subtype}\" felengedve r\u00f6vid nyomva tart\u00e1s ut\u00e1n" } }, "options": { @@ -59,6 +68,7 @@ "init": { "data": { "allow_hue_groups": "Hue csoportok enged\u00e9lyez\u00e9se", + "allow_hue_scenes": "Hue jelenetek enged\u00e9lyez\u00e9se", "allow_unreachable": "Hagyja, hogy az el\u00e9rhetetlen izz\u00f3k helyesen jelents\u00e9k \u00e1llapotukat" } } diff --git a/homeassistant/components/hue/translations/id.json b/homeassistant/components/hue/translations/id.json index c9e0bcd75d4..1084d980ea5 100644 --- a/homeassistant/components/hue/translations/id.json +++ b/homeassistant/components/hue/translations/id.json @@ -35,6 +35,10 @@ }, "device_automation": { "trigger_subtype": { + "1": "Tombol pertama", + "2": "Tombol kedua", + "3": "Tombol ketiga", + "4": "Tombol keempat", "button_1": "Tombol pertama", "button_2": "Tombol kedua", "button_3": "Tombol ketiga", @@ -47,11 +51,16 @@ "turn_on": "Nyalakan" }, "trigger_type": { + "double_short_release": "Kedua \"{subtype}\" dilepaskan", + "initial_press": "Tombol \"{subtype}\" awalnya ditekan", + "long_release": "Tombol \"{subtype}\" dilepaskan setelah ditekan lama", "remote_button_long_release": "Tombol \"{subtype}\" dilepaskan setelah ditekan lama", "remote_button_short_press": "Tombol \"{subtype}\" ditekan", "remote_button_short_release": "Tombol \"{subtype}\" dilepaskan", "remote_double_button_long_press": "Kedua \"{subtype}\" dilepaskan setelah ditekan lama", - "remote_double_button_short_press": "Kedua \"{subtype}\" dilepas" + "remote_double_button_short_press": "Kedua \"{subtype}\" dilepas", + "repeat": "Tombol \"{subtype}\" ditekan terus", + "short_release": "Tombol \"{subtype}\" dilepaskan setelah ditekan sebentar" } }, "options": { @@ -59,6 +68,7 @@ "init": { "data": { "allow_hue_groups": "Izinkan grup Hue", + "allow_hue_scenes": "Izinkan skenario Hue", "allow_unreachable": "Izinkan bohlam yang tidak dapat dijangkau untuk melaporkan statusnya dengan benar" } } diff --git a/homeassistant/components/hue/translations/ja.json b/homeassistant/components/hue/translations/ja.json index f51e0680c67..d1bcad85ca9 100644 --- a/homeassistant/components/hue/translations/ja.json +++ b/homeassistant/components/hue/translations/ja.json @@ -2,12 +2,16 @@ "config": { "abort": { "all_configured": "\u3059\u3079\u3066\u306e\u3001Philips Hue bridge\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", + "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", "discover_timeout": "Hue bridge\u3092\u767a\u898b(\u63a2\u308a\u5f53\u3066)\u3067\u304d\u307e\u305b\u3093", "no_bridges": "Hue bridge\u306f\u767a\u898b\u3055\u308c\u307e\u305b\u3093\u3067\u3057\u305f", "not_hue_bridge": "Hue bridge\u3067\u306f\u3042\u308a\u307e\u305b\u3093", - "unknown": "\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f" + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" }, "error": { + "linking": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc", "register_failed": "\u767b\u9332\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3001\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044" }, "step": { @@ -18,11 +22,55 @@ "title": "Hue bridge\u3092\u30d4\u30c3\u30af\u30a2\u30c3\u30d7" }, "link": { + "description": "\u30d6\u30ea\u30c3\u30b8\u306e\u30dc\u30bf\u30f3\u3092\u62bc\u3059\u3068\u3001Philips Hue\u304cHome Assistant\u306b\u767b\u9332\u3055\u308c\u307e\u3059\u3002\n\n![\u30d6\u30ea\u30c3\u30b8\u306e\u30dc\u30bf\u30f3\u306e\u4f4d\u7f6e](/static/images/config_philips_hue.jpg)", "title": "\u30ea\u30f3\u30af\u30cf\u30d6" }, "manual": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, "title": "Hue bridges\u3092\u624b\u52d5\u3067\u8a2d\u5b9a\u3059\u308b" } } + }, + "device_automation": { + "trigger_subtype": { + "1": "1\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "2": "2\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "3": "3\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "4": "4\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "button_1": "1\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "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", + "dim_down": "\u8584\u6697\u304f\u3059\u308b", + "dim_up": "\u5fae\u304b\u306b\u660e\u308b\u304f\u3059\u308b", + "double_buttons_1_3": "1\u756a\u76ee\u30683\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "double_buttons_2_4": "2\u756a\u76ee\u30684\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "turn_off": "\u30aa\u30d5\u306b\u3059\u308b", + "turn_on": "\u30aa\u30f3\u306b\u3059\u308b" + }, + "trigger_type": { + "double_short_release": "\u4e21\u65b9\u306e \"{subtype}\" \u3092\u96e2\u3059", + "initial_press": "\u30dc\u30bf\u30f3 \"{subtype}\" \u6700\u521d\u306b\u62bc\u3055\u308c\u305f", + "long_release": "\u30dc\u30bf\u30f3 \"{subtype}\" \u96e2\u3057\u305f\u5f8c\u306b\u9577\u62bc\u3057", + "remote_button_short_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u304c\u62bc\u3055\u308c\u307e\u3057\u305f\u3002", + "remote_button_short_release": "\"{subtype}\" \u30dc\u30bf\u30f3\u304c\u30ea\u30ea\u30fc\u30b9\u3055\u308c\u307e\u3057\u305f", + "remote_double_button_long_press": "\u4e21\u65b9\u306e \"{subtype}\" \u306f\u9577\u62bc\u3057\u5f8c\u306b\u30ea\u30ea\u30fc\u30b9\u3055\u308c\u307e\u3057\u305f", + "remote_double_button_short_press": "\u4e21\u65b9\u306e \"{subtype}\" \u3092\u96e2\u3059", + "repeat": "\u30dc\u30bf\u30f3 \"{subtype}\" \u3092\u62bc\u3057\u305f\u307e\u307e", + "short_release": "\u30dc\u30bf\u30f3 \"{subtype}\" \u77ed\u62bc\u3057\u306e\u5f8c\u306b\u96e2\u3059" + } + }, + "options": { + "step": { + "init": { + "data": { + "allow_hue_groups": "Hue(\u8272\u76f8)\u30b0\u30eb\u30fc\u30d7\u306e\u8a31\u53ef", + "allow_hue_scenes": "Hue\u30b7\u30fc\u30f3\u3092\u8a31\u53ef", + "allow_unreachable": "\u5230\u9054\u3067\u304d\u306a\u304b\u3063\u305f\u96fb\u7403(bulbs)\u304c\u305d\u306e\u72b6\u614b\u3092\u6b63\u3057\u304f\u5831\u544a\u3067\u304d\u308b\u3088\u3046\u306b\u3059\u308b" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/hue/translations/nl.json b/homeassistant/components/hue/translations/nl.json index 0938c18e1ea..12eeaf71af0 100644 --- a/homeassistant/components/hue/translations/nl.json +++ b/homeassistant/components/hue/translations/nl.json @@ -35,6 +35,10 @@ }, "device_automation": { "trigger_subtype": { + "1": "Eerste knop", + "2": "Tweede knop", + "3": "Derde knop", + "4": "Vierde knop", "button_1": "Eerste knop", "button_2": "Tweede knop", "button_3": "Derde knop", @@ -47,11 +51,16 @@ "turn_on": "Inschakelen" }, "trigger_type": { + "double_short_release": "Beide \" {subtype} \" losgelaten", + "initial_press": "Knop \" {subtype} \" aanvankelijk ingedrukt", + "long_release": "Knop \"{subtype}\" losgelaten na lang indrukken", "remote_button_long_release": "\"{subtype}\" knop losgelaten na lang drukken", "remote_button_short_press": "\"{subtype}\" knop ingedrukt", "remote_button_short_release": "\"{subtype}\" knop losgelaten", "remote_double_button_long_press": "Beide \"{subtype}\" losgelaten na lang indrukken", - "remote_double_button_short_press": "Beide \"{subtype}\" losgelaten" + "remote_double_button_short_press": "Beide \"{subtype}\" losgelaten", + "repeat": "Knop \" {subtype} \" ingedrukt gehouden", + "short_release": "Knop \"{subtype}\" losgelaten na kort indrukken" } }, "options": { @@ -59,6 +68,7 @@ "init": { "data": { "allow_hue_groups": "Sta Hue-groepen toe", + "allow_hue_scenes": "Sta Hue sc\u00e8nes toe", "allow_unreachable": "Onbereikbare lampen toestaan hun status correct te melden" } } diff --git a/homeassistant/components/hue/translations/no.json b/homeassistant/components/hue/translations/no.json index e2b52628749..c813f80d198 100644 --- a/homeassistant/components/hue/translations/no.json +++ b/homeassistant/components/hue/translations/no.json @@ -35,6 +35,10 @@ }, "device_automation": { "trigger_subtype": { + "1": "F\u00f8rste knapp", + "2": "Andre knapp", + "3": "Tredje knapp", + "4": "Fjerde knapp", "button_1": "F\u00f8rste knapp", "button_2": "Andre knapp", "button_3": "Tredje knapp", @@ -47,11 +51,16 @@ "turn_on": "Sl\u00e5 p\u00e5" }, "trigger_type": { + "double_short_release": "Begge \"{subtype}\" er utgitt", + "initial_press": "Knappen \"{subtype}\" ble f\u00f8rst trykket", + "long_release": "Knapp \"{subtype}\" slippes etter lang trykk", "remote_button_long_release": "\"{subtype}\"-knappen sluppet etter langt trykk", "remote_button_short_press": "\"{subtype}\" -knappen ble trykket", "remote_button_short_release": "\"{subtype}\"-knappen sluppet", "remote_double_button_long_press": "Begge \"{subtype}\" utgitt etter lang trykk", - "remote_double_button_short_press": "Begge \"{subtype}\" utgitt" + "remote_double_button_short_press": "Begge \"{subtype}\" utgitt", + "repeat": "Knappen \" {subtype} \" holdt nede", + "short_release": "Knapp \"{subtype}\" slippes etter kort trykk" } }, "options": { @@ -59,6 +68,7 @@ "init": { "data": { "allow_hue_groups": "Tillat Hue-grupper", + "allow_hue_scenes": "Tillat Hue-scener", "allow_unreachable": "Tillat uoppn\u00e5elige p\u00e6rer \u00e5 rapportere sin tilstand riktig" } } diff --git a/homeassistant/components/hue/translations/pl.json b/homeassistant/components/hue/translations/pl.json index b144393c3d1..1b8d3d4ef2d 100644 --- a/homeassistant/components/hue/translations/pl.json +++ b/homeassistant/components/hue/translations/pl.json @@ -35,6 +35,10 @@ }, "device_automation": { "trigger_subtype": { + "1": "pierwszy", + "2": "drugi", + "3": "trzeci", + "4": "czwarty", "button_1": "pierwszy", "button_2": "drugi", "button_3": "trzeci", @@ -47,11 +51,16 @@ "turn_on": "w\u0142\u0105cznik" }, "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", "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" + "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" } }, "options": { @@ -59,6 +68,7 @@ "init": { "data": { "allow_hue_groups": "Zezwalaj na grupowanie Hue", + "allow_hue_scenes": "Zezwalaj na sceny dla Hue", "allow_unreachable": "Zezwalaj nieosi\u0105galnym \u017car\u00f3wkom na poprawne raportowanie ich stanu" } } diff --git a/homeassistant/components/hue/translations/ru.json b/homeassistant/components/hue/translations/ru.json index 81cbe6a385f..237d91cc817 100644 --- a/homeassistant/components/hue/translations/ru.json +++ b/homeassistant/components/hue/translations/ru.json @@ -35,6 +35,10 @@ }, "device_automation": { "trigger_subtype": { + "1": "\u041f\u0435\u0440\u0432\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "2": "\u0412\u0442\u043e\u0440\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "3": "\u0422\u0440\u0435\u0442\u044c\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "4": "\u0427\u0435\u0442\u0432\u0435\u0440\u0442\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", "button_1": "\u041f\u0435\u0440\u0432\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", "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", @@ -47,11 +51,16 @@ "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c" }, "trigger_type": { + "double_short_release": "{subtype} \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u044b \u043f\u043e\u0441\u043b\u0435 \u043a\u043e\u0440\u043e\u0442\u043a\u043e\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0438\u044f", + "initial_press": "{subtype} \u043f\u0440\u0435\u0434\u0432\u0430\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u043d\u0430\u0436\u0430\u0442\u0430", + "long_release": "{subtype} \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430 \u043f\u043e\u0441\u043b\u0435 \u0434\u043e\u043b\u0433\u043e\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0438\u044f", "remote_button_long_release": "{subtype} \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430 \u043f\u043e\u0441\u043b\u0435 \u0434\u043e\u043b\u0433\u043e\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0438\u044f", "remote_button_short_press": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430", "remote_button_short_release": "{subtype} \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430 \u043f\u043e\u0441\u043b\u0435 \u043a\u043e\u0440\u043e\u0442\u043a\u043e\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0438\u044f", "remote_double_button_long_press": "{subtype} \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u044b \u043f\u043e\u0441\u043b\u0435 \u0434\u043e\u043b\u0433\u043e\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0438\u044f", - "remote_double_button_short_press": "{subtype} \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u044b \u043f\u043e\u0441\u043b\u0435 \u043a\u043e\u0440\u043e\u0442\u043a\u043e\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0438\u044f" + "remote_double_button_short_press": "{subtype} \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u044b \u043f\u043e\u0441\u043b\u0435 \u043a\u043e\u0440\u043e\u0442\u043a\u043e\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0438\u044f", + "repeat": "{subtype} \u0443\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f \u043d\u0430\u0436\u0430\u0442\u043e\u0439", + "short_release": "{subtype} \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430 \u043f\u043e\u0441\u043b\u0435 \u043a\u043e\u0440\u043e\u0442\u043a\u043e\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0438\u044f" } }, "options": { @@ -59,6 +68,7 @@ "init": { "data": { "allow_hue_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0433\u0440\u0443\u043f\u043f\u044b Hue", + "allow_hue_scenes": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0441\u0446\u0435\u043d\u044b Hue", "allow_unreachable": "\u041f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e \u0441\u043e\u043e\u0431\u0449\u0430\u0442\u044c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435 \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432" } } diff --git a/homeassistant/components/hue/translations/sl.json b/homeassistant/components/hue/translations/sl.json index c68971f36f9..fd2c04b8787 100644 --- a/homeassistant/components/hue/translations/sl.json +++ b/homeassistant/components/hue/translations/sl.json @@ -29,6 +29,10 @@ }, "device_automation": { "trigger_subtype": { + "1": "Prvi gumb", + "2": "Drugi gumb", + "3": "Tretji gumb", + "4": "\u010cetrti gumb", "button_1": "Prvi gumb", "button_2": "Drugi gumb", "button_3": "Tretji gumb", @@ -52,7 +56,8 @@ "step": { "init": { "data": { - "allow_hue_groups": "Dovoli skupine Hue" + "allow_hue_groups": "Dovoli skupine Hue", + "allow_hue_scenes": "Dovoli Hue scene" } } } diff --git a/homeassistant/components/hue/translations/tr.json b/homeassistant/components/hue/translations/tr.json index 984c91e8f36..a883283048f 100644 --- a/homeassistant/components/hue/translations/tr.json +++ b/homeassistant/components/hue/translations/tr.json @@ -1,23 +1,33 @@ { "config": { "abort": { + "all_configured": "T\u00fcm Philips Hue k\u00f6pr\u00fcleri zaten yap\u0131land\u0131r\u0131lm\u0131\u015ft\u0131r", "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", + "discover_timeout": "Hue k\u00f6pr\u00fcleri bulunam\u0131yor", + "no_bridges": "Philips Hue k\u00f6pr\u00fcs\u00fc bulunamad\u0131", + "not_hue_bridge": "Hue k\u00f6pr\u00fcs\u00fc de\u011fil", "unknown": "Beklenmeyen hata" }, "error": { - "linking": "Beklenmeyen hata" + "linking": "Beklenmeyen hata", + "register_failed": "Kay\u0131t ba\u015far\u0131s\u0131z oldu, l\u00fctfen tekrar deneyin" }, "step": { "init": { "data": { - "host": "Ana Bilgisayar" - } + "host": "Ana bilgisayar" + }, + "title": "Hue k\u00f6pr\u00fcs\u00fcn\u00fc se\u00e7in" + }, + "link": { + "description": "Philips Hue'yu Home Assistant'a kaydetmek i\u00e7in k\u00f6pr\u00fcdeki d\u00fc\u011fmeye bas\u0131n. \n\n ![K\u00f6pr\u00fcdeki d\u00fc\u011fmenin konumu](/static/images/config_philips_hue.jpg)", + "title": "Ba\u011flant\u0131 Merkezi" }, "manual": { "data": { - "host": "Ana Bilgisayar" + "host": "Ana bilgisayar" }, "title": "Bir Hue k\u00f6pr\u00fcs\u00fcn\u00fc manuel olarak yap\u0131land\u0131rma" } @@ -25,14 +35,32 @@ }, "device_automation": { "trigger_subtype": { + "1": "\u0130lk d\u00fc\u011fme", + "2": "\u0130kinci d\u00fc\u011fme", + "3": "\u00dc\u00e7\u00fcnc\u00fc d\u00fc\u011fme", + "4": "D\u00f6rd\u00fcnc\u00fc d\u00fc\u011fme", "button_1": "\u0130lk d\u00fc\u011fme", "button_2": "\u0130kinci d\u00fc\u011fme", "button_3": "\u00dc\u00e7\u00fcnc\u00fc d\u00fc\u011fme", "button_4": "D\u00f6rd\u00fcnc\u00fc d\u00fc\u011fme", + "dim_down": "K\u0131sma", + "dim_up": "A\u00e7ma", "double_buttons_1_3": "Birinci ve \u00dc\u00e7\u00fcnc\u00fc d\u00fc\u011fmeler", "double_buttons_2_4": "\u0130kinci ve D\u00f6rd\u00fcnc\u00fc d\u00fc\u011fmeler", "turn_off": "Kapat", "turn_on": "A\u00e7" + }, + "trigger_type": { + "double_short_release": "Her iki \"{subtype}\" de b\u0131rak\u0131ld\u0131", + "initial_press": "Ba\u015flang\u0131\u00e7ta \" {subtype} \" d\u00fc\u011fmesine bas\u0131ld\u0131", + "long_release": "\" {subtype} \" d\u00fc\u011fmesi uzun bas\u0131ld\u0131ktan sonra b\u0131rak\u0131ld\u0131", + "remote_button_long_release": "\" {subtype} \" d\u00fc\u011fmesi uzun bas\u0131ld\u0131ktan sonra b\u0131rak\u0131ld\u0131", + "remote_button_short_press": "\" {subtype} \" d\u00fc\u011fmesine bas\u0131ld\u0131", + "remote_button_short_release": "\" {subtype} \" d\u00fc\u011fmesi b\u0131rak\u0131ld\u0131", + "remote_double_button_long_press": "Her iki \" {subtype} \" uzun bas\u0131\u015ftan sonra b\u0131rak\u0131ld\u0131", + "remote_double_button_short_press": "Her iki \"{subtype}\" de b\u0131rak\u0131ld\u0131", + "repeat": "\" {subtype} \" d\u00fc\u011fmesi bas\u0131l\u0131 tutuldu", + "short_release": "K\u0131sa bas\u0131ld\u0131ktan sonra \"{subtype}\" d\u00fc\u011fmesi b\u0131rak\u0131ld\u0131" } }, "options": { @@ -40,6 +68,7 @@ "init": { "data": { "allow_hue_groups": "Hue gruplar\u0131na izin ver", + "allow_hue_scenes": "Hue sahnelerine izin ver", "allow_unreachable": "Ula\u015f\u0131lamayan ampullerin durumlar\u0131n\u0131 do\u011fru \u015fekilde bildirmesine izin verin" } } diff --git a/homeassistant/components/hue/translations/zh-Hant.json b/homeassistant/components/hue/translations/zh-Hant.json index f1b8a70f070..32207430e90 100644 --- a/homeassistant/components/hue/translations/zh-Hant.json +++ b/homeassistant/components/hue/translations/zh-Hant.json @@ -35,6 +35,10 @@ }, "device_automation": { "trigger_subtype": { + "1": "\u7b2c\u4e00\u500b\u6309\u9215", + "2": "\u7b2c\u4e8c\u500b\u6309\u9215", + "3": "\u7b2c\u4e09\u500b\u6309\u9215", + "4": "\u7b2c\u56db\u500b\u6309\u9215", "button_1": "\u7b2c\u4e00\u500b\u6309\u9215", "button_2": "\u7b2c\u4e8c\u500b\u6309\u9215", "button_3": "\u7b2c\u4e09\u500b\u6309\u9215", @@ -47,11 +51,16 @@ "turn_on": "\u958b\u555f" }, "trigger_type": { + "double_short_release": "\"{subtype}\" \u4e00\u8d77\u91cb\u653e", + "initial_press": "\u6309\u9215 \"{subtype}\" \u6700\u521d\u6309\u4e0b", + "long_release": "\u6309\u9215 \"{subtype}\" \u9577\u6309\u5f8c\u91cb\u653e", "remote_button_long_release": "\"{subtype}\" \u6309\u9215\u9577\u6309\u5f8c\u91cb\u653e", "remote_button_short_press": "\"{subtype}\" \u6309\u9215\u5df2\u6309\u4e0b", "remote_button_short_release": "\"{subtype}\" \u6309\u9215\u5df2\u91cb\u653e", "remote_double_button_long_press": "\"{subtype}\" \u4e00\u8d77\u9577\u6309\u5f8c\u91cb\u653e", - "remote_double_button_short_press": "\"{subtype}\" \u4e00\u8d77\u91cb\u653e" + "remote_double_button_short_press": "\"{subtype}\" \u4e00\u8d77\u91cb\u653e", + "repeat": "\u6309\u9215 \"{subtype}\" \u6309\u4e0b", + "short_release": "\u6309\u9215 \"{subtype}\" \u77ed\u6309\u5f8c\u91cb\u653e" } }, "options": { @@ -59,6 +68,7 @@ "init": { "data": { "allow_hue_groups": "\u5141\u8a31 Hue \u7fa4\u7d44", + "allow_hue_scenes": "\u5141\u8a31 Hue \u5834\u666f", "allow_unreachable": "\u5141\u8a31\u7121\u6cd5\u9023\u7dda\u7684\u71c8\u6ce1\u6b63\u78ba\u56de\u5831\u5176\u72c0\u614b" } } diff --git a/homeassistant/components/hue/v1/__init__.py b/homeassistant/components/hue/v1/__init__.py new file mode 100644 index 00000000000..fdca29a0d94 --- /dev/null +++ b/homeassistant/components/hue/v1/__init__.py @@ -0,0 +1 @@ +"""Hue V1 API specific platform implementation.""" diff --git a/homeassistant/components/hue/v1/binary_sensor.py b/homeassistant/components/hue/v1/binary_sensor.py new file mode 100644 index 00000000000..21650e52b9c --- /dev/null +++ b/homeassistant/components/hue/v1/binary_sensor.py @@ -0,0 +1,56 @@ +"""Hue binary sensor entities.""" +from aiohue.v1.sensors import TYPE_ZLL_PRESENCE + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOTION, + BinarySensorEntity, +) + +from ..const import DOMAIN as HUE_DOMAIN +from .sensor_base import SENSOR_CONFIG_MAP, GenericZLLSensor + +PRESENCE_NAME_FORMAT = "{} motion" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer binary sensor setup to the shared sensor module.""" + bridge = hass.data[HUE_DOMAIN][config_entry.entry_id] + + if not bridge.sensor_manager: + return + + await bridge.sensor_manager.async_register_component( + "binary_sensor", async_add_entities + ) + + +class HuePresence(GenericZLLSensor, BinarySensorEntity): + """The presence sensor entity for a Hue motion sensor device.""" + + _attr_device_class = DEVICE_CLASS_MOTION + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.sensor.presence + + @property + def extra_state_attributes(self): + """Return the device state attributes.""" + attributes = super().extra_state_attributes + if "sensitivity" in self.sensor.config: + attributes["sensitivity"] = self.sensor.config["sensitivity"] + if "sensitivitymax" in self.sensor.config: + attributes["sensitivity_max"] = self.sensor.config["sensitivitymax"] + return attributes + + +SENSOR_CONFIG_MAP.update( + { + TYPE_ZLL_PRESENCE: { + "platform": "binary_sensor", + "name_format": PRESENCE_NAME_FORMAT, + "class": HuePresence, + } + } +) diff --git a/homeassistant/components/hue/v1/device_trigger.py b/homeassistant/components/hue/v1/device_trigger.py new file mode 100644 index 00000000000..d6b471b7257 --- /dev/null +++ b/homeassistant/components/hue/v1/device_trigger.py @@ -0,0 +1,185 @@ +"""Provides device automations for Philips Hue events in V1 bridge/api.""" +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_EVENT, + CONF_PLATFORM, + CONF_TYPE, + CONF_UNIQUE_ID, +) +from homeassistant.helpers.device_registry import DeviceEntry + +from ..const import ATTR_HUE_EVENT, CONF_SUBTYPE, DOMAIN + +if TYPE_CHECKING: + from ..bridge import HueBridge + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + {vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str} +) + + +CONF_SHORT_PRESS = "remote_button_short_press" +CONF_SHORT_RELEASE = "remote_button_short_release" +CONF_LONG_RELEASE = "remote_button_long_release" +CONF_DOUBLE_SHORT_RELEASE = "remote_double_button_short_press" +CONF_DOUBLE_LONG_RELEASE = "remote_double_button_long_press" + +CONF_TURN_ON = "turn_on" +CONF_TURN_OFF = "turn_off" +CONF_DIM_UP = "dim_up" +CONF_DIM_DOWN = "dim_down" +CONF_BUTTON_1 = "button_1" +CONF_BUTTON_2 = "button_2" +CONF_BUTTON_3 = "button_3" +CONF_BUTTON_4 = "button_4" +CONF_DOUBLE_BUTTON_1 = "double_buttons_1_3" +CONF_DOUBLE_BUTTON_2 = "double_buttons_2_4" + +HUE_DIMMER_REMOTE_MODEL = "Hue dimmer switch" # RWL020/021 +HUE_DIMMER_REMOTE = { + (CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, + (CONF_SHORT_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2002}, + (CONF_LONG_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2003}, + (CONF_SHORT_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3002}, + (CONF_LONG_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3003}, + (CONF_SHORT_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 4002}, + (CONF_LONG_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 4003}, +} + +HUE_BUTTON_REMOTE_MODEL = "Hue Smart button" # ZLLSWITCH/ROM001 +HUE_BUTTON_REMOTE = { + (CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, +} + +HUE_WALL_REMOTE_MODEL = "Hue wall switch module" # ZLLSWITCH/RDM001 +HUE_WALL_REMOTE = { + (CONF_SHORT_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 1002}, + (CONF_SHORT_RELEASE, CONF_BUTTON_2): {CONF_EVENT: 2002}, +} + +HUE_TAP_REMOTE_MODEL = "Hue tap switch" # ZGPSWITCH +HUE_TAP_REMOTE = { + (CONF_SHORT_PRESS, CONF_BUTTON_1): {CONF_EVENT: 34}, + (CONF_SHORT_PRESS, CONF_BUTTON_2): {CONF_EVENT: 16}, + (CONF_SHORT_PRESS, CONF_BUTTON_3): {CONF_EVENT: 17}, + (CONF_SHORT_PRESS, CONF_BUTTON_4): {CONF_EVENT: 18}, +} + +HUE_FOHSWITCH_REMOTE_MODEL = "Friends of Hue Switch" # ZGPSWITCH +HUE_FOHSWITCH_REMOTE = { + (CONF_SHORT_PRESS, CONF_BUTTON_1): {CONF_EVENT: 20}, + (CONF_LONG_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 16}, + (CONF_SHORT_PRESS, CONF_BUTTON_2): {CONF_EVENT: 21}, + (CONF_LONG_RELEASE, CONF_BUTTON_2): {CONF_EVENT: 17}, + (CONF_SHORT_PRESS, CONF_BUTTON_3): {CONF_EVENT: 23}, + (CONF_LONG_RELEASE, CONF_BUTTON_3): {CONF_EVENT: 19}, + (CONF_SHORT_PRESS, CONF_BUTTON_4): {CONF_EVENT: 22}, + (CONF_LONG_RELEASE, CONF_BUTTON_4): {CONF_EVENT: 18}, + (CONF_DOUBLE_SHORT_RELEASE, CONF_DOUBLE_BUTTON_1): {CONF_EVENT: 101}, + (CONF_DOUBLE_LONG_RELEASE, CONF_DOUBLE_BUTTON_1): {CONF_EVENT: 100}, + (CONF_DOUBLE_SHORT_RELEASE, CONF_DOUBLE_BUTTON_2): {CONF_EVENT: 99}, + (CONF_DOUBLE_LONG_RELEASE, CONF_DOUBLE_BUTTON_2): {CONF_EVENT: 98}, +} + + +REMOTES = { + HUE_DIMMER_REMOTE_MODEL: HUE_DIMMER_REMOTE, + HUE_TAP_REMOTE_MODEL: HUE_TAP_REMOTE, + HUE_BUTTON_REMOTE_MODEL: HUE_BUTTON_REMOTE, + HUE_WALL_REMOTE_MODEL: HUE_WALL_REMOTE, + HUE_FOHSWITCH_REMOTE_MODEL: HUE_FOHSWITCH_REMOTE, +} + + +def _get_hue_event_from_device_id(hass, device_id): + """Resolve hue event from device id.""" + for bridge in hass.data.get(DOMAIN, {}).values(): + for hue_event in bridge.sensor_manager.current_events.values(): + if device_id == hue_event.device_registry_id: + return hue_event + + return None + + +async def async_validate_trigger_config(bridge, device_entry, config): + """Validate config.""" + config = TRIGGER_SCHEMA(config) + trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) + + if not device_entry: + raise InvalidDeviceAutomationConfig( + f"Device {config[CONF_DEVICE_ID]} not found" + ) + + if device_entry.model not in REMOTES: + raise InvalidDeviceAutomationConfig( + f"Device model {device_entry.model} is not a remote" + ) + + if trigger not in REMOTES[device_entry.model]: + raise InvalidDeviceAutomationConfig( + f"Device does not support trigger {trigger}" + ) + + return config + + +async def async_attach_trigger(bridge, device_entry, config, action, automation_info): + """Listen for state changes based on configuration.""" + hass = bridge.hass + + hue_event = _get_hue_event_from_device_id(hass, device_entry.id) + if hue_event is None: + raise InvalidDeviceAutomationConfig + + trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) + + trigger = REMOTES[device_entry.model][trigger] + + event_config = { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: ATTR_HUE_EVENT, + event_trigger.CONF_EVENT_DATA: {CONF_UNIQUE_ID: hue_event.unique_id, **trigger}, + } + + event_config = event_trigger.TRIGGER_SCHEMA(event_config) + return await event_trigger.async_attach_trigger( + hass, event_config, action, automation_info, platform_type="device" + ) + + +async def async_get_triggers(bridge: "HueBridge", device: DeviceEntry): + """Return device triggers for device on `v1` bridge. + + Make sure device is a supported remote model. + Retrieve the hue event object matching device entry. + Generate device trigger list. + """ + if device.model not in REMOTES: + return + + triggers = [] + for trigger, subtype in REMOTES[device.model]: + triggers.append( + { + CONF_DEVICE_ID: device.id, + CONF_DOMAIN: DOMAIN, + CONF_PLATFORM: "device", + CONF_TYPE: trigger, + CONF_SUBTYPE: subtype, + } + ) + + return triggers diff --git a/homeassistant/components/hue/helpers.py b/homeassistant/components/hue/v1/helpers.py similarity index 77% rename from homeassistant/components/hue/helpers.py rename to homeassistant/components/hue/v1/helpers.py index 739e27d3360..d4582f0bb52 100644 --- a/homeassistant/components/hue/helpers.py +++ b/homeassistant/components/hue/v1/helpers.py @@ -1,9 +1,9 @@ """Helper functions for Philips Hue.""" -from homeassistant import config_entries + from homeassistant.helpers.device_registry import async_get_registry as get_dev_reg from homeassistant.helpers.entity_registry import async_get_registry as get_ent_reg -from .const import DOMAIN +from ..const import DOMAIN async def remove_devices(bridge, api_ids, current): @@ -30,14 +30,3 @@ async def remove_devices(bridge, api_ids, current): for item_id in removed_items: del current[item_id] - - -def create_config_flow(hass, host): - """Start a config flow.""" - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"host": host}, - ) - ) diff --git a/homeassistant/components/hue/hue_event.py b/homeassistant/components/hue/v1/hue_event.py similarity index 90% rename from homeassistant/components/hue/hue_event.py rename to homeassistant/components/hue/v1/hue_event.py index 00b6c3e44f2..9074baaaa88 100644 --- a/homeassistant/components/hue/hue_event.py +++ b/homeassistant/components/hue/v1/hue_event.py @@ -1,7 +1,7 @@ """Representation of a Hue remote firing events for button presses.""" import logging -from aiohue.sensors import ( +from aiohue.v1.sensors import ( EVENT_BUTTON, TYPE_ZGP_SWITCH, TYPE_ZLL_ROTARY, @@ -12,11 +12,11 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_EVENT, CONF_ID, CONF_UNIQUE from homeassistant.core import callback from homeassistant.util import dt as dt_util, slugify +from ..const import ATTR_HUE_EVENT from .sensor_device import GenericHueDevice -_LOGGER = logging.getLogger(__name__) +LOGGER = logging.getLogger(__name__) -CONF_HUE_EVENT = "hue_event" CONF_LAST_UPDATED = "last_updated" EVENT_NAME_FORMAT = "{}" @@ -44,11 +44,6 @@ class HueEvent(GenericHueDevice): self.async_update_callback ) ) - self.bridge.reset_jobs.append( - self.bridge.listen_updates( - self.sensor.ITEM_TYPE, self.sensor.id, self.async_update_callback - ) - ) @callback def async_update_callback(self): @@ -90,7 +85,7 @@ class HueEvent(GenericHueDevice): CONF_EVENT: state, CONF_LAST_UPDATED: self.sensor.lastupdated, } - self.bridge.hass.bus.async_fire(CONF_HUE_EVENT, data) + self.bridge.hass.bus.async_fire(ATTR_HUE_EVENT, data) async def async_update_device_registry(self): """Update device registry.""" @@ -102,7 +97,7 @@ class HueEvent(GenericHueDevice): config_entry_id=self.bridge.config_entry.entry_id, **self.device_info ) self.device_registry_id = entry.id - _LOGGER.debug( + LOGGER.debug( "Event registry with entry_id: %s and device_id: %s", self.device_registry_id, self.device_id, diff --git a/homeassistant/components/hue/v1/light.py b/homeassistant/components/hue/v1/light.py new file mode 100644 index 00000000000..9cddb665006 --- /dev/null +++ b/homeassistant/components/hue/v1/light.py @@ -0,0 +1,557 @@ +"""Support for the Philips Hue lights.""" +from __future__ import annotations + +from datetime import timedelta +from functools import partial +import logging +import random + +import aiohue +import async_timeout + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_FLASH, + ATTR_HS_COLOR, + ATTR_TRANSITION, + EFFECT_COLORLOOP, + EFFECT_RANDOM, + FLASH_LONG, + FLASH_SHORT, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + SUPPORT_EFFECT, + SUPPORT_FLASH, + SUPPORT_TRANSITION, + LightEntity, +) +from homeassistant.core import callback +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) +from homeassistant.util import color + +from ..bridge import HueBridge +from ..const import ( + CONF_ALLOW_HUE_GROUPS, + CONF_ALLOW_UNREACHABLE, + DEFAULT_ALLOW_HUE_GROUPS, + DEFAULT_ALLOW_UNREACHABLE, + DOMAIN as HUE_DOMAIN, + GROUP_TYPE_ENTERTAINMENT, + GROUP_TYPE_LIGHT_GROUP, + GROUP_TYPE_LIGHT_SOURCE, + GROUP_TYPE_LUMINAIRE, + GROUP_TYPE_ROOM, + GROUP_TYPE_ZONE, + REQUEST_REFRESH_DELAY, +) +from .helpers import remove_devices + +SCAN_INTERVAL = timedelta(seconds=5) + +LOGGER = logging.getLogger(__name__) + +SUPPORT_HUE_ON_OFF = SUPPORT_FLASH | SUPPORT_TRANSITION +SUPPORT_HUE_DIMMABLE = SUPPORT_HUE_ON_OFF | SUPPORT_BRIGHTNESS +SUPPORT_HUE_COLOR_TEMP = SUPPORT_HUE_DIMMABLE | SUPPORT_COLOR_TEMP +SUPPORT_HUE_COLOR = SUPPORT_HUE_DIMMABLE | SUPPORT_EFFECT | SUPPORT_COLOR +SUPPORT_HUE_EXTENDED = SUPPORT_HUE_COLOR_TEMP | SUPPORT_HUE_COLOR + +SUPPORT_HUE = { + "Extended color light": SUPPORT_HUE_EXTENDED, + "Color light": SUPPORT_HUE_COLOR, + "Dimmable light": SUPPORT_HUE_DIMMABLE, + "On/Off plug-in unit": SUPPORT_HUE_ON_OFF, + "Color temperature light": SUPPORT_HUE_COLOR_TEMP, +} + +ATTR_IS_HUE_GROUP = "is_hue_group" +GAMUT_TYPE_UNAVAILABLE = "None" +# Minimum Hue Bridge API version to support groups +# 1.4.0 introduced extended group info +# 1.12 introduced the state object for groups +# 1.13 introduced "any_on" to group state objects +GROUP_MIN_API_VERSION = (1, 13, 0) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up Hue lights. + + Can only be called when a user accidentally mentions hue platform in their + config. But even in that case it would have been ignored. + """ + + +def create_light(item_class, coordinator, bridge, is_group, rooms, api, item_id): + """Create the light.""" + api_item = api[item_id] + + if is_group: + supported_features = 0 + for light_id in api_item.lights: + if light_id not in bridge.api.lights: + continue + light = bridge.api.lights[light_id] + supported_features |= SUPPORT_HUE.get(light.type, SUPPORT_HUE_EXTENDED) + supported_features = supported_features or SUPPORT_HUE_EXTENDED + else: + supported_features = SUPPORT_HUE.get(api_item.type, SUPPORT_HUE_EXTENDED) + return item_class( + coordinator, bridge, is_group, api_item, supported_features, rooms + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Hue lights from a config entry.""" + bridge: HueBridge = hass.data[HUE_DOMAIN][config_entry.entry_id] + api_version = tuple(int(v) for v in bridge.api.config.apiversion.split(".")) + rooms = {} + + allow_groups = config_entry.options.get( + CONF_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_HUE_GROUPS + ) + supports_groups = api_version >= GROUP_MIN_API_VERSION + if allow_groups and not supports_groups: + LOGGER.warning("Please update your Hue bridge to support groups") + + light_coordinator = DataUpdateCoordinator( + hass, + LOGGER, + name="light", + update_method=partial(async_safe_fetch, bridge, bridge.api.lights.update), + update_interval=SCAN_INTERVAL, + request_refresh_debouncer=Debouncer( + bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True + ), + ) + + # First do a refresh to see if we can reach the hub. + # Otherwise we will declare not ready. + await light_coordinator.async_refresh() + + if not light_coordinator.last_update_success: + raise PlatformNotReady + + if not supports_groups: + update_lights_without_group_support = partial( + async_update_items, + bridge, + bridge.api.lights, + {}, + async_add_entities, + partial(create_light, HueLight, light_coordinator, bridge, False, rooms), + None, + ) + # We add a listener after fetching the data, so manually trigger listener + bridge.reset_jobs.append( + light_coordinator.async_add_listener(update_lights_without_group_support) + ) + return + + group_coordinator = DataUpdateCoordinator( + hass, + LOGGER, + name="group", + update_method=partial(async_safe_fetch, bridge, bridge.api.groups.update), + update_interval=SCAN_INTERVAL, + request_refresh_debouncer=Debouncer( + bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True + ), + ) + + if allow_groups: + update_groups = partial( + async_update_items, + bridge, + bridge.api.groups, + {}, + async_add_entities, + partial(create_light, HueLight, group_coordinator, bridge, True, None), + None, + ) + + bridge.reset_jobs.append(group_coordinator.async_add_listener(update_groups)) + + cancel_update_rooms_listener = None + + @callback + def _async_update_rooms(): + """Update rooms.""" + nonlocal cancel_update_rooms_listener + rooms.clear() + for item_id in bridge.api.groups: + group = bridge.api.groups[item_id] + if group.type not in [GROUP_TYPE_ROOM, GROUP_TYPE_ZONE]: + continue + for light_id in group.lights: + rooms[light_id] = group.name + + # Once we do a rooms update, we cancel the listener + # until the next time lights are added + bridge.reset_jobs.remove(cancel_update_rooms_listener) + cancel_update_rooms_listener() # pylint: disable=not-callable + cancel_update_rooms_listener = None + + @callback + def _setup_rooms_listener(): + nonlocal cancel_update_rooms_listener + if cancel_update_rooms_listener is not None: + # If there are new lights added before _async_update_rooms + # is called we should not add another listener + return + + cancel_update_rooms_listener = group_coordinator.async_add_listener( + _async_update_rooms + ) + bridge.reset_jobs.append(cancel_update_rooms_listener) + + _setup_rooms_listener() + await group_coordinator.async_refresh() + + update_lights_with_group_support = partial( + async_update_items, + bridge, + bridge.api.lights, + {}, + async_add_entities, + partial(create_light, HueLight, light_coordinator, bridge, False, rooms), + _setup_rooms_listener, + ) + # We add a listener after fetching the data, so manually trigger listener + bridge.reset_jobs.append( + light_coordinator.async_add_listener(update_lights_with_group_support) + ) + update_lights_with_group_support() + + +async def async_safe_fetch(bridge, fetch_method): + """Safely fetch data.""" + try: + with async_timeout.timeout(4): + return await bridge.async_request_call(fetch_method) + except aiohue.Unauthorized as err: + await bridge.handle_unauthorized_error() + raise UpdateFailed("Unauthorized") from err + except aiohue.AiohueException as err: + raise UpdateFailed(f"Hue error: {err}") from err + + +@callback +def async_update_items( + bridge, api, current, async_add_entities, create_item, new_items_callback +): + """Update items.""" + new_items = [] + + for item_id in api: + if item_id in current: + continue + + current[item_id] = create_item(api, item_id) + new_items.append(current[item_id]) + + bridge.hass.async_create_task(remove_devices(bridge, api, current)) + + if new_items: + # This is currently used to setup the listener to update rooms + if new_items_callback: + new_items_callback() + async_add_entities(new_items) + + +def hue_brightness_to_hass(value): + """Convert hue brightness 1..254 to hass format 0..255.""" + return min(255, round((value / 254) * 255)) + + +def hass_to_hue_brightness(value): + """Convert hass brightness 0..255 to hue 1..254 scale.""" + return max(1, round((value / 255) * 254)) + + +class HueLight(CoordinatorEntity, LightEntity): + """Representation of a Hue light.""" + + def __init__(self, coordinator, bridge, is_group, light, supported_features, rooms): + """Initialize the light.""" + super().__init__(coordinator) + self.light = light + self.bridge = bridge + self.is_group = is_group + self._supported_features = supported_features + self._rooms = rooms + self.allow_unreachable = self.bridge.config_entry.options.get( + CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE + ) + + if is_group: + self.is_osram = False + self.is_philips = False + self.is_innr = False + self.is_ewelink = False + self.is_livarno = False + self.gamut_typ = GAMUT_TYPE_UNAVAILABLE + self.gamut = None + else: + self.is_osram = light.manufacturername == "OSRAM" + self.is_philips = light.manufacturername == "Philips" + self.is_innr = light.manufacturername == "innr" + self.is_ewelink = light.manufacturername == "eWeLink" + self.is_livarno = light.manufacturername.startswith("_TZ3000_") + self.gamut_typ = self.light.colorgamuttype + self.gamut = self.light.colorgamut + LOGGER.debug("Color gamut of %s: %s", self.name, str(self.gamut)) + if self.light.swupdatestate == "readytoinstall": + err = ( + "Please check for software updates of the %s " + "bulb in the Philips Hue App." + ) + LOGGER.warning(err, self.name) + if self.gamut and not color.check_valid_gamut(self.gamut): + err = "Color gamut of %s: %s, not valid, setting gamut to None." + LOGGER.debug(err, self.name, str(self.gamut)) + self.gamut_typ = GAMUT_TYPE_UNAVAILABLE + self.gamut = None + + @property + def unique_id(self): + """Return the unique ID of this Hue light.""" + unique_id = self.light.uniqueid + if not unique_id and self.is_group: + unique_id = self.light.id + + return unique_id + + @property + def device_id(self): + """Return the ID of this Hue light.""" + return self.unique_id + + @property + def name(self): + """Return the name of the Hue light.""" + return self.light.name + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + if self.is_group: + bri = self.light.action.get("bri") + else: + bri = self.light.state.get("bri") + + if bri is None: + return bri + + return hue_brightness_to_hass(bri) + + @property + def _color_mode(self): + """Return the hue color mode.""" + if self.is_group: + return self.light.action.get("colormode") + return self.light.state.get("colormode") + + @property + def hs_color(self): + """Return the hs color value.""" + mode = self._color_mode + source = self.light.action if self.is_group else self.light.state + + if mode in ("xy", "hs") and "xy" in source: + return color.color_xy_to_hs(*source["xy"], self.gamut) + + return None + + @property + def color_temp(self): + """Return the CT color value.""" + # Don't return color temperature unless in color temperature mode + if self._color_mode != "ct": + return None + + if self.is_group: + return self.light.action.get("ct") + return self.light.state.get("ct") + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + if self.is_group: + return super().min_mireds + + min_mireds = self.light.controlcapabilities.get("ct", {}).get("min") + + # We filter out '0' too, which can be incorrectly reported by 3rd party buls + if not min_mireds: + return super().min_mireds + + return min_mireds + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + if self.is_group: + return super().max_mireds + if self.is_livarno: + return 500 + + max_mireds = self.light.controlcapabilities.get("ct", {}).get("max") + + if not max_mireds: + return super().max_mireds + + return max_mireds + + @property + def is_on(self): + """Return true if device is on.""" + if self.is_group: + return self.light.state["any_on"] + return self.light.state["on"] + + @property + def available(self): + """Return if light is available.""" + return self.coordinator.last_update_success and ( + self.is_group or self.allow_unreachable or self.light.state["reachable"] + ) + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported_features + + @property + def effect(self): + """Return the current effect.""" + return self.light.state.get("effect", None) + + @property + def effect_list(self): + """Return the list of supported effects.""" + if self.is_osram: + return [EFFECT_RANDOM] + return [EFFECT_COLORLOOP, EFFECT_RANDOM] + + @property + def device_info(self) -> DeviceInfo | None: + """Return the device info.""" + if self.light.type in ( + GROUP_TYPE_ENTERTAINMENT, + GROUP_TYPE_LIGHT_GROUP, + GROUP_TYPE_ROOM, + GROUP_TYPE_LUMINAIRE, + GROUP_TYPE_LIGHT_SOURCE, + GROUP_TYPE_ZONE, + ): + return None + + suggested_area = None + if self._rooms and self.light.id in self._rooms: + suggested_area = self._rooms[self.light.id] + + return DeviceInfo( + identifiers={(HUE_DOMAIN, self.device_id)}, + manufacturer=self.light.manufacturername, + # productname added in Hue Bridge API 1.24 + # (published 03/05/2018) + model=self.light.productname or self.light.modelid, + name=self.name, + sw_version=self.light.swversion, + suggested_area=suggested_area, + via_device=(HUE_DOMAIN, self.bridge.api.config.bridgeid), + ) + + async def async_turn_on(self, **kwargs): + """Turn the specified or all lights on.""" + command = {"on": True} + + if ATTR_TRANSITION in kwargs: + command["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10) + + if ATTR_HS_COLOR in kwargs: + if self.is_osram: + command["hue"] = int(kwargs[ATTR_HS_COLOR][0] / 360 * 65535) + command["sat"] = int(kwargs[ATTR_HS_COLOR][1] / 100 * 255) + else: + # Philips hue bulb models respond differently to hue/sat + # requests, so we convert to XY first to ensure a consistent + # color. + xy_color = color.color_hs_to_xy(*kwargs[ATTR_HS_COLOR], self.gamut) + command["xy"] = xy_color + elif ATTR_COLOR_TEMP in kwargs: + temp = kwargs[ATTR_COLOR_TEMP] + command["ct"] = max(self.min_mireds, min(temp, self.max_mireds)) + + if ATTR_BRIGHTNESS in kwargs: + command["bri"] = hass_to_hue_brightness(kwargs[ATTR_BRIGHTNESS]) + + flash = kwargs.get(ATTR_FLASH) + + if flash == FLASH_LONG: + command["alert"] = "lselect" + del command["on"] + elif flash == FLASH_SHORT: + command["alert"] = "select" + del command["on"] + elif not self.is_innr and not self.is_ewelink and not self.is_livarno: + command["alert"] = "none" + + if ATTR_EFFECT in kwargs: + effect = kwargs[ATTR_EFFECT] + if effect == EFFECT_COLORLOOP: + command["effect"] = "colorloop" + elif effect == EFFECT_RANDOM: + command["hue"] = random.randrange(0, 65535) + command["sat"] = random.randrange(150, 254) + else: + command["effect"] = "none" + + if self.is_group: + await self.bridge.async_request_call(self.light.set_action, **command) + else: + await self.bridge.async_request_call(self.light.set_state, **command) + + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs): + """Turn the specified or all lights off.""" + command = {"on": False} + + if ATTR_TRANSITION in kwargs: + command["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10) + + flash = kwargs.get(ATTR_FLASH) + + if flash == FLASH_LONG: + command["alert"] = "lselect" + del command["on"] + elif flash == FLASH_SHORT: + command["alert"] = "select" + del command["on"] + elif not self.is_innr and not self.is_livarno: + command["alert"] = "none" + + if self.is_group: + await self.bridge.async_request_call(self.light.set_action, **command) + else: + await self.bridge.async_request_call(self.light.set_state, **command) + + await self.coordinator.async_request_refresh() + + @property + def extra_state_attributes(self): + """Return the device state attributes.""" + if not self.is_group: + return {} + return {ATTR_IS_HUE_GROUP: self.is_group} diff --git a/homeassistant/components/hue/v1/sensor.py b/homeassistant/components/hue/v1/sensor.py new file mode 100644 index 00000000000..df12fe84274 --- /dev/null +++ b/homeassistant/components/hue/v1/sensor.py @@ -0,0 +1,135 @@ +"""Hue sensor entities.""" +from aiohue.v1.sensors import ( + TYPE_ZLL_LIGHTLEVEL, + TYPE_ZLL_ROTARY, + TYPE_ZLL_SWITCH, + TYPE_ZLL_TEMPERATURE, +) + +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + LIGHT_LUX, + PERCENTAGE, + TEMP_CELSIUS, +) + +from ..const import DOMAIN as HUE_DOMAIN +from .sensor_base import SENSOR_CONFIG_MAP, GenericHueSensor, GenericZLLSensor + +LIGHT_LEVEL_NAME_FORMAT = "{} light level" +REMOTE_NAME_FORMAT = "{} battery level" +TEMPERATURE_NAME_FORMAT = "{} temperature" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer sensor setup to the shared sensor module.""" + bridge = hass.data[HUE_DOMAIN][config_entry.entry_id] + + if not bridge.sensor_manager: + return + + await bridge.sensor_manager.async_register_component("sensor", async_add_entities) + + +class GenericHueGaugeSensorEntity(GenericZLLSensor, SensorEntity): + """Parent class for all 'gauge' Hue device sensors.""" + + +class HueLightLevel(GenericHueGaugeSensorEntity): + """The light level sensor entity for a Hue motion sensor device.""" + + _attr_device_class = DEVICE_CLASS_ILLUMINANCE + _attr_native_unit_of_measurement = LIGHT_LUX + + @property + def native_value(self): + """Return the state of the device.""" + if self.sensor.lightlevel is None: + return None + + # https://developers.meethue.com/develop/hue-api/supported-devices/#clip_zll_lightlevel + # Light level in 10000 log10 (lux) +1 measured by sensor. Logarithm + # scale used because the human eye adjusts to light levels and small + # changes at low lux levels are more noticeable than at high lux + # levels. + return round(float(10 ** ((self.sensor.lightlevel - 1) / 10000)), 2) + + @property + def extra_state_attributes(self): + """Return the device state attributes.""" + attributes = super().extra_state_attributes + attributes.update( + { + "lightlevel": self.sensor.lightlevel, + "daylight": self.sensor.daylight, + "dark": self.sensor.dark, + "threshold_dark": self.sensor.tholddark, + "threshold_offset": self.sensor.tholdoffset, + } + ) + return attributes + + +class HueTemperature(GenericHueGaugeSensorEntity): + """The temperature sensor entity for a Hue motion sensor device.""" + + _attr_device_class = DEVICE_CLASS_TEMPERATURE + _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_native_unit_of_measurement = TEMP_CELSIUS + + @property + def native_value(self): + """Return the state of the device.""" + if self.sensor.temperature is None: + return None + + return self.sensor.temperature / 100 + + +class HueBattery(GenericHueSensor, SensorEntity): + """Battery class for when a batt-powered device is only represented as an event.""" + + _attr_device_class = DEVICE_CLASS_BATTERY + _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_native_unit_of_measurement = PERCENTAGE + _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return f"{self.sensor.uniqueid}-battery" + + @property + def native_value(self): + """Return the state of the battery.""" + return self.sensor.battery + + +SENSOR_CONFIG_MAP.update( + { + TYPE_ZLL_LIGHTLEVEL: { + "platform": "sensor", + "name_format": LIGHT_LEVEL_NAME_FORMAT, + "class": HueLightLevel, + }, + TYPE_ZLL_TEMPERATURE: { + "platform": "sensor", + "name_format": TEMPERATURE_NAME_FORMAT, + "class": HueTemperature, + }, + TYPE_ZLL_SWITCH: { + "platform": "sensor", + "name_format": REMOTE_NAME_FORMAT, + "class": HueBattery, + }, + TYPE_ZLL_ROTARY: { + "platform": "sensor", + "name_format": REMOTE_NAME_FORMAT, + "class": HueBattery, + }, + } +) diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/v1/sensor_base.py similarity index 94% rename from homeassistant/components/hue/sensor_base.py rename to homeassistant/components/hue/v1/sensor_base.py index 957565c54e9..142941a1859 100644 --- a/homeassistant/components/hue/sensor_base.py +++ b/homeassistant/components/hue/v1/sensor_base.py @@ -6,7 +6,7 @@ import logging from typing import Any from aiohue import AiohueException, Unauthorized -from aiohue.sensors import TYPE_ZLL_PRESENCE +from aiohue.v1.sensors import TYPE_ZLL_PRESENCE import async_timeout from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT @@ -14,13 +14,13 @@ from homeassistant.core import callback from homeassistant.helpers import debounce, entity from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import REQUEST_REFRESH_DELAY +from ..const import REQUEST_REFRESH_DELAY from .helpers import remove_devices from .hue_event import EVENT_CONFIG_MAP from .sensor_device import GenericHueDevice SENSOR_CONFIG_MAP: dict[str, Any] = {} -_LOGGER = logging.getLogger(__name__) +LOGGER = logging.getLogger(__name__) def _device_id(aiohue_sensor): @@ -49,19 +49,19 @@ class SensorManager: self._enabled_platforms = ("binary_sensor", "sensor") self.coordinator = DataUpdateCoordinator( bridge.hass, - _LOGGER, + LOGGER, name="sensor", update_method=self.async_update_data, update_interval=self.SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( - bridge.hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True + bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True ), ) async def async_update_data(self): """Update sensor data.""" try: - with async_timeout.timeout(4): + async with async_timeout.timeout(4): return await self.bridge.async_request_call( self.bridge.api.sensors.update ) @@ -76,7 +76,7 @@ class SensorManager: self._component_add_entities[platform] = async_add_entities if len(self._component_add_entities) < len(self._enabled_platforms): - _LOGGER.debug("Aborting start with %s, waiting for the rest", platform) + LOGGER.debug("Aborting start with %s, waiting for the rest", platform) return # We have all components available, start the updating. @@ -173,7 +173,7 @@ class GenericHueSensor(GenericHueDevice, entity.Entity): def available(self): """Return if sensor is available.""" return self.bridge.sensor_manager.coordinator.last_update_success and ( - self.bridge.allow_unreachable + self.allow_unreachable # remotes like Hue Tap (ZGPSwitchSensor) have no _reachability_ or self.sensor.config.get("reachable", True) ) diff --git a/homeassistant/components/hue/sensor_device.py b/homeassistant/components/hue/v1/sensor_device.py similarity index 83% rename from homeassistant/components/hue/sensor_device.py rename to homeassistant/components/hue/v1/sensor_device.py index 92c586ff8e0..176b5f118b2 100644 --- a/homeassistant/components/hue/sensor_device.py +++ b/homeassistant/components/hue/v1/sensor_device.py @@ -1,7 +1,11 @@ """Support for the Philips Hue sensor devices.""" from homeassistant.helpers import entity -from .const import DOMAIN as HUE_DOMAIN +from ..const import ( + CONF_ALLOW_UNREACHABLE, + DEFAULT_ALLOW_UNREACHABLE, + DOMAIN as HUE_DOMAIN, +) class GenericHueDevice(entity.Entity): @@ -13,6 +17,9 @@ class GenericHueDevice(entity.Entity): self._name = name self._primary_sensor = primary_sensor self.bridge = bridge + self.allow_unreachable = bridge.config_entry.options.get( + CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE + ) @property def primary_sensor(self): @@ -53,12 +60,3 @@ class GenericHueDevice(entity.Entity): sw_version=self.primary_sensor.swversion, via_device=(HUE_DOMAIN, self.bridge.api.config.bridgeid), ) - - async def async_added_to_hass(self) -> None: - """Handle entity being added to Home Assistant.""" - self.async_on_remove( - self.bridge.listen_updates( - self.sensor.ITEM_TYPE, self.sensor.id, self.async_write_ha_state - ) - ) - await super().async_added_to_hass() diff --git a/homeassistant/components/hue/v2/__init__.py b/homeassistant/components/hue/v2/__init__.py new file mode 100644 index 00000000000..ebcf4873dc7 --- /dev/null +++ b/homeassistant/components/hue/v2/__init__.py @@ -0,0 +1 @@ +"""Hue V2 API specific platform implementation.""" diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py new file mode 100644 index 00000000000..f655e203755 --- /dev/null +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -0,0 +1,110 @@ +"""Support for Hue binary sensors.""" +from __future__ import annotations + +from typing import Any, Union + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.config import EntertainmentConfigurationController +from aiohue.v2.controllers.events import EventType +from aiohue.v2.controllers.sensors import MotionController +from aiohue.v2.models.entertainment import ( + EntertainmentConfiguration, + EntertainmentStatus, +) +from aiohue.v2.models.motion import Motion + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOTION, + DEVICE_CLASS_RUNNING, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from ..bridge import HueBridge +from ..const import DOMAIN +from .entity import HueBaseEntity + +SensorType = Union[Motion, EntertainmentConfiguration] +ControllerType = Union[MotionController, EntertainmentConfigurationController] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Hue Sensors from Config Entry.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + api: HueBridgeV2 = bridge.api + + @callback + def register_items(controller: ControllerType, sensor_class: SensorType): + @callback + def async_add_sensor(event_type: EventType, resource: SensorType) -> None: + """Add Hue Binary Sensor.""" + async_add_entities([sensor_class(bridge, controller, resource)]) + + # add all current items in controller + for sensor in controller: + async_add_sensor(EventType.RESOURCE_ADDED, sensor) + + # register listener for new sensors + config_entry.async_on_unload( + controller.subscribe( + async_add_sensor, event_filter=EventType.RESOURCE_ADDED + ) + ) + + # setup for each binary-sensor-type hue resource + register_items(api.sensors.motion, HueMotionSensor) + register_items(api.config.entertainment_configuration, HueEntertainmentActiveSensor) + + +class HueBinarySensorBase(HueBaseEntity, BinarySensorEntity): + """Representation of a Hue binary_sensor.""" + + def __init__( + self, + bridge: HueBridge, + controller: ControllerType, + resource: SensorType, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(bridge, controller, resource) + self.resource = resource + self.controller = controller + + +class HueMotionSensor(HueBinarySensorBase): + """Representation of a Hue Motion sensor.""" + + _attr_device_class = DEVICE_CLASS_MOTION + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.resource.motion.motion + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the optional state attributes.""" + return {"motion_valid": self.resource.motion.motion_valid} + + +class HueEntertainmentActiveSensor(HueBinarySensorBase): + """Representation of a Hue Entertainment Configuration as binary sensor.""" + + _attr_device_class = DEVICE_CLASS_RUNNING + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.resource.status == EntertainmentStatus.ACTIVE + + @property + def name(self) -> str: + """Return sensor name.""" + type_title = self.resource.type.value.replace("_", " ").title() + return f"{self.resource.name}: {type_title}" diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py new file mode 100644 index 00000000000..64bdcc7a4f2 --- /dev/null +++ b/homeassistant/components/hue/v2/device.py @@ -0,0 +1,87 @@ +"""Handles Hue resource of type `device` mapping to Home Assistant device.""" +from typing import TYPE_CHECKING + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.models.device import Device, DeviceArchetypes + +from homeassistant.const import ( + ATTR_CONNECTIONS, + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SUGGESTED_AREA, + ATTR_SW_VERSION, + ATTR_VIA_DEVICE, +) +from homeassistant.core import callback +from homeassistant.helpers import device_registry + +from ..const import DOMAIN + +if TYPE_CHECKING: + from ..bridge import HueBridge + + +async def async_setup_devices(bridge: "HueBridge"): + """Manage setup of devices from Hue devices.""" + entry = bridge.config_entry + hass = bridge.hass + api: HueBridgeV2 = bridge.api # to satisfy typing + dev_reg = device_registry.async_get(hass) + dev_controller = api.devices + + @callback + def add_device(hue_device: Device) -> device_registry.DeviceEntry: + """Register a Hue device in device registry.""" + model = f"{hue_device.product_data.product_name} ({hue_device.product_data.model_id})" + params = { + ATTR_IDENTIFIERS: {(DOMAIN, hue_device.id)}, + ATTR_SW_VERSION: hue_device.product_data.software_version, + ATTR_NAME: hue_device.metadata.name, + ATTR_MODEL: model, + ATTR_MANUFACTURER: hue_device.product_data.manufacturer_name, + } + if room := dev_controller.get_room(hue_device.id): + params[ATTR_SUGGESTED_AREA] = room.metadata.name + if hue_device.metadata.archetype == DeviceArchetypes.BRIDGE_V2: + params[ATTR_IDENTIFIERS].add((DOMAIN, api.config.bridge_id)) + else: + params[ATTR_VIA_DEVICE] = (DOMAIN, api.config.bridge_device.id) + zigbee = dev_controller.get_zigbee_connectivity(hue_device.id) + if zigbee and zigbee.mac_address: + params[ATTR_CONNECTIONS] = { + (device_registry.CONNECTION_NETWORK_MAC, zigbee.mac_address) + } + + return dev_reg.async_get_or_create(config_entry_id=entry.entry_id, **params) + + @callback + def remove_device(hue_device_id: str) -> None: + """Remove device from registry.""" + if device := dev_reg.async_get_device({(DOMAIN, hue_device_id)}): + # note: removal of any underlying entities is handled by core + dev_reg.async_remove_device(device.id) + + @callback + def handle_device_event(evt_type: EventType, hue_device: Device) -> None: + """Handle event from Hue devices controller.""" + if evt_type == EventType.RESOURCE_DELETED: + remove_device(hue_device.id) + else: + # updates to existing device will also be handled by this call + add_device(hue_device) + + # create/update all current devices found in controller + known_devices = [add_device(hue_device) for hue_device in dev_controller] + + # Check for nodes that no longer exist and remove them + for device in device_registry.async_entries_for_config_entry( + dev_reg, entry.entry_id + ): + if device not in known_devices: + dev_reg.async_remove_device(device.id) + + # add listener for updates on Hue devices controller + entry.async_on_unload(dev_controller.subscribe(handle_device_event)) diff --git a/homeassistant/components/hue/v2/device_trigger.py b/homeassistant/components/hue/v2/device_trigger.py new file mode 100644 index 00000000000..74863a1897e --- /dev/null +++ b/homeassistant/components/hue/v2/device_trigger.py @@ -0,0 +1,131 @@ +"""Provides device automations for Philips Hue events.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from aiohue.v2.models.button import ButtonEvent +from aiohue.v2.models.resource import ResourceTypes +import voluptuous as vol + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_PLATFORM, + CONF_TYPE, + CONF_UNIQUE_ID, +) +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.typing import ConfigType + +from ..const import ATTR_HUE_EVENT, CONF_SUBTYPE, DOMAIN + +if TYPE_CHECKING: + from aiohue.v2 import HueBridgeV2 + + from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, + ) + + from ..bridge import HueBridge + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): str, + vol.Required(CONF_SUBTYPE): int, + vol.Optional(CONF_UNIQUE_ID): str, + } +) + +DEFAULT_BUTTON_EVENT_TYPES = ( + # all except `DOUBLE_SHORT_RELEASE` + ButtonEvent.INITIAL_PRESS, + ButtonEvent.REPEAT, + ButtonEvent.SHORT_RELEASE, + ButtonEvent.LONG_RELEASE, +) + +DEVICE_SPECIFIC_EVENT_TYPES = { + # device specific overrides of specific supported button events + "Hue tap switch": (ButtonEvent.INITIAL_PRESS,), +} + + +async def async_validate_trigger_config( + bridge: "HueBridge", + device_entry: DeviceEntry, + config: ConfigType, +): + """Validate config.""" + config = TRIGGER_SCHEMA(config) + return config + + +async def async_attach_trigger( + bridge: "HueBridge", + device_entry: DeviceEntry, + config: ConfigType, + action: "AutomationActionType", + automation_info: "AutomationTriggerInfo", +) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + hass = bridge.hass + event_config = event_trigger.TRIGGER_SCHEMA( + { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: ATTR_HUE_EVENT, + event_trigger.CONF_EVENT_DATA: { + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + CONF_TYPE: config[CONF_TYPE], + CONF_SUBTYPE: config[CONF_SUBTYPE], + }, + } + ) + return await event_trigger.async_attach_trigger( + hass, event_config, action, automation_info, platform_type="device" + ) + + +async def async_get_triggers(bridge: "HueBridge", device_entry: DeviceEntry): + """Return device triggers for device on `v2` bridge.""" + api: HueBridgeV2 = bridge.api + + # Get Hue device id from device identifier + hue_dev_id = get_hue_device_id(device_entry) + # extract triggers from all button resources of this Hue device + triggers = [] + model_id = api.devices[hue_dev_id].product_data.product_name + for resource in api.devices.get_sensors(hue_dev_id): + if resource.type != ResourceTypes.BUTTON: + continue + for event_type in DEVICE_SPECIFIC_EVENT_TYPES.get( + model_id, DEFAULT_BUTTON_EVENT_TYPES + ): + triggers.append( + { + CONF_DEVICE_ID: device_entry.id, + CONF_DOMAIN: DOMAIN, + CONF_PLATFORM: "device", + CONF_TYPE: event_type.value, + CONF_SUBTYPE: resource.metadata.control_id, + CONF_UNIQUE_ID: resource.id, + } + ) + return triggers + + +@callback +def get_hue_device_id(device_entry: DeviceEntry) -> str | None: + """Get Hue device id from device entry.""" + return next( + ( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + and ":" not in identifier[1] # filter out v1 mac id + ), + None, + ) diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py new file mode 100644 index 00000000000..6dbc959fd9c --- /dev/null +++ b/homeassistant/components/hue/v2/entity.py @@ -0,0 +1,128 @@ +"""Generic Hue Entity Model.""" +from __future__ import annotations + +from aiohue.v2.controllers.base import BaseResourcesController +from aiohue.v2.controllers.events import EventType +from aiohue.v2.models.clip import CLIPResource +from aiohue.v2.models.connectivity import ConnectivityServiceStatus +from aiohue.v2.models.resource import ResourceTypes + +from homeassistant.core import callback +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry + +from ..bridge import HueBridge +from ..const import DOMAIN + +RESOURCE_TYPE_NAMES = { + # a simple mapping of hue resource type to Hass name + ResourceTypes.LIGHT_LEVEL: "Illuminance", + ResourceTypes.DEVICE_POWER: "Battery", +} + + +class HueBaseEntity(Entity): + """Generic Entity Class for a Hue resource.""" + + _attr_should_poll = False + + def __init__( + self, + bridge: HueBridge, + controller: BaseResourcesController, + resource: CLIPResource, + ) -> None: + """Initialize a generic Hue resource entity.""" + self.bridge = bridge + self.controller = controller + self.resource = resource + self.device = controller.get_device(resource.id) + self.logger = bridge.logger.getChild(resource.type.value) + + # Entity class attributes + self._attr_unique_id = resource.id + # device is precreated in main handler + # this attaches the entity to the precreated device + if self.device is not None: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.device.id)}, + ) + + @property + def name(self) -> str: + """Return name for the entity.""" + if self.device is None: + # this is just a guard + # creating a pretty name for device-less entities (e.g. groups/scenes) + # should be handled in the platform instead + return self.resource.type.value + # if resource is a light, use the name from metadata + if self.resource.type == ResourceTypes.LIGHT: + return self.resource.name + # for sensors etc, use devicename + pretty name of type + dev_name = self.device.metadata.name + type_title = RESOURCE_TYPE_NAMES.get( + self.resource.type, self.resource.type.value.replace("_", " ").title() + ) + return f"{dev_name} {type_title}" + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + # Add value_changed callbacks. + self.async_on_remove( + self.controller.subscribe( + self._handle_event, + self.resource.id, + (EventType.RESOURCE_UPDATED, EventType.RESOURCE_DELETED), + ) + ) + # also subscribe to device update event to catch devicer changes (e.g. name) + if self.device is None: + return + self.async_on_remove( + self.bridge.api.devices.subscribe( + self._handle_event, + self.device.id, + EventType.RESOURCE_UPDATED, + ) + ) + # subscribe to zigbee_connectivity to catch availability changes + if zigbee := self.bridge.api.devices.get_zigbee_connectivity(self.device.id): + self.bridge.api.sensors.zigbee_connectivity.subscribe( + self._handle_event, + zigbee.id, + EventType.RESOURCE_UPDATED, + ) + + @property + def available(self) -> bool: + """Return entity availability.""" + if self.device is None: + # devices without a device attached should be always available + return True + if self.resource.type == ResourceTypes.ZIGBEE_CONNECTIVITY: + # the zigbee connectivity sensor itself should be always available + return True + if zigbee := self.bridge.api.devices.get_zigbee_connectivity(self.device.id): + # all device-attached entities get availability from the zigbee connectivity + return zigbee.status == ConnectivityServiceStatus.CONNECTED + return True + + @callback + def on_update(self) -> None: + """Call on update event.""" + # used in subclasses + + @callback + def _handle_event(self, event_type: EventType, resource: CLIPResource) -> None: + """Handle status event for this resource (or it's parent).""" + if event_type == EventType.RESOURCE_DELETED and resource.id == self.resource.id: + self.logger.debug("Received delete for %s", self.entity_id) + # non-device bound entities like groups and scenes need to be removed here + # all others will be be removed by device setup in case of device removal + ent_reg = async_get_entity_registry(self.hass) + ent_reg.async_remove(self.entity_id) + else: + self.logger.debug("Received status update for %s", self.entity_id) + self.on_update() + self.async_write_ha_state() diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py new file mode 100644 index 00000000000..312fef6629f --- /dev/null +++ b/homeassistant/components/hue/v2/group.py @@ -0,0 +1,250 @@ +"""Support for Hue groups (room/zone).""" +from __future__ import annotations + +from typing import Any + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.controllers.groups import GroupedLight, Room, Zone + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_TRANSITION, + ATTR_XY_COLOR, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_ONOFF, + COLOR_MODE_XY, + SUPPORT_TRANSITION, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from ..bridge import HueBridge +from ..const import DOMAIN +from .entity import HueBaseEntity + +ALLOWED_ERRORS = [ + "device (groupedLight) has communication issues, command (on) may not have effect", + 'device (groupedLight) is "soft off", command (on) may not have effect', + "device (light) has communication issues, command (on) may not have effect", + 'device (light) is "soft off", command (on) may not have effect', +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Hue groups on light platform.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + api: HueBridgeV2 = bridge.api + + # to prevent race conditions (groupedlight is created before zone/room) + # we create groupedlights from the room/zone and actually use the + # underlying grouped_light resource for control + + @callback + def async_add_light(event_type: EventType, resource: Room | Zone) -> None: + """Add Grouped Light for Hue Room/Zone.""" + if grouped_light_id := resource.grouped_light: + grouped_light = api.groups.grouped_light[grouped_light_id] + light = GroupedHueLight(bridge, grouped_light, resource) + async_add_entities([light]) + + # add current items + for item in api.groups.room.items + api.groups.zone.items: + async_add_light(EventType.RESOURCE_ADDED, item) + + # register listener for new zones/rooms + config_entry.async_on_unload( + api.groups.room.subscribe( + async_add_light, event_filter=EventType.RESOURCE_ADDED + ) + ) + config_entry.async_on_unload( + api.groups.zone.subscribe( + async_add_light, event_filter=EventType.RESOURCE_ADDED + ) + ) + + +class GroupedHueLight(HueBaseEntity, LightEntity): + """Representation of a Grouped Hue light.""" + + # Entities for Hue groups are disabled by default + _attr_entity_registry_enabled_default = False + _attr_icon = "mdi:lightbulb-group" + + def __init__( + self, bridge: HueBridge, resource: GroupedLight, group: Room | Zone + ) -> None: + """Initialize the light.""" + controller = bridge.api.groups.grouped_light + super().__init__(bridge, controller, resource) + self.resource = resource + self.group = group + self.controller = controller + self.api: HueBridgeV2 = bridge.api + self._attr_supported_features |= SUPPORT_TRANSITION + + self._update_values() + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + await super().async_added_to_hass() + + # subscribe to group updates + self.async_on_remove( + self.api.groups.subscribe(self._handle_event, self.group.id) + ) + # We need to watch the underlying lights too + # if we want feedback about color/brightness changes + if self._attr_supported_color_modes: + light_ids = tuple( + x.id for x in self.controller.get_lights(self.resource.id) + ) + self.async_on_remove( + self.api.lights.subscribe(self._handle_event, light_ids) + ) + + @property + def name(self) -> str: + """Return name of room/zone for this grouped light.""" + return self.group.metadata.name + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self.resource.on.on + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the optional state attributes.""" + scenes = { + x.metadata.name for x in self.api.scenes if x.group.rid == self.group.id + } + lights = {x.metadata.name for x in self.controller.get_lights(self.resource.id)} + return { + "is_hue_group": True, + "hue_scenes": scenes, + "hue_type": self.group.type.value, + "lights": lights, + } + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + transition = kwargs.get(ATTR_TRANSITION) + xy_color = kwargs.get(ATTR_XY_COLOR) + color_temp = kwargs.get(ATTR_COLOR_TEMP) + brightness = kwargs.get(ATTR_BRIGHTNESS) + if brightness is not None: + # Hue uses a range of [0, 100] to control brightness. + brightness = float((brightness / 255) * 100) + if transition is not None: + # hue transition duration is in steps of 100 ms + transition = int(transition * 100) + + # NOTE: a grouped_light can only handle turn on/off + # To set other features, you'll have to control the attached lights + if ( + brightness is None + and xy_color is None + and color_temp is None + and transition is None + ): + await self.bridge.async_request_call( + self.controller.set_state, + id=self.resource.id, + on=True, + allowed_errors=ALLOWED_ERRORS, + ) + return + + # redirect all other feature commands to underlying lights + # note that this silently ignores params sent to light that are not supported + for light in self.controller.get_lights(self.resource.id): + await self.bridge.async_request_call( + self.api.lights.set_state, + light.id, + on=True, + brightness=brightness if light.supports_dimming else None, + color_xy=xy_color if light.supports_color else None, + color_temp=color_temp if light.supports_color_temperature else None, + transition_time=transition, + allowed_errors=ALLOWED_ERRORS, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self.bridge.async_request_call( + self.controller.set_state, + id=self.resource.id, + on=False, + allowed_errors=ALLOWED_ERRORS, + ) + + @callback + def on_update(self) -> None: + """Call on update event.""" + self._update_values() + + @callback + def _update_values(self) -> None: + """Set base values from underlying lights of a group.""" + supported_color_modes = set() + lights_with_color_support = 0 + lights_with_color_temp_support = 0 + lights_with_dimming_support = 0 + total_brightness = 0 + all_lights = self.controller.get_lights(self.resource.id) + lights_in_colortemp_mode = 0 + # loop through all lights to find capabilities + for light in all_lights: + if color_temp := light.color_temperature: + lights_with_color_temp_support += 1 + # we assume mired values from the first capable light + self._attr_color_temp = color_temp.mirek + self._attr_max_mireds = color_temp.mirek_schema.mirek_maximum + self._attr_min_mireds = color_temp.mirek_schema.mirek_minimum + if color_temp.mirek is not None and color_temp.mirek_valid: + lights_in_colortemp_mode += 1 + if color := light.color: + lights_with_color_support += 1 + # we assume xy values from the first capable light + self._attr_xy_color = (color.xy.x, color.xy.y) + if dimming := light.dimming: + lights_with_dimming_support += 1 + total_brightness += dimming.brightness + # this is a bit hacky because light groups may contain lights + # of different capabilities. We set a colormode as supported + # if any of the lights support it + # this means that the state is derived from only some of the lights + # and will never be 100% accurate but it will be close + if lights_with_color_support > 0: + supported_color_modes.add(COLOR_MODE_XY) + if lights_with_color_temp_support > 0: + supported_color_modes.add(COLOR_MODE_COLOR_TEMP) + if lights_with_dimming_support > 0: + if len(supported_color_modes) == 0: + # only add color mode brightness if no color variants + supported_color_modes.add(COLOR_MODE_BRIGHTNESS) + self._attr_brightness = round( + ((total_brightness / lights_with_dimming_support) / 100) * 255 + ) + else: + supported_color_modes.add(COLOR_MODE_ONOFF) + self._attr_supported_color_modes = supported_color_modes + # pick a winner for the current colormode + if lights_in_colortemp_mode == lights_with_color_temp_support: + self._attr_color_mode = COLOR_MODE_COLOR_TEMP + elif lights_with_color_support > 0: + self._attr_color_mode = COLOR_MODE_XY + elif lights_with_dimming_support > 0: + self._attr_color_mode = COLOR_MODE_BRIGHTNESS + else: + self._attr_color_mode = COLOR_MODE_ONOFF diff --git a/homeassistant/components/hue/v2/hue_event.py b/homeassistant/components/hue/v2/hue_event.py new file mode 100644 index 00000000000..86dabc26660 --- /dev/null +++ b/homeassistant/components/hue/v2/hue_event.py @@ -0,0 +1,57 @@ +"""Handle forward of events transmitted by Hue devices to HASS.""" +import logging +from typing import TYPE_CHECKING + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.models.button import Button + +from homeassistant.const import CONF_DEVICE_ID, CONF_ID, CONF_TYPE, CONF_UNIQUE_ID +from homeassistant.core import callback +from homeassistant.helpers import device_registry +from homeassistant.util import slugify + +from ..const import ATTR_HUE_EVENT, CONF_SUBTYPE, DOMAIN as DOMAIN + +CONF_CONTROL_ID = "control_id" + +if TYPE_CHECKING: + from ..bridge import HueBridge + +LOGGER = logging.getLogger(__name__) + + +async def async_setup_hue_events(bridge: "HueBridge"): + """Manage listeners for stateless Hue sensors that emit events.""" + hass = bridge.hass + api: HueBridgeV2 = bridge.api # to satisfy typing + conf_entry = bridge.config_entry + dev_reg = device_registry.async_get(hass) + + # at this time the `button` resource is the only source of hue events + btn_controller = api.sensors.button + + @callback + def handle_button_event(evt_type: EventType, hue_resource: Button) -> None: + """Handle event from Hue devices controller.""" + LOGGER.debug("Received button event: %s", hue_resource) + hue_device = btn_controller.get_device(hue_resource.id) + device = dev_reg.async_get_device({(DOMAIN, hue_device.id)}) + + # Fire event + data = { + # send slugified entity name as id = backwards compatibility with previous version + CONF_ID: slugify(f"{hue_device.metadata.name}: Button"), + CONF_DEVICE_ID: device.id, # type: ignore + CONF_UNIQUE_ID: hue_resource.id, + CONF_TYPE: hue_resource.button.last_event.value, + CONF_SUBTYPE: hue_resource.metadata.control_id, + } + hass.bus.async_fire(ATTR_HUE_EVENT, data) + + # add listener for updates from `button` resource + conf_entry.async_on_unload( + btn_controller.subscribe( + handle_button_event, event_filter=EventType.RESOURCE_UPDATED + ) + ) diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py new file mode 100644 index 00000000000..42972f2242c --- /dev/null +++ b/homeassistant/components/hue/v2/light.py @@ -0,0 +1,187 @@ +"""Support for Hue lights.""" +from __future__ import annotations + +from typing import Any + +from aiohue import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.controllers.lights import LightsController +from aiohue.v2.models.light import Light + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_TRANSITION, + ATTR_XY_COLOR, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_ONOFF, + COLOR_MODE_XY, + SUPPORT_TRANSITION, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from ..bridge import HueBridge +from ..const import DOMAIN +from .entity import HueBaseEntity + +ALLOWED_ERRORS = [ + "device (light) has communication issues, command (on) may not have effect", + 'device (light) is "soft off", command (on) may not have effect', +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Hue Light from Config Entry.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + api: HueBridgeV2 = bridge.api + controller: LightsController = api.lights + + @callback + def async_add_light(event_type: EventType, resource: Light) -> None: + """Add Hue Light.""" + light = HueLight(bridge, controller, resource) + async_add_entities([light]) + + # add all current items in controller + for light in controller: + async_add_light(EventType.RESOURCE_ADDED, resource=light) + + # register listener for new lights + config_entry.async_on_unload( + controller.subscribe(async_add_light, event_filter=EventType.RESOURCE_ADDED) + ) + + +class HueLight(HueBaseEntity, LightEntity): + """Representation of a Hue light.""" + + def __init__( + self, bridge: HueBridge, controller: LightsController, resource: Light + ) -> None: + """Initialize the light.""" + super().__init__(bridge, controller, resource) + self.resource = resource + self.controller = controller + self._supported_color_modes = set() + if self.resource.supports_color: + self._supported_color_modes.add(COLOR_MODE_XY) + if self.resource.supports_color_temperature: + self._supported_color_modes.add(COLOR_MODE_COLOR_TEMP) + if self.resource.supports_dimming: + if len(self._supported_color_modes) == 0: + # only add color mode brightness if no color variants + self._supported_color_modes.add(COLOR_MODE_BRIGHTNESS) + # support transition if brightness control + self._attr_supported_features |= SUPPORT_TRANSITION + + @property + def brightness(self) -> int | None: + """Return the brightness of this light between 0..255.""" + if dimming := self.resource.dimming: + # Hue uses a range of [0, 100] to control brightness. + return round((dimming.brightness / 100) * 255) + return None + + @property + def color_mode(self) -> str: + """Return the current color mode of the light.""" + if color_temp := self.resource.color_temperature: + if color_temp.mirek_valid and color_temp.mirek is not None: + return COLOR_MODE_COLOR_TEMP + if self.resource.supports_color: + return COLOR_MODE_XY + if self.resource.supports_dimming: + return COLOR_MODE_BRIGHTNESS + return COLOR_MODE_ONOFF + + @property + def is_on(self) -> bool: + """Return true if device is on (brightness above 0).""" + return self.resource.on.on + + @property + def xy_color(self) -> tuple[float, float] | None: + """Return the xy color.""" + if color := self.resource.color: + return (color.xy.x, color.xy.y) + return None + + @property + def color_temp(self) -> int: + """Return the color temperature.""" + if color_temp := self.resource.color_temperature: + return color_temp.mirek + return 0 + + @property + def min_mireds(self) -> int: + """Return the coldest color_temp that this light supports.""" + if color_temp := self.resource.color_temperature: + return color_temp.mirek_schema.mirek_minimum + return 0 + + @property + def max_mireds(self) -> int: + """Return the warmest color_temp that this light supports.""" + if color_temp := self.resource.color_temperature: + return color_temp.mirek_schema.mirek_maximum + return 0 + + @property + def supported_color_modes(self) -> set | None: + """Flag supported features.""" + return self._supported_color_modes + + @property + def extra_state_attributes(self) -> dict[str, str] | None: + """Return the optional state attributes.""" + return { + "mode": self.resource.mode.value, + "dynamics": self.resource.dynamics.status.value, + } + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + transition = kwargs.get(ATTR_TRANSITION) + xy_color = kwargs.get(ATTR_XY_COLOR) + color_temp = kwargs.get(ATTR_COLOR_TEMP) + brightness = kwargs.get(ATTR_BRIGHTNESS) + if brightness is not None: + # Hue uses a range of [0, 100] to control brightness. + brightness = float((brightness / 255) * 100) + if transition is not None: + # hue transition duration is in steps of 100 ms + transition = int(transition * 100) + + await self.bridge.async_request_call( + self.controller.set_state, + id=self.resource.id, + on=True, + brightness=brightness, + color_xy=xy_color, + color_temp=color_temp, + transition_time=transition, + allowed_errors=ALLOWED_ERRORS, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + transition = kwargs.get(ATTR_TRANSITION) + if transition is not None: + # hue transition duration is in steps of 100 ms + transition = int(transition * 100) + await self.bridge.async_request_call( + self.controller.set_state, + id=self.resource.id, + on=False, + transition_time=transition, + allowed_errors=ALLOWED_ERRORS, + ) diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py new file mode 100644 index 00000000000..bb801b7817d --- /dev/null +++ b/homeassistant/components/hue/v2/sensor.py @@ -0,0 +1,174 @@ +"""Support for Hue sensors.""" +from __future__ import annotations + +from typing import Any, Union + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.controllers.sensors import ( + DevicePowerController, + LightLevelController, + SensorsController, + TemperatureController, + ZigbeeConnectivityController, +) +from aiohue.v2.models.connectivity import ZigbeeConnectivity +from aiohue.v2.models.device_power import DevicePower +from aiohue.v2.models.light_level import LightLevel +from aiohue.v2.models.temperature import Temperature + +from homeassistant.components.binary_sensor import DEVICE_CLASS_CONNECTIVITY +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + LIGHT_LUX, + PERCENTAGE, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from ..bridge import HueBridge +from ..const import DOMAIN +from .entity import HueBaseEntity + +SensorType = Union[DevicePower, LightLevel, Temperature, ZigbeeConnectivity] +ControllerType = Union[ + DevicePowerController, + LightLevelController, + TemperatureController, + ZigbeeConnectivityController, +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Hue Sensors from Config Entry.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + api: HueBridgeV2 = bridge.api + ctrl_base: SensorsController = api.sensors + + @callback + def register_items(controller: ControllerType, sensor_class: SensorType): + @callback + def async_add_sensor(event_type: EventType, resource: SensorType) -> None: + """Add Hue Sensor.""" + async_add_entities([sensor_class(bridge, controller, resource)]) + + # add all current items in controller + for sensor in controller: + async_add_sensor(EventType.RESOURCE_ADDED, sensor) + + # register listener for new sensors + config_entry.async_on_unload( + controller.subscribe( + async_add_sensor, event_filter=EventType.RESOURCE_ADDED + ) + ) + + # setup for each sensor-type hue resource + register_items(ctrl_base.temperature, HueTemperatureSensor) + register_items(ctrl_base.light_level, HueLightLevelSensor) + register_items(ctrl_base.device_power, HueBatterySensor) + register_items(ctrl_base.zigbee_connectivity, HueZigbeeConnectivitySensor) + + +class HueSensorBase(HueBaseEntity, SensorEntity): + """Representation of a Hue sensor.""" + + _attr_state_class = STATE_CLASS_MEASUREMENT + + def __init__( + self, + bridge: HueBridge, + controller: ControllerType, + resource: SensorType, + ) -> None: + """Initialize the light.""" + super().__init__(bridge, controller, resource) + self.resource = resource + self.controller = controller + + +class HueTemperatureSensor(HueSensorBase): + """Representation of a Hue Temperature sensor.""" + + _attr_native_unit_of_measurement = TEMP_CELSIUS + _attr_device_class = DEVICE_CLASS_TEMPERATURE + + @property + def native_value(self) -> float: + """Return the value reported by the sensor.""" + return round(self.resource.temperature.temperature, 1) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the optional state attributes.""" + return {"temperature_valid": self.resource.temperature.temperature_valid} + + +class HueLightLevelSensor(HueSensorBase): + """Representation of a Hue LightLevel (illuminance) sensor.""" + + _attr_native_unit_of_measurement = LIGHT_LUX + _attr_device_class = DEVICE_CLASS_ILLUMINANCE + + @property + def native_value(self) -> int: + """Return the value reported by the sensor.""" + # Light level in 10000 log10 (lux) +1 measured by sensor. Logarithm + # scale used because the human eye adjusts to light levels and small + # changes at low lux levels are more noticeable than at high lux + # levels. + return int(10 ** ((self.resource.light.light_level - 1) / 10000)) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the optional state attributes.""" + return { + "light_level": self.resource.light.light_level, + "light_level_valid": self.resource.light.light_level_valid, + } + + +class HueBatterySensor(HueSensorBase): + """Representation of a Hue Battery sensor.""" + + _attr_native_unit_of_measurement = PERCENTAGE + _attr_device_class = DEVICE_CLASS_BATTERY + _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + + @property + def native_value(self) -> int: + """Return the value reported by the sensor.""" + return self.resource.power_state.battery_level + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the optional state attributes.""" + return {"battery_state": self.resource.power_state.battery_state.value} + + +class HueZigbeeConnectivitySensor(HueSensorBase): + """Representation of a Hue ZigbeeConnectivity sensor.""" + + _attr_device_class = DEVICE_CLASS_CONNECTIVITY + _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + _attr_entity_registry_enabled_default = False + + @property + def native_value(self) -> str: + """Return the value reported by the sensor.""" + return self.resource.status.value + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the optional state attributes.""" + return {"mac_address": self.resource.mac_address} diff --git a/homeassistant/components/huisbaasje/translations/ja.json b/homeassistant/components/huisbaasje/translations/ja.json new file mode 100644 index 00000000000..323de60808b --- /dev/null +++ b/homeassistant/components/huisbaasje/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": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huisbaasje/translations/tr.json b/homeassistant/components/huisbaasje/translations/tr.json index 80f1529066b..6ed28a58c79 100644 --- a/homeassistant/components/huisbaasje/translations/tr.json +++ b/homeassistant/components/huisbaasje/translations/tr.json @@ -4,13 +4,14 @@ "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" + "unknown": "Beklenmeyen hata" }, "step": { "user": { "data": { - "password": "\u015eifre", + "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" } } diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index d15f70f181a..bf3a45d6e91 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -8,6 +8,7 @@ from typing import Any, final import voluptuous as vol +from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_MODE, @@ -27,7 +28,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from .const import ( +from .const import ( # noqa: F401 ATTR_AVAILABLE_MODES, ATTR_HUMIDITY, ATTR_MAX_HUMIDITY, @@ -47,9 +48,21 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=60) -DEVICE_CLASSES = [DEVICE_CLASS_HUMIDIFIER, DEVICE_CLASS_DEHUMIDIFIER] +ENTITY_ID_FORMAT = DOMAIN + ".{}" -DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) + +class HumidifierDeviceClass(StrEnum): + """Device class for humidifiers.""" + + HUMIDIFIER = "humidifier" + DEHUMIDIFIER = "dehumidifier" + + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(HumidifierDeviceClass)) + +# DEVICE_CLASSES below is deprecated as of 2021.12 +# use the HumidifierDeviceClass enum instead. +DEVICE_CLASSES = [cls.value for cls in HumidifierDeviceClass] @bind_hass @@ -106,12 +119,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class HumidifierEntityDescription(ToggleEntityDescription): """A class that describes humidifier entities.""" + device_class: HumidifierDeviceClass | str | None = None + class HumidifierEntity(ToggleEntity): """Base class for humidifier entities.""" entity_description: HumidifierEntityDescription _attr_available_modes: list[str] | None + _attr_device_class: HumidifierDeviceClass | str | None _attr_max_humidity: int = DEFAULT_MAX_HUMIDITY _attr_min_humidity: int = DEFAULT_MIN_HUMIDITY _attr_mode: str | None @@ -131,6 +147,15 @@ class HumidifierEntity(ToggleEntity): return data + @property + def device_class(self) -> HumidifierDeviceClass | str | None: + """Return the class of this entity.""" + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class + return None + @final @property def state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/humidifier/const.py b/homeassistant/components/humidifier/const.py index c2508770187..a3df2756af9 100644 --- a/homeassistant/components/humidifier/const.py +++ b/homeassistant/components/humidifier/const.py @@ -19,6 +19,8 @@ DEFAULT_MAX_HUMIDITY = 100 DOMAIN = "humidifier" +# DEVICE_CLASS_* below are deprecated as of 2021.12 +# use the HumidifierDeviceClass enum instead. DEVICE_CLASS_HUMIDIFIER = "humidifier" DEVICE_CLASS_DEHUMIDIFIER = "dehumidifier" diff --git a/homeassistant/components/humidifier/device_condition.py b/homeassistant/components/humidifier/device_condition.py index f2bf032b195..c8204c91a29 100644 --- a/homeassistant/components/humidifier/device_condition.py +++ b/homeassistant/components/humidifier/device_condition.py @@ -66,16 +66,13 @@ async def async_get_conditions( @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" - if config_validation: - config = CONDITION_SCHEMA(config) - if config[CONF_TYPE] == "is_mode": attribute = ATTR_MODE else: - return toggle_entity.async_condition_from_config(config) + return toggle_entity.async_condition_from_config(hass, config) def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py index 9c6ca5cea55..1c7a09305e1 100644 --- a/homeassistant/components/humidifier/device_trigger.py +++ b/homeassistant/components/humidifier/device_trigger.py @@ -100,8 +100,10 @@ async def async_attach_trigger( if CONF_FOR in config: numeric_state_config[CONF_FOR] = config[CONF_FOR] - numeric_state_config = numeric_state_trigger.TRIGGER_SCHEMA( - numeric_state_config + numeric_state_config = ( + await numeric_state_trigger.async_validate_trigger_config( + hass, numeric_state_config + ) ) return await numeric_state_trigger.async_attach_trigger( hass, numeric_state_config, action, automation_info, platform_type="device" diff --git a/homeassistant/components/humidifier/translations/bg.json b/homeassistant/components/humidifier/translations/bg.json index 21aa58a9e64..5bf60744a03 100644 --- a/homeassistant/components/humidifier/translations/bg.json +++ b/homeassistant/components/humidifier/translations/bg.json @@ -1,7 +1,14 @@ { + "device_automation": { + "condition_type": { + "is_off": "{entity_name} \u0435 \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "is_on": "{entity_name} \u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043e" + } + }, "state": { "_": { - "off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d" + "off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "on": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d" } } } \ No newline at end of file diff --git a/homeassistant/components/humidifier/translations/ca.json b/homeassistant/components/humidifier/translations/ca.json index 5040fd4b419..bf0c1d805f6 100644 --- a/homeassistant/components/humidifier/translations/ca.json +++ b/homeassistant/components/humidifier/translations/ca.json @@ -20,8 +20,8 @@ }, "state": { "_": { - "off": "off", - "on": "on" + "off": "OFF", + "on": "ON" } }, "title": "Humidificador" diff --git a/homeassistant/components/humidifier/translations/ja.json b/homeassistant/components/humidifier/translations/ja.json new file mode 100644 index 00000000000..9b272bfb55f --- /dev/null +++ b/homeassistant/components/humidifier/translations/ja.json @@ -0,0 +1,28 @@ +{ + "device_automation": { + "action_type": { + "set_humidity": "{entity_name} \u6e7f\u5ea6\u3092\u8a2d\u5b9a", + "set_mode": "{entity_name} \u30e2\u30fc\u30c9\u3092\u5909\u66f4", + "toggle": "\u30c8\u30b0\u30eb {entity_name}", + "turn_off": "\u30aa\u30d5\u306b\u3059\u308b {entity_name}", + "turn_on": "\u30aa\u30f3\u306b\u3059\u308b {entity_name}" + }, + "condition_type": { + "is_mode": "{entity_name} \u306f\u7279\u5b9a\u306e\u30e2\u30fc\u30c9\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "is_off": "{entity_name} \u306f\u30aa\u30d5\u3067\u3059", + "is_on": "{entity_name} \u304c\u30aa\u30f3\u3067\u3059" + }, + "trigger_type": { + "target_humidity_changed": "{entity_name} \u30bf\u30fc\u30b2\u30c3\u30c8\u6e7f\u5ea6\u304c\u5909\u5316\u3057\u307e\u3057\u305f", + "turned_off": "{entity_name} \u30aa\u30d5\u306b\u306a\u308a\u307e\u3057\u305f", + "turned_on": "{entity_name} \u30aa\u30f3\u306b\u306a\u3063\u3066\u3044\u307e\u3059" + } + }, + "state": { + "_": { + "off": "\u30aa\u30d5", + "on": "\u30aa\u30f3" + } + }, + "title": "\u52a0\u6e7f\u5668" +} \ No newline at end of file diff --git a/homeassistant/components/humidifier/translations/tr.json b/homeassistant/components/humidifier/translations/tr.json index 7bcdbc46a0b..08808ca696d 100644 --- a/homeassistant/components/humidifier/translations/tr.json +++ b/homeassistant/components/humidifier/translations/tr.json @@ -1,7 +1,10 @@ { "device_automation": { "action_type": { + "set_humidity": "{entity_name} i\u00e7in nemi ayarla", "set_mode": "{entity_name} \u00fczerindeki mod de\u011fi\u015ftirme", + "toggle": "{entity_name} de\u011fi\u015ftir", + "turn_off": "{entity_name} kapat", "turn_on": "{entity_name} a\u00e7\u0131n" }, "condition_type": { diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index a8461f4da5c..a9c620a4baa 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -89,12 +89,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: (await shades.get_resources())[SHADE_DATA] ) except HUB_EXCEPTIONS as err: - _LOGGER.error("Connection error to PowerView hub: %s", hub_address) - raise ConfigEntryNotReady from err - + raise ConfigEntryNotReady( + f"Connection error to PowerView hub: {hub_address}: {err}" + ) from err if not device_info: - _LOGGER.error("Unable to initialize PowerView hub: %s", hub_address) - raise ConfigEntryNotReady + raise ConfigEntryNotReady(f"Unable to initialize PowerView hub: {hub_address}") async def async_update_data(): """Fetch data from shade endpoint.""" diff --git a/homeassistant/components/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py index 8dbe21eb10e..e0ebef7abbb 100644 --- a/homeassistant/components/hunterdouglas_powerview/config_flow.py +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -8,8 +8,9 @@ import async_timeout import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS +from homeassistant.components import dhcp, zeroconf from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import async_get_device_info @@ -85,25 +86,29 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return info, None - async def async_step_dhcp(self, discovery_info): + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle DHCP discovery.""" - self.discovered_ip = discovery_info[IP_ADDRESS] - self.discovered_name = discovery_info[HOSTNAME] + self.discovered_ip = discovery_info.ip + self.discovered_name = discovery_info.hostname return await self.async_step_discovery_confirm() - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle zeroconf discovery.""" - self.discovered_ip = discovery_info[CONF_HOST] - name = discovery_info[CONF_NAME] + self.discovered_ip = discovery_info.host + name = discovery_info.name if name.endswith(POWERVIEW_SUFFIX): name = name[: -len(POWERVIEW_SUFFIX)] self.discovered_name = name return await self.async_step_discovery_confirm() - async def async_step_homekit(self, discovery_info): + async def async_step_homekit( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle HomeKit discovery.""" - self.discovered_ip = discovery_info[CONF_HOST] - name = discovery_info[CONF_NAME] + self.discovered_ip = discovery_info.host + name = discovery_info.name if name.endswith(HAP_SUFFIX): name = name[: -len(HAP_SUFFIX)] self.discovered_name = name diff --git a/homeassistant/components/hunterdouglas_powerview/const.py b/homeassistant/components/hunterdouglas_powerview/const.py index e827b055995..ea87150a9ca 100644 --- a/homeassistant/components/hunterdouglas_powerview/const.py +++ b/homeassistant/components/hunterdouglas_powerview/const.py @@ -3,7 +3,7 @@ import asyncio from aiohttp.client_exceptions import ServerDisconnectedError -from aiopvapi.helpers.aiorequest import PvApiConnectionError +from aiopvapi.helpers.aiorequest import PvApiConnectionError, PvApiResponseStatusError DOMAIN = "hunterdouglas_powerview" @@ -62,7 +62,12 @@ PV_SHADE_DATA = "pv_shade_data" PV_ROOM_DATA = "pv_room_data" COORDINATOR = "coordinator" -HUB_EXCEPTIONS = (ServerDisconnectedError, asyncio.TimeoutError, PvApiConnectionError) +HUB_EXCEPTIONS = ( + ServerDisconnectedError, + asyncio.TimeoutError, + PvApiConnectionError, + PvApiResponseStatusError, +) LEGACY_DEVICE_SUB_REVISION = 1 LEGACY_DEVICE_REVISION = 0 diff --git a/homeassistant/components/hunterdouglas_powerview/translations/ja.json b/homeassistant/components/hunterdouglas_powerview/translations/ja.json new file mode 100644 index 00000000000..a4c7674472a --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/translations/ja.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name} ({host})", + "step": { + "link": { + "description": "{name} ({host})\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b?", + "title": "PowerView Hub\u306b\u63a5\u7d9a" + }, + "user": { + "data": { + "host": "IP\u30a2\u30c9\u30ec\u30b9" + }, + "title": "PowerView Hub\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hunterdouglas_powerview/translations/tr.json b/homeassistant/components/hunterdouglas_powerview/translations/tr.json index 01b0359789e..c489b2906d8 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/tr.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/tr.json @@ -7,11 +7,17 @@ "cannot_connect": "Ba\u011flanma hatas\u0131", "unknown": "Beklenmeyen hata" }, + "flow_title": "{name} ({host})", "step": { + "link": { + "description": "{name} ( {host} ) kurulumu yapmak istiyor musunuz?", + "title": "PowerView Hub'a ba\u011flan\u0131n" + }, "user": { "data": { - "host": "\u0130p Adresi" - } + "host": "IP Adresi" + }, + "title": "PowerView Hub'a ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index da52fd878d8..d82a15cebe9 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -116,7 +116,7 @@ class HVVDepartureSensor(SensorEntity): departure_time + timedelta(minutes=departure["timeOffset"]) + timedelta(seconds=delay) - ).isoformat() + ) self.attr.update( { diff --git a/homeassistant/components/hvv_departures/translations/bg.json b/homeassistant/components/hvv_departures/translations/bg.json new file mode 100644 index 00000000000..fb5ab8f3eb5 --- /dev/null +++ b/homeassistant/components/hvv_departures/translations/bg.json @@ -0,0 +1,30 @@ +{ + "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" + }, + "step": { + "user": { + "data": { + "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" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "offset": "\u041e\u0442\u043c\u0435\u0441\u0442\u0432\u0430\u043d\u0435 (\u043c\u0438\u043d\u0443\u0442\u0438)" + }, + "title": "\u041e\u043f\u0446\u0438\u0438" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hvv_departures/translations/ja.json b/homeassistant/components/hvv_departures/translations/ja.json new file mode 100644 index 00000000000..89656d2669c --- /dev/null +++ b/homeassistant/components/hvv_departures/translations/ja.json @@ -0,0 +1,47 @@ +{ + "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "no_results": "\u7d50\u679c\u306a\u3057\u3002\u5225\u306e\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3/\u30a2\u30c9\u30ec\u30b9\u3067\u8a66\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "step": { + "station": { + "data": { + "station": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3/\u30a2\u30c9\u30ec\u30b9" + }, + "title": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3/\u30a2\u30c9\u30ec\u30b9\u3092\u5165\u529b" + }, + "station_select": { + "data": { + "station": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3/\u30a2\u30c9\u30ec\u30b9" + }, + "title": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3/\u30a2\u30c9\u30ec\u30b9\u306e\u9078\u629e" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "HVV API\u306b\u63a5\u7d9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "filter": "\u884c\u3092\u9078\u629e", + "offset": "\u30aa\u30d5\u30bb\u30c3\u30c8(\u5206)", + "real_time": "\u30ea\u30a2\u30eb\u30bf\u30a4\u30e0\u30c7\u30fc\u30bf\u3092\u4f7f\u7528\u3059\u308b" + }, + "description": "\u3053\u306e\u51fa\u767a\u30bb\u30f3\u30b5\u30fc\u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u5909\u66f4\u3059\u308b", + "title": "\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hvv_departures/translations/tr.json b/homeassistant/components/hvv_departures/translations/tr.json index 74fc593062b..ce6250d10bd 100644 --- a/homeassistant/components/hvv_departures/translations/tr.json +++ b/homeassistant/components/hvv_departures/translations/tr.json @@ -5,15 +5,42 @@ }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", - "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "no_results": "Sonu\u00e7 yok. Farkl\u0131 bir istasyon/adres ile deneyin" }, "step": { + "station": { + "data": { + "station": "\u0130stasyon/Adres" + }, + "title": "\u0130stasyon/Adres Girin" + }, + "station_select": { + "data": { + "station": "\u0130stasyon/Adres" + }, + "title": "\u0130stasyon/Adres Se\u00e7in" + }, "user": { "data": { "host": "Ana Bilgisayar", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "HVV API'sine ba\u011flan\u0131n" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "filter": "Sat\u0131rlar\u0131 se\u00e7in", + "offset": "Uzakl\u0131k (dakika)", + "real_time": "Ger\u00e7ek zamanl\u0131 verileri kullan\u0131n" + }, + "description": "Bu hareket sens\u00f6r\u00fc i\u00e7in se\u00e7enekleri de\u011fi\u015ftirin", + "title": "Se\u00e7enekler" } } } diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index f8c02309569..ee9e931a351 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -83,4 +83,4 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity): _LOGGER.debug("New cycle time: %s", next_cycle) self._attr_native_value = dt.utc_from_timestamp( dt.as_timestamp(dt.now()) + next_cycle - ).isoformat() + ) diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index c1763c3c21c..4b6492559ea 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -186,7 +186,7 @@ class HyperionCamera(Camera): return False self._image_stream_clients += 1 - self.is_streaming = True + self._attr_is_streaming = True self.async_write_ha_state() return True @@ -196,7 +196,7 @@ class HyperionCamera(Camera): if not self._image_stream_clients: await self._client.async_send_image_stream_stop() - self.is_streaming = False + self._attr_is_streaming = False self.async_write_ha_state() @asynccontextmanager diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 6c76f03e3de..4d6253cb161 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -10,7 +10,7 @@ from urllib.parse import urlparse from hyperion import client, const import voluptuous as vol -from homeassistant.components.ssdp import ATTR_SSDP_LOCATION, ATTR_UPNP_SERIAL +from homeassistant.components import ssdp from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, @@ -151,7 +151,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="cannot_connect") return await self._advance_to_auth_step_if_necessary(hyperion_client) - async def async_step_ssdp(self, discovery_info: dict[str, Any]) -> FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a flow initiated by SSDP.""" # Sample data provided by SSDP: { # 'ssdp_location': 'http://192.168.0.1:8090/description.xml', @@ -188,22 +188,24 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): # SSDP requires user confirmation. self._require_confirm = True - self._data[CONF_HOST] = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname + self._data[CONF_HOST] = urlparse(discovery_info.ssdp_location).hostname try: - self._port_ui = urlparse(discovery_info[ATTR_SSDP_LOCATION]).port + self._port_ui = ( + urlparse(discovery_info.ssdp_location).port or const.DEFAULT_PORT_UI + ) except ValueError: self._port_ui = const.DEFAULT_PORT_UI try: self._data[CONF_PORT] = int( - discovery_info.get("ports", {}).get( + discovery_info.upnp.get("ports", {}).get( "jsonServer", const.DEFAULT_PORT_JSON ) ) except ValueError: self._data[CONF_PORT] = const.DEFAULT_PORT_JSON - if not (hyperion_id := discovery_info.get(ATTR_UPNP_SERIAL)): + if not (hyperion_id := discovery_info.upnp.get(ssdp.ATTR_UPNP_SERIAL)): return self.async_abort(reason="no_id") # For discovery mechanisms, we set the unique_id as early as possible to diff --git a/homeassistant/components/hyperion/translations/ca.json b/homeassistant/components/hyperion/translations/ca.json index 3a1de53102b..c0021e911cb 100644 --- a/homeassistant/components/hyperion/translations/ca.json +++ b/homeassistant/components/hyperion/translations/ca.json @@ -12,7 +12,7 @@ }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", - "invalid_access_token": "Token d'acc\u00e9s no v\u00e0lid" + "invalid_access_token": "Token d'acc\u00e9s inv\u00e0lid" }, "step": { "auth": { diff --git a/homeassistant/components/hyperion/translations/ja.json b/homeassistant/components/hyperion/translations/ja.json new file mode 100644 index 00000000000..ccd32102cff --- /dev/null +++ b/homeassistant/components/hyperion/translations/ja.json @@ -0,0 +1,54 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\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", + "auth_new_token_not_granted_error": "\u65b0\u3057\u304f\u4f5c\u6210\u3057\u305f\u30c8\u30fc\u30af\u30f3\u304cHyperion UI\u3067\u627f\u8a8d\u3055\u308c\u307e\u305b\u3093\u3067\u3057\u305f", + "auth_new_token_not_work_error": "\u65b0\u3057\u304f\u4f5c\u6210\u3055\u308c\u305f\u30c8\u30fc\u30af\u30f3\u3092\u4f7f\u7528\u3057\u305f\u8a8d\u8a3c\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "auth_required_error": "\u627f\u8a8d\u304c\u5fc5\u8981\u304b\u3069\u3046\u304b\u3092\u5224\u65ad\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "no_id": "Hyperion Ambilight\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306f\u305d\u306eID\u3092\u30ec\u30dd\u30fc\u30c8\u3057\u3066\u3044\u307e\u305b\u3093", + "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_access_token": "\u7121\u52b9\u306a\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3" + }, + "step": { + "auth": { + "data": { + "create_token": "\u65b0\u3057\u3044\u30c8\u30fc\u30af\u30f3\u3092\u81ea\u52d5\u7684\u306b\u4f5c\u6210\u3059\u308b", + "token": "\u307e\u305f\u306f\u65e2\u5b58\u306e\u30c8\u30fc\u30af\u30f3\u3092\u63d0\u4f9b\u3057\u307e\u3059" + }, + "description": "Hyperion Ambilight\u30b5\u30fc\u30d0\u30fc\u3078\u306e\u8a8d\u8a3c\u8a2d\u5b9a" + }, + "confirm": { + "description": "\u3053\u306eHyperion Ambilight\u3092Home Assistant\u306b\u8ffd\u52a0\u3057\u307e\u3059\u304b\uff1f \n\n**Host:** {host}\n**Port:** {port}\n**ID**: {id}", + "title": "Hyperion Ambilight\u30b5\u30fc\u30d3\u30b9\u306e\u8ffd\u52a0\u3092\u78ba\u8a8d" + }, + "create_token": { + "description": "\u4ee5\u4e0b\u306e\u3001\u9001\u4fe1(submit)\u3092\u9078\u629e\u3057\u3066\u3001\u65b0\u3057\u3044\u8a8d\u8a3c\u30c8\u30fc\u30af\u30f3\u3092\u30ea\u30af\u30a8\u30b9\u30c8\u3057\u307e\u3059\u3002\u30ea\u30af\u30a8\u30b9\u30c8\u3092\u627f\u8a8d\u3059\u308b\u305f\u3081\u306b\u3001Hyperion UI\u306b\u30ea\u30c0\u30a4\u30ec\u30af\u30c8\u3055\u308c\u307e\u3059\u3002\u8868\u793a\u3055\u308c\u305fID\u304c \"{auth_id}\" \u3067\u3042\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u65b0\u3057\u3044\u8a8d\u8a3c\u30c8\u30fc\u30af\u30f3\u3092\u81ea\u52d5\u7684\u306b\u4f5c\u6210\u3057\u307e\u3059" + }, + "create_token_external": { + "title": "Hyperion UI\u3067\u65b0\u3057\u3044\u30c8\u30fc\u30af\u30f3\u306e\u53d7\u3051\u5165\u308c\u308b" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "effect_show_list": "Hyperion effects\u3092\u8868\u793a", + "priority": "\u8272\u3068\u30a8\u30d5\u30a7\u30af\u30c8\u306b\u4f7f\u7528\u3059\u308bHyperion\u306e\u512a\u5148\u9806\u4f4d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hyperion/translations/pl.json b/homeassistant/components/hyperion/translations/pl.json index 67e89e817f0..22c2f9b9072 100644 --- a/homeassistant/components/hyperion/translations/pl.json +++ b/homeassistant/components/hyperion/translations/pl.json @@ -27,7 +27,7 @@ "title": "Potwierdzanie dodania us\u0142ugi Hyperion Ambilight" }, "create_token": { - "description": "Naci\u015bnij **Prze\u015blij** poni\u017cej, aby za\u017c\u0105da\u0107 nowego tokena uwierzytelniania. Nast\u0105pi przekierowanie do interfejsu u\u017cytkownika Hyperion, aby zatwierdzi\u0107 \u017c\u0105danie. Sprawd\u017a, czy wy\u015bwietlany identyfikator to \u201e {auth_id} \u201d", + "description": "Naci\u015bnij **Zatwierd\u017a** poni\u017cej, aby za\u017c\u0105da\u0107 nowego tokena uwierzytelniania. Nast\u0105pi przekierowanie do interfejsu u\u017cytkownika Hyperion, aby zatwierdzi\u0107 \u017c\u0105danie. Sprawd\u017a, czy wy\u015bwietlany identyfikator to \u201e {auth_id} \u201d", "title": "Automatyczne tworzenie nowego tokena uwierzytelniaj\u0105cego" }, "create_token_external": { diff --git a/homeassistant/components/hyperion/translations/tr.json b/homeassistant/components/hyperion/translations/tr.json index 7b3f9f845a1..0368ce2dde3 100644 --- a/homeassistant/components/hyperion/translations/tr.json +++ b/homeassistant/components/hyperion/translations/tr.json @@ -12,19 +12,22 @@ }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", - "invalid_access_token": "Ge\u00e7ersiz eri\u015fim belirteci" + "invalid_access_token": "Ge\u00e7ersiz eri\u015fim anahtar\u0131" }, "step": { "auth": { "data": { "create_token": "Otomatik olarak yeni belirte\u00e7 olu\u015fturma", "token": "Veya \u00f6nceden varolan belirte\u00e7 leri sa\u011flay\u0131n" - } + }, + "description": "Hyperion Ambilight sunucunuz i\u00e7in yetkilendirmeyi yap\u0131land\u0131r\u0131n" }, "confirm": { + "description": "Bu Hyperion Ambilight'\u0131 Home Assistant'a eklemek istiyor musunuz? \n\n **Ana bilgisayar:** {host}\n **:** {port}\n **Kimlik**: {id}", "title": "Hyperion Ambilight hizmetinin eklenmesini onaylay\u0131n" }, "create_token": { + "description": "Yeni bir kimlik do\u011frulama belirteci istemek i\u00e7in a\u015fa\u011f\u0131dan **G\u00f6nder**'i se\u00e7in. \u0130ste\u011fi onaylamak i\u00e7in Hyperion kullan\u0131c\u0131 aray\u00fcz\u00fcne y\u00f6nlendirileceksiniz. L\u00fctfen g\u00f6sterilen kimli\u011fin \" {auth_id} \" oldu\u011funu do\u011frulay\u0131n", "title": "Otomatik olarak yeni kimlik do\u011frulama belirteci olu\u015fturun" }, "create_token_external": { @@ -32,7 +35,7 @@ }, "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "port": "Port" } } @@ -42,6 +45,7 @@ "step": { "init": { "data": { + "effect_show_list": "Hyperion efektleri g\u00f6sterilecek", "priority": "Renkler ve efektler i\u00e7in kullan\u0131lacak hyperion \u00f6nceli\u011fi" } } diff --git a/homeassistant/components/ialarm/translations/ja.json b/homeassistant/components/ialarm/translations/ja.json new file mode 100644 index 00000000000..6077685b7f4 --- /dev/null +++ b/homeassistant/components/ialarm/translations/ja.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ialarm/translations/tr.json b/homeassistant/components/ialarm/translations/tr.json index 21a477c75a7..b524964cfd8 100644 --- a/homeassistant/components/ialarm/translations/tr.json +++ b/homeassistant/components/ialarm/translations/tr.json @@ -1,9 +1,16 @@ { "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "port": "Port" } } diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py index de0e76fc3aa..3d7336fea5b 100644 --- a/homeassistant/components/iammeter/sensor.py +++ b/homeassistant/components/iammeter/sensor.py @@ -42,7 +42,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= config_port = config[CONF_PORT] config_name = config[CONF_NAME] try: - with async_timeout.timeout(PLATFORM_TIMEOUT): + async with async_timeout.timeout(PLATFORM_TIMEOUT): api = await real_time_api(config_host, config_port) except (IamMeterError, asyncio.TimeoutError) as err: _LOGGER.error("Device is not ready") @@ -50,7 +50,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_update_data(): try: - with async_timeout.timeout(PLATFORM_TIMEOUT): + async with async_timeout.timeout(PLATFORM_TIMEOUT): return await api.get_data() except (IamMeterError, asyncio.TimeoutError) as err: raise UpdateFailed from err diff --git a/homeassistant/components/iaqualink/translations/ja.json b/homeassistant/components/iaqualink/translations/ja.json new file mode 100644 index 00000000000..cb4afb4d130 --- /dev/null +++ b/homeassistant/components/iaqualink/translations/ja.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "iAqualink\u30a2\u30ab\u30a6\u30f3\u30c8\u306e\u30e6\u30fc\u30b6\u30fc\u540d\u3068\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "iAqualink\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/translations/tr.json b/homeassistant/components/iaqualink/translations/tr.json index c2c70f3e45b..43d3a4ef044 100644 --- a/homeassistant/components/iaqualink/translations/tr.json +++ b/homeassistant/components/iaqualink/translations/tr.json @@ -12,7 +12,8 @@ "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" }, - "description": "L\u00fctfen iAqualink hesab\u0131n\u0131z i\u00e7in kullan\u0131c\u0131 ad\u0131 ve parolay\u0131 girin." + "description": "L\u00fctfen iAqualink hesab\u0131n\u0131z i\u00e7in kullan\u0131c\u0131 ad\u0131 ve parolay\u0131 girin.", + "title": "iAqualink'e ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/icloud/translations/bg.json b/homeassistant/components/icloud/translations/bg.json index d074f0ff9b9..f7f1faf74f5 100644 --- a/homeassistant/components/icloud/translations/bg.json +++ b/homeassistant/components/icloud/translations/bg.json @@ -1,5 +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" + }, "error": { "send_verification_code": "\u041a\u043e\u0434\u044a\u0442 \u0437\u0430 \u043f\u043e\u0442\u0432\u044a\u0440\u0436\u0434\u0435\u043d\u0438\u0435 \u043d\u0435 \u0431\u0435 \u0438\u0437\u043f\u0440\u0430\u0442\u0435\u043d", "validate_verification_code": "\u041f\u043e\u0442\u0432\u044a\u0440\u0436\u0434\u0430\u0432\u0430\u043d\u0435\u0442\u043e \u043d\u0430 \u043a\u043e\u0434\u0430 \u0437\u0430 \u043f\u043e\u0442\u0432\u044a\u0440\u0436\u0434\u0435\u043d\u0438\u0435 \u043d\u0435 \u0431\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e" @@ -7,6 +10,7 @@ "step": { "user": { "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", "username": "Email" } } diff --git a/homeassistant/components/icloud/translations/ja.json b/homeassistant/components/icloud/translations/ja.json index c27c96e570a..d60b1e1a335 100644 --- a/homeassistant/components/icloud/translations/ja.json +++ b/homeassistant/components/icloud/translations/ja.json @@ -1,6 +1,23 @@ { "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "no_device": "\"iPhone\u3092\u63a2\u3059\" \u304c\u6709\u52b9\u306b\u306a\u3063\u3066\u3044\u308b\u30c7\u30d0\u30a4\u30b9\u306f\u3042\u308a\u307e\u305b\u3093", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "send_verification_code": "\u78ba\u8a8d\u30b3\u30fc\u30c9\u306e\u9001\u4fe1\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "validate_verification_code": "\u8a8d\u8a3c\u30b3\u30fc\u30c9\u306e\u78ba\u8a8d\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3001\u518d\u5ea6\u8a66\u3057\u304f\u3060\u3055\u3044\u3002" + }, "step": { + "reauth": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u4ee5\u524d\u306b\u5165\u529b\u3057\u305f {username} \u306e\u30d1\u30b9\u30ef\u30fc\u30c9\u306f\u4f7f\u3048\u306a\u304f\u306a\u308a\u307e\u3057\u305f\u3002\u3053\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u5f15\u304d\u7d9a\u304d\u4f7f\u7528\u3059\u308b\u306b\u306f\u3001\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u66f4\u65b0\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, "trusted_device": { "data": { "trusted_device": "\u4fe1\u983c\u3067\u304d\u308b\u30c7\u30d0\u30a4\u30b9" @@ -10,7 +27,9 @@ }, "user": { "data": { - "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "E\u30e1\u30fc\u30eb", + "with_family": "\u5bb6\u65cf\u3068\u5171\u6709" }, "description": "\u8cc7\u683c\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", "title": "iCloud \u306e\u8cc7\u683c\u60c5\u5831" @@ -19,6 +38,7 @@ "data": { "verification_code": "\u8a8d\u8a3c\u30b3\u30fc\u30c9" }, + "description": "iCloud\u304b\u3089\u53d7\u3051\u53d6\u3063\u305f\u78ba\u8a8d\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", "title": "iCloud \u306e\u8a8d\u8a3c\u30b3\u30fc\u30c9" } } diff --git a/homeassistant/components/icloud/translations/tr.json b/homeassistant/components/icloud/translations/tr.json index 86581625d96..0f917b132f4 100644 --- a/homeassistant/components/icloud/translations/tr.json +++ b/homeassistant/components/icloud/translations/tr.json @@ -7,21 +7,39 @@ }, "error": { "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", - "validate_verification_code": "Do\u011frulama kodunuzu do\u011frulamay\u0131 ba\u015faramad\u0131n\u0131z, bir g\u00fcven ayg\u0131t\u0131 se\u00e7in ve do\u011frulamay\u0131 yeniden ba\u015flat\u0131n" + "send_verification_code": "Do\u011frulama kodu g\u00f6nderilemedi", + "validate_verification_code": "Do\u011frulama kodunuz do\u011frulanamad\u0131, tekrar deneyin" }, "step": { "reauth": { "data": { "password": "Parola" }, - "description": "{username} i\u00e7in \u00f6nceden girdi\u011finiz \u015fifreniz art\u0131k \u00e7al\u0131\u015fm\u0131yor. Bu entegrasyonu kullanmaya devam etmek i\u00e7in \u015fifrenizi g\u00fcncelleyin." + "description": "{username} i\u00e7in \u00f6nceden girdi\u011finiz \u015fifreniz art\u0131k \u00e7al\u0131\u015fm\u0131yor. Bu entegrasyonu kullanmaya devam etmek i\u00e7in \u015fifrenizi g\u00fcncelleyin.", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, + "trusted_device": { + "data": { + "trusted_device": "G\u00fcvenilir ayg\u0131t" + }, + "description": "G\u00fcvenilir cihaz\u0131n\u0131z\u0131 se\u00e7in", + "title": "iCloud g\u00fcvenilen ayg\u0131t" }, "user": { "data": { "password": "Parola", "username": "E-posta", "with_family": "Aileyle" - } + }, + "description": "Kimlik bilgilerinizi girin", + "title": "iCloud kimlik bilgileri" + }, + "verification_code": { + "data": { + "verification_code": "Do\u011frulama kodu" + }, + "description": "L\u00fctfen iCloud'dan yeni ald\u0131\u011f\u0131n\u0131z do\u011frulama kodunu girin", + "title": "iCloud do\u011frulama kodu" } } } diff --git a/homeassistant/components/ifttt/translations/ja.json b/homeassistant/components/ifttt/translations/ja.json new file mode 100644 index 00000000000..81616a31dd7 --- /dev/null +++ b/homeassistant/components/ifttt/translations/ja.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "webhook_not_internet_accessible": "Webhook\u30e1\u30c3\u30bb\u30fc\u30b8\u3092\u53d7\u4fe1\u3059\u308b\u306b\u306f\u3001Home Assistant\u306e\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306b\u3001\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u304b\u3089\u30a2\u30af\u30bb\u30b9\u3067\u304d\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "create_entry": { + "default": "Home Assistant\u306b\u30a4\u30d9\u30f3\u30c8\u3092\u9001\u4fe1\u3059\u308b\u306b\u306f\u3001[IFTTT Webhook applet]({applet_url})\u306e\"Make a web request\"\u30a2\u30af\u30b7\u30e7\u30f3\u3092\u4f7f\u7528\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\n\n\u4ee5\u4e0b\u306e\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n\n- URL: `{webhook_url}`\n- Method(\u65b9\u5f0f): POST\n- Content Type: application/json\n\n\u53d7\u4fe1\u30c7\u30fc\u30bf\u3092\u51e6\u7406\u3059\u308b\u305f\u3081\u306b\u30aa\u30fc\u30c8\u30e1\u30fc\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a\u3059\u308b\u65b9\u6cd5\u306b\u3064\u3044\u3066\u306f\u3001[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8]({docs_url})\u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "user": { + "description": "IFTTT\u3092\u8a2d\u5b9a\u3057\u3066\u3082\u3088\u308d\u3057\u3044\u3067\u3059\u304b\uff1f", + "title": "IFTTT\u306eWebhook\u30a2\u30d7\u30ec\u30c3\u30c8\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ifttt/translations/tr.json b/homeassistant/components/ifttt/translations/tr.json index 84adcdf8225..b42268fa889 100644 --- a/homeassistant/components/ifttt/translations/tr.json +++ b/homeassistant/components/ifttt/translations/tr.json @@ -3,6 +3,15 @@ "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr.", "webhook_not_internet_accessible": "Webhook mesajlar\u0131n\u0131 alabilmek i\u00e7in Home Assistant \u00f6rne\u011finize internetten eri\u015filebilmelidir." + }, + "create_entry": { + "default": "Etkinlikleri Home Assistant'a g\u00f6ndermek i\u00e7in [IFTTT Webhook uygulamas\u0131ndan]( {applet_url} ) \"Web iste\u011fi yap\" eylemini kullanman\u0131z gerekir. \n\n A\u015fa\u011f\u0131daki bilgileri doldurun: \n\n - URL: ` {webhook_url} `\n - Y\u00f6ntem: POST\n - \u0130\u00e7erik T\u00fcr\u00fc: uygulama/json \n\n Gelen verileri i\u015flemek i\u00e7in otomasyonlar\u0131n nas\u0131l yap\u0131land\u0131r\u0131laca\u011f\u0131 hakk\u0131nda [belgelere]( {docs_url}" + }, + "step": { + "user": { + "description": "IFTTT'yi kurmak istedi\u011finizden emin misiniz?", + "title": "IFTTT Webhook Uygulamas\u0131n\u0131 ayarlay\u0131n" + } } } } \ No newline at end of file diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index c0fe8944c66..3a75a841fde 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -286,8 +286,7 @@ def get_manual_configuration(hass, config, conf, ihc_controller, controller_id): def autosetup_ihc_products(hass: HomeAssistant, config, ihc_controller, controller_id): """Auto setup of IHC products from the IHC project file.""" - project_xml = ihc_controller.get_project() - if not project_xml: + if not (project_xml := ihc_controller.get_project()): _LOGGER.error("Unable to read project from IHC controller") return False project = ElementTree.fromstring(project_xml) diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index c3d6b2198ce..89a1348e90e 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -134,7 +134,7 @@ class ImapSensor(SensorEntity): idle = await self._connection.idle_start() await self._connection.wait_server_push() self._connection.idle_done() - with async_timeout.timeout(10): + async with async_timeout.timeout(10): await idle else: self.async_write_ha_state() diff --git a/homeassistant/components/input_boolean/translations/ca.json b/homeassistant/components/input_boolean/translations/ca.json index 8e3e86e9166..23600285d58 100644 --- a/homeassistant/components/input_boolean/translations/ca.json +++ b/homeassistant/components/input_boolean/translations/ca.json @@ -1,8 +1,8 @@ { "state": { "_": { - "off": "off", - "on": "on" + "off": "OFF", + "on": "ON" } }, "title": "Entrada booleana" diff --git a/homeassistant/components/input_boolean/translations/hu.json b/homeassistant/components/input_boolean/translations/hu.json index bdf99ca8f47..3f2e5be2694 100644 --- a/homeassistant/components/input_boolean/translations/hu.json +++ b/homeassistant/components/input_boolean/translations/hu.json @@ -5,5 +5,5 @@ "on": "Be" } }, - "title": "Logikai bemenet" + "title": "Logikai v\u00e1lt\u00f3" } \ No newline at end of file diff --git a/homeassistant/components/input_boolean/translations/ja.json b/homeassistant/components/input_boolean/translations/ja.json index 15dd3796187..5af782e4e46 100644 --- a/homeassistant/components/input_boolean/translations/ja.json +++ b/homeassistant/components/input_boolean/translations/ja.json @@ -4,5 +4,6 @@ "off": "\u30aa\u30d5", "on": "\u30aa\u30f3" } - } + }, + "title": "\u771f\u507d\u5024\u5165\u529b(booleans)" } \ No newline at end of file diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index de767d69ba8..c25680b7180 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -87,19 +87,16 @@ def valid_initial(conf): return conf if conf[CONF_HAS_DATE] and conf[CONF_HAS_TIME]: - parsed_value = dt_util.parse_datetime(initial) - if parsed_value is not None: + if dt_util.parse_datetime(initial) is not None: return conf raise vol.Invalid(f"Initial value '{initial}' can't be parsed as a datetime") if conf[CONF_HAS_DATE]: - parsed_value = dt_util.parse_date(initial) - if parsed_value is not None: + if dt_util.parse_date(initial) is not None: return conf raise vol.Invalid(f"Initial value '{initial}' can't be parsed as a date") - parsed_value = dt_util.parse_time(initial) - if parsed_value is not None: + if dt_util.parse_time(initial) is not None: return conf raise vol.Invalid(f"Initial value '{initial}' can't be parsed as a time") @@ -270,8 +267,7 @@ class InputDatetime(RestoreEntity): default_value = py_datetime.datetime.today().strftime("%Y-%m-%d 00:00:00") # Priority 2: Old state - old_state = await self.async_get_last_state() - if old_state is None: + if (old_state := await self.async_get_last_state()) is None: self._current_datetime = dt_util.parse_datetime(default_value) return @@ -283,15 +279,13 @@ class InputDatetime(RestoreEntity): current_datetime = date_time elif self.has_date: - date = dt_util.parse_date(old_state.state) - if date is None: + if (date := dt_util.parse_date(old_state.state)) is None: current_datetime = dt_util.parse_datetime(default_value) else: current_datetime = py_datetime.datetime.combine(date, DEFAULT_TIME) else: - time = dt_util.parse_time(old_state.state) - if time is None: + if (time := dt_util.parse_time(old_state.state)) is None: current_datetime = dt_util.parse_datetime(default_value) else: current_datetime = py_datetime.datetime.combine( diff --git a/homeassistant/components/input_datetime/translations/ja.json b/homeassistant/components/input_datetime/translations/ja.json new file mode 100644 index 00000000000..aef27609568 --- /dev/null +++ b/homeassistant/components/input_datetime/translations/ja.json @@ -0,0 +1,3 @@ +{ + "title": "\u65e5\u6642\u3092\u5165\u529b" +} \ No newline at end of file diff --git a/homeassistant/components/input_number/translations/ja.json b/homeassistant/components/input_number/translations/ja.json new file mode 100644 index 00000000000..3104ee351fc --- /dev/null +++ b/homeassistant/components/input_number/translations/ja.json @@ -0,0 +1,3 @@ +{ + "title": "\u756a\u53f7\u5165\u529b" +} \ No newline at end of file diff --git a/homeassistant/components/input_select/translations/ja.json b/homeassistant/components/input_select/translations/ja.json new file mode 100644 index 00000000000..c84c50cd7bc --- /dev/null +++ b/homeassistant/components/input_select/translations/ja.json @@ -0,0 +1,3 @@ +{ + "title": "\u9078\u629e\u80a2\u5165\u529b" +} \ No newline at end of file diff --git a/homeassistant/components/input_text/translations/ja.json b/homeassistant/components/input_text/translations/ja.json new file mode 100644 index 00000000000..1d16098fa72 --- /dev/null +++ b/homeassistant/components/input_text/translations/ja.json @@ -0,0 +1,3 @@ +{ + "title": "\u30c6\u30ad\u30b9\u30c8\u5165\u529b" +} \ No newline at end of file diff --git a/homeassistant/components/insteon/api/device.py b/homeassistant/components/insteon/api/device.py index 6815ec43031..1071451876b 100644 --- a/homeassistant/components/insteon/api/device.py +++ b/homeassistant/components/insteon/api/device.py @@ -63,8 +63,7 @@ async def websocket_get_device( if not (ha_device := dev_registry.async_get(msg[DEVICE_ID])): notify_device_not_found(connection, msg, HA_DEVICE_NOT_FOUND) return - device = get_insteon_device_from_ha_device(ha_device) - if not device: + if not (device := get_insteon_device_from_ha_device(ha_device)): notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) return ha_name = compute_device_name(ha_device) diff --git a/homeassistant/components/insteon/insteon_entity.py b/homeassistant/components/insteon/insteon_entity.py index aa2d4367225..719b58a3d9f 100644 --- a/homeassistant/components/insteon/insteon_entity.py +++ b/homeassistant/components/insteon/insteon_entity.py @@ -68,8 +68,7 @@ class InsteonEntity(Entity): if (description := self._insteon_device.description) is None: description = "Unknown Device" # Get an extension label if there is one - extension = self._get_label() - if extension: + if extension := self._get_label(): extension = f" {extension}" return f"{description} {self._insteon_device.address}{extension}" diff --git a/homeassistant/components/insteon/translations/bg.json b/homeassistant/components/insteon/translations/bg.json index 3f29fc43cbd..65bf4bad59a 100644 --- a/homeassistant/components/insteon/translations/bg.json +++ b/homeassistant/components/insteon/translations/bg.json @@ -5,7 +5,8 @@ "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\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "select_single": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0435\u0434\u043d\u0430 \u043e\u043f\u0446\u0438\u044f." }, "step": { "hubv1": { @@ -31,7 +32,10 @@ "step": { "change_hub_config": { "data": { - "port": "\u041f\u043e\u0440\u0442" + "host": "IP \u0430\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "port": "\u041f\u043e\u0440\u0442", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" } } } diff --git a/homeassistant/components/insteon/translations/ja.json b/homeassistant/components/insteon/translations/ja.json new file mode 100644 index 00000000000..d4ddf083f1b --- /dev/null +++ b/homeassistant/components/insteon/translations/ja.json @@ -0,0 +1,109 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "select_single": "\u30aa\u30d7\u30b7\u30e7\u30f3\u30921\u3064\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "hubv1": { + "data": { + "host": "IP\u30a2\u30c9\u30ec\u30b9", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "Insteon Hub Version 1(2014\u4ee5\u524d)\u306e\u8a2d\u5b9a\u3092\u884c\u3044\u307e\u3059\u3002", + "title": "Insteon Hub Version 1" + }, + "hubv2": { + "data": { + "host": "IP\u30a2\u30c9\u30ec\u30b9", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "Insteon Hub Version 2\u306e\u8a2d\u5b9a\u3092\u884c\u3044\u307e\u3059\u3002", + "title": "Insteon Hub Version 2" + }, + "plm": { + "data": { + "device": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "description": "Insteon PowerLink Modem (PLM)\u306e\u8a2d\u5b9a\u3092\u884c\u3044\u307e\u3059\u3002", + "title": "Insteon PLM" + }, + "user": { + "data": { + "modem_type": "\u30e2\u30c7\u30e0\u306e\u7a2e\u985e\u3002" + }, + "description": "Insteon modem type\u3092\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "Insteon" + } + } + }, + "options": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "input_error": "\u30a8\u30f3\u30c8\u30ea\u304c\u7121\u52b9\u3067\u3059\u3002\u5024\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "select_single": "\u30aa\u30d7\u30b7\u30e7\u30f3\u30921\u3064\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "add_override": { + "data": { + "address": "\u30c7\u30d0\u30a4\u30b9\u306e\u30a2\u30c9\u30ec\u30b9(\u4f8b: 1a2b3c)", + "cat": "\u30c7\u30d0\u30a4\u30b9\u30ab\u30c6\u30b4\u30ea\u30fc(\u4f8b: 0x10)", + "subcat": "\u30c7\u30d0\u30a4\u30b9 \u30b5\u30d6\u30ab\u30c6\u30b4\u30ea\u30fc(\u4f8b: 0x0a)" + }, + "description": "\u30c7\u30d0\u30a4\u30b9\u3092\u8ffd\u52a0\u3057\u3066\u4e0a\u66f8\u304d\u3057\u307e\u3059\u3002", + "title": "Insteon" + }, + "add_x10": { + "data": { + "housecode": "\u30cf\u30a6\u30b9\u30b3\u30fc\u30c9(a\uff5ep)", + "platform": "\u30d7\u30e9\u30c3\u30c8\u30db\u30fc\u30e0", + "steps": "\u8abf\u5149\u30b9\u30c6\u30c3\u30d7(\u30e9\u30a4\u30c8\u30c7\u30d0\u30a4\u30b9\u306e\u307f\u3001\u30c7\u30d5\u30a9\u30eb\u30c822)", + "unitcode": "\u30e6\u30cb\u30c3\u30c8\u30b3\u30fc\u30c9(1\u301c16)" + }, + "description": "Insteon Hub\u306e\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5909\u66f4\u3059\u308b\u3002", + "title": "Insteon" + }, + "change_hub_config": { + "data": { + "host": "IP\u30a2\u30c9\u30ec\u30b9", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "Insteon Hub\u306e\u63a5\u7d9a\u60c5\u5831\u3092\u5909\u66f4\u3057\u307e\u3059\u3002\u3053\u306e\u5909\u66f4\u3092\u884c\u3063\u305f\u5f8c\u3001Home Assistant\u3092\u518d\u8d77\u52d5\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\u3053\u308c\u306f\u3001Hub\u81ea\u4f53\u306e\u8a2d\u5b9a\u3092\u5909\u66f4\u3059\u308b\u3082\u306e\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002Hub\u306e\u8a2d\u5b9a\u3092\u5909\u66f4\u3059\u308b\u306b\u306f\u3001Hub\u30a2\u30d7\u30ea\u3092\u4f7f\u7528\u3057\u307e\u3059\u3002", + "title": "Insteon" + }, + "init": { + "data": { + "add_override": "\u30c7\u30d0\u30a4\u30b9\u3092\u8ffd\u52a0\u3057\u3066\u4e0a\u66f8\u304d\u3057\u307e\u3059\u3002", + "add_x10": "X10 \u30c7\u30d0\u30a4\u30b9\u3092\u8ffd\u52a0\u3057\u307e\u3059\u3002", + "change_hub_config": "Hub\u306e\u8a2d\u5b9a\u3092\u5909\u66f4\u3057\u307e\u3059\u3002", + "remove_override": "\u30c7\u30d0\u30a4\u30b9\u3092\u524a\u9664\u3057\u3066\u4e0a\u66f8\u304d\u3057\u307e\u3059\u3002", + "remove_x10": "X10\u30c7\u30d0\u30a4\u30b9\u524a\u9664\u3092\u524a\u9664\u3057\u307e\u3059\u3002" + }, + "description": "\u8a2d\u5b9a\u3059\u308b\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "Insteon" + }, + "remove_override": { + "data": { + "address": "\u524a\u9664\u3059\u308b\u30c7\u30d0\u30a4\u30b9\u306e\u30a2\u30c9\u30ec\u30b9\u3092\u9078\u629e" + }, + "description": "\u30c7\u30d0\u30a4\u30b9\u3092\u524a\u9664\u3057\u3066\u4e0a\u66f8\u304d\u3057\u307e\u3059", + "title": "Insteon" + }, + "remove_x10": { + "data": { + "address": "\u524a\u9664\u3059\u308b\u30c7\u30d0\u30a4\u30b9\u306e\u30a2\u30c9\u30ec\u30b9\u3092\u9078\u629e" + }, + "description": "X10\u30c7\u30d0\u30a4\u30b9\u306e\u524a\u9664", + "title": "Insteon" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/insteon/translations/tr.json b/homeassistant/components/insteon/translations/tr.json index 6c41f53b31e..cd1940cbec3 100644 --- a/homeassistant/components/insteon/translations/tr.json +++ b/homeassistant/components/insteon/translations/tr.json @@ -5,19 +5,21 @@ "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "select_single": "Bir se\u00e7enek belirleyin." }, "step": { "hubv1": { "data": { - "host": "\u0130p Adresi", + "host": "IP Adresi", "port": "Port" }, + "description": "Insteon Hub S\u00fcr\u00fcm 1'i (2014 \u00f6ncesi) yap\u0131land\u0131r\u0131n.", "title": "Insteon Hub S\u00fcr\u00fcm 1" }, "hubv2": { "data": { - "host": "\u0130p Adresi", + "host": "IP Adresi", "password": "Parola", "port": "Port", "username": "Kullan\u0131c\u0131 Ad\u0131" @@ -26,7 +28,11 @@ "title": "Insteon Hub S\u00fcr\u00fcm 2" }, "plm": { - "description": "Insteon PowerLink Modemini (PLM) yap\u0131land\u0131r\u0131n." + "data": { + "device": "USB Cihaz Yolu" + }, + "description": "Insteon PowerLink Modemini (PLM) yap\u0131land\u0131r\u0131n.", + "title": "Insteon PLM'si" }, "user": { "data": { @@ -40,11 +46,24 @@ "options": { "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", - "input_error": "Ge\u00e7ersiz giri\u015fler, l\u00fctfen de\u011ferlerinizi kontrol edin." + "input_error": "Ge\u00e7ersiz giri\u015fler, l\u00fctfen de\u011ferlerinizi kontrol edin.", + "select_single": "Bir se\u00e7enek belirleyin." }, "step": { + "add_override": { + "data": { + "address": "Cihaz adresi (\u00f6rnek: 1a2b3c)", + "cat": "Cihaz alt kategorisi (yani 0x0a)", + "subcat": "Cihaz alt kategorisi (yani 0x0a)" + }, + "description": "Bir cihaz\u0131 ge\u00e7ersiz k\u0131lma ekleyin.", + "title": "Insteon" + }, "add_x10": { "data": { + "housecode": "Ev kodu (a - p)", + "platform": "Platform", + "steps": "Dimmer ad\u0131mlar\u0131 (yaln\u0131zca hafif cihazlar i\u00e7in varsay\u0131lan 22)", "unitcode": "Birim kodu (1-16)" }, "description": "Insteon Hub parolas\u0131n\u0131 de\u011fi\u015ftirin.", @@ -52,11 +71,12 @@ }, "change_hub_config": { "data": { - "host": "\u0130p Adresi", + "host": "IP Adresi", "password": "Parola", "port": "Port", "username": "Kullan\u0131c\u0131 Ad\u0131" }, + "description": "Insteon Hub ba\u011flant\u0131 bilgilerini de\u011fi\u015ftirin. Bu de\u011fi\u015fikli\u011fi yapt\u0131ktan sonra Home Assistant'\u0131 yeniden ba\u015flatman\u0131z gerekir. Bu, Hub'\u0131n yap\u0131land\u0131rmas\u0131n\u0131 de\u011fi\u015ftirmez. Hub'daki yap\u0131land\u0131rmay\u0131 de\u011fi\u015ftirmek i\u00e7in Hub uygulamas\u0131n\u0131 kullan\u0131n.", "title": "Insteon" }, "init": { diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 6c0e09af0c5..463cb3b4e05 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -124,8 +124,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity): async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() - state = await self.async_get_last_state() - if state: + if state := await self.async_get_last_state(): try: self._state = Decimal(state.state) except (DecimalException, ValueError) as err: diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index afe0fb519a8..4305489e25d 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -120,8 +120,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= _LOGGER.error("Error connecting to the %s server", device_type) raise PlatformNotReady from ex - ih_devices = controller.get_devices() - if ih_devices: + if ih_devices := controller.get_devices(): async_add_entities( [ IntesisAC(ih_device_id, device, controller) @@ -194,8 +193,7 @@ class IntesisAC(ClimateEntity): self._support |= SUPPORT_PRESET_MODE # Setup HVAC modes - modes = controller.get_mode_list(ih_device_id) - if modes: + if modes := controller.get_mode_list(ih_device_id): mode_list = [MAP_IH_TO_HVAC_MODE[mode] for mode in modes] self._hvac_mode_list.extend(mode_list) self._hvac_mode_list.append(HVAC_MODE_OFF) diff --git a/homeassistant/components/ios/translations/ja.json b/homeassistant/components/ios/translations/ja.json new file mode 100644 index 00000000000..60fbfbea06f --- /dev/null +++ b/homeassistant/components/ios/translations/ja.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "confirm": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iota/__init__.py b/homeassistant/components/iota/__init__.py deleted file mode 100644 index 04db9140122..00000000000 --- a/homeassistant/components/iota/__init__.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Support for IOTA wallets.""" -from datetime import timedelta - -from iota import Iota -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import load_platform -from homeassistant.helpers.entity import Entity - -CONF_IRI = "iri" -CONF_TESTNET = "testnet" -CONF_WALLET_NAME = "name" -CONF_WALLET_SEED = "seed" -CONF_WALLETS = "wallets" - -DOMAIN = "iota" - -IOTA_PLATFORMS = ["sensor"] - -SCAN_INTERVAL = timedelta(minutes=10) - -WALLET_CONFIG = vol.Schema( - { - vol.Required(CONF_WALLET_NAME): cv.string, - vol.Required(CONF_WALLET_SEED): cv.string, - } -) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_IRI): cv.string, - vol.Optional(CONF_TESTNET, default=False): cv.boolean, - vol.Required(CONF_WALLETS): vol.All(cv.ensure_list, [WALLET_CONFIG]), - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -def setup(hass, config): - """Set up the IOTA component.""" - iota_config = config[DOMAIN] - - for platform in IOTA_PLATFORMS: - load_platform(hass, platform, DOMAIN, iota_config, config) - - return True - - -class IotaDevice(Entity): - """Representation of a IOTA device.""" - - def __init__(self, name, seed, iri, is_testnet=False): - """Initialise the IOTA device.""" - self._name = name - self._seed = seed - self.iri = iri - self.is_testnet = is_testnet - - @property - def name(self): - """Return the default name of the device.""" - return self._name - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - return {CONF_WALLET_NAME: self._name} - - @property - def api(self): - """Construct API object for interaction with the IRI node.""" - - return Iota(adapter=self.iri, seed=self._seed) diff --git a/homeassistant/components/iota/manifest.json b/homeassistant/components/iota/manifest.json deleted file mode 100644 index 36e9a79d8d4..00000000000 --- a/homeassistant/components/iota/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "iota", - "name": "IOTA", - "documentation": "https://www.home-assistant.io/integrations/iota", - "requirements": ["pyota==2.0.5"], - "codeowners": [], - "iot_class": "cloud_polling" -} diff --git a/homeassistant/components/iota/sensor.py b/homeassistant/components/iota/sensor.py deleted file mode 100644 index 687a4ca35d6..00000000000 --- a/homeassistant/components/iota/sensor.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Support for IOTA wallet sensors.""" -from datetime import timedelta - -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_NAME - -from . import CONF_WALLETS, IotaDevice - -ATTR_TESTNET = "testnet" -ATTR_URL = "url" - -CONF_IRI = "iri" -CONF_SEED = "seed" -CONF_TESTNET = "testnet" - -SCAN_INTERVAL = timedelta(minutes=3) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the IOTA sensor.""" - iota_config = discovery_info - sensors = [ - IotaBalanceSensor(wallet, iota_config) for wallet in iota_config[CONF_WALLETS] - ] - - sensors.append(IotaNodeSensor(iota_config=iota_config)) - - add_entities(sensors) - - -class IotaBalanceSensor(IotaDevice, SensorEntity): - """Implement an IOTA sensor for displaying wallets balance.""" - - def __init__(self, wallet_config, iota_config): - """Initialize the sensor.""" - super().__init__( - name=wallet_config[CONF_NAME], - seed=wallet_config[CONF_SEED], - iri=iota_config[CONF_IRI], - is_testnet=iota_config[CONF_TESTNET], - ) - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} Balance" - - @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.""" - return "IOTA" - - def update(self): - """Fetch new balance from IRI.""" - self._state = self.api.get_inputs()["totalBalance"] - - -class IotaNodeSensor(IotaDevice, SensorEntity): - """Implement an IOTA sensor for displaying attributes of node.""" - - def __init__(self, iota_config): - """Initialize the sensor.""" - super().__init__( - name="Node Info", - seed=None, - iri=iota_config[CONF_IRI], - is_testnet=iota_config[CONF_TESTNET], - ) - self._state = None - self._attr = {ATTR_URL: self.iri, ATTR_TESTNET: self.is_testnet} - - @property - def name(self): - """Return the name of the sensor.""" - return "IOTA Node" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - return self._attr - - def update(self): - """Fetch new attributes IRI node.""" - node_info = self.api.get_node_info() - self._state = node_info.get("appVersion") - - # convert values to raw string formats - self._attr.update({k: str(v) for k, v in node_info.items()}) diff --git a/homeassistant/components/iotawatt/translations/fr.json b/homeassistant/components/iotawatt/translations/fr.json new file mode 100644 index 00000000000..9cb1d7dfd16 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/fr.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "H\u00f4te" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/id.json b/homeassistant/components/iotawatt/translations/id.json index a48af7cd34d..ef50c938292 100644 --- a/homeassistant/components/iotawatt/translations/id.json +++ b/homeassistant/components/iotawatt/translations/id.json @@ -10,6 +10,12 @@ "data": { "password": "Kata Sandi", "username": "Nama Pengguna" + }, + "description": "Perangkat IoTawatt memerlukan otentikasi. Masukkan nama pengguna dan kata sandi dan klik tombol Kirim." + }, + "user": { + "data": { + "host": "Host" } } } diff --git a/homeassistant/components/iotawatt/translations/ja.json b/homeassistant/components/iotawatt/translations/ja.json new file mode 100644 index 00000000000..973f21fdcb7 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/ja.json @@ -0,0 +1,23 @@ +{ + "config": { + "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": { + "auth": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "IoTawatt\u30c7\u30d0\u30a4\u30b9\u306b\u306f\u8a8d\u8a3c\u304c\u5fc5\u8981\u3067\u3059\u3002\u30e6\u30fc\u30b6\u30fc\u540d\u3068\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3001\u9001\u4fe1(submit) \u30dc\u30bf\u30f3\u3092\u30af\u30ea\u30c3\u30af\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/pl.json b/homeassistant/components/iotawatt/translations/pl.json index d6fa1ba5735..dac83695f0c 100644 --- a/homeassistant/components/iotawatt/translations/pl.json +++ b/homeassistant/components/iotawatt/translations/pl.json @@ -11,7 +11,7 @@ "password": "Has\u0142o", "username": "Nazwa u\u017cytkownika" }, - "description": "Urz\u0105dzenie IoTawatt wymaga uwierzytelnienia. Wprowad\u017a nazw\u0119 u\u017cytkownika i has\u0142o, a nast\u0119pnie kliknij przycisk Prze\u015blij." + "description": "Urz\u0105dzenie IoTawatt wymaga uwierzytelnienia. Wprowad\u017a nazw\u0119 u\u017cytkownika i has\u0142o, a nast\u0119pnie kliknij przycisk \"Zatwierd\u017a\"." }, "user": { "data": { diff --git a/homeassistant/components/iotawatt/translations/tr.json b/homeassistant/components/iotawatt/translations/tr.json new file mode 100644 index 00000000000..af6af5b8633 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/tr.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "auth": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "IoTawatt cihaz\u0131 kimlik do\u011frulama gerektirir. L\u00fctfen kullan\u0131c\u0131 ad\u0131n\u0131 ve \u015fifreyi girin ve G\u00f6nder d\u00fc\u011fmesine t\u0131klay\u0131n." + }, + "user": { + "data": { + "host": "Ana bilgisayar" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py index dfc4abba707..4e102b69782 100644 --- a/homeassistant/components/iperf3/sensor.py +++ b/homeassistant/components/iperf3/sensor.py @@ -60,8 +60,7 @@ class Iperf3Sensor(RestoreEntity, SensorEntity): ) ) - state = await self.async_get_last_state() - if not state: + if not (state := await self.async_get_last_state()): return self._attr_native_value = state.state diff --git a/homeassistant/components/ipma/translations/ja.json b/homeassistant/components/ipma/translations/ja.json new file mode 100644 index 00000000000..078026d8333 --- /dev/null +++ b/homeassistant/components/ipma/translations/ja.json @@ -0,0 +1,24 @@ +{ + "config": { + "error": { + "name_exists": "\u540d\u524d\u306f\u3059\u3067\u306b\u5b58\u5728\u3057\u307e\u3059" + }, + "step": { + "user": { + "data": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "mode": "\u30e2\u30fc\u30c9", + "name": "\u540d\u524d" + }, + "description": "\u30dd\u30eb\u30c8\u30ac\u30eb\u6d77\u6d0b\u5927\u6c17\u7814\u7a76\u6240(Instituto Portugu\u00eas do Mar e Atmosfera)", + "title": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3" + } + } + }, + "system_health": { + "info": { + "api_endpoint_reachable": "IPMA API\u30a8\u30f3\u30c9\u30dd\u30a4\u30f3\u30c8\u306b\u5230\u9054\u53ef\u80fd" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ipma/translations/tr.json b/homeassistant/components/ipma/translations/tr.json index a8df63645ab..6ef99de4832 100644 --- a/homeassistant/components/ipma/translations/tr.json +++ b/homeassistant/components/ipma/translations/tr.json @@ -1,12 +1,18 @@ { "config": { + "error": { + "name_exists": "Bu ad zaten var" + }, "step": { "user": { "data": { "latitude": "Enlem", "longitude": "Boylam", - "mode": "Mod" - } + "mode": "Mod", + "name": "Ad" + }, + "description": "Instituto Portugu\u00eas do Mar e Atmosfera", + "title": "Konum" } } }, diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index 32a5967f8b4..f831bac2eef 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -142,7 +142,7 @@ async def async_get_api(hass): async def async_get_location(hass, api, latitude, longitude): """Retrieve pyipma location, location name to be used as the entity name.""" - with async_timeout.timeout(30): + async with async_timeout.timeout(30): location = await Location.get(api, float(latitude), float(longitude)) _LOGGER.debug( @@ -172,7 +172,7 @@ class IPMAWeather(WeatherEntity): @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self): """Update Condition and Forecast.""" - with async_timeout.timeout(10): + async with async_timeout.timeout(10): new_observation = await self._location.observation(self._api) new_forecast = await self._location.forecast(self._api) diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py index b760fccb598..432094eee8e 100644 --- a/homeassistant/components/ipp/config_flow.py +++ b/homeassistant/components/ipp/config_flow.py @@ -15,6 +15,7 @@ from pyipp import ( ) import voluptuous as vol +from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.const import ( CONF_HOST, @@ -99,25 +100,32 @@ class IPPFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) - async def async_step_zeroconf(self, discovery_info: dict[str, Any]) -> FlowResult: + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle zeroconf discovery.""" - port = discovery_info[CONF_PORT] - zctype = discovery_info["type"] - name = discovery_info[CONF_NAME].replace(f".{zctype}", "") + host = discovery_info.host + + # Avoid probing devices that already have an entry + self._async_abort_entries_match({CONF_HOST: host}) + + port = discovery_info.port + zctype = discovery_info.type + name = discovery_info.name.replace(f".{zctype}", "") tls = zctype == "_ipps._tcp.local." - base_path = discovery_info["properties"].get("rp", "ipp/print") + base_path = discovery_info.properties.get("rp", "ipp/print") self.context.update({"title_placeholders": {"name": name}}) self.discovery_info.update( { - CONF_HOST: discovery_info[CONF_HOST], + CONF_HOST: host, CONF_PORT: port, CONF_SSL: tls, CONF_VERIFY_SSL: False, CONF_BASE_PATH: f"/{base_path}", CONF_NAME: name, - CONF_UUID: discovery_info["properties"].get("UUID"), + CONF_UUID: discovery_info.properties.get("UUID"), } ) diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index 02e2082bbd3..2cc7d00a633 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -1,7 +1,7 @@ """Support for IPP sensors.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta from typing import Any from homeassistant.components.sensor import SensorEntity @@ -187,7 +187,6 @@ class IPPUptimeSensor(IPPSensor): ) @property - def native_value(self) -> str: + def native_value(self) -> datetime: """Return the state of the sensor.""" - uptime = utcnow() - timedelta(seconds=self.coordinator.data.info.uptime) - return uptime.replace(microsecond=0).isoformat() + return utcnow() - timedelta(seconds=self.coordinator.data.info.uptime) diff --git a/homeassistant/components/ipp/translations/ja.json b/homeassistant/components/ipp/translations/ja.json new file mode 100644 index 00000000000..65226f78cbc --- /dev/null +++ b/homeassistant/components/ipp/translations/ja.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "\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", + "connection_upgrade": "\u63a5\u7d9a\u30a2\u30c3\u30d7\u30b0\u30ec\u30fc\u30c9(connection upgrade)\u304c\u5fc5\u8981\u306a\u305f\u3081\u3001\u30d7\u30ea\u30f3\u30bf\u30fc\u3078\u306e\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "ipp_error": "IPP\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002", + "ipp_version_error": "IPP\u306e\u30d0\u30fc\u30b8\u30e7\u30f3\u304c\u30d7\u30ea\u30f3\u30bf\u30fc\u3067\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002", + "parse_error": "\u30d7\u30ea\u30f3\u30bf\u30fc\u304b\u3089\u306e\u5fdc\u7b54\u306e\u89e3\u6790\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "unique_id_required": "\u691c\u51fa\u306b\u5fc5\u8981\u306a\u30e6\u30cb\u30fc\u30af(\u4e00\u610f)ID\u304c\u30c7\u30d0\u30a4\u30b9\u306b\u3042\u308a\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "connection_upgrade": "\u30d7\u30ea\u30f3\u30bf\u30fc\u3078\u306e\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002SSL/TLS\u30aa\u30d7\u30b7\u30e7\u30f3\u306b\u30c1\u30a7\u30c3\u30af\u3092\u5165\u308c\u3066(option checked)\u3001\u3082\u3046\u4e00\u5ea6\u3084\u308a\u76f4\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "base_path": "\u30d7\u30ea\u30f3\u30bf\u30fc\u3078\u306e\u76f8\u5bfe\u30d1\u30b9", + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8", + "ssl": "SSL\u8a3c\u660e\u66f8\u3092\u4f7f\u7528\u3059\u308b", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + }, + "description": "\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u5370\u5237\u30d7\u30ed\u30c8\u30b3\u30eb(IPP)\u3092\u4ecb\u3057\u3066\u30d7\u30ea\u30f3\u30bf\u30fc\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3001Home Assistant\u3068\u9023\u643a\u3059\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002", + "title": "\u30d7\u30ea\u30f3\u30bf\u30fc\u3092\u30ea\u30f3\u30af\u3059\u308b" + }, + "zeroconf_confirm": { + "description": "{name} \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f", + "title": "\u691c\u51fa\u3055\u308c\u305f\u30d7\u30ea\u30f3\u30bf\u30fc" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ipp/translations/tr.json b/homeassistant/components/ipp/translations/tr.json index 78b9a868bd2..cca4103324f 100644 --- a/homeassistant/components/ipp/translations/tr.json +++ b/homeassistant/components/ipp/translations/tr.json @@ -2,19 +2,28 @@ "config": { "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "connection_upgrade": "Ba\u011flant\u0131 y\u00fckseltmesi gerekti\u011finden yaz\u0131c\u0131ya ba\u011flan\u0131lamad\u0131.", + "ipp_error": "IPP hatas\u0131yla kar\u015f\u0131la\u015f\u0131ld\u0131.", + "ipp_version_error": "IPP s\u00fcr\u00fcm\u00fc yaz\u0131c\u0131 taraf\u0131ndan desteklenmiyor.", + "parse_error": "Yaz\u0131c\u0131dan gelen yan\u0131t ayr\u0131\u015ft\u0131r\u0131lamad\u0131.", + "unique_id_required": "Ke\u015fif i\u00e7in cihaz\u0131n benzersiz kimli\u011fi eksik." }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", "connection_upgrade": "Yaz\u0131c\u0131ya ba\u011flan\u0131lamad\u0131. L\u00fctfen SSL / TLS se\u00e7ene\u011fi i\u015faretli olarak tekrar deneyin." }, - "flow_title": "Yaz\u0131c\u0131: {name}", + "flow_title": "{name}", "step": { "user": { "data": { - "host": "Ana Bilgisayar", - "port": "Port" + "base_path": "Yaz\u0131c\u0131n\u0131n yolu", + "host": "Ana bilgisayar", + "port": "Port", + "ssl": "SSL sertifikas\u0131 kullan\u0131r", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" }, + "description": "Home Assistant ile entegre olmas\u0131 i\u00e7in yaz\u0131c\u0131n\u0131z\u0131 \u0130nternet Yazd\u0131rma Protokol\u00fc (IPP) arac\u0131l\u0131\u011f\u0131yla kurun.", "title": "Yaz\u0131c\u0131n\u0131z\u0131 ba\u011flay\u0131n" }, "zeroconf_confirm": { diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index 8d556251e4e..b0f7086cad7 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -11,7 +11,6 @@ from pyiqvia import Client from pyiqvia.errors import IQVIAError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client @@ -43,9 +42,6 @@ PLATFORMS = ["sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up IQVIA as config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} - if not entry.unique_id: # If the config entry doesn't already have a unique ID, set one: hass.config_entries.async_update_entry( @@ -55,6 +51,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: websession = aiohttp_client.async_get_clientsession(hass) client = Client(entry.data[CONF_ZIP_CODE], session=websession) + # We disable the client's request retry abilities here to avoid a lengthy (and + # blocking) startup: + client.disable_request_retries() + async def async_get_data_from_api( api_coro: Callable[..., Awaitable] ) -> dict[str, Any]: @@ -94,7 +94,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # API calls fail: raise ConfigEntryNotReady() + # Once we've successfully authenticated, we re-enable client request retries: + client.enable_request_retries() + + hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinators + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -121,7 +126,7 @@ class IQVIAEntity(CoordinatorEntity): """Initialize.""" super().__init__(coordinator) - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._attr_extra_state_attributes = {} self._attr_unique_id = f"{entry.data[CONF_ZIP_CODE]}_{description.key}" self._entry = entry self.entity_description = description diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 7c0194a4896..f78ca1e258c 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -3,7 +3,7 @@ "name": "IQVIA", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iqvia", - "requirements": ["numpy==1.21.2", "pyiqvia==1.1.0"], + "requirements": ["numpy==1.21.4", "pyiqvia==2021.11.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/iqvia/translations/bg.json b/homeassistant/components/iqvia/translations/bg.json index 26125dcaa14..e59b361d2e0 100644 --- a/homeassistant/components/iqvia/translations/bg.json +++ b/homeassistant/components/iqvia/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u0423\u0441\u043b\u0443\u0433\u0430\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430" + }, "error": { "invalid_zip_code": "\u041f\u043e\u0449\u0435\u043d\u0441\u043a\u0438\u044f\u0442 \u043a\u043e\u0434 \u0435 \u043d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d" }, diff --git a/homeassistant/components/iqvia/translations/ja.json b/homeassistant/components/iqvia/translations/ja.json new file mode 100644 index 00000000000..7a9e136ddc9 --- /dev/null +++ b/homeassistant/components/iqvia/translations/ja.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "invalid_zip_code": "\u90f5\u4fbf\u756a\u53f7\u304c\u7121\u52b9\u3067\u3059" + }, + "step": { + "user": { + "data": { + "zip_code": "\u90f5\u4fbf\u756a\u53f7" + }, + "description": "\u7c73\u56fd\u307e\u305f\u306f\u30ab\u30ca\u30c0\u306e\u90f5\u4fbf\u756a\u53f7\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "IQVIA" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/translations/tr.json b/homeassistant/components/iqvia/translations/tr.json index 717f6d72b94..0b8a702d696 100644 --- a/homeassistant/components/iqvia/translations/tr.json +++ b/homeassistant/components/iqvia/translations/tr.json @@ -2,6 +2,18 @@ "config": { "abort": { "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "invalid_zip_code": "Posta kodu ge\u00e7ersiz" + }, + "step": { + "user": { + "data": { + "zip_code": "Posta kodu" + }, + "description": "ABD veya Kanada Posta kodunuzu doldurun.", + "title": "IQVIA" + } } } } \ No newline at end of file diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py index 99cc65bb548..38a95fb803b 100644 --- a/homeassistant/components/islamic_prayer_times/sensor.py +++ b/homeassistant/components/islamic_prayer_times/sensor.py @@ -45,10 +45,8 @@ class IslamicPrayerTimeSensor(SensorEntity): @property def native_value(self): """Return the state of the sensor.""" - return ( - self.client.prayer_times_info.get(self.sensor_type) - .astimezone(dt_util.UTC) - .isoformat() + return self.client.prayer_times_info.get(self.sensor_type).astimezone( + dt_util.UTC ) async def async_added_to_hass(self): diff --git a/homeassistant/components/islamic_prayer_times/translations/ja.json b/homeassistant/components/islamic_prayer_times/translations/ja.json new file mode 100644 index 00000000000..ea6ad0c6522 --- /dev/null +++ b/homeassistant/components/islamic_prayer_times/translations/ja.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "user": { + "description": "\u30a4\u30b9\u30e9\u30e0\u6559\u306e\u7948\u308a\u306e\u6642\u9593\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u304b\uff1f", + "title": "Islamic Prayer Times\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "calculation_method": "\u7948\u308a\u306e\u8a08\u7b97\u65b9\u6cd5" + } + } + } + }, + "title": "\u30a4\u30b9\u30e9\u30e0\u306e\u7948\u308a\u306e\u6642\u9593" +} \ No newline at end of file diff --git a/homeassistant/components/islamic_prayer_times/translations/tr.json b/homeassistant/components/islamic_prayer_times/translations/tr.json index a152eb19468..a3cf464873d 100644 --- a/homeassistant/components/islamic_prayer_times/translations/tr.json +++ b/homeassistant/components/islamic_prayer_times/translations/tr.json @@ -2,6 +2,22 @@ "config": { "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + }, + "step": { + "user": { + "description": "\u0130slami Namaz Vakitleri kurmak ister misiniz?", + "title": "\u0130slami Namaz Vakitlerini Ayarlay\u0131n" + } } - } + }, + "options": { + "step": { + "init": { + "data": { + "calculation_method": "Dua hesaplama y\u00f6ntemi" + } + } + } + }, + "title": "\u0130slami Namaz Vakitleri" } \ No newline at end of file diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 65be6a74c19..9250b567d1b 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -200,7 +200,7 @@ async def async_setup_entry( _LOGGER.info(repr(isy.clock)) hass_isy_data[ISY994_ISY] = isy - await _async_get_or_create_isy_device_in_registry(hass, entry, isy) + _async_get_or_create_isy_device_in_registry(hass, entry, isy) # Load platforms for the devices in the ISY controller that we support. hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -259,10 +259,11 @@ def _async_isy_to_configuration_url(isy: ISY) -> str: return f"{proto}://{connection_info['addr']}:{connection_info['port']}" -async def _async_get_or_create_isy_device_in_registry( +@callback +def _async_get_or_create_isy_device_in_registry( hass: HomeAssistant, entry: config_entries.ConfigEntry, isy ) -> None: - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) url = _async_isy_to_configuration_url(isy) device_registry.async_get_or_create( config_entry_id=entry.entry_id, diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 34c7a40cfc0..7289f7d416e 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -10,8 +10,7 @@ from pyisy.connection import Connection import voluptuous as vol from homeassistant import config_entries, core, data_entry_flow, exceptions -from homeassistant.components import ssdp -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp, ssdp from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import aiohttp_client @@ -89,7 +88,7 @@ async def validate_input(hass: core.HomeAssistant, data): ) try: - with async_timeout.timeout(30): + async with async_timeout.timeout(30): isy_conf_xml = await isy_conn.test_connection() except ISYInvalidAuthError as error: raise InvalidAuth from error @@ -184,17 +183,17 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) raise data_entry_flow.AbortFlow("already_configured") - async def async_step_dhcp(self, discovery_info): + async def async_step_dhcp( + self, discovery_info: dhcp.DhcpServiceInfo + ) -> data_entry_flow.FlowResult: """Handle a discovered isy994 via dhcp.""" - friendly_name = discovery_info[HOSTNAME] - url = f"http://{discovery_info[IP_ADDRESS]}" - mac = discovery_info[MAC_ADDRESS] + friendly_name = discovery_info.hostname + url = f"http://{discovery_info.ip}" + mac = discovery_info.macaddress isy_mac = ( f"{mac[0:2]}:{mac[2:4]}:{mac[4:6]}:{mac[6:8]}:{mac[8:10]}:{mac[10:12]}" ) - await self._async_set_unique_id_or_update( - isy_mac, discovery_info[IP_ADDRESS], None - ) + await self._async_set_unique_id_or_update(isy_mac, discovery_info.ip, None) self.discovered_conf = { CONF_NAME: friendly_name, @@ -204,12 +203,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = self.discovered_conf return await self.async_step_user() - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp( + self, discovery_info: ssdp.SsdpServiceInfo + ) -> data_entry_flow.FlowResult: """Handle a discovered isy994.""" - friendly_name = discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME] - url = discovery_info[ssdp.ATTR_SSDP_LOCATION] + friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + url = discovery_info.ssdp_location parsed_url = urlparse(url) - mac = discovery_info[ssdp.ATTR_UPNP_UDN] + mac = discovery_info.upnp[ssdp.ATTR_UPNP_UDN] if mac.startswith(UDN_UUID_PREFIX): mac = mac[len(UDN_UUID_PREFIX) :] if url.endswith(ISY_URL_POSTFIX): diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py index 509fd259830..3d208d18fa9 100644 --- a/homeassistant/components/isy994/light.py +++ b/homeassistant/components/isy994/light.py @@ -117,8 +117,7 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity): await super().async_added_to_hass() self._last_brightness = self.brightness or 255 - last_state = await self.async_get_last_state() - if not last_state: + if not (last_state := await self.async_get_last_state()): return if ( diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index c34e8a1c67b..5b18d6cd33a 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -274,9 +274,10 @@ def async_setup_services(hass: HomeAssistant): # noqa: C901 return _LOGGER.error("Could not set variable value; not found or enabled on the ISY") - async def async_cleanup_registry_entries(service) -> None: + @callback + def async_cleanup_registry_entries(service) -> None: """Remove extra entities that are no longer part of the integration.""" - entity_registry = await er.async_get_registry(hass) + entity_registry = er.async_get(hass) config_ids = [] current_unique_ids = [] diff --git a/homeassistant/components/isy994/translations/bg.json b/homeassistant/components/isy994/translations/bg.json index 64b2d259c20..04a015b0d61 100644 --- a/homeassistant/components/isy994/translations/bg.json +++ b/homeassistant/components/isy994/translations/bg.json @@ -8,6 +8,7 @@ "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": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/isy994/translations/id.json b/homeassistant/components/isy994/translations/id.json index 099e3607d1e..aeea471514c 100644 --- a/homeassistant/components/isy994/translations/id.json +++ b/homeassistant/components/isy994/translations/id.json @@ -36,5 +36,13 @@ "title": "Opsi ISY994" } } + }, + "system_health": { + "info": { + "device_connected": "Terhubung ke ISY", + "host_reachable": "Host dapat dijangkau", + "last_heartbeat": "Waktu Detak Terakhir", + "websocket_status": "Status Soket Peristiwa" + } } } \ No newline at end of file diff --git a/homeassistant/components/isy994/translations/ja.json b/homeassistant/components/isy994/translations/ja.json new file mode 100644 index 00000000000..438eadfcd83 --- /dev/null +++ b/homeassistant/components/isy994/translations/ja.json @@ -0,0 +1,48 @@ +{ + "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_host": "\u30db\u30b9\u30c8\u30a8\u30f3\u30c8\u30ea\u306f\u304d\u3061\u3093\u3068\u3057\u305fURL\u5f62\u5f0f\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3067\u3057\u305f \u4f8b: http://192.168.10.100:80", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "host": "URL", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "tls": "ISY\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u30fc\u306eTLS\u30d0\u30fc\u30b8\u30e7\u30f3\u3002", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u30db\u30b9\u30c8\u30a8\u30f3\u30c8\u30ea\u306f\u304d\u3061\u3093\u3068\u3057\u305fURL\u5f62\u5f0f\u3067\u3042\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059 \u4f8b: http://192.168.10.100:80", + "title": "\u3042\u306a\u305f\u306eISY994\u306b\u63a5\u7d9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ignore_string": "\u7121\u8996\u3059\u308b\u6587\u5b57\u5217", + "restore_light_state": "\u30e9\u30a4\u30c8\u306e\u660e\u308b\u3055\u3092\u5fa9\u5143\u3059\u308b", + "sensor_string": "\u30ce\u30fc\u30c9 \u30bb\u30f3\u30b5\u30fc\u6587\u5b57\u5217", + "variable_sensor_string": "\u53ef\u5909\u30bb\u30f3\u30b5\u30fc\u6587\u5b57\u5217(Variable Sensor String)" + }, + "description": "ISY\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u8a2d\u5b9a:\n \u2022 Node Sensor String: \u540d\u524d\u306b\u3001'Node Sensor String' \u3092\u542b\u3080\u4efb\u610f\u306e\u30c7\u30d0\u30a4\u30b9\u307e\u305f\u306f\u30d5\u30a9\u30eb\u30c0\u306f\u3001\u30bb\u30f3\u30b5\u30fc\u307e\u305f\u306f\u30d0\u30a4\u30ca\u30ea\u30bb\u30f3\u30b5\u30fc\u3068\u3057\u3066\u6271\u308f\u308c\u307e\u3059\u3002\n \u2022 Ignore String: \u540d\u524d\u306b\u3001'Ignore String'\u3092\u6301\u3064\u30c7\u30d0\u30a4\u30b9\u306f\u7121\u8996\u3055\u308c\u307e\u3059\u3002\n \u2022 Variable Sensor String: 'Variable Sensor String' \u3092\u542b\u3080\u5909\u6570\u306f\u3001\u30bb\u30f3\u30b5\u30fc\u3068\u3057\u3066\u8ffd\u52a0\u3055\u308c\u307e\u3059\u3002\n \u2022 Restore Light Brightness: \u6709\u52b9\u306b\u3059\u308b\u3068\u3001\u30c7\u30d0\u30a4\u30b9\u7d44\u307f\u8fbc\u307f\u306e\u30aa\u30f3\u30ec\u30d9\u30eb\u3067\u306f\u306a\u304f\u3001\u30e9\u30a4\u30c8\u3092\u30aa\u30f3\u306b\u3057\u305f\u3068\u304d\u306b\u3001\u4ee5\u524d\u306e\u660e\u308b\u3055\u306b\u5fa9\u5143\u3055\u308c\u307e\u3059\u3002", + "title": "ISY994\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + }, + "system_health": { + "info": { + "device_connected": "ISY\u63a5\u7d9a\u6e08", + "host_reachable": "\u30db\u30b9\u30c8\u5230\u9054\u53ef\u80fd", + "last_heartbeat": "\u6700\u5f8c\u306e\u30cf\u30fc\u30c8\u30d3\u30fc\u30c8\u30bf\u30a4\u30e0", + "websocket_status": "\u30a4\u30d9\u30f3\u30c8\u30bd\u30b1\u30c3\u30c8 \u30b9\u30c6\u30fc\u30bf\u30b9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/isy994/translations/tr.json b/homeassistant/components/isy994/translations/tr.json index d2d58a89f31..78fc66196f1 100644 --- a/homeassistant/components/isy994/translations/tr.json +++ b/homeassistant/components/isy994/translations/tr.json @@ -9,13 +9,17 @@ "invalid_host": "Ana bilgisayar giri\u015fi tam URL bi\u00e7iminde de\u011fildi, \u00f6r. http://192.168.10.100:80", "unknown": "Beklenmeyen hata" }, + "flow_title": "{name} ({host})", "step": { "user": { "data": { "host": "URL", "password": "Parola", + "tls": "ISY denetleyicisinin TLS s\u00fcr\u00fcm\u00fc.", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "description": "Ana bilgisayar giri\u015fi tam URL bi\u00e7iminde olmal\u0131d\u0131r, \u00f6r. http://192.168.10.100:80", + "title": "ISY994'\u00fcn\u00fcze ba\u011flan\u0131n" } } }, @@ -23,10 +27,22 @@ "step": { "init": { "data": { + "ignore_string": "Dizeyi Yoksay", + "restore_light_state": "I\u015f\u0131k Parlakl\u0131\u011f\u0131n\u0131 Geri Y\u00fckle", + "sensor_string": "D\u00fc\u011f\u00fcm Sens\u00f6r\u00fc Dizisi", "variable_sensor_string": "De\u011fi\u015fken Sens\u00f6r Dizesi" }, - "description": "ISY Entegrasyonu i\u00e7in se\u00e7enekleri ayarlay\u0131n:\n \u2022 D\u00fc\u011f\u00fcm Sens\u00f6r\u00fc Dizisi: Ad\u0131nda 'D\u00fc\u011f\u00fcm Sens\u00f6r\u00fc Dizisi' i\u00e7eren herhangi bir cihaz veya klas\u00f6r, bir sens\u00f6r veya ikili sens\u00f6r olarak ele al\u0131nacakt\u0131r.\n \u2022 Ignore String: Ad\u0131nda 'Ignore String' olan herhangi bir cihaz yoksay\u0131lacakt\u0131r.\n \u2022 De\u011fi\u015fken Sens\u00f6r Dizisi: 'De\u011fi\u015fken Sens\u00f6r Dizisi' i\u00e7eren herhangi bir de\u011fi\u015fken sens\u00f6r olarak eklenecektir.\n \u2022 I\u015f\u0131k Parlakl\u0131\u011f\u0131n\u0131 Geri Y\u00fckle: Etkinle\u015ftirilirse, bir \u0131\u015f\u0131k a\u00e7\u0131ld\u0131\u011f\u0131nda cihaz\u0131n yerle\u015fik On-Level yerine \u00f6nceki parlakl\u0131k geri y\u00fcklenir." + "description": "ISY Entegrasyonu i\u00e7in se\u00e7enekleri ayarlay\u0131n:\n \u2022 D\u00fc\u011f\u00fcm Sens\u00f6r\u00fc Dizisi: Ad\u0131nda 'D\u00fc\u011f\u00fcm Sens\u00f6r\u00fc Dizisi' i\u00e7eren herhangi bir cihaz veya klas\u00f6r, bir sens\u00f6r veya ikili sens\u00f6r olarak ele al\u0131nacakt\u0131r.\n \u2022 Ignore String: Ad\u0131nda 'Ignore String' olan herhangi bir cihaz yoksay\u0131lacakt\u0131r.\n \u2022 De\u011fi\u015fken Sens\u00f6r Dizisi: 'De\u011fi\u015fken Sens\u00f6r Dizisi' i\u00e7eren herhangi bir de\u011fi\u015fken sens\u00f6r olarak eklenecektir.\n \u2022 I\u015f\u0131k Parlakl\u0131\u011f\u0131n\u0131 Geri Y\u00fckle: Etkinle\u015ftirilirse, bir \u0131\u015f\u0131k a\u00e7\u0131ld\u0131\u011f\u0131nda cihaz\u0131n yerle\u015fik On-Level yerine \u00f6nceki parlakl\u0131k geri y\u00fcklenir.", + "title": "ISY994 Se\u00e7enekleri" } } + }, + "system_health": { + "info": { + "device_connected": "ISY Ba\u011fl\u0131", + "host_reachable": "Ana Bilgisayara Ula\u015f\u0131labilir", + "last_heartbeat": "Son Kalp At\u0131\u015f\u0131 Zaman\u0131", + "websocket_status": "Olay Soketi Durumu" + } } } \ No newline at end of file diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 67d121d760e..db47b087c0d 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -209,7 +209,8 @@ class ControllerDevice(ClimateEntity): return self.async_write_ha_state() for zone in self.zones.values(): - zone.async_schedule_update_ha_state() + if zone.hass is not None: + zone.async_schedule_update_ha_state() self.async_on_remove( async_dispatcher_connect( @@ -244,7 +245,8 @@ class ControllerDevice(ClimateEntity): self._available = available self.async_write_ha_state() for zone in self.zones.values(): - zone.async_schedule_update_ha_state() + if zone.hass is not None: + zone.async_schedule_update_ha_state() @property def unique_id(self): @@ -495,6 +497,8 @@ class ZoneDevice(ClimateEntity): """Handle zone data updates.""" if zone is not self._zone: return + if not self.available: + return self._name = zone.name.title() self.async_write_ha_state() diff --git a/homeassistant/components/izone/manifest.json b/homeassistant/components/izone/manifest.json index 6da770f5c0b..82927fef795 100644 --- a/homeassistant/components/izone/manifest.json +++ b/homeassistant/components/izone/manifest.json @@ -2,7 +2,7 @@ "domain": "izone", "name": "iZone", "documentation": "https://www.home-assistant.io/integrations/izone", - "requirements": ["python-izone==1.1.6"], + "requirements": ["python-izone==1.1.8"], "codeowners": ["@Swamp-Ig"], "config_flow": true, "homekit": { diff --git a/homeassistant/components/izone/translations/ja.json b/homeassistant/components/izone/translations/ja.json new file mode 100644 index 00000000000..bd5ae39dec4 --- /dev/null +++ b/homeassistant/components/izone/translations/ja.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "confirm": { + "description": "iZone\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/izone/translations/tr.json b/homeassistant/components/izone/translations/tr.json index faa20ed0ece..01a709e9924 100644 --- a/homeassistant/components/izone/translations/tr.json +++ b/homeassistant/components/izone/translations/tr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, "step": { diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py new file mode 100644 index 00000000000..a58108b05ab --- /dev/null +++ b/homeassistant/components/jellyfin/__init__.py @@ -0,0 +1,36 @@ +"""The Jellyfin integration.""" +import logging + +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__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Jellyfin from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + client = create_client() + try: + 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") + return False + else: + hass.data[DOMAIN][entry.entry_id] = {DATA_CLIENT: client} + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + hass.data[DOMAIN].pop(entry.entry_id) + + return True diff --git a/homeassistant/components/jellyfin/client_wrapper.py b/homeassistant/components/jellyfin/client_wrapper.py new file mode 100644 index 00000000000..9f6380e2181 --- /dev/null +++ b/homeassistant/components/jellyfin/client_wrapper.py @@ -0,0 +1,94 @@ +"""Utility methods for initializing a Jellyfin client.""" +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 +from jellyfin_apiclient_python.connection_manager import ( + CONNECTION_STATE, + ConnectionManager, +) + +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 + + +async def validate_input( + hass: HomeAssistant, user_input: dict[str, Any], client: JellyfinClient +) -> str: + """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( + _connect, client, url, username, password + ) + + return userid + + +def create_client() -> JellyfinClient: + """Create a new Jellyfin client.""" + jellyfin = Jellyfin() + client = jellyfin.get_client() + _setup_client(client) + return client + + +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()) + + client.config.app(USER_APP_NAME, CLIENT_VERSION, player_name, client_uuid) + client.config.http(USER_AGENT) + + +def _connect(client: JellyfinClient, url: str, username: str, password: str) -> str: + """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) + _login(client.auth, url, username, password) + return _get_id(client.jellyfin) + + +def _connect_to_address(connection_manager: ConnectionManager, url: str) -> None: + """Connect to the Jellyfin server.""" + state = connection_manager.connect_to_address(url) + if state["State"] != CONNECTION_STATE["ServerSignIn"]: + raise CannotConnect + + +def _login( + connection_manager: ConnectionManager, + url: str, + username: str, + password: str, +) -> 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: + """Set the unique userid from a Jellyfin server.""" + settings: dict[str, Any] = api.get_user_settings() + userid: str = settings["Id"] + return userid + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate the server is unreachable.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate the credentials are invalid.""" diff --git a/homeassistant/components/jellyfin/config_flow.py b/homeassistant/components/jellyfin/config_flow.py new file mode 100644 index 00000000000..55de7c12e44 --- /dev/null +++ b/homeassistant/components/jellyfin/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for the Jellyfin integration.""" +from __future__ import annotations + +import logging +from typing import Any + +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 .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Jellyfin.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a user defined configuration.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors: dict[str, str] = {} + + if user_input is not None: + client = create_client() + try: + userid = await validate_input(self.hass, user_input, client) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception as ex: # pylint: disable=broad-except + errors["base"] = "unknown" + _LOGGER.exception(ex) + else: + await self.async_set_unique_id(userid) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_URL], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/jellyfin/const.py b/homeassistant/components/jellyfin/const.py new file mode 100644 index 00000000000..d8379859e54 --- /dev/null +++ b/homeassistant/components/jellyfin/const.py @@ -0,0 +1,40 @@ +"""Constants for the Jellyfin integration.""" + +from typing import Final + +DOMAIN: Final = "jellyfin" + +CLIENT_VERSION: Final = "1.0" + +COLLECTION_TYPE_MOVIES: Final = "movies" +COLLECTION_TYPE_TVSHOWS: Final = "tvshows" +COLLECTION_TYPE_MUSIC: Final = "music" + +DATA_CLIENT: Final = "client" + +ITEM_KEY_COLLECTION_TYPE: Final = "CollectionType" +ITEM_KEY_ID: Final = "Id" +ITEM_KEY_IMAGE_TAGS: Final = "ImageTags" +ITEM_KEY_INDEX_NUMBER: Final = "IndexNumber" +ITEM_KEY_MEDIA_SOURCES: Final = "MediaSources" +ITEM_KEY_MEDIA_TYPE: Final = "MediaType" +ITEM_KEY_NAME: Final = "Name" + +ITEM_TYPE_ALBUM: Final = "MusicAlbum" +ITEM_TYPE_ARTIST: Final = "MusicArtist" +ITEM_TYPE_AUDIO: Final = "Audio" +ITEM_TYPE_LIBRARY: Final = "CollectionFolder" + +MAX_IMAGE_WIDTH: Final = 500 +MAX_STREAMING_BITRATE: Final = "140000000" + + +MEDIA_SOURCE_KEY_PATH: Final = "Path" + +MEDIA_TYPE_AUDIO: Final = "Audio" +MEDIA_TYPE_NONE: Final = "" + +SUPPORTED_COLLECTION_TYPES: Final = [COLLECTION_TYPE_MUSIC] + +USER_APP_NAME: Final = "Home Assistant" +USER_AGENT: Final = f"Home-Assistant/{CLIENT_VERSION}" diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json new file mode 100644 index 00000000000..345cecc2eb6 --- /dev/null +++ b/homeassistant/components/jellyfin/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "jellyfin", + "name": "Jellyfin", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/jellyfin", + "requirements": [ + "jellyfin-apiclient-python==1.7.2" + ], + "iot_class": "local_polling", + "codeowners": [ + "@j-stienstra" + ] +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py new file mode 100644 index 00000000000..55e849a1f14 --- /dev/null +++ b/homeassistant/components/jellyfin/media_source.py @@ -0,0 +1,326 @@ +"""The Media Source implementation for the Jellyfin integration.""" +from __future__ import annotations + +import logging +import mimetypes +from typing import Any +import urllib.parse + +from jellyfin_apiclient_python.api import jellyfin_url +from jellyfin_apiclient_python.client import JellyfinClient + +from homeassistant.components.media_player.const import ( + MEDIA_CLASS_ALBUM, + MEDIA_CLASS_ARTIST, + MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_TRACK, +) +from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.media_source.models import ( + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, +) +from homeassistant.core import HomeAssistant + +from .const import ( + COLLECTION_TYPE_MUSIC, + DATA_CLIENT, + DOMAIN, + ITEM_KEY_COLLECTION_TYPE, + ITEM_KEY_ID, + ITEM_KEY_IMAGE_TAGS, + ITEM_KEY_INDEX_NUMBER, + ITEM_KEY_MEDIA_SOURCES, + ITEM_KEY_MEDIA_TYPE, + ITEM_KEY_NAME, + ITEM_TYPE_ALBUM, + ITEM_TYPE_ARTIST, + ITEM_TYPE_AUDIO, + ITEM_TYPE_LIBRARY, + MAX_IMAGE_WIDTH, + MAX_STREAMING_BITRATE, + MEDIA_SOURCE_KEY_PATH, + MEDIA_TYPE_AUDIO, + MEDIA_TYPE_NONE, + SUPPORTED_COLLECTION_TYPES, +) + +_LOGGER = logging.getLogger(__name__) + + +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] + + data = hass.data[DOMAIN][entry.entry_id] + client: JellyfinClient = data[DATA_CLIENT] + + return JellyfinSource(hass, client) + + +class JellyfinSource(MediaSource): + """Represents a Jellyfin server.""" + + name: str = "Jellyfin" + + def __init__(self, hass: HomeAssistant, client: JellyfinClient) -> None: + """Initialize the Jellyfin media source.""" + super().__init__(DOMAIN) + + self.hass = hass + + self.client = client + self.api = client.jellyfin + self.url = jellyfin_url(client, "") + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Return a streamable URL and associated mime type.""" + media_item = await self.hass.async_add_executor_job( + self.api.get_item, item.identifier + ) + + stream_url = self._get_stream_url(media_item) + mime_type = _media_mime_type(media_item) + + return PlayMedia(stream_url, mime_type) + + async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: + """Return a browsable Jellyfin media source.""" + if not item.identifier: + return await self._build_libraries() + + media_item = await self.hass.async_add_executor_job( + self.api.get_item, item.identifier + ) + + item_type = media_item["Type"] + if item_type == ITEM_TYPE_LIBRARY: + return await self._build_library(media_item, True) + if item_type == ITEM_TYPE_ARTIST: + return await self._build_artist(media_item, True) + if item_type == ITEM_TYPE_ALBUM: + return await self._build_album(media_item, True) + + raise BrowseError(f"Unsupported item type {item_type}") + + async def _build_libraries(self) -> BrowseMediaSource: + """Return all supported libraries the user has access to as media sources.""" + base = BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=MEDIA_TYPE_NONE, + title=self.name, + can_play=False, + can_expand=True, + children_media_class=MEDIA_CLASS_DIRECTORY, + ) + + libraries = await self._get_libraries() + + base.children = [] + + for library in libraries: + base.children.append(await self._build_library(library, False)) + + return base + + async def _get_libraries(self) -> list[dict[str, Any]]: + """Return all supported libraries a user has access to.""" + response = await self.hass.async_add_executor_job(self.api.get_media_folders) + libraries = response["Items"] + result = [] + for library in libraries: + if ITEM_KEY_COLLECTION_TYPE in library: + if library[ITEM_KEY_COLLECTION_TYPE] in SUPPORTED_COLLECTION_TYPES: + result.append(library) + return result + + async def _build_library( + self, library: dict[str, Any], include_children: bool + ) -> BrowseMediaSource: + """Return a single library as a browsable media source.""" + collection_type = library[ITEM_KEY_COLLECTION_TYPE] + + if collection_type == COLLECTION_TYPE_MUSIC: + return await self._build_music_library(library, include_children) + + raise BrowseError(f"Unsupported collection type {collection_type}") + + async def _build_music_library( + self, library: dict[str, Any], include_children: bool + ) -> BrowseMediaSource: + """Return a single music library as a browsable media source.""" + library_id = library[ITEM_KEY_ID] + library_name = library[ITEM_KEY_NAME] + + result = BrowseMediaSource( + domain=DOMAIN, + identifier=library_id, + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=MEDIA_TYPE_NONE, + title=library_name, + can_play=False, + can_expand=True, + ) + + if include_children: + result.children_media_class = MEDIA_CLASS_ARTIST + result.children = await self._build_artists(library_id) # type: ignore[assignment] + + return result + + async def _build_artists(self, library_id: str) -> list[BrowseMediaSource]: + """Return all artists in the music library.""" + artists = await self._get_children(library_id, ITEM_TYPE_ARTIST) + artists = sorted(artists, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + return [await self._build_artist(artist, False) for artist in artists] + + async def _build_artist( + self, artist: dict[str, Any], include_children: bool + ) -> BrowseMediaSource: + """Return a single artist as a browsable media source.""" + artist_id = artist[ITEM_KEY_ID] + artist_name = artist[ITEM_KEY_NAME] + thumbnail_url = self._get_thumbnail_url(artist) + + result = BrowseMediaSource( + domain=DOMAIN, + identifier=artist_id, + media_class=MEDIA_CLASS_ARTIST, + media_content_type=MEDIA_TYPE_NONE, + title=artist_name, + can_play=False, + can_expand=True, + thumbnail=thumbnail_url, + ) + + if include_children: + result.children_media_class = MEDIA_CLASS_ALBUM + result.children = await self._build_albums(artist_id) # type: ignore[assignment] + + return result + + async def _build_albums(self, artist_id: str) -> list[BrowseMediaSource]: + """Return all albums of a single artist as browsable media sources.""" + albums = await self._get_children(artist_id, ITEM_TYPE_ALBUM) + albums = sorted(albums, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + return [await self._build_album(album, False) for album in albums] + + async def _build_album( + self, album: dict[str, Any], include_children: bool + ) -> BrowseMediaSource: + """Return a single album as a browsable media source.""" + album_id = album[ITEM_KEY_ID] + album_title = album[ITEM_KEY_NAME] + thumbnail_url = self._get_thumbnail_url(album) + + result = BrowseMediaSource( + domain=DOMAIN, + identifier=album_id, + media_class=MEDIA_CLASS_ALBUM, + media_content_type=MEDIA_TYPE_NONE, + title=album_title, + can_play=False, + can_expand=True, + thumbnail=thumbnail_url, + ) + + if include_children: + result.children_media_class = MEDIA_CLASS_TRACK + result.children = await self._build_tracks(album_id) # type: ignore[assignment] + + return result + + async def _build_tracks(self, album_id: str) -> list[BrowseMediaSource]: + """Return all tracks of a single album as browsable media sources.""" + tracks = await self._get_children(album_id, ITEM_TYPE_AUDIO) + tracks = sorted(tracks, key=lambda k: k[ITEM_KEY_INDEX_NUMBER]) # type: ignore[no-any-return] + return [self._build_track(track) for track in tracks] + + def _build_track(self, track: dict[str, Any]) -> BrowseMediaSource: + """Return a single track as a browsable media source.""" + track_id = track[ITEM_KEY_ID] + track_title = track[ITEM_KEY_NAME] + mime_type = _media_mime_type(track) + thumbnail_url = self._get_thumbnail_url(track) + + result = BrowseMediaSource( + domain=DOMAIN, + identifier=track_id, + media_class=MEDIA_CLASS_TRACK, + media_content_type=mime_type, + title=track_title, + can_play=True, + can_expand=False, + thumbnail=thumbnail_url, + ) + + return result + + async def _get_children( + self, parent_id: str, item_type: str + ) -> list[dict[str, Any]]: + """Return all children for the parent_id whose item type is item_type.""" + params = { + "Recursive": "true", + "ParentId": parent_id, + "IncludeItemTypes": item_type, + } + if item_type == ITEM_TYPE_AUDIO: + params["Fields"] = ITEM_KEY_MEDIA_SOURCES + + result = await self.hass.async_add_executor_job(self.api.user_items, "", params) + return result["Items"] # type: ignore[no-any-return] + + def _get_thumbnail_url(self, media_item: dict[str, Any]) -> str | None: + """Return the URL for the primary image of a media item if available.""" + image_tags = media_item[ITEM_KEY_IMAGE_TAGS] + + if "Primary" not in image_tags: + return None + + item_id = media_item[ITEM_KEY_ID] + return str(self.api.artwork(item_id, "Primary", MAX_IMAGE_WIDTH)) + + def _get_stream_url(self, media_item: dict[str, Any]) -> str: + """Return the stream URL for a media item.""" + media_type = media_item[ITEM_KEY_MEDIA_TYPE] + + if media_type == MEDIA_TYPE_AUDIO: + return self._get_audio_stream_url(media_item) + + raise BrowseError(f"Unsupported media type {media_type}") + + def _get_audio_stream_url(self, media_item: dict[str, Any]) -> str: + """Return the stream URL for a music media item.""" + item_id = media_item[ITEM_KEY_ID] + user_id = self.client.config.data["auth.user_id"] + device_id = self.client.config.data["app.device_id"] + api_key = self.client.config.data["auth.token"] + + params = urllib.parse.urlencode( + { + "UserId": user_id, + "DeviceId": device_id, + "api_key": api_key, + "MaxStreamingBitrate": MAX_STREAMING_BITRATE, + } + ) + + return f"{self.url}Audio/{item_id}/universal?{params}" + + +def _media_mime_type(media_item: dict[str, Any]) -> str: + """Return the mime type of a media item.""" + media_source = media_item[ITEM_KEY_MEDIA_SOURCES][0] + path = media_source[MEDIA_SOURCE_KEY_PATH] + mime_type, _ = mimetypes.guess_type(path) + + if mime_type is not None: + return mime_type + + raise BrowseError(f"Unable to determine mime type for path {path}") diff --git a/homeassistant/components/jellyfin/strings.json b/homeassistant/components/jellyfin/strings.json new file mode 100644 index 00000000000..7587c1d86d9 --- /dev/null +++ b/homeassistant/components/jellyfin/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/bg.json b/homeassistant/components/jellyfin/translations/bg.json new file mode 100644 index 00000000000..e99b99fdb61 --- /dev/null +++ b/homeassistant/components/jellyfin/translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "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" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/ca.json b/homeassistant/components/jellyfin/translations/ca.json new file mode 100644 index 00000000000..feb588e0fd9 --- /dev/null +++ b/homeassistant/components/jellyfin/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "url": "URL", + "username": "Nom d'usuari" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/de.json b/homeassistant/components/jellyfin/translations/de.json new file mode 100644 index 00000000000..c94ba7e9662 --- /dev/null +++ b/homeassistant/components/jellyfin/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "url": "URL", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/en.json b/homeassistant/components/jellyfin/translations/en.json new file mode 100644 index 00000000000..fd85dc7acb6 --- /dev/null +++ b/homeassistant/components/jellyfin/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "password": "Password", + "url": "URL", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/es.json b/homeassistant/components/jellyfin/translations/es.json new file mode 100644 index 00000000000..1cc7ab64c75 --- /dev/null +++ b/homeassistant/components/jellyfin/translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "url": "URL", + "username": "Usuario" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/et.json b/homeassistant/components/jellyfin/translations/et.json new file mode 100644 index 00000000000..65665ff244f --- /dev/null +++ b/homeassistant/components/jellyfin/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Juba seadistatud, lubatud on ainult \u00fcks seadistus." + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "password": "Salas\u00f5na", + "url": "URL", + "username": "Kasutajanimi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/fr.json b/homeassistant/components/jellyfin/translations/fr.json new file mode 100644 index 00000000000..c797b7e93a3 --- /dev/null +++ b/homeassistant/components/jellyfin/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "url": "URL", + "username": "Nom d'utilisateur" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/he.json b/homeassistant/components/jellyfin/translations/he.json new file mode 100644 index 00000000000..89da99fcf9b --- /dev/null +++ b/homeassistant/components/jellyfin/translations/he.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/hu.json b/homeassistant/components/jellyfin/translations/hu.json new file mode 100644 index 00000000000..d92411833c4 --- /dev/null +++ b/homeassistant/components/jellyfin/translations/hu.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "url": "URL", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/id.json b/homeassistant/components/jellyfin/translations/id.json new file mode 100644 index 00000000000..687cd917130 --- /dev/null +++ b/homeassistant/components/jellyfin/translations/id.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "password": "Kata Sandi", + "url": "URL", + "username": "Nama Pengguna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/it.json b/homeassistant/components/jellyfin/translations/it.json new file mode 100644 index 00000000000..e00681ee6df --- /dev/null +++ b/homeassistant/components/jellyfin/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "password": "Password", + "url": "URL", + "username": "Nome utente" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/ja.json b/homeassistant/components/jellyfin/translations/ja.json new file mode 100644 index 00000000000..69cc9a5279d --- /dev/null +++ b/homeassistant/components/jellyfin/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "url": "URL", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/nl.json b/homeassistant/components/jellyfin/translations/nl.json new file mode 100644 index 00000000000..1072cfff418 --- /dev/null +++ b/homeassistant/components/jellyfin/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "url": "URL", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/no.json b/homeassistant/components/jellyfin/translations/no.json new file mode 100644 index 00000000000..c1351b9a97f --- /dev/null +++ b/homeassistant/components/jellyfin/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "url": "URL", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/pl.json b/homeassistant/components/jellyfin/translations/pl.json new file mode 100644 index 00000000000..ffa33d7e6ed --- /dev/null +++ b/homeassistant/components/jellyfin/translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "password": "Has\u0142o", + "url": "URL", + "username": "Nazwa u\u017cytkownika" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/ru.json b/homeassistant/components/jellyfin/translations/ru.json new file mode 100644 index 00000000000..b94d8def5e3 --- /dev/null +++ b/homeassistant/components/jellyfin/translations/ru.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "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.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "url": "URL-\u0430\u0434\u0440\u0435\u0441", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/sl.json b/homeassistant/components/jellyfin/translations/sl.json new file mode 100644 index 00000000000..4502cfe9345 --- /dev/null +++ b/homeassistant/components/jellyfin/translations/sl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u017de konfigurirano. Mo\u017ena je samo ena konfiguracija." + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "password": "Geslo", + "url": "URL", + "username": "Uporabni\u0161ko ime" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/tr.json b/homeassistant/components/jellyfin/translations/tr.json new file mode 100644 index 00000000000..bb1d5df64fa --- /dev/null +++ b/homeassistant/components/jellyfin/translations/tr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "password": "Parola", + "url": "URL", + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/zh-Hant.json b/homeassistant/components/jellyfin/translations/zh-Hant.json new file mode 100644 index 00000000000..3f24589c235 --- /dev/null +++ b/homeassistant/components/jellyfin/translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "url": "\u7db2\u5740", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 09a56a9e26a..6db80036614 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -170,10 +170,8 @@ class JewishCalendarSensor(SensorEntity): self._holiday_attrs: dict[str, str] = {} @property - def native_value(self) -> StateType: + def native_value(self) -> datetime | StateType: """Return the state of the sensor.""" - if isinstance(self._state, datetime): - return self._state.isoformat() return self._state async def async_update(self) -> None: @@ -262,11 +260,11 @@ class JewishCalendarTimeSensor(JewishCalendarSensor): _attr_device_class = DEVICE_CLASS_TIMESTAMP @property - def native_value(self) -> StateType | None: + def native_value(self) -> datetime | None: """Return the state of the sensor.""" if self._state is None: return None - return dt_util.as_utc(self._state).isoformat() + return dt_util.as_utc(self._state) def get_state( self, daytime_date: HDate, after_shkia_date: HDate, after_tzais_date: HDate diff --git a/homeassistant/components/juicenet/translations/bg.json b/homeassistant/components/juicenet/translations/bg.json index 084c18c771d..ae376d0bcb9 100644 --- a/homeassistant/components/juicenet/translations/bg.json +++ b/homeassistant/components/juicenet/translations/bg.json @@ -4,7 +4,7 @@ "already_configured": "\u041f\u0440\u043e\u0444\u0438\u043b\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" }, "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_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" } diff --git a/homeassistant/components/juicenet/translations/ja.json b/homeassistant/components/juicenet/translations/ja.json new file mode 100644 index 00000000000..bc06bd44c9b --- /dev/null +++ b/homeassistant/components/juicenet/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "api_token": "API\u30c8\u30fc\u30af\u30f3" + }, + "description": "https://home.juice.net/Manage \u304b\u3089API\u30c8\u30fc\u30af\u30f3\u304c\u5fc5\u8981\u306b\u306a\u308a\u307e\u3059\u3002", + "title": "JuiceNet\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/juicenet/translations/tr.json b/homeassistant/components/juicenet/translations/tr.json index 53890eb41e2..336a8308f0b 100644 --- a/homeassistant/components/juicenet/translations/tr.json +++ b/homeassistant/components/juicenet/translations/tr.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "api_token": "API Belirteci" + "api_token": "API Anahtar\u0131" }, "description": "API Belirtecine https://home.juice.net/Manage adresinden ihtiyac\u0131n\u0131z olacak.", "title": "JuiceNet'e ba\u011flan\u0131n" diff --git a/homeassistant/components/kaiterra/api_data.py b/homeassistant/components/kaiterra/api_data.py index b426a298ddb..f34ae161c6d 100644 --- a/homeassistant/components/kaiterra/api_data.py +++ b/homeassistant/components/kaiterra/api_data.py @@ -53,7 +53,7 @@ class KaiterraApiData: """Get the data from Kaiterra API.""" try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): data = await self._api.get_latest_sensor_readings(self._devices) except (ClientResponseError, asyncio.TimeoutError): _LOGGER.debug("Couldn't fetch data from Kaiterra API") diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index 96caea06304..a4ef406dd92 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_CONSIDER_HOME, @@ -104,20 +104,20 @@ class KeeneticFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Import a config entry.""" return await self.async_step_user(user_input) - async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered device.""" - friendly_name = discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "") + friendly_name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "") # Filter out items not having "keenetic" in their name if "keenetic" not in friendly_name.lower(): return self.async_abort(reason="not_keenetic_ndms2") # Filters out items having no/empty UDN - if not discovery_info.get(ssdp.ATTR_UPNP_UDN): + if not discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): return self.async_abort(reason="no_udn") - host = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname - await self.async_set_unique_id(discovery_info[ssdp.ATTR_UPNP_UDN]) + host = urlparse(discovery_info.ssdp_location).hostname + await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._async_abort_entries_match({CONF_HOST: host}) diff --git a/homeassistant/components/keenetic_ndms2/translations/id.json b/homeassistant/components/keenetic_ndms2/translations/id.json index 900745bc29e..e48a5051ea3 100644 --- a/homeassistant/components/keenetic_ndms2/translations/id.json +++ b/homeassistant/components/keenetic_ndms2/translations/id.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Akun sudah dikonfigurasi" + "already_configured": "Akun sudah dikonfigurasi", + "no_udn": "Info penemuan SSDP tidak memiliki UDN", + "not_keenetic_ndms2": "Item yang ditemukan bukan router Keenetic" }, "error": { "cannot_connect": "Gagal terhubung" diff --git a/homeassistant/components/keenetic_ndms2/translations/ja.json b/homeassistant/components/keenetic_ndms2/translations/ja.json new file mode 100644 index 00000000000..90fc5e60155 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/translations/ja.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "no_udn": "SSDP\u691c\u51fa\u60c5\u5831\u306b\u3001UDN\u304c\u3042\u308a\u307e\u305b\u3093", + "not_keenetic_ndms2": "\u767a\u898b\u3055\u308c\u305f\u30a2\u30a4\u30c6\u30e0\u306fKeenetic router\u3067\u306f\u3042\u308a\u307e\u305b\u3093" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Keenetic NDMS2\u30eb\u30fc\u30bf\u30fc\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "consider_home": "\u30db\u30fc\u30e0\u30a4\u30f3\u30bf\u30fc\u30d0\u30eb\u3092\u691c\u8a0e", + "include_arp": "ARP\u30c7\u30fc\u30bf\u3092\u4f7f\u7528(\u30db\u30c3\u30c8\u30b9\u30dd\u30c3\u30c8 \u30c7\u30fc\u30bf\u3092\u4f7f\u7528\u3057\u305f\u5834\u5408\u306f\u7121\u8996)", + "include_associated": "WiFi AP\u30a2\u30bd\u30b7\u30a8\u30fc\u30b7\u30e7\u30f3 \u30c7\u30fc\u30bf\u3092\u4f7f\u7528(\u30db\u30c3\u30c8\u30b9\u30dd\u30c3\u30c8\u30c7\u30fc\u30bf\u3092\u4f7f\u7528\u3057\u305f\u5834\u5408\u306f\u7121\u8996)", + "interfaces": "\u30b9\u30ad\u30e3\u30f3\u3059\u308b\u30a4\u30f3\u30bf\u30fc\u30d5\u30a7\u30fc\u30b9\u3092\u9078\u629e", + "scan_interval": "\u30b9\u30ad\u30e3\u30f3\u9593\u9694", + "try_hotspot": "'ip hotspot'\u30c7\u30fc\u30bf\u3092\u4f7f\u7528\u3059\u308b(\u6700\u3082\u6b63\u78ba)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keenetic_ndms2/translations/tr.json b/homeassistant/components/keenetic_ndms2/translations/tr.json new file mode 100644 index 00000000000..099ca45fc71 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/translations/tr.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "no_udn": "SSDP ke\u015fif bilgisinin UDN'si yok", + "not_keenetic_ndms2": "Bulunan \u00f6\u011fe bir Keenetic y\u00f6nlendirici de\u011fil" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "host": "Ana bilgisayar", + "password": "Parola", + "port": "Port", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "title": "Keenetic NDMS2 Router'\u0131 kurun" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "consider_home": "Ev aral\u0131\u011f\u0131n\u0131 g\u00f6z \u00f6n\u00fcnde bulundurun", + "include_arp": "ARP verilerini kullan (hotspot verileri kullan\u0131l\u0131yorsa yoksay\u0131l\u0131r)", + "include_associated": "WiFi AP ili\u015fkilendirme verilerini kullan (hotspot verileri kullan\u0131l\u0131yorsa yoksay\u0131l\u0131r)", + "interfaces": "Taranacak aray\u00fczleri se\u00e7in", + "scan_interval": "Tarama aral\u0131\u011f\u0131", + "try_hotspot": "'ip hotspot' verilerini kullan\u0131n (en do\u011frusu)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kiwi/lock.py b/homeassistant/components/kiwi/lock.py index 8a0eeed83f0..650d6d34de8 100644 --- a/homeassistant/components/kiwi/lock.py +++ b/homeassistant/components/kiwi/lock.py @@ -39,8 +39,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): except KiwiException as exc: _LOGGER.error(exc) return - available_locks = kiwi.get_locks() - if not available_locks: + if not (available_locks := kiwi.get_locks()): # No locks found; abort setup routine. _LOGGER.info("No KIWI locks found in your account") return diff --git a/homeassistant/components/kmtronic/translations/id.json b/homeassistant/components/kmtronic/translations/id.json index ed8fde32106..d436321da7a 100644 --- a/homeassistant/components/kmtronic/translations/id.json +++ b/homeassistant/components/kmtronic/translations/id.json @@ -17,5 +17,14 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "reverse": "Logika sakelar terbalik (gunakan NC)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/kmtronic/translations/ja.json b/homeassistant/components/kmtronic/translations/ja.json new file mode 100644 index 00000000000..2b345d6ffcf --- /dev/null +++ b/homeassistant/components/kmtronic/translations/ja.json @@ -0,0 +1,30 @@ +{ + "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "reverse": "\u30b9\u30a4\u30c3\u30c1\u306e\u8ad6\u7406\u3092\u9006\u306b\u3059\u308b(NC\u3092\u4f7f\u7528)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kmtronic/translations/tr.json b/homeassistant/components/kmtronic/translations/tr.json new file mode 100644 index 00000000000..c10979d7c83 --- /dev/null +++ b/homeassistant/components/kmtronic/translations/tr.json @@ -0,0 +1,30 @@ +{ + "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": "Ana bilgisayar", + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "reverse": "Ters anahtar mant\u0131\u011f\u0131 (NC kullan\u0131n)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 57c88b84cc7..ba6689a023d 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -10,42 +10,53 @@ from xknx import XKNX from xknx.core import XknxConnectionState from xknx.core.telegram_queue import TelegramQueue from xknx.dpt import DPTArray, DPTBase, DPTBinary -from xknx.exceptions import XKNXException +from xknx.exceptions import ConversionError, XKNXException from xknx.io import ConnectionConfig, ConnectionType from xknx.telegram import AddressFilter, Telegram -from xknx.telegram.address import parse_device_group_address +from xknx.telegram.address import ( + DeviceGroupAddress, + GroupAddress, + InternalGroupAddress, + parse_device_group_address, +) from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_EVENT, CONF_HOST, CONF_PORT, + CONF_TYPE, EVENT_HOMEASSISTANT_STOP, - SERVICE_RELOAD, ) from homeassistant.core import Event, HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from .const import ( + CONF_KNX_CONNECTION_TYPE, CONF_KNX_EXPOSE, CONF_KNX_INDIVIDUAL_ADDRESS, CONF_KNX_ROUTING, CONF_KNX_TUNNELING, + DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS, - SupportedPlatforms, + SUPPORTED_PLATFORMS, ) from .expose import KNXExposeSensor, KNXExposeTime, create_knx_exposure from .schema import ( BinarySensorSchema, + ButtonSchema, ClimateSchema, ConnectionSchema, CoverSchema, + EventSchema, ExposeSchema, FanSchema, LightSchema, @@ -77,6 +88,15 @@ SERVICE_KNX_READ: Final = "read" CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( + # deprecated since 2021.12 + cv.deprecated(ConnectionSchema.CONF_KNX_STATE_UPDATER), + cv.deprecated(ConnectionSchema.CONF_KNX_RATE_LIMIT), + cv.deprecated(CONF_KNX_ROUTING), + cv.deprecated(CONF_KNX_TUNNELING), + cv.deprecated(CONF_KNX_INDIVIDUAL_ADDRESS), + cv.deprecated(ConnectionSchema.CONF_KNX_MCAST_GRP), + cv.deprecated(ConnectionSchema.CONF_KNX_MCAST_PORT), + cv.deprecated(CONF_KNX_EVENT_FILTER), # deprecated since 2021.4 cv.deprecated("config_file"), # deprecated since 2021.2 @@ -89,8 +109,10 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_KNX_EVENT_FILTER, default=[]): vol.All( cv.ensure_list, [cv.string] ), + **EventSchema.SCHEMA, **ExposeSchema.platform_node(), **BinarySensorSchema.platform_node(), + **ButtonSchema.platform_node(), **ClimateSchema.platform_node(), **CoverSchema.platform_node(), **FanSchema.platform_node(), @@ -149,6 +171,7 @@ SERVICE_KNX_EVENT_REGISTER_SCHEMA = vol.Schema( cv.ensure_list, [ga_validator], ), + vol.Optional(CONF_TYPE): sensor_type_validator, vol.Optional(SERVICE_KNX_ATTR_REMOVE, default=False): cv.boolean, } ) @@ -171,35 +194,72 @@ SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA = vol.Any( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the KNX integration.""" - try: - knx_module = KNXModule(hass, config) - hass.data[DOMAIN] = knx_module - await knx_module.start() - except XKNXException as ex: - _LOGGER.warning("Could not connect to KNX interface: %s", ex) - hass.components.persistent_notification.async_create( - f"Could not connect to KNX interface:
{ex}", title="KNX" + """Start the KNX integration.""" + conf: ConfigType | None = config.get(DOMAIN) + + if conf is None: + # If we have a config entry, setup is done by that config entry. + # If there is no config entry, this should fail. + return bool(hass.config_entries.async_entries(DOMAIN)) + + conf = dict(conf) + + hass.data[DATA_KNX_CONFIG] = conf + + # Only import if we haven't before. + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf + ) ) - if CONF_KNX_EXPOSE in config[DOMAIN]: - for expose_config in config[DOMAIN][CONF_KNX_EXPOSE]: + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Load a config entry.""" + conf = hass.data.get(DATA_KNX_CONFIG) + + # When reloading + if conf is None: + conf = await async_integration_yaml_config(hass, DOMAIN) + if not conf or DOMAIN not in conf: + return False + + conf = conf[DOMAIN] + + # If user didn't have configuration.yaml config, generate defaults + if conf is None: + conf = CONFIG_SCHEMA({DOMAIN: dict(entry.data)})[DOMAIN] + + config = {**conf, **entry.data} + + try: + knx_module = KNXModule(hass, config, entry) + await knx_module.start() + except XKNXException as ex: + raise ConfigEntryNotReady from ex + + hass.data[DATA_KNX_CONFIG] = conf + hass.data[DOMAIN] = knx_module + + if CONF_KNX_EXPOSE in config: + for expose_config in config[CONF_KNX_EXPOSE]: knx_module.exposures.append( create_knx_exposure(hass, knx_module.xknx, expose_config) ) - for platform in SupportedPlatforms: - if platform.value not in config[DOMAIN]: - continue + hass.config_entries.async_setup_platforms( + entry, [platform for platform in SUPPORTED_PLATFORMS if platform in config] + ) + + # set up notify platform, no entry support for notify component yet, + # have to use discovery to load platform. + if NotifySchema.PLATFORM in conf: hass.async_create_task( discovery.async_load_platform( - hass, - platform.value, - DOMAIN, - { - "platform_config": config[DOMAIN][platform.value], - }, - config, + hass, "notify", DOMAIN, conf[NotifySchema.PLATFORM], config ) ) @@ -233,125 +293,109 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA, ) - async def reload_service_handler(service_call: ServiceCall) -> None: - """Remove all KNX components and load new ones from config.""" - - # First check for config file. If for some reason it is no longer there - # or knx is no longer mentioned, stop the reload. - config = await async_integration_yaml_config(hass, DOMAIN) - if not config or DOMAIN not in config: - return - - await asyncio.gather( - *(platform.async_reset() for platform in async_get_platforms(hass, DOMAIN)) - ) - await knx_module.xknx.stop() - - await async_setup(hass, config) - - async_register_admin_service( - hass, DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=vol.Schema({}) - ) - return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unloading the KNX platforms.""" + # if not loaded directly return + if not hass.data.get(DOMAIN): + return True + + knx_module: KNXModule = hass.data[DOMAIN] + for exposure in knx_module.exposures: + exposure.shutdown() + + unload_ok = await hass.config_entries.async_unload_platforms( + entry, + [ + platform + for platform in SUPPORTED_PLATFORMS + if platform in hass.data[DATA_KNX_CONFIG] + ], + ) + if unload_ok: + await knx_module.stop() + hass.data.pop(DOMAIN) + hass.data.pop(DATA_KNX_CONFIG) + + return unload_ok + + +async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update a given config entry.""" + await hass.config_entries.async_reload(entry.entry_id) + + class KNXModule: """Representation of KNX Object.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + def __init__( + self, hass: HomeAssistant, config: ConfigType, entry: ConfigEntry + ) -> None: """Initialize KNX module.""" self.hass = hass self.config = config self.connected = False self.exposures: list[KNXExposeSensor | KNXExposeTime] = [] self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {} + self.entry = entry self.init_xknx() - self._knx_event_callback: TelegramQueue.Callback = self.register_callback() self.xknx.connection_manager.register_connection_state_changed_cb( self.connection_state_changed_cb ) + self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {} + self._group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} + self._knx_event_callback: TelegramQueue.Callback = ( + self.register_event_callback() + ) + + self.entry.async_on_unload( + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) + ) + + self.entry.async_on_unload(self.entry.add_update_listener(async_update_entry)) + def init_xknx(self) -> None: """Initialize XKNX object.""" self.xknx = XKNX( - own_address=self.config[DOMAIN][CONF_KNX_INDIVIDUAL_ADDRESS], - rate_limit=self.config[DOMAIN][ConnectionSchema.CONF_KNX_RATE_LIMIT], - multicast_group=self.config[DOMAIN][ConnectionSchema.CONF_KNX_MCAST_GRP], - multicast_port=self.config[DOMAIN][ConnectionSchema.CONF_KNX_MCAST_PORT], + own_address=self.config[CONF_KNX_INDIVIDUAL_ADDRESS], + rate_limit=self.config[ConnectionSchema.CONF_KNX_RATE_LIMIT], + multicast_group=self.config[ConnectionSchema.CONF_KNX_MCAST_GRP], + multicast_port=self.config[ConnectionSchema.CONF_KNX_MCAST_PORT], connection_config=self.connection_config(), - state_updater=self.config[DOMAIN][ConnectionSchema.CONF_KNX_STATE_UPDATER], + state_updater=self.config[ConnectionSchema.CONF_KNX_STATE_UPDATER], ) async def start(self) -> None: """Start XKNX object. Connect to tunneling or Routing device.""" await self.xknx.start() - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) - async def stop(self, event: Event) -> None: + async def stop(self, event: Event | None = None) -> None: """Stop XKNX object. Disconnect from tunneling or Routing device.""" await self.xknx.stop() def connection_config(self) -> ConnectionConfig: """Return the connection_config.""" - if CONF_KNX_TUNNELING in self.config[DOMAIN]: - return self.connection_config_tunneling() - if CONF_KNX_ROUTING in self.config[DOMAIN]: - return self.connection_config_routing() - return ConnectionConfig(auto_reconnect=True) - - def connection_config_routing(self) -> ConnectionConfig: - """Return the connection_config if routing is configured.""" - local_ip = None - # all configuration values are optional - if self.config[DOMAIN][CONF_KNX_ROUTING] is not None: - local_ip = self.config[DOMAIN][CONF_KNX_ROUTING].get( - ConnectionSchema.CONF_KNX_LOCAL_IP + _conn_type: str = self.config[CONF_KNX_CONNECTION_TYPE] + if _conn_type == CONF_KNX_ROUTING: + return ConnectionConfig( + connection_type=ConnectionType.ROUTING, + auto_reconnect=True, + ) + if _conn_type == CONF_KNX_TUNNELING: + return ConnectionConfig( + connection_type=ConnectionType.TUNNELING, + gateway_ip=self.config[CONF_HOST], + gateway_port=self.config[CONF_PORT], + local_ip=self.config.get(ConnectionSchema.CONF_KNX_LOCAL_IP), + route_back=self.config.get(ConnectionSchema.CONF_KNX_ROUTE_BACK, False), + auto_reconnect=True, ) - return ConnectionConfig( - connection_type=ConnectionType.ROUTING, local_ip=local_ip - ) - def connection_config_tunneling(self) -> ConnectionConfig: - """Return the connection_config if tunneling is configured.""" - gateway_ip = self.config[DOMAIN][CONF_KNX_TUNNELING][CONF_HOST] - gateway_port = self.config[DOMAIN][CONF_KNX_TUNNELING][CONF_PORT] - local_ip = self.config[DOMAIN][CONF_KNX_TUNNELING].get( - ConnectionSchema.CONF_KNX_LOCAL_IP - ) - route_back = self.config[DOMAIN][CONF_KNX_TUNNELING][ - ConnectionSchema.CONF_KNX_ROUTE_BACK - ] - return ConnectionConfig( - connection_type=ConnectionType.TUNNELING, - gateway_ip=gateway_ip, - gateway_port=gateway_port, - local_ip=local_ip, - route_back=route_back, - auto_reconnect=True, - ) - - async def telegram_received_cb(self, telegram: Telegram) -> None: - """Call invoked after a KNX telegram was received.""" - data = None - # Not all telegrams have serializable data. - if ( - isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)) - and telegram.payload.value is not None - ): - data = telegram.payload.value.value - - self.hass.bus.async_fire( - "knx_event", - { - "data": data, - "destination": str(telegram.destination_address), - "direction": telegram.direction.value, - "source": str(telegram.source_address), - "telegramtype": telegram.payload.__class__.__name__, - }, - ) + return ConnectionConfig(auto_reconnect=True) async def connection_state_changed_cb(self, state: XknxConnectionState) -> None: """Call invoked after a KNX connection state change was received.""" @@ -359,11 +403,69 @@ class KNXModule: if tasks := [device.after_update() for device in self.xknx.devices]: await asyncio.gather(*tasks) - def register_callback(self) -> TelegramQueue.Callback: - """Register callback within XKNX TelegramQueue.""" - address_filters = list( - map(AddressFilter, self.config[DOMAIN][CONF_KNX_EVENT_FILTER]) + async def telegram_received_cb(self, telegram: Telegram) -> None: + """Call invoked after a KNX telegram was received.""" + # Not all telegrams have serializable data. + data: int | tuple[int, ...] | None = None + value = None + if ( + isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)) + and telegram.payload.value is not None + and isinstance( + telegram.destination_address, (GroupAddress, InternalGroupAddress) + ) + ): + data = telegram.payload.value.value + + if isinstance(data, tuple): + if transcoder := ( + self._group_address_transcoder.get(telegram.destination_address) + or next( + ( + _transcoder + for _filter, _transcoder in self._address_filter_transcoder.items() + if _filter.match(telegram.destination_address) + ), + None, + ) + ): + try: + value = transcoder.from_knx(data) + except ConversionError as err: + _LOGGER.warning( + "Error in `knx_event` at decoding type '%s' from telegram %s\n%s", + transcoder.__name__, + telegram, + err, + ) + + self.hass.bus.async_fire( + "knx_event", + { + "data": data, + "destination": str(telegram.destination_address), + "direction": telegram.direction.value, + "value": value, + "source": str(telegram.source_address), + "telegramtype": telegram.payload.__class__.__name__, + }, ) + + def register_event_callback(self) -> TelegramQueue.Callback: + """Register callback for knx_event within XKNX TelegramQueue.""" + # backwards compatibility for deprecated CONF_KNX_EVENT_FILTER + # use `address_filters = []` when this is not needed anymore + address_filters = list(map(AddressFilter, self.config[CONF_KNX_EVENT_FILTER])) + for filter_set in self.config[CONF_EVENT]: + _filters = list(map(AddressFilter, filter_set[KNX_ADDRESS])) + address_filters.extend(_filters) + if (dpt := filter_set.get(CONF_TYPE)) and ( + transcoder := DPTBase.parse_transcoder(dpt) + ): + self._address_filter_transcoder.update( + {_filter: transcoder for _filter in _filters} # type: ignore[misc] + ) + return self.xknx.telegram_queue.register_telegram_received_cb( self.telegram_received_cb, address_filters=address_filters, @@ -374,7 +476,7 @@ class KNXModule: async def service_event_register_modify(self, call: ServiceCall) -> None: """Service for adding or removing a GroupAddress to the knx_event filter.""" attr_address = call.data[KNX_ADDRESS] - group_addresses = map(parse_device_group_address, attr_address) + group_addresses = list(map(parse_device_group_address, attr_address)) if call.data.get(SERVICE_KNX_ATTR_REMOVE): for group_address in group_addresses: @@ -385,8 +487,16 @@ class KNXModule: "Service event_register could not remove event for '%s'", str(group_address), ) + if group_address in self._group_address_transcoder: + del self._group_address_transcoder[group_address] return + if (dpt := call.data.get(CONF_TYPE)) and ( + transcoder := DPTBase.parse_transcoder(dpt) + ): + self._group_address_transcoder.update( + {_address: transcoder for _address in group_addresses} # type: ignore[misc] + ) for group_address in group_addresses: if group_address in self._knx_event_callback.group_addresses: continue diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index 5ed0e55765d..9005ca707b9 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -6,6 +6,7 @@ from typing import Any from xknx import XKNX from xknx.devices import BinarySensor as XknxBinarySensor +from homeassistant import config_entries from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -14,33 +15,30 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt +from homeassistant.helpers.typing import ConfigType -from .const import ATTR_COUNTER, ATTR_LAST_KNX_UPDATE, ATTR_SOURCE, DOMAIN +from .const import ATTR_COUNTER, ATTR_SOURCE, DATA_KNX_CONFIG, DOMAIN from .knx_entity import KnxEntity from .schema import BinarySensorSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up binary sensor(s) for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - - platform_config = discovery_info["platform_config"] + """Set up the KNX binary sensor platform.""" xknx: XKNX = hass.data[DOMAIN].xknx + config: ConfigType = hass.data[DATA_KNX_CONFIG] async_add_entities( - KNXBinarySensor(xknx, entity_config) for entity_config in platform_config + KNXBinarySensor(xknx, entity_config) + for entity_config in config[Platform.BINARY_SENSOR] ) @@ -92,7 +90,4 @@ class KNXBinarySensor(KnxEntity, BinarySensorEntity, RestoreEntity): attr[ATTR_COUNTER] = self._device.counter if self._device.last_telegram is not None: attr[ATTR_SOURCE] = str(self._device.last_telegram.source_address) - attr[ATTR_LAST_KNX_UPDATE] = str( - dt.as_utc(self._device.last_telegram.timestamp) - ) return attr diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py new file mode 100644 index 00000000000..274ced80146 --- /dev/null +++ b/homeassistant/components/knx/button.py @@ -0,0 +1,61 @@ +"""Support for KNX/IP buttons.""" +from __future__ import annotations + +from xknx import XKNX +from xknx.devices import RawValue as XknxRawValue + +from homeassistant import config_entries +from homeassistant.components.button import ButtonEntity +from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType + +from .const import ( + CONF_PAYLOAD, + CONF_PAYLOAD_LENGTH, + DATA_KNX_CONFIG, + DOMAIN, + KNX_ADDRESS, +) +from .knx_entity import KnxEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the KNX binary sensor platform.""" + xknx: XKNX = hass.data[DOMAIN].xknx + config: ConfigType = hass.data[DATA_KNX_CONFIG] + + async_add_entities( + KNXButton(xknx, entity_config) for entity_config in config[Platform.BUTTON] + ) + + +class KNXButton(KnxEntity, ButtonEntity): + """Representation of a KNX button.""" + + _device: XknxRawValue + + def __init__(self, xknx: XKNX, config: ConfigType) -> None: + """Initialize a KNX button.""" + super().__init__( + device=XknxRawValue( + xknx, + name=config[CONF_NAME], + payload_length=config[CONF_PAYLOAD_LENGTH], + group_address=config[KNX_ADDRESS], + ) + ) + self._payload = config[CONF_PAYLOAD] + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_unique_id = ( + f"{self._device.remote_value.group_address}_{self._payload}" + ) + + async def async_press(self) -> None: + """Press the button.""" + await self._device.set(self._payload) diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 2099270f036..63fbb170ca7 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -6,8 +6,8 @@ from typing import Any from xknx import XKNX from xknx.devices import Climate as XknxClimate, ClimateMode as XknxClimateMode from xknx.dpt.dpt_hvac_mode import HVACControllerMode, HVACOperationMode -from xknx.telegram.address import parse_device_group_address +from homeassistant import config_entries from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( CURRENT_HVAC_IDLE, @@ -22,13 +22,19 @@ from homeassistant.const import ( CONF_ENTITY_CATEGORY, CONF_NAME, TEMP_CELSIUS, + Platform, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType -from .const import CONTROLLER_MODES, CURRENT_HVAC_ACTIONS, DOMAIN, PRESET_MODES +from .const import ( + CONTROLLER_MODES, + CURRENT_HVAC_ACTIONS, + DATA_KNX_CONFIG, + DOMAIN, + PRESET_MODES, +) from .knx_entity import KnxEntity from .schema import ClimateSchema @@ -37,63 +43,16 @@ CONTROLLER_MODES_INV = {value: key for key, value in CONTROLLER_MODES.items()} PRESET_MODES_INV = {value: key for key, value in PRESET_MODES.items()} -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up climate(s) for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - - platform_config = discovery_info["platform_config"] xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.CLIMATE] - _async_migrate_unique_id(hass, platform_config) - async_add_entities( - KNXClimate(xknx, entity_config) for entity_config in platform_config - ) - - -@callback -def _async_migrate_unique_id( - hass: HomeAssistant, platform_config: list[ConfigType] -) -> None: - """Change unique_ids used in 2021.4 to include target_temperature GA.""" - entity_registry = er.async_get(hass) - for entity_config in platform_config: - # normalize group address strings - ga_temperature_state was the old uid - ga_temperature_state = parse_device_group_address( - entity_config[ClimateSchema.CONF_TEMPERATURE_ADDRESS][0] - ) - old_uid = str(ga_temperature_state) - - entity_id = entity_registry.async_get_entity_id("climate", DOMAIN, old_uid) - if entity_id is None: - continue - ga_target_temperature_state = parse_device_group_address( - entity_config[ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS][0] - ) - target_temp = entity_config.get(ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS) - ga_target_temperature = ( - parse_device_group_address(target_temp[0]) - if target_temp is not None - else None - ) - setpoint_shift = entity_config.get(ClimateSchema.CONF_SETPOINT_SHIFT_ADDRESS) - ga_setpoint_shift = ( - parse_device_group_address(setpoint_shift[0]) - if setpoint_shift is not None - else None - ) - new_uid = ( - f"{ga_temperature_state}_" - f"{ga_target_temperature_state}_" - f"{ga_target_temperature}_" - f"{ga_setpoint_shift}" - ) - entity_registry.async_update_entity(entity_id, new_unique_id=new_uid) + async_add_entities(KNXClimate(xknx, entity_config) for entity_config in config) def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate: @@ -268,10 +227,10 @@ class KNXClimate(KnxEntity, ClimateEntity): return CURRENT_HVAC_OFF if self._device.is_active is False: return CURRENT_HVAC_IDLE - if self._device.mode is not None and self._device.mode.supports_controller_mode: - return CURRENT_HVAC_ACTIONS.get( - self._device.mode.controller_mode.value, CURRENT_HVAC_IDLE - ) + if ( + self._device.mode is not None and self._device.mode.supports_controller_mode + ) or self._device.is_active: + return CURRENT_HVAC_ACTIONS.get(self.hvac_mode, CURRENT_HVAC_IDLE) return None async def async_set_hvac_mode(self, hvac_mode: str) -> None: diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py new file mode 100644 index 00000000000..30071752731 --- /dev/null +++ b/homeassistant/components/knx/config_flow.py @@ -0,0 +1,417 @@ +"""Config flow for KNX.""" +from __future__ import annotations + +from typing import Any, Final + +import voluptuous as vol +from xknx import XKNX +from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT +from xknx.io.gateway_scanner import GatewayDescriptor, GatewayScanner + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry, OptionsFlow +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_KNX_AUTOMATIC, + CONF_KNX_CONNECTION_TYPE, + CONF_KNX_INDIVIDUAL_ADDRESS, + CONF_KNX_INITIAL_CONNECTION_TYPES, + CONF_KNX_ROUTING, + CONF_KNX_TUNNELING, + DOMAIN, +) +from .schema import ConnectionSchema + +CONF_KNX_GATEWAY: Final = "gateway" +CONF_MAX_RATE_LIMIT: Final = 60 + +DEFAULT_ENTRY_DATA: Final = { + ConnectionSchema.CONF_KNX_STATE_UPDATER: ConnectionSchema.CONF_KNX_DEFAULT_STATE_UPDATER, + ConnectionSchema.CONF_KNX_RATE_LIMIT: ConnectionSchema.CONF_KNX_DEFAULT_RATE_LIMIT, + CONF_KNX_INDIVIDUAL_ADDRESS: XKNX.DEFAULT_ADDRESS, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT, +} + + +class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a KNX config flow.""" + + VERSION = 1 + + _tunnels: list + _gateway_ip: str = "" + _gateway_port: int = DEFAULT_MCAST_PORT + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> KNXOptionsFlowHandler: + """Get the options flow for this handler.""" + return KNXOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input: dict | None = None) -> FlowResult: + """Handle a flow initialized by the user.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + self._tunnels = [] + return await self.async_step_type() + + async def async_step_type(self, user_input: dict | None = None) -> FlowResult: + """Handle connection type configuration.""" + errors: dict = {} + supported_connection_types = CONF_KNX_INITIAL_CONNECTION_TYPES.copy() + fields = {} + + if user_input is None: + gateways = await scan_for_gateways() + + if gateways: + supported_connection_types.insert(0, CONF_KNX_AUTOMATIC) + self._tunnels = [ + gateway for gateway in gateways if gateway.supports_tunnelling + ] + + fields = { + vol.Required(CONF_KNX_CONNECTION_TYPE): vol.In( + supported_connection_types + ) + } + + if user_input is not None: + connection_type = user_input[CONF_KNX_CONNECTION_TYPE] + if connection_type == CONF_KNX_AUTOMATIC: + return self.async_create_entry( + title=CONF_KNX_AUTOMATIC.capitalize(), + data={**DEFAULT_ENTRY_DATA, **user_input}, + ) + + if connection_type == CONF_KNX_ROUTING: + return await self.async_step_routing() + + if connection_type == CONF_KNX_TUNNELING and self._tunnels: + return await self.async_step_tunnel() + + return await self.async_step_manual_tunnel() + + return self.async_show_form( + step_id="type", data_schema=vol.Schema(fields), errors=errors + ) + + async def async_step_manual_tunnel( + self, user_input: dict | None = None + ) -> FlowResult: + """General setup.""" + errors: dict = {} + + if user_input is not None: + return self.async_create_entry( + title=f"{CONF_KNX_TUNNELING.capitalize()} @ {user_input[CONF_HOST]}", + data={ + **DEFAULT_ENTRY_DATA, + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + CONF_KNX_INDIVIDUAL_ADDRESS: user_input[ + CONF_KNX_INDIVIDUAL_ADDRESS + ], + ConnectionSchema.CONF_KNX_ROUTE_BACK: user_input[ + ConnectionSchema.CONF_KNX_ROUTE_BACK + ], + ConnectionSchema.CONF_KNX_LOCAL_IP: user_input.get( + ConnectionSchema.CONF_KNX_LOCAL_IP + ), + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + }, + ) + + fields = { + vol.Required(CONF_HOST, default=self._gateway_ip): str, + vol.Required(CONF_PORT, default=self._gateway_port): vol.Coerce(int), + vol.Required( + CONF_KNX_INDIVIDUAL_ADDRESS, default=XKNX.DEFAULT_ADDRESS + ): str, + vol.Required( + ConnectionSchema.CONF_KNX_ROUTE_BACK, default=False + ): vol.Coerce(bool), + vol.Optional(ConnectionSchema.CONF_KNX_LOCAL_IP): str, + } + + return self.async_show_form( + step_id="manual_tunnel", data_schema=vol.Schema(fields), errors=errors + ) + + async def async_step_tunnel(self, user_input: dict | None = None) -> FlowResult: + """Select a tunnel from a list. Will be skipped if the gateway scan was unsuccessful or if only one gateway was found.""" + errors: dict = {} + + if user_input is not None: + gateway: GatewayDescriptor = next( + gateway + for gateway in self._tunnels + if user_input[CONF_KNX_GATEWAY] == str(gateway) + ) + + self._gateway_ip = gateway.ip_addr + self._gateway_port = gateway.port + + return await self.async_step_manual_tunnel() + + tunnel_repr = { + str(tunnel) for tunnel in self._tunnels if tunnel.supports_tunnelling + } + + # skip this step if the user has only one unique gateway. + if len(tunnel_repr) == 1: + _gateway: GatewayDescriptor = self._tunnels[0] + self._gateway_ip = _gateway.ip_addr + self._gateway_port = _gateway.port + return await self.async_step_manual_tunnel() + + fields = {vol.Required(CONF_KNX_GATEWAY): vol.In(tunnel_repr)} + + return self.async_show_form( + step_id="tunnel", data_schema=vol.Schema(fields), errors=errors + ) + + async def async_step_routing(self, user_input: dict | None = None) -> FlowResult: + """Routing setup.""" + errors: dict = {} + + if user_input is not None: + return self.async_create_entry( + title=CONF_KNX_ROUTING.capitalize(), + data={ + **DEFAULT_ENTRY_DATA, + ConnectionSchema.CONF_KNX_MCAST_GRP: user_input[ + ConnectionSchema.CONF_KNX_MCAST_GRP + ], + ConnectionSchema.CONF_KNX_MCAST_PORT: user_input[ + ConnectionSchema.CONF_KNX_MCAST_PORT + ], + CONF_KNX_INDIVIDUAL_ADDRESS: user_input[ + CONF_KNX_INDIVIDUAL_ADDRESS + ], + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + }, + ) + + fields = { + vol.Required( + CONF_KNX_INDIVIDUAL_ADDRESS, default=XKNX.DEFAULT_ADDRESS + ): str, + vol.Required( + ConnectionSchema.CONF_KNX_MCAST_GRP, default=DEFAULT_MCAST_GRP + ): str, + vol.Required( + ConnectionSchema.CONF_KNX_MCAST_PORT, default=DEFAULT_MCAST_PORT + ): cv.port, + } + + return self.async_show_form( + step_id="routing", data_schema=vol.Schema(fields), errors=errors + ) + + async def async_step_import(self, config: dict | None = None) -> FlowResult: + """Import a config entry. + + Performs a one time import of the YAML configuration and creates a config entry based on it + if not already done before. + """ + if self._async_current_entries() or not config: + return self.async_abort(reason="single_instance_allowed") + + data = { + ConnectionSchema.CONF_KNX_RATE_LIMIT: min( + config[ConnectionSchema.CONF_KNX_RATE_LIMIT], CONF_MAX_RATE_LIMIT + ), + ConnectionSchema.CONF_KNX_STATE_UPDATER: config[ + ConnectionSchema.CONF_KNX_STATE_UPDATER + ], + ConnectionSchema.CONF_KNX_MCAST_GRP: config[ + ConnectionSchema.CONF_KNX_MCAST_GRP + ], + ConnectionSchema.CONF_KNX_MCAST_PORT: config[ + ConnectionSchema.CONF_KNX_MCAST_PORT + ], + CONF_KNX_INDIVIDUAL_ADDRESS: config[CONF_KNX_INDIVIDUAL_ADDRESS], + } + + if CONF_KNX_TUNNELING in config: + return self.async_create_entry( + title=f"{CONF_KNX_TUNNELING.capitalize()} @ {config[CONF_KNX_TUNNELING][CONF_HOST]}", + data={ + **DEFAULT_ENTRY_DATA, + CONF_HOST: config[CONF_KNX_TUNNELING][CONF_HOST], + CONF_PORT: config[CONF_KNX_TUNNELING][CONF_PORT], + ConnectionSchema.CONF_KNX_LOCAL_IP: config[CONF_KNX_TUNNELING].get( + ConnectionSchema.CONF_KNX_LOCAL_IP + ), + ConnectionSchema.CONF_KNX_ROUTE_BACK: config[CONF_KNX_TUNNELING][ + ConnectionSchema.CONF_KNX_ROUTE_BACK + ], + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + **data, + }, + ) + + if CONF_KNX_ROUTING in config: + return self.async_create_entry( + title=CONF_KNX_ROUTING.capitalize(), + data={ + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + **data, + }, + ) + + return self.async_create_entry( + title=CONF_KNX_AUTOMATIC.capitalize(), + data={ + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + **data, + }, + ) + + +class KNXOptionsFlowHandler(OptionsFlow): + """Handle KNX options.""" + + general_settings: dict + current_config: dict + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize KNX options flow.""" + self.config_entry = config_entry + + async def async_step_tunnel( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage KNX tunneling options.""" + if ( + self.general_settings.get(CONF_KNX_CONNECTION_TYPE) == CONF_KNX_TUNNELING + and user_input is None + ): + return self.async_show_form( + step_id="tunnel", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, default=self.current_config.get(CONF_HOST) + ): str, + vol.Required( + CONF_PORT, default=self.current_config.get(CONF_PORT, 3671) + ): cv.port, + vol.Optional(ConnectionSchema.CONF_KNX_LOCAL_IP): str, + vol.Required( + ConnectionSchema.CONF_KNX_ROUTE_BACK, + default=self.current_config.get( + ConnectionSchema.CONF_KNX_ROUTE_BACK, False + ), + ): vol.Coerce(bool), + } + ), + last_step=True, + ) + + entry_data = { + **DEFAULT_ENTRY_DATA, + **self.general_settings, + CONF_HOST: self.current_config.get(CONF_HOST, ""), + } + + if user_input is not None: + entry_data = { + **entry_data, + **user_input, + } + + entry_title = entry_data[CONF_KNX_CONNECTION_TYPE].capitalize() + if entry_data[CONF_KNX_CONNECTION_TYPE] == CONF_KNX_TUNNELING: + entry_title = f"{CONF_KNX_TUNNELING.capitalize()} @ {entry_data[CONF_HOST]}" + + self.hass.config_entries.async_update_entry( + self.config_entry, + data=entry_data, + title=entry_title, + ) + + return self.async_create_entry(title="", data={}) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage KNX options.""" + if user_input is not None: + self.general_settings = user_input + return await self.async_step_tunnel() + + supported_connection_types = [ + CONF_KNX_AUTOMATIC, + CONF_KNX_TUNNELING, + CONF_KNX_ROUTING, + ] + self.current_config = self.config_entry.data # type: ignore + + data_schema = { + vol.Required( + CONF_KNX_CONNECTION_TYPE, + default=self.current_config.get(CONF_KNX_CONNECTION_TYPE), + ): vol.In(supported_connection_types), + vol.Required( + CONF_KNX_INDIVIDUAL_ADDRESS, + default=self.current_config[CONF_KNX_INDIVIDUAL_ADDRESS], + ): str, + vol.Required( + ConnectionSchema.CONF_KNX_MCAST_GRP, + default=self.current_config.get( + ConnectionSchema.CONF_KNX_MCAST_GRP, DEFAULT_MCAST_GRP + ), + ): str, + vol.Required( + ConnectionSchema.CONF_KNX_MCAST_PORT, + default=self.current_config.get( + ConnectionSchema.CONF_KNX_MCAST_PORT, DEFAULT_MCAST_PORT + ), + ): cv.port, + } + + if self.show_advanced_options: + data_schema[ + vol.Required( + ConnectionSchema.CONF_KNX_STATE_UPDATER, + default=self.current_config.get( + ConnectionSchema.CONF_KNX_STATE_UPDATER, + ConnectionSchema.CONF_KNX_DEFAULT_STATE_UPDATER, + ), + ) + ] = bool + data_schema[ + vol.Required( + ConnectionSchema.CONF_KNX_RATE_LIMIT, + default=self.current_config.get( + ConnectionSchema.CONF_KNX_RATE_LIMIT, + ConnectionSchema.CONF_KNX_DEFAULT_RATE_LIMIT, + ), + ) + ] = vol.All(vol.Coerce(int), vol.Range(min=1, max=CONF_MAX_RATE_LIMIT)) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema(data_schema), + last_step=self.current_config.get(CONF_KNX_CONNECTION_TYPE) + != CONF_KNX_TUNNELING, + ) + + +async def scan_for_gateways(stop_on_found: int = 0) -> list: + """Scan for gateways within the network.""" + xknx = XKNX() + gatewayscanner = GatewayScanner( + xknx, stop_on_found=stop_on_found, timeout_in_seconds=2 + ) + return await gatewayscanner.scan() diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 421297da9d6..950deff95c1 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -20,6 +20,7 @@ from homeassistant.components.climate.const import ( PRESET_NONE, PRESET_SLEEP, ) +from homeassistant.const import Platform DOMAIN: Final = "knx" @@ -29,15 +30,21 @@ KNX_ADDRESS: Final = "address" CONF_INVERT: Final = "invert" CONF_KNX_EXPOSE: Final = "expose" CONF_KNX_INDIVIDUAL_ADDRESS: Final = "individual_address" +CONF_KNX_CONNECTION_TYPE: Final = "connection_type" +CONF_KNX_AUTOMATIC: Final = "automatic" CONF_KNX_ROUTING: Final = "routing" CONF_KNX_TUNNELING: Final = "tunneling" +CONF_PAYLOAD: Final = "payload" +CONF_PAYLOAD_LENGTH: Final = "payload_length" CONF_RESET_AFTER: Final = "reset_after" CONF_RESPOND_TO_READ: Final = "respond_to_read" CONF_STATE_ADDRESS: Final = "state_address" CONF_SYNC_STATE: Final = "sync_state" +CONF_KNX_INITIAL_CONNECTION_TYPES: Final = [CONF_KNX_TUNNELING, CONF_KNX_ROUTING] + +DATA_KNX_CONFIG: Final = "knx_config" ATTR_COUNTER: Final = "counter" -ATTR_LAST_KNX_UPDATE: Final = "last_knx_update" ATTR_SOURCE: Final = "source" @@ -48,22 +55,21 @@ class ColorTempModes(Enum): RELATIVE = "DPT-5.001" -class SupportedPlatforms(Enum): - """Supported platforms.""" - - BINARY_SENSOR = "binary_sensor" - CLIMATE = "climate" - COVER = "cover" - FAN = "fan" - LIGHT = "light" - NOTIFY = "notify" - NUMBER = "number" - SCENE = "scene" - SELECT = "select" - SENSOR = "sensor" - SWITCH = "switch" - WEATHER = "weather" - +SUPPORTED_PLATFORMS: Final = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CLIMATE, + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.NOTIFY, + Platform.NUMBER, + Platform.SCENE, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.WEATHER, +] # Map KNX controller modes to HA modes. This list might not be complete. CONTROLLER_MODES: Final = { @@ -77,11 +83,11 @@ CONTROLLER_MODES: Final = { } CURRENT_HVAC_ACTIONS: Final = { - "Heat": CURRENT_HVAC_HEAT, - "Cool": CURRENT_HVAC_COOL, - "Off": CURRENT_HVAC_OFF, - "Fan only": CURRENT_HVAC_FAN, - "Dry": CURRENT_HVAC_DRY, + HVAC_MODE_HEAT: CURRENT_HVAC_HEAT, + HVAC_MODE_COOL: CURRENT_HVAC_COOL, + HVAC_MODE_OFF: CURRENT_HVAC_OFF, + HVAC_MODE_FAN_ONLY: CURRENT_HVAC_FAN, + HVAC_MODE_DRY: CURRENT_HVAC_DRY, } PRESET_MODES: Final = { diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index 8eb906d1ba0..96996e0ef27 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -7,8 +7,8 @@ from typing import Any from xknx import XKNX from xknx.devices import Cover as XknxCover, Device as XknxDevice -from xknx.telegram.address import parse_device_group_address +from homeassistant import config_entries from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, @@ -23,61 +23,32 @@ from homeassistant.components.cover import ( SUPPORT_STOP_TILT, CoverEntity, ) -from homeassistant.const import CONF_DEVICE_CLASS, CONF_ENTITY_CATEGORY, CONF_NAME +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_ENTITY_CATEGORY, + CONF_NAME, + Platform, +) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_utc_time_change -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import DATA_KNX_CONFIG, DOMAIN from .knx_entity import KnxEntity from .schema import CoverSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up cover(s) for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.COVER] - _async_migrate_unique_id(hass, platform_config) - async_add_entities( - KNXCover(xknx, entity_config) for entity_config in platform_config - ) - - -@callback -def _async_migrate_unique_id( - hass: HomeAssistant, platform_config: list[ConfigType] -) -> None: - """Change unique_ids used in 2021.4 to include position_target GA.""" - entity_registry = er.async_get(hass) - for entity_config in platform_config: - # normalize group address strings - ga_updown was the old uid but is optional - updown_addresses = entity_config.get(CoverSchema.CONF_MOVE_LONG_ADDRESS) - if updown_addresses is None: - continue - ga_updown = parse_device_group_address(updown_addresses[0]) - old_uid = str(ga_updown) - - entity_id = entity_registry.async_get_entity_id("cover", DOMAIN, old_uid) - if entity_id is None: - continue - position_target_addresses = entity_config.get(CoverSchema.CONF_POSITION_ADDRESS) - ga_position_target = ( - parse_device_group_address(position_target_addresses[0]) - if position_target_addresses is not None - else None - ) - new_uid = f"{ga_updown}_{ga_position_target}" - entity_registry.async_update_entity(entity_id, new_unique_id=new_uid) + async_add_entities(KNXCover(xknx, entity_config) for entity_config in config) class KNXCover(KnxEntity, CoverEntity): diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index b4b15c977fd..6fa5a3ba728 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -137,8 +137,7 @@ class KNXExposeSensor: async def _async_entity_changed(self, event: Event) -> None: """Handle entity change.""" new_state = event.data.get("new_state") - new_value = self._get_expose_value(new_state) - if new_value is None: + if (new_value := self._get_expose_value(new_state)) is None: return old_state = event.data.get("old_state") # don't use default value for comparison on first state change (old_state is None) diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index a000cdec973..38c90aa149d 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -7,37 +7,35 @@ from typing import Any, Final from xknx import XKNX from xknx.devices import Fan as XknxFan +from homeassistant import config_entries from homeassistant.components.fan import SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity -from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME +from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) -from .const import DOMAIN, KNX_ADDRESS +from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS from .knx_entity import KnxEntity from .schema import FanSchema DEFAULT_PERCENTAGE: Final = 50 -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up fans for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] + """Set up fan(s) for KNX platform.""" xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.FAN] - async_add_entities(KNXFan(xknx, entity_config) for entity_config in platform_config) + async_add_entities(KNXFan(xknx, entity_config) for entity_config in config) class KNXFan(KnxEntity, FanEntity): diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index bee96270c36..0f8f2fe89af 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -5,8 +5,8 @@ from typing import Any, Tuple, cast from xknx import XKNX from xknx.devices.light import Light as XknxLight, XYYColor -from xknx.telegram.address import parse_device_group_address +from homeassistant import config_entries from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -23,101 +23,27 @@ from homeassistant.components.light import ( COLOR_MODE_XY, LightEntity, ) -from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType import homeassistant.util.color as color_util -from .const import DOMAIN, KNX_ADDRESS, ColorTempModes +from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS, ColorTempModes from .knx_entity import KnxEntity from .schema import LightSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up lights for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] + """Set up light(s) for KNX platform.""" xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.LIGHT] - _async_migrate_unique_id(hass, platform_config) - async_add_entities( - KNXLight(xknx, entity_config) for entity_config in platform_config - ) - - -@callback -def _async_migrate_unique_id( - hass: HomeAssistant, platform_config: list[ConfigType] -) -> None: - """Change unique_ids used in 2021.4 to exchange individual color switch address for brightness address.""" - entity_registry = er.async_get(hass) - for entity_config in platform_config: - individual_colors_config = entity_config.get(LightSchema.CONF_INDIVIDUAL_COLORS) - if individual_colors_config is None: - continue - try: - ga_red_switch = individual_colors_config[LightSchema.CONF_RED][KNX_ADDRESS][ - 0 - ] - ga_green_switch = individual_colors_config[LightSchema.CONF_GREEN][ - KNX_ADDRESS - ][0] - ga_blue_switch = individual_colors_config[LightSchema.CONF_BLUE][ - KNX_ADDRESS - ][0] - except KeyError: - continue - # normalize group address strings - ga_red_switch = parse_device_group_address(ga_red_switch) - ga_green_switch = parse_device_group_address(ga_green_switch) - ga_blue_switch = parse_device_group_address(ga_blue_switch) - # white config is optional so it has to be checked for `None` extra - white_config = individual_colors_config.get(LightSchema.CONF_WHITE) - white_switch = ( - white_config.get(KNX_ADDRESS) if white_config is not None else None - ) - ga_white_switch = ( - parse_device_group_address(white_switch[0]) - if white_switch is not None - else None - ) - - old_uid = ( - f"{ga_red_switch}_" - f"{ga_green_switch}_" - f"{ga_blue_switch}_" - f"{ga_white_switch}" - ) - entity_id = entity_registry.async_get_entity_id("light", DOMAIN, old_uid) - if entity_id is None: - continue - - ga_red_brightness = parse_device_group_address( - individual_colors_config[LightSchema.CONF_RED][ - LightSchema.CONF_BRIGHTNESS_ADDRESS - ][0] - ) - ga_green_brightness = parse_device_group_address( - individual_colors_config[LightSchema.CONF_GREEN][ - LightSchema.CONF_BRIGHTNESS_ADDRESS - ][0] - ) - ga_blue_brightness = parse_device_group_address( - individual_colors_config[LightSchema.CONF_BLUE][ - LightSchema.CONF_BRIGHTNESS_ADDRESS - ][0] - ) - - new_uid = f"{ga_red_brightness}_{ga_green_brightness}_{ga_blue_brightness}" - entity_registry.async_update_entity(entity_id, new_unique_id=new_uid) + async_add_entities(KNXLight(xknx, entity_config) for entity_config in config) def _create_light(xknx: XKNX, config: ConfigType) -> XknxLight: @@ -268,19 +194,27 @@ class KNXLight(KnxEntity, LightEntity): if self._device.current_xyy_color is not None: _, brightness = self._device.current_xyy_color return brightness - if (rgb := self.rgb_color) is not None: - return max(rgb) + if self._device.supports_color or self._device.supports_rgbw: + rgb, white = self._device.current_color + if rgb is None: + return white + if white is None: + return max(rgb) + return max(*rgb, white) return None @property def rgb_color(self) -> tuple[int, int, int] | None: """Return the rgb color value [int, int, int].""" - if (rgbw := self.rgbw_color) is not None: - # used in brightness calculation when no address is given - return color_util.color_rgbw_to_rgb(*rgbw) if self._device.supports_color: rgb, _ = self._device.current_color - return rgb + if rgb is not None: + if not self._device.supports_brightness: + # brightness will be calculated from color so color must not hold brightness again + return cast( + Tuple[int, int, int], color_util.match_max_scale((255,), rgb) + ) + return rgb return None @property @@ -289,6 +223,12 @@ class KNXLight(KnxEntity, LightEntity): if self._device.supports_rgbw: rgb, white = self._device.current_color if rgb is not None and white is not None: + if not self._device.supports_brightness: + # brightness will be calculated from color so color must not hold brightness again + return cast( + Tuple[int, int, int, int], + color_util.match_max_scale((255,), (*rgb, white)), + ) return (*rgb, white) return None @@ -376,16 +316,21 @@ class KNXLight(KnxEntity, LightEntity): rgb: tuple[int, int, int], white: int | None, brightness: int | None ) -> None: """Set color of light. Normalize colors for brightness when not writable.""" - if brightness: - if self._device.brightness.writable: - await self._device.set_color(rgb, white) + if self._device.brightness.writable: + # let the KNX light controller handle brightness + await self._device.set_color(rgb, white) + if brightness: await self._device.set_brightness(brightness) - return - rgb = cast( - Tuple[int, int, int], - tuple(color * brightness // 255 for color in rgb), - ) - white = white * brightness // 255 if white is not None else None + return + + if brightness is None: + # normalize for brightness if brightness is derived from color + brightness = self.brightness or 255 + rgb = cast( + Tuple[int, int, int], + tuple(color * brightness // 255 for color in rgb), + ) + white = white * brightness // 255 if white is not None else None await self._device.set_color(rgb, white) # return after RGB(W) color has changed as it implicitly sets the brightness @@ -433,18 +378,16 @@ class KNXLight(KnxEntity, LightEntity): return # default to white if color not known for RGB(W) if self.color_mode == COLOR_MODE_RGBW: - rgbw = self.rgbw_color - if not rgbw or not any(rgbw): - await self._device.set_color((0, 0, 0), brightness) - return - await set_color(rgbw[:3], rgbw[3], brightness) + _rgbw = self.rgbw_color + if not _rgbw or not any(_rgbw): + _rgbw = (0, 0, 0, 255) + await set_color(_rgbw[:3], _rgbw[3], brightness) return if self.color_mode == COLOR_MODE_RGB: - rgb = self.rgb_color - if not rgb or not any(rgb): - await self._device.set_color((brightness, brightness, brightness)) - return - await set_color(rgb, None, brightness) + _rgb = self.rgb_color + if not _rgb or not any(_rgb): + _rgb = (255, 255, 255) + await set_color(_rgb, None, brightness) return async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 6c0b1811a6b..b793c667353 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -1,9 +1,16 @@ { "domain": "knx", "name": "KNX", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/knx", - "requirements": ["xknx==0.18.11"], - "codeowners": ["@Julius2342", "@farmio", "@marvin-w"], + "requirements": [ + "xknx==0.18.13" + ], + "codeowners": [ + "@Julius2342", + "@farmio", + "@marvin-w" + ], "quality_scale": "silver", "iot_class": "local_push" -} +} \ No newline at end of file diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index 6f549d9cfac..61bee14e5e2 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -20,10 +20,10 @@ async def async_get_service( discovery_info: DiscoveryInfoType | None = None, ) -> KNXNotificationService | None: """Get the KNX notification service.""" - if not discovery_info or not discovery_info["platform_config"]: + if not discovery_info: return None - platform_config = discovery_info["platform_config"] + platform_config: dict = discovery_info xknx: XKNX = hass.data[DOMAIN].xknx notification_devices = [] diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index 659c334fd4a..9f12aa4ce24 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -6,6 +6,7 @@ from typing import cast from xknx import XKNX from xknx.devices import NumericValue +from homeassistant import config_entries from homeassistant.components.number import NumberEntity from homeassistant.const import ( CONF_ENTITY_CATEGORY, @@ -14,32 +15,34 @@ from homeassistant.const import ( CONF_TYPE, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType -from .const import CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, DOMAIN, KNX_ADDRESS +from .const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + DATA_KNX_CONFIG, + DOMAIN, + KNX_ADDRESS, +) from .knx_entity import KnxEntity from .schema import NumberSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up number entities for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] + """Set up number(s) for KNX platform.""" xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.NUMBER] - async_add_entities( - KNXNumber(xknx, entity_config) for entity_config in platform_config - ) + async_add_entities(KNXNumber(xknx, entity_config) for entity_config in config) def _create_numeric_value(xknx: XKNX, config: ConfigType) -> NumericValue: diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py index b09dc678be3..a028cebc8f7 100644 --- a/homeassistant/components/knx/scene.py +++ b/homeassistant/components/knx/scene.py @@ -6,32 +6,28 @@ from typing import Any from xknx import XKNX from xknx.devices import Scene as XknxScene +from homeassistant import config_entries from homeassistant.components.scene import Scene -from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME +from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, KNX_ADDRESS +from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS from .knx_entity import KnxEntity from .schema import SceneSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the scenes for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] + """Set up scene(s) for KNX platform.""" xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.SCENE] - async_add_entities( - KNXScene(xknx, entity_config) for entity_config in platform_config - ) + async_add_entities(KNXScene(xknx, entity_config) for entity_config in config) class KNXScene(KnxEntity, Scene): diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 0e54a9abbc5..4b7105f15f1 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -9,7 +9,7 @@ import voluptuous as vol from xknx import XKNX from xknx.devices.climate import SetpointShiftMode from xknx.dpt import DPTBase, DPTNumeric -from xknx.exceptions import CouldNotParseAddress +from xknx.exceptions import ConversionError, CouldNotParseAddress from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from xknx.telegram.address import IndividualAddress, parse_device_group_address @@ -18,17 +18,19 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.components.climate.const import HVAC_MODE_HEAT, HVAC_MODES from homeassistant.components.cover import DEVICE_CLASSES as COVER_DEVICE_CLASSES -from homeassistant.components.number.const import MODE_AUTO, MODE_BOX, MODE_SLIDER +from homeassistant.components.number import NumberMode from homeassistant.components.sensor import CONF_STATE_CLASS, STATE_CLASSES_SCHEMA from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_ENTITY_CATEGORY, CONF_ENTITY_ID, + CONF_EVENT, CONF_HOST, CONF_MODE, CONF_NAME, CONF_PORT, CONF_TYPE, + Platform, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA @@ -39,6 +41,8 @@ from .const import ( CONF_KNX_INDIVIDUAL_ADDRESS, CONF_KNX_ROUTING, CONF_KNX_TUNNELING, + CONF_PAYLOAD, + CONF_PAYLOAD_LENGTH, CONF_RESET_AFTER, CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, @@ -47,7 +51,6 @@ from .const import ( KNX_ADDRESS, PRESET_MODES, ColorTempModes, - SupportedPlatforms, ) ################## @@ -122,20 +125,49 @@ def numeric_type_validator(value: Any) -> str | int: raise vol.Invalid(f"value '{value}' is not a valid numeric sensor type.") +def _max_payload_value(payload_length: int) -> int: + if payload_length == 0: + return 0x3F + return int(256 ** payload_length) - 1 + + +def button_payload_sub_validator(entity_config: OrderedDict) -> OrderedDict: + """Validate a button entity payload configuration.""" + if _type := entity_config.get(CONF_TYPE): + _payload = entity_config[ButtonSchema.CONF_VALUE] + if (transcoder := DPTBase.parse_transcoder(_type)) is None: + raise vol.Invalid(f"'type: {_type}' is not a valid sensor type.") + entity_config[CONF_PAYLOAD_LENGTH] = transcoder.payload_length + try: + entity_config[CONF_PAYLOAD] = int.from_bytes( + transcoder.to_knx(_payload), byteorder="big" + ) + except ConversionError as ex: + raise vol.Invalid( + f"'payload: {_payload}' not valid for 'type: {_type}'" + ) from ex + return entity_config + + _payload = entity_config[CONF_PAYLOAD] + _payload_length = entity_config[CONF_PAYLOAD_LENGTH] + if _payload > (max_payload := _max_payload_value(_payload_length)): + raise vol.Invalid( + f"'payload: {_payload}' exceeds possible maximum for " + f"payload_length {_payload_length}: {max_payload}" + ) + return entity_config + + def select_options_sub_validator(entity_config: OrderedDict) -> OrderedDict: """Validate a select entity options configuration.""" options_seen = set() payloads_seen = set() - payload_length = entity_config[SelectSchema.CONF_PAYLOAD_LENGTH] - if payload_length == 0: - max_payload = 0x3F - else: - max_payload = 256 ** payload_length - 1 + payload_length = entity_config[CONF_PAYLOAD_LENGTH] for opt in entity_config[SelectSchema.CONF_OPTIONS]: option = opt[SelectSchema.CONF_OPTION] - payload = opt[SelectSchema.CONF_PAYLOAD] - if payload > max_payload: + payload = opt[CONF_PAYLOAD] + if payload > (max_payload := _max_payload_value(payload_length)): raise vol.Invalid( f"'payload: {payload}' for 'option: {option}' exceeds possible" f" maximum of 'payload_length: {payload_length}': {max_payload}" @@ -169,7 +201,11 @@ sync_state_validator = vol.Any( class ConnectionSchema: - """Voluptuous schema for KNX connection.""" + """ + Voluptuous schema for KNX connection. + + DEPRECATED: Migrated to config and options flow. Will be removed in a future version of Home Assistant. + """ CONF_KNX_LOCAL_IP = "local_ip" CONF_KNX_MCAST_GRP = "multicast_group" @@ -178,6 +214,9 @@ class ConnectionSchema: CONF_KNX_ROUTE_BACK = "route_back" CONF_KNX_STATE_UPDATER = "state_updater" + CONF_KNX_DEFAULT_STATE_UPDATER = True + CONF_KNX_DEFAULT_RATE_LIMIT = 20 + TUNNELING_SCHEMA = vol.Schema( { vol.Optional(CONF_PORT, default=DEFAULT_MCAST_PORT): cv.port, @@ -197,13 +236,37 @@ class ConnectionSchema: ): ia_validator, vol.Optional(CONF_KNX_MCAST_GRP, default=DEFAULT_MCAST_GRP): cv.string, vol.Optional(CONF_KNX_MCAST_PORT, default=DEFAULT_MCAST_PORT): cv.port, - vol.Optional(CONF_KNX_STATE_UPDATER, default=True): cv.boolean, - vol.Optional(CONF_KNX_RATE_LIMIT, default=20): vol.All( + vol.Optional( + CONF_KNX_STATE_UPDATER, default=CONF_KNX_DEFAULT_STATE_UPDATER + ): cv.boolean, + vol.Optional(CONF_KNX_RATE_LIMIT, default=CONF_KNX_DEFAULT_RATE_LIMIT): vol.All( vol.Coerce(int), vol.Range(min=1, max=100) ), } +######### +# EVENT +######### + + +class EventSchema: + """Voluptuous schema for KNX events.""" + + KNX_EVENT_FILTER_SCHEMA = vol.Schema( + { + vol.Required(KNX_ADDRESS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_TYPE): sensor_type_validator, + } + ) + + SCHEMA = { + vol.Optional(CONF_EVENT, default=[]): vol.All( + cv.ensure_list, [KNX_EVENT_FILTER_SCHEMA] + ) + } + + ############# # PLATFORMS ############# @@ -212,14 +275,14 @@ class ConnectionSchema: class KNXPlatformSchema(ABC): """Voluptuous schema for KNX platform entity configuration.""" - PLATFORM_NAME: ClassVar[str] + PLATFORM: ClassVar[Platform | str] ENTITY_SCHEMA: ClassVar[vol.Schema] @classmethod def platform_node(cls) -> dict[vol.Optional, vol.All]: """Return a schema node for the platform.""" return { - vol.Optional(cls.PLATFORM_NAME): vol.All( + vol.Optional(str(cls.PLATFORM)): vol.All( cv.ensure_list, [cls.ENTITY_SCHEMA] ) } @@ -228,7 +291,7 @@ class KNXPlatformSchema(ABC): class BinarySensorSchema(KNXPlatformSchema): """Voluptuous schema for KNX binary sensors.""" - PLATFORM_NAME = SupportedPlatforms.BINARY_SENSOR.value + PLATFORM = Platform.BINARY_SENSOR CONF_STATE_ADDRESS = CONF_STATE_ADDRESS CONF_SYNC_STATE = CONF_SYNC_STATE @@ -261,10 +324,71 @@ class BinarySensorSchema(KNXPlatformSchema): ) +class ButtonSchema(KNXPlatformSchema): + """Voluptuous schema for KNX buttons.""" + + PLATFORM = Platform.BUTTON + + CONF_VALUE = "value" + DEFAULT_NAME = "KNX Button" + + payload_or_value_msg = f"Please use only one of `{CONF_PAYLOAD}` or `{CONF_VALUE}`" + length_or_type_msg = ( + f"Please use only one of `{CONF_PAYLOAD_LENGTH}` or `{CONF_TYPE}`" + ) + + ENTITY_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(KNX_ADDRESS): ga_validator, + vol.Exclusive( + CONF_PAYLOAD, "payload_or_value", msg=payload_or_value_msg + ): object, + vol.Exclusive( + CONF_VALUE, "payload_or_value", msg=payload_or_value_msg + ): object, + vol.Exclusive( + CONF_PAYLOAD_LENGTH, "length_or_type", msg=length_or_type_msg + ): object, + vol.Exclusive( + CONF_TYPE, "length_or_type", msg=length_or_type_msg + ): object, + vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, + } + ), + vol.Any( + vol.Schema( + # encoded value + { + vol.Required(CONF_VALUE): vol.Any(int, float, str), + vol.Required(CONF_TYPE): sensor_type_validator, + }, + extra=vol.ALLOW_EXTRA, + ), + vol.Schema( + # raw payload - default is DPT 1 style True + { + vol.Optional(CONF_PAYLOAD, default=1): cv.positive_int, + vol.Optional(CONF_PAYLOAD_LENGTH, default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=14) + ), + vol.Optional(CONF_VALUE): None, + vol.Optional(CONF_TYPE): None, + }, + extra=vol.ALLOW_EXTRA, + ), + ), + # calculate raw CONF_PAYLOAD and CONF_PAYLOAD_LENGTH + # from CONF_VALUE and CONF_TYPE if given and check payload size + button_payload_sub_validator, + ) + + class ClimateSchema(KNXPlatformSchema): """Voluptuous schema for KNX climate devices.""" - PLATFORM_NAME = SupportedPlatforms.CLIMATE.value + PLATFORM = Platform.CLIMATE CONF_ACTIVE_STATE_ADDRESS = "active_state_address" CONF_SETPOINT_SHIFT_ADDRESS = "setpoint_shift_address" @@ -383,7 +507,7 @@ class ClimateSchema(KNXPlatformSchema): class CoverSchema(KNXPlatformSchema): """Voluptuous schema for KNX covers.""" - PLATFORM_NAME = SupportedPlatforms.COVER.value + PLATFORM = Platform.COVER CONF_MOVE_LONG_ADDRESS = "move_long_address" CONF_MOVE_SHORT_ADDRESS = "move_short_address" @@ -438,7 +562,7 @@ class CoverSchema(KNXPlatformSchema): class ExposeSchema(KNXPlatformSchema): """Voluptuous schema for KNX exposures.""" - PLATFORM_NAME = CONF_KNX_EXPOSE + PLATFORM = CONF_KNX_EXPOSE CONF_KNX_EXPOSE_TYPE = CONF_TYPE CONF_KNX_EXPOSE_ATTRIBUTE = "attribute" @@ -475,7 +599,7 @@ class ExposeSchema(KNXPlatformSchema): class FanSchema(KNXPlatformSchema): """Voluptuous schema for KNX fans.""" - PLATFORM_NAME = SupportedPlatforms.FAN.value + PLATFORM = Platform.FAN CONF_STATE_ADDRESS = CONF_STATE_ADDRESS CONF_OSCILLATION_ADDRESS = "oscillation_address" @@ -500,7 +624,7 @@ class FanSchema(KNXPlatformSchema): class LightSchema(KNXPlatformSchema): """Voluptuous schema for KNX lights.""" - PLATFORM_NAME = SupportedPlatforms.LIGHT.value + PLATFORM = Platform.LIGHT CONF_STATE_ADDRESS = CONF_STATE_ADDRESS CONF_BRIGHTNESS_ADDRESS = "brightness_address" @@ -640,7 +764,7 @@ class LightSchema(KNXPlatformSchema): class NotifySchema(KNXPlatformSchema): """Voluptuous schema for KNX notifications.""" - PLATFORM_NAME = SupportedPlatforms.NOTIFY.value + PLATFORM = Platform.NOTIFY DEFAULT_NAME = "KNX Notify" @@ -655,21 +779,21 @@ class NotifySchema(KNXPlatformSchema): class NumberSchema(KNXPlatformSchema): """Voluptuous schema for KNX numbers.""" - PLATFORM_NAME = SupportedPlatforms.NUMBER.value + PLATFORM = Platform.NUMBER CONF_MAX = "max" CONF_MIN = "min" CONF_STEP = "step" DEFAULT_NAME = "KNX Number" - NUMBER_MODES: Final = [MODE_AUTO, MODE_BOX, MODE_SLIDER] - ENTITY_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean, - vol.Optional(CONF_MODE, default=MODE_AUTO): vol.In(NUMBER_MODES), + vol.Optional(CONF_MODE, default=NumberMode.AUTO): vol.Coerce( + NumberMode + ), vol.Required(CONF_TYPE): numeric_type_validator, vol.Required(KNX_ADDRESS): ga_list_validator, vol.Optional(CONF_STATE_ADDRESS): ga_list_validator, @@ -686,7 +810,7 @@ class NumberSchema(KNXPlatformSchema): class SceneSchema(KNXPlatformSchema): """Voluptuous schema for KNX scenes.""" - PLATFORM_NAME = SupportedPlatforms.SCENE.value + PLATFORM = Platform.SCENE CONF_SCENE_NUMBER = "scene_number" @@ -706,12 +830,10 @@ class SceneSchema(KNXPlatformSchema): class SelectSchema(KNXPlatformSchema): """Voluptuous schema for KNX selects.""" - PLATFORM_NAME = SupportedPlatforms.SELECT.value + PLATFORM = Platform.SELECT CONF_OPTION = "option" CONF_OPTIONS = "options" - CONF_PAYLOAD = "payload" - CONF_PAYLOAD_LENGTH = "payload_length" DEFAULT_NAME = "KNX Select" ENTITY_SCHEMA = vol.All( @@ -741,7 +863,7 @@ class SelectSchema(KNXPlatformSchema): class SensorSchema(KNXPlatformSchema): """Voluptuous schema for KNX sensors.""" - PLATFORM_NAME = SupportedPlatforms.SENSOR.value + PLATFORM = Platform.SENSOR CONF_ALWAYS_CALLBACK = "always_callback" CONF_STATE_ADDRESS = CONF_STATE_ADDRESS @@ -764,7 +886,7 @@ class SensorSchema(KNXPlatformSchema): class SwitchSchema(KNXPlatformSchema): """Voluptuous schema for KNX switches.""" - PLATFORM_NAME = SupportedPlatforms.SWITCH.value + PLATFORM = Platform.SWITCH CONF_INVERT = CONF_INVERT CONF_STATE_ADDRESS = CONF_STATE_ADDRESS @@ -785,7 +907,7 @@ class SwitchSchema(KNXPlatformSchema): class WeatherSchema(KNXPlatformSchema): """Voluptuous schema for KNX weather station.""" - PLATFORM_NAME = SupportedPlatforms.WEATHER.value + PLATFORM = Platform.WEATHER CONF_SYNC_STATE = CONF_SYNC_STATE CONF_KNX_TEMPERATURE_ADDRESS = "address_temperature" diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index e548ad27c8a..5baa068eaa6 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -4,22 +4,27 @@ from __future__ import annotations from xknx import XKNX from xknx.devices import Device as XknxDevice, RawValue +from homeassistant import config_entries from homeassistant.components.select import SelectEntity from homeassistant.const import ( CONF_ENTITY_CATEGORY, CONF_NAME, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from .const import ( + CONF_PAYLOAD, + CONF_PAYLOAD_LENGTH, CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, + DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS, ) @@ -27,21 +32,16 @@ from .knx_entity import KnxEntity from .schema import SelectSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up select entities for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] + """Set up select(s) for KNX platform.""" xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.SELECT] - async_add_entities( - KNXSelect(xknx, entity_config) for entity_config in platform_config - ) + async_add_entities(KNXSelect(xknx, entity_config) for entity_config in config) def _create_raw_value(xknx: XKNX, config: ConfigType) -> RawValue: @@ -49,7 +49,7 @@ def _create_raw_value(xknx: XKNX, config: ConfigType) -> RawValue: return RawValue( xknx, name=config[CONF_NAME], - payload_length=config[SelectSchema.CONF_PAYLOAD_LENGTH], + payload_length=config[CONF_PAYLOAD_LENGTH], group_address=config[KNX_ADDRESS], group_address_state=config.get(CONF_STATE_ADDRESS), respond_to_read=config[CONF_RESPOND_TO_READ], @@ -66,7 +66,7 @@ class KNXSelect(KnxEntity, SelectEntity, RestoreEntity): """Initialize a KNX select.""" super().__init__(_create_raw_value(xknx, config)) self._option_payloads: dict[str, int] = { - option[SelectSchema.CONF_OPTION]: option[SelectSchema.CONF_PAYLOAD] + option[SelectSchema.CONF_OPTION]: option[CONF_PAYLOAD] for option in config[SelectSchema.CONF_OPTIONS] } self._attr_options = list(self._option_payloads) diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index fb64f65968b..ceb9f435d83 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -6,37 +6,32 @@ from typing import Any from xknx import XKNX from xknx.devices import Sensor as XknxSensor +from homeassistant import config_entries from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASSES, SensorEntity, ) -from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, CONF_TYPE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -from homeassistant.util import dt +from homeassistant.helpers.typing import ConfigType, StateType -from .const import ATTR_LAST_KNX_UPDATE, ATTR_SOURCE, DOMAIN +from .const import ATTR_SOURCE, DATA_KNX_CONFIG, DOMAIN from .knx_entity import KnxEntity from .schema import SensorSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up sensor(s) for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.SENSOR] - async_add_entities( - KNXSensor(xknx, entity_config) for entity_config in platform_config - ) + async_add_entities(KNXSensor(xknx, entity_config) for entity_config in config) def _create_sensor(xknx: XKNX, config: ConfigType) -> XknxSensor: @@ -82,7 +77,4 @@ class KNXSensor(KnxEntity, SensorEntity): if self._device.last_telegram is not None: attr[ATTR_SOURCE] = str(self._device.last_telegram.source_address) - attr[ATTR_LAST_KNX_UPDATE] = str( - dt.as_utc(self._device.last_telegram.timestamp) - ) return attr diff --git a/homeassistant/components/knx/services.yaml b/homeassistant/components/knx/services.yaml index 1ea5d9b6faa..11519be48f3 100644 --- a/homeassistant/components/knx/services.yaml +++ b/homeassistant/components/knx/services.yaml @@ -45,6 +45,13 @@ event_register: example: "1/1/0" selector: 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)." + required: false + example: "2byte_float" + selector: + text: remove: name: "Remove event registration" description: "If `True` the group address(es) will be removed." @@ -93,6 +100,3 @@ exposure_register: default: false selector: boolean: -reload: - name: "Reload KNX configuration" - description: "Reload the KNX configuration from YAML." diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json new file mode 100644 index 00000000000..7f770c25427 --- /dev/null +++ b/homeassistant/components/knx/strings.json @@ -0,0 +1,65 @@ +{ + "config": { + "step": { + "type": { + "description": "Please enter the connection type we should use for your KNX connection. \n AUTOMATIC - The integration takes care of the connectivity to your KNX Bus by performing a gateway scan. \n TUNNELING - The integration will connect to your KNX bus via tunneling. \n ROUTING - The integration will connect to your KNX bus via routing.", + "data": { + "connection_type": "KNX Connection Type" + } + }, + "tunnel": { + "description": "Please select a gateway from the list.", + "data": { + "gateway": "KNX Tunnel Connection" + } + }, + "manual_tunnel": { + "description": "Please enter the connection information of your tunneling device.", + "data": { + "port": "[%key:common::config_flow::data::port%]", + "host": "[%key:common::config_flow::data::host%]", + "individual_address": "Individual address for the connection", + "route_back": "Route Back / NAT Mode", + "local_ip": "Local IP (leave empty if unsure)" + } + }, + "routing": { + "description": "Please configure the routing options.", + "data": { + "individual_address": "Individual address for the routing connection", + "multicast_group": "The multicast group used for routing", + "multicast_port": "The multicast port used for routing" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "connection_type": "KNX Connection Type", + "individual_address": "Default individual address", + "multicast_group": "Multicast group used for routing and discovery", + "multicast_port": "Multicast port used for routing and discovery", + "state_updater": "Globally enable reading states from the KNX Bus", + "rate_limit": "Maximum outgoing telegrams per second" + } + }, + "tunnel": { + "data": { + "port": "[%key:common::config_flow::data::port%]", + "host": "[%key:common::config_flow::data::host%]", + "route_back": "Route Back / NAT Mode", + "local_ip": "Local IP (leave empty if unsure)" + } + } + } + } +} diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index c775ce70d32..9f4eb6fc632 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -6,6 +6,7 @@ from typing import Any from xknx import XKNX from xknx.devices import Switch as XknxSwitch +from homeassistant import config_entries from homeassistant.components.switch import SwitchEntity from homeassistant.const import ( CONF_ENTITY_CATEGORY, @@ -13,32 +14,28 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType -from .const import CONF_RESPOND_TO_READ, DOMAIN, KNX_ADDRESS +from .const import CONF_RESPOND_TO_READ, DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS from .knx_entity import KnxEntity from .schema import SwitchSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up switch(es) for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.SWITCH] - async_add_entities( - KNXSwitch(xknx, entity_config) for entity_config in platform_config - ) + async_add_entities(KNXSwitch(xknx, entity_config) for entity_config in config) class KNXSwitch(KnxEntity, SwitchEntity, RestoreEntity): diff --git a/homeassistant/components/knx/translations/bg.json b/homeassistant/components/knx/translations/bg.json new file mode 100644 index 00000000000..52e22a0a80e --- /dev/null +++ b/homeassistant/components/knx/translations/bg.json @@ -0,0 +1,29 @@ +{ + "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", + "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" + }, + "step": { + "manual_tunnel": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + } + } + } + }, + "options": { + "step": { + "tunnel": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/translations/ca.json b/homeassistant/components/knx/translations/ca.json new file mode 100644 index 00000000000..fef4093de94 --- /dev/null +++ b/homeassistant/components/knx/translations/ca.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "already_configured": "El servei ja est\u00e0 configurat", + "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "step": { + "manual_tunnel": { + "data": { + "host": "Amfitri\u00f3", + "individual_address": "Adre\u00e7a individual de la connexi\u00f3", + "port": "Port", + "route_back": "Encaminament de retorn / Mode NAT" + }, + "description": "Introdueix la informaci\u00f3 de connexi\u00f3 del dispositiu de t\u00fanel." + }, + "routing": { + "data": { + "individual_address": "Adre\u00e7a individual de la connexi\u00f3 d'encaminament", + "multicast_group": "Grup de multidifusi\u00f3 utilitzat per a l'encaminament", + "multicast_port": "Port de multidifusi\u00f3 utilitzat per a l'encaminament" + }, + "description": "Configura les opcions d'encaminament." + }, + "tunnel": { + "data": { + "gateway": "Connexi\u00f3 t\u00fanel KNX" + }, + "description": "Selecciona una passarel\u00b7la d'enlla\u00e7 de la llista." + }, + "type": { + "data": { + "connection_type": "Tipus de connexi\u00f3 KNX" + }, + "description": "Introdueix el tipus de connexi\u00f3 a utilitzar per a la connexi\u00f3 KNX.\n AUTOM\u00c0TICA: la integraci\u00f3 s'encarrega de la connectivitat al bus KNX realitzant una exploraci\u00f3 de la passarel\u00b7la.\n T\u00daNEL: la integraci\u00f3 es connectar\u00e0 al bus KNX mitjan\u00e7ant un t\u00fanel.\n ENCAMINAMENT: la integraci\u00f3 es connectar\u00e0 al bus KNX mitjan\u00e7ant l'encaminament." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "connection_type": "Tipus de connexi\u00f3 KNX", + "individual_address": "Adre\u00e7a individual predeterminada", + "multicast_group": "Grup de multidifusi\u00f3 utilitzat per a encaminament i descobriment", + "multicast_port": "Port de multidifusi\u00f3 utilitzat per a encaminament i descobriment", + "rate_limit": "Telegrames de sortida m\u00e0xims per segon", + "state_updater": "Habilita la lectura global d'estats del bus KNX" + } + }, + "tunnel": { + "data": { + "host": "Amfitri\u00f3", + "port": "Port", + "route_back": "Encaminament de retorn / Mode NAT" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/translations/de.json b/homeassistant/components/knx/translations/de.json new file mode 100644 index 00000000000..4d26412bdae --- /dev/null +++ b/homeassistant/components/knx/translations/de.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "already_configured": "Der Dienst ist bereits konfiguriert", + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "step": { + "manual_tunnel": { + "data": { + "host": "Host", + "individual_address": "Individuelle Adresse f\u00fcr die Verbindung", + "port": "Port", + "route_back": "Route Back / NAT-Modus" + }, + "description": "Bitte gib die Verbindungsinformationen deines Tunnelger\u00e4ts ein." + }, + "routing": { + "data": { + "individual_address": "Individuelle Adresse f\u00fcr die Routingverbindung", + "multicast_group": "Die f\u00fcr das Routing verwendete Multicast-Gruppe", + "multicast_port": "Der f\u00fcr das Routing verwendete Multicast-Port" + }, + "description": "Bitte konfiguriere die Routing-Optionen." + }, + "tunnel": { + "data": { + "gateway": "KNX Tunnel Verbindung" + }, + "description": "Bitte w\u00e4hle ein Gateway aus der Liste aus." + }, + "type": { + "data": { + "connection_type": "KNX-Verbindungstyp" + }, + "description": "Bitte gib den Verbindungstyp ein, den wir f\u00fcr deine KNX-Verbindung verwenden sollen. \n AUTOMATISCH - Die Integration k\u00fcmmert sich um die Verbindung zu deinem KNX Bus, indem sie einen Gateway-Scan durchf\u00fchrt. \n TUNNELING - Die Integration stellt die Verbindung zu deinem KNX Bus \u00fcber Tunneling her. \n ROUTING - Die Integration stellt die Verbindung zu deinem KNX-Bus \u00fcber Routing her." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "connection_type": "KNX-Verbindungstyp", + "individual_address": "Individuelle Standardadresse", + "multicast_group": "Multicast-Gruppe f\u00fcr Routing und Discovery verwenden", + "multicast_port": "Multicast-Port f\u00fcr Routing und Discovery verwenden", + "rate_limit": "Maximal ausgehende Telegrams pro Sekunde", + "state_updater": "Lesen von Zust\u00e4nden aus dem KNX Bus global freigeben" + } + }, + "tunnel": { + "data": { + "host": "Host", + "port": "Port", + "route_back": "Route Back / NAT-Modus" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/translations/en.json b/homeassistant/components/knx/translations/en.json new file mode 100644 index 00000000000..5320f0cfb03 --- /dev/null +++ b/homeassistant/components/knx/translations/en.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "already_configured": "Service is already configured", + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "step": { + "manual_tunnel": { + "data": { + "host": "Host", + "individual_address": "Individual address for the connection", + "local_ip": "Local IP (leave empty if unsure)", + "port": "Port", + "route_back": "Route Back / NAT Mode" + }, + "description": "Please enter the connection information of your tunneling device." + }, + "routing": { + "data": { + "individual_address": "Individual address for the routing connection", + "multicast_group": "The multicast group used for routing", + "multicast_port": "The multicast port used for routing" + }, + "description": "Please configure the routing options." + }, + "tunnel": { + "data": { + "gateway": "KNX Tunnel Connection" + }, + "description": "Please select a gateway from the list." + }, + "type": { + "data": { + "connection_type": "KNX Connection Type" + }, + "description": "Please enter the connection type we should use for your KNX connection. \n AUTOMATIC - The integration takes care of the connectivity to your KNX Bus by performing a gateway scan. \n TUNNELING - The integration will connect to your KNX bus via tunneling. \n ROUTING - The integration will connect to your KNX bus via routing." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "connection_type": "KNX Connection Type", + "individual_address": "Default individual address", + "multicast_group": "Multicast group used for routing and discovery", + "multicast_port": "Multicast port used for routing and discovery", + "rate_limit": "Maximum outgoing telegrams per second", + "state_updater": "Globally enable reading states from the KNX Bus" + } + }, + "tunnel": { + "data": { + "host": "Host", + "local_ip": "Local IP (leave empty if unsure)", + "port": "Port", + "route_back": "Route Back / NAT Mode" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/translations/et.json b/homeassistant/components/knx/translations/et.json new file mode 100644 index 00000000000..d64e025f029 --- /dev/null +++ b/homeassistant/components/knx/translations/et.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "already_configured": "Teenus on juba seadistatud.", + "single_instance_allowed": "Juba seadistatud. Lubatud on ainult \u00fcks sidumine." + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "step": { + "manual_tunnel": { + "data": { + "host": "Host", + "individual_address": "\u00dchenduse individuaalne aadress", + "port": "Port", + "route_back": "Marsruudi tagasitee / NAT-re\u017eiim" + }, + "description": "Sisesta tunneldamisseadme \u00fchenduse teave." + }, + "routing": { + "data": { + "individual_address": "Marsruutimis\u00fchenduse individuaalne aadress", + "multicast_group": "Marsruutimiseks kasutatav multisaater\u00fchm", + "multicast_port": "Marsruutimiseks kasutatav multisaateport" + }, + "description": "Konfigureeri marsruutimissuvandid." + }, + "tunnel": { + "data": { + "gateway": "KNX tunneli \u00fchendus" + }, + "description": "Vali loendist l\u00fc\u00fcs." + }, + "type": { + "data": { + "connection_type": "KNX \u00fchenduse t\u00fc\u00fcp" + }, + "description": "Sisesta \u00fchenduse t\u00fc\u00fcp, mida kasutada KNX-\u00fchenduse jaoks. \n AUTOMAATNE \u2013 sidumine hoolitseb KNX siini \u00fchenduvuse eest, tehes l\u00fc\u00fcsikontrolli. \n TUNNELING - sidumine \u00fchendub KNX siiniga tunneli kaudu. \n MARSRUUTIMINE \u2013 sidumine \u00fchendub marsruudi kaudu KNX siiniga." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "connection_type": "KNX \u00fchenduse t\u00fc\u00fcp", + "individual_address": "Vaikimisi individuaalne aadress", + "multicast_group": "Marsruutimiseks ja avastamiseks kasutatav multisaategrupp", + "multicast_port": "Marsruutimiseks ja avastamiseks kasutatav multisaateport", + "rate_limit": "Maksimaalne v\u00e4ljaminevate teavituste arv sekundis", + "state_updater": "Luba globaalselt seisundi lugemine KNX-siinilt" + } + }, + "tunnel": { + "data": { + "host": "Host", + "port": "Port", + "route_back": "Marsruudi tagasitee / NAT-re\u017eiim" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/translations/he.json b/homeassistant/components/knx/translations/he.json new file mode 100644 index 00000000000..3c338886e22 --- /dev/null +++ b/homeassistant/components/knx/translations/he.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8", + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "manual_tunnel": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05ea\u05d7\u05d4" + } + } + } + }, + "options": { + "step": { + "tunnel": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05ea\u05d7\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/translations/hu.json b/homeassistant/components/knx/translations/hu.json new file mode 100644 index 00000000000..9d2e4d5f858 --- /dev/null +++ b/homeassistant/components/knx/translations/hu.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", + "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s" + }, + "step": { + "manual_tunnel": { + "data": { + "host": "C\u00edm", + "individual_address": "A kapcsolat egy\u00e9ni c\u00edme", + "port": "Port", + "route_back": "\u00datvonal (route) vissza / NAT m\u00f3d" + }, + "description": "Adja meg az alag\u00fatkezel\u0151 (tunneling) eszk\u00f6z csatlakoz\u00e1si adatait." + }, + "routing": { + "data": { + "individual_address": "Az \u00fatv\u00e1laszt\u00e1si (routing) kapcsolat egy\u00e9ni c\u00edme", + "multicast_group": "Az \u00fatv\u00e1laszt\u00e1shoz haszn\u00e1lt multicast csoport", + "multicast_port": "Az \u00fatv\u00e1laszt\u00e1shoz haszn\u00e1lt multicast portsz\u00e1m" + }, + "description": "K\u00e9rem, konfigur\u00e1lja az \u00fatv\u00e1laszt\u00e1si (routing) be\u00e1ll\u00edt\u00e1sokat." + }, + "tunnel": { + "data": { + "gateway": "KNX alag\u00fat (tunnel) kapcsolat" + }, + "description": "V\u00e1lasszon egy \u00e1tj\u00e1r\u00f3t a list\u00e1b\u00f3l." + }, + "type": { + "data": { + "connection_type": "KNX csatlakoz\u00e1s t\u00edpusa" + }, + "description": "K\u00e9rem, adja meg a KNX-kapcsolathoz haszn\u00e1land\u00f3 kapcsolatt\u00edpust. \n AUTOMATIKUS - Az integr\u00e1ci\u00f3 gondoskodik a KNX buszhoz val\u00f3 kapcsol\u00f3d\u00e1sr\u00f3l egy \u00e1tj\u00e1r\u00f3 keres\u00e9s elv\u00e9gz\u00e9s\u00e9vel. \n TUNNELING - Az integr\u00e1ci\u00f3 alag\u00faton kereszt\u00fcl csatlakozik a KNX buszhoz. \n ROUTING - Az integr\u00e1ci\u00f3 a KNX buszhoz \u00fatv\u00e1laszt\u00e1ssal csatlakozik." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "connection_type": "KNX csatlakoz\u00e1s t\u00edpusa", + "individual_address": "Alap\u00e9rtelmezett egy\u00e9ni c\u00edm", + "multicast_group": "\u00datv\u00e1laszt\u00e1shoz \u00e9s felder\u00edt\u00e9shez haszn\u00e1lt multicast csoport", + "multicast_port": "\u00datv\u00e1laszt\u00e1shoz \u00e9s felder\u00edt\u00e9shez haszn\u00e1lt multicast portsz\u00e1m\n", + "rate_limit": "Maxim\u00e1lis kimen\u0151 \u00fczenet darabsz\u00e1m m\u00e1sodpercenk\u00e9nt", + "state_updater": "Glob\u00e1lisan enged\u00e9lyezi az \u00e1llapotok olvas\u00e1s\u00e1t a KNX buszr\u00f3l." + } + }, + "tunnel": { + "data": { + "host": "C\u00edm", + "port": "Port", + "route_back": "\u00datvonal (route) vissza / NAT m\u00f3d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/translations/id.json b/homeassistant/components/knx/translations/id.json new file mode 100644 index 00000000000..cfab50507ae --- /dev/null +++ b/homeassistant/components/knx/translations/id.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "already_configured": "Layanan sudah dikonfigurasi", + "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." + }, + "error": { + "cannot_connect": "Gagal terhubung" + }, + "step": { + "manual_tunnel": { + "data": { + "host": "Host", + "individual_address": "Alamat individu untuk koneksi", + "port": "Port", + "route_back": "Dirutekan Kembali/Mode NAT" + }, + "description": "Masukkan informasi koneksi untuk perangkat tunneling Anda." + }, + "routing": { + "data": { + "individual_address": "Alamat individu untuk koneksi routing", + "multicast_group": "Grup multicast yang digunakan untuk routing", + "multicast_port": "Port multicast yang digunakan untuk routing" + }, + "description": "Konfigurasikan opsi routing." + }, + "tunnel": { + "data": { + "gateway": "Koneksi Tunnel KNX" + }, + "description": "Pilih gateway dari daftar." + }, + "type": { + "data": { + "connection_type": "Jenis Koneksi KNX" + }, + "description": "Masukkan jenis koneksi yang harus kami gunakan untuk koneksi KNX Anda. \nOTOMATIS - Integrasi melakukan konektivitas ke bus KNX Anda dengan melakukan pemindaian gateway. \nTUNNELING - Integrasi akan terhubung ke bus KNX Anda melalui tunneling. \nROUTING - Integrasi akan terhubung ke bus KNX Anda melalui routing." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "connection_type": "Jenis Koneksi KNX", + "individual_address": "Alamat individu default", + "multicast_group": "Grup multicast yang digunakan untuk routing dan penemuan", + "multicast_port": "Port multicast yang digunakan untuk routing dan penemuan", + "rate_limit": "Jumlah maksimal telegram keluar per detik", + "state_updater": "Aktifkan status membaca secara global dari KNX Bus" + } + }, + "tunnel": { + "data": { + "host": "Host", + "port": "Port", + "route_back": "Dirutekan Kembali/Mode NAT" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/translations/ja.json b/homeassistant/components/knx/translations/ja.json new file mode 100644 index 00000000000..23614ebcbc2 --- /dev/null +++ b/homeassistant/components/knx/translations/ja.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "manual_tunnel": { + "data": { + "host": "\u30db\u30b9\u30c8", + "individual_address": "\u63a5\u7d9a\u7528\u306e\u500b\u5225\u30a2\u30c9\u30ec\u30b9", + "port": "\u30dd\u30fc\u30c8", + "route_back": "\u30eb\u30fc\u30c8\u30d0\u30c3\u30af / NAT\u30e2\u30fc\u30c9" + }, + "description": "\u30c8\u30f3\u30cd\u30ea\u30f3\u30b0\u30c7\u30d0\u30a4\u30b9\u306e\u63a5\u7d9a\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "routing": { + "data": { + "individual_address": "\u30eb\u30fc\u30c6\u30a3\u30f3\u30b0\u63a5\u7d9a\u306e\u500b\u5225\u306e\u30a2\u30c9\u30ec\u30b9", + "multicast_group": "\u30eb\u30fc\u30c6\u30a3\u30f3\u30b0\u306b\u4f7f\u7528\u3059\u308b\u30de\u30eb\u30c1\u30ad\u30e3\u30b9\u30c8\u30b0\u30eb\u30fc\u30d7", + "multicast_port": "\u30eb\u30fc\u30c6\u30a3\u30f3\u30b0\u306b\u4f7f\u7528\u3059\u308b\u30de\u30eb\u30c1\u30ad\u30e3\u30b9\u30c8\u30dd\u30fc\u30c8" + }, + "description": "\u30eb\u30fc\u30c6\u30a3\u30f3\u30b0\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "tunnel": { + "data": { + "gateway": "KNX\u30c8\u30f3\u30cd\u30eb\u63a5\u7d9a" + }, + "description": "\u30ea\u30b9\u30c8\u304b\u3089\u30b2\u30fc\u30c8\u30a6\u30a7\u30a4\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "type": { + "data": { + "connection_type": "KNX\u63a5\u7d9a\u30bf\u30a4\u30d7" + }, + "description": "KNX\u63a5\u7d9a\u306b\u4f7f\u7528\u3059\u308b\u63a5\u7d9a\u30bf\u30a4\u30d7\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002 \n AUTOMATIC - \u30b2\u30fc\u30c8\u30a6\u30a7\u30a4\u30b9\u30ad\u30e3\u30f3\u3092\u5b9f\u884c\u3057\u3066\u3001KNX \u30d0\u30b9\u3078\u306e\u63a5\u7d9a\u3092\u884c\u3044\u307e\u3059\u3002 \n TUNNELING - \u30c8\u30f3\u30cd\u30ea\u30f3\u30b0\u3092\u4ecb\u3057\u3066\u3001KNX\u30d0\u30b9\u306b\u63a5\u7d9a\u3057\u307e\u3059\u3002 \n ROUTING - \u30eb\u30fc\u30c6\u30a3\u30f3\u30b0\u3092\u4ecb\u3057\u3066\u3001KNX \u30d0\u30b9\u306b\u63a5\u7d9a\u3057\u307e\u3059\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "connection_type": "KNX\u63a5\u7d9a\u30bf\u30a4\u30d7", + "individual_address": "\u30c7\u30d5\u30a9\u30eb\u30c8\u306e\u500b\u5225\u30a2\u30c9\u30ec\u30b9", + "multicast_group": "\u30eb\u30fc\u30c6\u30a3\u30f3\u30b0\u3068\u691c\u51fa(discovery)\u306b\u4f7f\u7528\u3055\u308c\u308b\u30de\u30eb\u30c1\u30ad\u30e3\u30b9\u30c8\u30b0\u30eb\u30fc\u30d7", + "multicast_port": "\u30eb\u30fc\u30c6\u30a3\u30f3\u30b0\u3068\u691c\u51fa(discovery)\u306b\u4f7f\u7528\u3055\u308c\u308b\u30de\u30eb\u30c1\u30ad\u30e3\u30b9\u30c8\u30dd\u30fc\u30c8", + "rate_limit": "1 \u79d2\u3042\u305f\u308a\u306e\u6700\u5927\u9001\u4fe1\u96fb\u5831(telegrams )\u6570", + "state_updater": "KNX\u30d0\u30b9\u304b\u3089\u306e\u8aad\u307f\u53d6\u308a\u72b6\u614b\u3092\u30b0\u30ed\u30fc\u30d0\u30eb\u306b\u6709\u52b9\u306b\u3059\u308b" + } + }, + "tunnel": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8", + "route_back": "\u30eb\u30fc\u30c8\u30d0\u30c3\u30af / NAT\u30e2\u30fc\u30c9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/translations/nl.json b/homeassistant/components/knx/translations/nl.json new file mode 100644 index 00000000000..3a5e3dc5d70 --- /dev/null +++ b/homeassistant/components/knx/translations/nl.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "already_configured": "Service is al geconfigureerd", + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + }, + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, + "step": { + "manual_tunnel": { + "data": { + "host": "Host", + "individual_address": "Individueel adres voor de verbinding", + "port": "Poort", + "route_back": "Route Back / NAT Mode" + }, + "description": "Voer de verbindingsinformatie van uw tunneling-apparaat in." + }, + "routing": { + "data": { + "individual_address": "Individueel adres voor de routing verbinding", + "multicast_group": "De multicast groep gebruikt voor de routing", + "multicast_port": "De multicast-poort gebruikt voor de routing" + }, + "description": "Configureer de routing opties" + }, + "tunnel": { + "data": { + "gateway": "KNX Tunnel Connection" + }, + "description": "Selecteer een gateway uit de lijst." + }, + "type": { + "data": { + "connection_type": "KNX-verbindingstype" + }, + "description": "Voer het verbindingstype in dat we moeten gebruiken voor uw KNX-verbinding.\n AUTOMATISCH - De integratie zorgt voor de connectiviteit met uw KNX-bus door een gateway-scan uit te voeren.\n TUNNELING - De integratie maakt verbinding met uw KNX-bus via tunneling.\n ROUTING - De integratie maakt via routing verbinding met uw KNX-bus." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "connection_type": "KNX-verbindingstype", + "individual_address": "Standaard individueel adres", + "multicast_group": "Multicast groep gebruikt voor routing en ontdekking", + "multicast_port": "Multicast poort gebruikt voor routing en ontdekking", + "rate_limit": "Maximaal aantal uitgaande telegrammen per seconde", + "state_updater": "Globaal vrijgeven van het lezen van de KNX bus" + } + }, + "tunnel": { + "data": { + "host": "Host", + "port": "Poort", + "route_back": "Route Back / NAT Mode" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/translations/no.json b/homeassistant/components/knx/translations/no.json new file mode 100644 index 00000000000..223dd66402a --- /dev/null +++ b/homeassistant/components/knx/translations/no.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "already_configured": "Tjenesten er allerede konfigurert", + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, + "step": { + "manual_tunnel": { + "data": { + "host": "Vert", + "individual_address": "Individuell adresse for tilkoblingen", + "port": "Port", + "route_back": "Rute tilbake / NAT-modus" + }, + "description": "Vennligst skriv inn tilkoblingsinformasjonen til tunnelenheten din." + }, + "routing": { + "data": { + "individual_address": "Individuell adresse for ruteforbindelsen", + "multicast_group": "Multicast-gruppen som brukes til ruting", + "multicast_port": "Multicast-porten som brukes til ruting" + }, + "description": "Vennligst konfigurer rutealternativene." + }, + "tunnel": { + "data": { + "gateway": "KNX Tunneltilkobling" + }, + "description": "Vennligst velg en gateway fra listen." + }, + "type": { + "data": { + "connection_type": "KNX tilkoblingstype" + }, + "description": "Vennligst skriv inn tilkoblingstypen vi skal bruke for din KNX-tilkobling.\n AUTOMATISK - Integrasjonen tar seg av tilkoblingen til KNX-bussen ved \u00e5 utf\u00f8re en gateway-skanning.\n TUNNELING - Integrasjonen vil kobles til KNX-bussen din via tunnelering.\n ROUTING - Integrasjonen vil kobles til din KNX-bussen via ruting." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "connection_type": "KNX tilkoblingstype", + "individual_address": "Standard individuell adresse", + "multicast_group": "Multicast-gruppe brukt til ruting og oppdagelse", + "multicast_port": "Multicast-port som brukes til ruting og oppdagelse", + "rate_limit": "Maksimalt utg\u00e5ende telegrammer per sekund", + "state_updater": "Aktiver lesetilstander globalt fra KNX-bussen" + } + }, + "tunnel": { + "data": { + "host": "Vert", + "port": "Port", + "route_back": "Rute tilbake / NAT-modus" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/translations/pl.json b/homeassistant/components/knx/translations/pl.json new file mode 100644 index 00000000000..c987e2cc937 --- /dev/null +++ b/homeassistant/components/knx/translations/pl.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "already_configured": "Us\u0142uga jest ju\u017c skonfigurowana", + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + }, + "step": { + "manual_tunnel": { + "data": { + "host": "Nazwa hosta lub adres IP", + "individual_address": "Indywidualny adres dla po\u0142\u0105czenia", + "port": "Port", + "route_back": "Tryb Route Back / NAT" + }, + "description": "Prosz\u0119 wprowadzi\u0107 informacje o po\u0142\u0105czeniu urz\u0105dzenia tuneluj\u0105cego." + }, + "routing": { + "data": { + "individual_address": "Indywidualny adres dla po\u0142\u0105czenia routingowego", + "multicast_group": "Grupa multicast u\u017cyta do routingu", + "multicast_port": "Port multicast u\u017cyty do routingu" + }, + "description": "Prosz\u0119 skonfigurowa\u0107 opcje routingu." + }, + "tunnel": { + "data": { + "gateway": "Po\u0142\u0105czenie tunelowe KNX" + }, + "description": "Prosz\u0119 wybra\u0107 bram\u0119 z listy." + }, + "type": { + "data": { + "connection_type": "Typ po\u0142\u0105czenia KNX" + }, + "description": "Prosz\u0119 wprowadzi\u0107 typ po\u0142\u0105czenia, kt\u00f3rego powinni\u015bmy u\u017cy\u0107 dla po\u0142\u0105czenia KNX. \n AUTOMATIC - Integracja sama zadba o po\u0142\u0105czenie z magistral\u0105 KNX poprzez skanowanie bramy. \n TUNNELING - Integracja po\u0142\u0105czy si\u0119 z magistral\u0105 KNX poprzez tunelowanie. \n ROUTING - Integracja po\u0142\u0105czy si\u0119 z magistral\u0105 KNX poprzez routing." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "connection_type": "Typ po\u0142\u0105czenia KNX", + "individual_address": "Domy\u015blny adres indywidualny", + "multicast_group": "Grupa multicast u\u017cywana do routingu i wykrywania", + "multicast_port": "Port multicast u\u017cywany do routingu i wykrywania", + "rate_limit": "Maksymalna liczba wychodz\u0105cych wiadomo\u015bci na sekund\u0119", + "state_updater": "Zezw\u00f3l globalnie na odczyt stan\u00f3w z magistrali KNX" + } + }, + "tunnel": { + "data": { + "host": "Nazwa hosta lub adres IP", + "port": "Port", + "route_back": "Tryb Route Back / NAT" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/translations/ru.json b/homeassistant/components/knx/translations/ru.json new file mode 100644 index 00000000000..93c15c33415 --- /dev/null +++ b/homeassistant/components/knx/translations/ru.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u0430 \u0441\u043b\u0443\u0436\u0431\u0430 \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", + "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." + }, + "step": { + "manual_tunnel": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "individual_address": "\u0418\u043d\u0434\u0438\u0432\u0438\u0434\u0443\u0430\u043b\u044c\u043d\u044b\u0439 \u0430\u0434\u0440\u0435\u0441 \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f", + "port": "\u041f\u043e\u0440\u0442", + "route_back": "\u041e\u0431\u0440\u0430\u0442\u043d\u044b\u0439 \u043c\u0430\u0440\u0448\u0440\u0443\u0442 / \u0440\u0435\u0436\u0438\u043c NAT" + }, + "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." + }, + "routing": { + "data": { + "individual_address": "\u0418\u043d\u0434\u0438\u0432\u0438\u0434\u0443\u0430\u043b\u044c\u043d\u044b\u0439 \u0430\u0434\u0440\u0435\u0441 \u0434\u043b\u044f \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0438\u0440\u0443\u0435\u043c\u043e\u0433\u043e \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f", + "multicast_group": "\u0413\u0440\u0443\u043f\u043f\u0430 \u043c\u043d\u043e\u0433\u043e\u0430\u0434\u0440\u0435\u0441\u043d\u043e\u0439 \u0440\u0430\u0441\u0441\u044b\u043b\u043a\u0438, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u0430\u044f \u0434\u043b\u044f \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0446\u0438\u0438", + "multicast_port": "\u041f\u043e\u0440\u0442 \u043c\u043d\u043e\u0433\u043e\u0430\u0434\u0440\u0435\u0441\u043d\u043e\u0439 \u0440\u0430\u0441\u0441\u044b\u043b\u043a\u0438, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u044b\u0439 \u0434\u043b\u044f \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0446\u0438\u0438" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043e\u0432 \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0446\u0438\u0438." + }, + "tunnel": { + "data": { + "gateway": "\u0422\u0443\u043d\u043d\u0435\u043b\u044c\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c \u0432\u0437\u0430\u0438\u043c\u043e\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f KNX" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0448\u043b\u044e\u0437 \u0438\u0437 \u0441\u043f\u0438\u0441\u043a\u0430." + }, + "type": { + "data": { + "connection_type": "\u0422\u0438\u043f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f KNX" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0442\u0438\u043f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043d\u0443\u0436\u043d\u043e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c.\nAUTOMATIC \u2014 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0431\u0443\u0434\u0435\u0442 \u043e\u0431\u0435\u0441\u043f\u0435\u0447\u0438\u0432\u0430\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0448\u0438\u043d\u0435 KNX, \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u044f \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0448\u043b\u044e\u0437\u0430.\nTUNNELING \u2014 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0441\u044f \u043a \u0448\u0438\u043d\u0435 KNX, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044f \u0442\u0443\u043d\u043d\u0435\u043b\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435.\nROUTING \u2014 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0441\u044f \u043a \u0448\u0438\u043d\u0435 KNX, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044f \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0446\u0438\u044e." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "connection_type": "\u0422\u0438\u043f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f KNX", + "individual_address": "\u0418\u043d\u0434\u0438\u0432\u0438\u0434\u0443\u0430\u043b\u044c\u043d\u044b\u0439 \u0430\u0434\u0440\u0435\u0441 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e", + "multicast_group": "\u0413\u0440\u0443\u043f\u043f\u0430 \u043c\u043d\u043e\u0433\u043e\u0430\u0434\u0440\u0435\u0441\u043d\u043e\u0439 \u0440\u0430\u0441\u0441\u044b\u043b\u043a\u0438, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u0430\u044f \u0434\u043b\u044f \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0446\u0438\u0438 \u0438 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u044f", + "multicast_port": "\u041f\u043e\u0440\u0442 \u043c\u043d\u043e\u0433\u043e\u0430\u0434\u0440\u0435\u0441\u043d\u043e\u0439 \u0440\u0430\u0441\u0441\u044b\u043b\u043a\u0438, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u044b\u0439 \u0434\u043b\u044f \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0446\u0438\u0438 \u0438 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u044f", + "rate_limit": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0435 \u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0445 \u0442\u0435\u043b\u0435\u0433\u0440\u0430\u043c\u043c \u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0443", + "state_updater": "\u0413\u043b\u043e\u0431\u0430\u043b\u044c\u043d\u043e \u0440\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0441\u0447\u0438\u0442\u044b\u0432\u0430\u043d\u0438\u0435 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0439 \u0441 \u0448\u0438\u043d\u044b KNX" + } + }, + "tunnel": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442", + "route_back": "\u041e\u0431\u0440\u0430\u0442\u043d\u044b\u0439 \u043c\u0430\u0440\u0448\u0440\u0443\u0442 / \u0440\u0435\u0436\u0438\u043c NAT" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/translations/sl.json b/homeassistant/components/knx/translations/sl.json new file mode 100644 index 00000000000..2e32080bfa0 --- /dev/null +++ b/homeassistant/components/knx/translations/sl.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Storitev je \u017ee konfigurirana", + "single_instance_allowed": "\u017de konfigurirano. Mo\u017ena je samo ena konfiguracija." + }, + "error": { + "cannot_connect": "Povezava ni uspela" + }, + "step": { + "manual_tunnel": { + "data": { + "host": "Gostitelj", + "individual_address": "Posamezni naslov za povezavo", + "port": "Vrata" + } + }, + "routing": { + "data": { + "multicast_group": "Multicast skupina izbrana za usmerjanje", + "multicast_port": "Multicast vrata izbrana za usmerjanje" + } + }, + "tunnel": { + "description": "Prosimo, izberite prehod s seznama." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "individual_address": "Privzet individualni naslov" + } + }, + "tunnel": { + "data": { + "host": "Gostitelj", + "port": "Vrata" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/translations/tr.json b/homeassistant/components/knx/translations/tr.json new file mode 100644 index 00000000000..18efaa74586 --- /dev/null +++ b/homeassistant/components/knx/translations/tr.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "step": { + "manual_tunnel": { + "data": { + "host": "Ana bilgisayar", + "individual_address": "Ba\u011flant\u0131 i\u00e7in bireysel adres", + "port": "Port", + "route_back": "Geri Y\u00f6nlendirme / NAT Modu" + }, + "description": "L\u00fctfen t\u00fcnel cihaz\u0131n\u0131z\u0131n ba\u011flant\u0131 bilgilerini girin." + }, + "routing": { + "data": { + "individual_address": "Y\u00f6nlendirme ba\u011flant\u0131s\u0131 i\u00e7in bireysel adres", + "multicast_group": "Y\u00f6nlendirme i\u00e7in kullan\u0131lan \u00e7ok noktaya yay\u0131n grubu", + "multicast_port": "Y\u00f6nlendirme i\u00e7in kullan\u0131lan \u00e7ok noktaya yay\u0131n ba\u011flant\u0131 noktas\u0131" + }, + "description": "L\u00fctfen y\u00f6nlendirme se\u00e7eneklerini yap\u0131land\u0131r\u0131n." + }, + "tunnel": { + "data": { + "gateway": "KNX T\u00fcnel Ba\u011flant\u0131s\u0131" + }, + "description": "L\u00fctfen listeden bir a\u011f ge\u00e7idi se\u00e7in." + }, + "type": { + "data": { + "connection_type": "KNX Ba\u011flant\u0131 T\u00fcr\u00fc" + }, + "description": "L\u00fctfen KNX ba\u011flant\u0131n\u0131z i\u00e7in kullanmam\u0131z gereken ba\u011flant\u0131 tipini giriniz.\n OTOMAT\u0130K - Entegrasyon, bir a\u011f ge\u00e7idi taramas\u0131 ger\u00e7ekle\u015ftirerek KNX Bus'\u0131n\u0131za olan ba\u011flant\u0131y\u0131 halleder.\n T\u00dcNELLEME - Entegrasyon, t\u00fcnelleme yoluyla KNX veri yolunuza ba\u011flanacakt\u0131r.\n Y\u00d6NLEND\u0130RME - Entegrasyon, y\u00f6nlendirme yoluyla KNX veri yolunuza ba\u011flanacakt\u0131r." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "connection_type": "KNX Ba\u011flant\u0131 T\u00fcr\u00fc", + "individual_address": "Varsay\u0131lan bireysel adres", + "multicast_group": "Y\u00f6nlendirme ve ke\u015fif i\u00e7in kullan\u0131lan \u00e7ok noktaya yay\u0131n grubu", + "multicast_port": "Y\u00f6nlendirme ve ke\u015fif i\u00e7in kullan\u0131lan \u00e7ok noktaya yay\u0131n ba\u011flant\u0131 noktas\u0131", + "rate_limit": "Saniyede maksimum giden telegram say\u0131s\u0131", + "state_updater": "KNX Veri Yolu'ndan okuma durumlar\u0131n\u0131 genel olarak etkinle\u015ftirin" + } + }, + "tunnel": { + "data": { + "host": "Ana bilgisayar", + "port": "Port", + "route_back": "Geri Y\u00f6nlendirme / NAT Modu" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/translations/zh-Hans.json b/homeassistant/components/knx/translations/zh-Hans.json new file mode 100644 index 00000000000..4ceb76c5ea3 --- /dev/null +++ b/homeassistant/components/knx/translations/zh-Hans.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, + "step": { + "manual_tunnel": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/translations/zh-Hant.json b/homeassistant/components/knx/translations/zh-Hant.json new file mode 100644 index 00000000000..c8185f1a867 --- /dev/null +++ b/homeassistant/components/knx/translations/zh-Hant.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "already_configured": "\u670d\u52d9\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "step": { + "manual_tunnel": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "individual_address": "\u9023\u7dda\u500b\u5225\u4f4d\u5740", + "port": "\u901a\u8a0a\u57e0", + "route_back": "\u8def\u7531\u8fd4\u56de / NAT \u6a21\u5f0f" + }, + "description": "\u8acb\u8f38\u5165\u901a\u9053\u88dd\u7f6e\u7684\u9023\u7dda\u8cc7\u8a0a\u3002" + }, + "routing": { + "data": { + "individual_address": "\u8def\u7531\u9023\u7dda\u500b\u5225\u4f4d\u5740", + "multicast_group": "\u4f7f\u7528\u65bc\u8def\u7531\u7684 Multicast \u7fa4\u7d44", + "multicast_port": "\u4f7f\u7528\u65bc\u8def\u7531\u7684 Multicast \u901a\u8a0a\u57e0" + }, + "description": "\u8acb\u8a2d\u5b9a\u8def\u7531\u9078\u9805\u3002" + }, + "tunnel": { + "data": { + "gateway": "KNX \u901a\u9053\u9023\u7dda" + }, + "description": "\u8acb\u5f9e\u5217\u8868\u4e2d\u9078\u64c7\u4e00\u7d44\u9598\u9053\u5668\u3002" + }, + "type": { + "data": { + "connection_type": "KNX \u9023\u7dda\u985e\u578b" + }, + "description": "\u8acb\u8f38\u5165 KNX \u9023\u7dda\u6240\u4f7f\u7528\u4e4b\u9023\u7dda\u985e\u578b\u3002 \n \u81ea\u52d5\uff08AUTOMATIC\uff09 - \u6574\u5408\u81ea\u52d5\u85c9\u7531\u9598\u9053\u5668\u6383\u63cf\u5f8c\u8655\u7406\u9023\u7dda\u554f\u984c\u3002\n \u901a\u9053\uff08TUNNELING\uff09 - \u6574\u5408\u5c07\u6703\u900f\u904e\u901a\u9053\u65b9\u5f0f\u8207 KNX Bus \u9032\u884c\u9023\u7dda\u3002\n \u8def\u7531\uff08ROUTING\uff09 - \u6574\u5408\u5c07\u6703\u900f\u904e\u8def\u7531\u65b9\u5f0f\u8207 KNX Bus \u9032\u884c\u9023\u7dda\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "connection_type": "KNX \u9023\u7dda\u985e\u578b", + "individual_address": "\u9810\u8a2d\u500b\u5225\u4f4d\u5740", + "multicast_group": "\u4f7f\u7528\u65bc\u8def\u7531\u8207\u63a2\u7d22\u7684 Multicast \u7fa4\u7d44", + "multicast_port": "\u4f7f\u7528\u65bc\u8def\u7531\u8207\u63a2\u7d22\u7684 Multicast \u901a\u8a0a\u57e0", + "rate_limit": "\u6700\u5927\u6bcf\u79d2\u767c\u51fa Telegram", + "state_updater": "\u7531 KNX Bus \u8b80\u53d6\u72c0\u614b\u5168\u555f\u7528" + } + }, + "tunnel": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "port": "\u901a\u8a0a\u57e0", + "route_back": "\u8def\u7531\u8fd4\u56de / NAT \u6a21\u5f0f" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py index 13ebd2480e3..6e71c09501f 100644 --- a/homeassistant/components/knx/weather.py +++ b/homeassistant/components/knx/weather.py @@ -4,32 +4,28 @@ from __future__ import annotations from xknx import XKNX from xknx.devices import Weather as XknxWeather +from homeassistant import config_entries from homeassistant.components.weather import WeatherEntity -from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, TEMP_CELSIUS +from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, TEMP_CELSIUS, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import DATA_KNX_CONFIG, DOMAIN from .knx_entity import KnxEntity from .schema import WeatherSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up weather entities for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] + """Set up switch(es) for KNX platform.""" xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.WEATHER] - async_add_entities( - KNXWeather(xknx, entity_config) for entity_config in platform_config - ) + async_add_entities(KNXWeather(xknx, entity_config) for entity_config in config) def _create_weather(xknx: XKNX, config: ConfigType) -> XknxWeather: diff --git a/homeassistant/components/kodi/config_flow.py b/homeassistant/components/kodi/config_flow.py index 404540d47aa..461df3b09e6 100644 --- a/homeassistant/components/kodi/config_flow.py +++ b/homeassistant/components/kodi/config_flow.py @@ -7,6 +7,7 @@ from pykodi import CannotConnectError, InvalidAuthError, Kodi, get_kodi_connecti import voluptuous as vol from homeassistant import config_entries, core, exceptions +from homeassistant.components import zeroconf from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -17,8 +18,8 @@ from homeassistant.const import ( 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.typing import DiscoveryInfoType from .const import ( CONF_WS_PORT, @@ -99,15 +100,17 @@ class KodiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._ssl: bool | None = DEFAULT_SSL self._discovery_name: str | None = None - async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle zeroconf discovery.""" - self._host = discovery_info["host"] - self._port = int(discovery_info["port"]) - self._name = discovery_info["hostname"][: -len(".local.")] - if not (uuid := discovery_info["properties"].get("uuid")): + self._host = discovery_info.host + self._port = int(discovery_info.port) + self._name = discovery_info.hostname[: -len(".local.")] + if not (uuid := discovery_info.properties.get("uuid")): return self.async_abort(reason="no_uuid") - self._discovery_name = discovery_info["name"] + self._discovery_name = discovery_info.name await self.async_set_unique_id(uuid) self._abort_if_unique_id_configured( diff --git a/homeassistant/components/kodi/manifest.json b/homeassistant/components/kodi/manifest.json index f88a893c7fa..6e46b0883d9 100644 --- a/homeassistant/components/kodi/manifest.json +++ b/homeassistant/components/kodi/manifest.json @@ -2,7 +2,7 @@ "domain": "kodi", "name": "Kodi", "documentation": "https://www.home-assistant.io/integrations/kodi", - "requirements": ["pykodi==0.2.6"], + "requirements": ["pykodi==0.2.7"], "codeowners": ["@OnFreund", "@cgtobi"], "zeroconf": ["_xbmc-jsonrpc-h._tcp.local."], "config_flow": true, diff --git a/homeassistant/components/kodi/translations/bg.json b/homeassistant/components/kodi/translations/bg.json index 2d54c793bb5..f92d4a5901c 100644 --- a/homeassistant/components/kodi/translations/bg.json +++ b/homeassistant/components/kodi/translations/bg.json @@ -1,14 +1,24 @@ { "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\u0445 \u043f\u0440\u0438 \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" }, "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\u0445 \u043f\u0440\u0438 \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": "{name}", "step": { + "credentials": { + "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/kodi/translations/ja.json b/homeassistant/components/kodi/translations/ja.json new file mode 100644 index 00000000000..c7cf892bb2b --- /dev/null +++ b/homeassistant/components/kodi/translations/ja.json @@ -0,0 +1,50 @@ +{ + "config": { + "abort": { + "already_configured": "\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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "no_uuid": "Kodi \u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306b\u30e6\u30cb\u30fc\u30af(\u4e00\u610f)ID\u304c\u3042\u308a\u307e\u305b\u3093\u3002\u3053\u308c\u306f\u3001Kodi \u306e\u30d0\u30fc\u30b8\u30e7\u30f3\u304c\u53e4\u3044(17.x \u4ee5\u4e0b)\u3053\u3068\u304c\u539f\u56e0\u3067\u3042\u308b\u53ef\u80fd\u6027\u304c\u9ad8\u3044\u3067\u3059\u3002\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u624b\u52d5\u3067\u8a2d\u5b9a\u3059\u308b\u304b\u3001\u3088\u308a\u65b0\u3057\u3044Kodi\u306e\u30d0\u30fc\u30b8\u30e7\u30f3\u306b\u30a2\u30c3\u30d7\u30b0\u30ec\u30fc\u30c9\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "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" + }, + "flow_title": "{name}", + "step": { + "credentials": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "Kodi\u306e\u30e6\u30fc\u30b6\u30fc\u540d\u3068\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u3053\u308c\u306f\u3001\u30b7\u30b9\u30c6\u30e0/\u8a2d\u5b9a/\u30cd\u30c3\u30c8\u30ef\u30fc\u30af/\u30b5\u30fc\u30d3\u30b9\u306b\u3042\u308a\u307e\u3059\u3002" + }, + "discovery_confirm": { + "description": "Home Assistant\u306bKodi (`{name}`) \u3092\u8ffd\u52a0\u3057\u307e\u3059\u304b\uff1f", + "title": "Kodi\u3092\u767a\u898b" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8", + "ssl": "SSL\u8a3c\u660e\u66f8\u3092\u4f7f\u7528\u3059\u308b" + }, + "description": "Kodi\u306e\u63a5\u7d9a\u60c5\u5831\u3067\u3059\u3002\u30b7\u30b9\u30c6\u30e0/\u8a2d\u5b9a/\u30cd\u30c3\u30c8\u30ef\u30fc\u30af/\u30b5\u30fc\u30d3\u30b9\u3067 \"HTTP\u306b\u3088\u308bKodi\u306e\u5236\u5fa1\u3092\u8a31\u53ef\u3059\u308b\" \u3092\u5fc5\u305a\u6709\u52b9\u306b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "ws_port": { + "data": { + "ws_port": "\u30dd\u30fc\u30c8" + }, + "description": "WebSocket\u30dd\u30fc\u30c8(Kodi\u3067\u306fTCP\u30dd\u30fc\u30c8\u3068\u547c\u3070\u308c\u308b\u3053\u3068\u3082\u3042\u308a\u307e\u3059)\u3002WebSocket\u3092\u4ecb\u3057\u3066\u63a5\u7d9a\u3059\u308b\u306b\u306f\u3001\u30b7\u30b9\u30c6\u30e0/\u8a2d\u5b9a/\u30cd\u30c3\u30c8\u30ef\u30fc\u30af/\u30b5\u30fc\u30d3\u30b9\u306b\u3042\u308b \"\u30d7\u30ed\u30b0\u30e9\u30e0\u306bKodi\u306e\u5236\u5fa1\u3092\u8a31\u53ef\u3059\u308b\" \u3092\u6709\u52b9\u306b\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002WebSocket\u304c\u6709\u52b9\u306b\u306a\u3063\u3066\u3044\u306a\u3044\u5834\u5408\u306f\u3001\u30dd\u30fc\u30c8\u3092\u524a\u9664\u3057\u3066\u7a7a\u306e\u307e\u307e\u306b\u3057\u307e\u3059\u3002" + } + } + }, + "device_automation": { + "trigger_type": { + "turn_off": "{entity_name} \u3092\u30aa\u30d5\u306b\u3059\u308b\u3088\u3046\u306b\u30af\u30a8\u30b9\u30c8\u3055\u308c\u307e\u3057\u305f", + "turn_on": "{entity_name} \u3092\u30aa\u30f3\u306b\u3059\u308b\u3088\u3046\u306b\u30ea\u30af\u30a8\u30b9\u30c8\u3055\u308c\u307e\u3057\u305f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kodi/translations/pl.json b/homeassistant/components/kodi/translations/pl.json index caac93e2b2d..f5f30a67af8 100644 --- a/homeassistant/components/kodi/translations/pl.json +++ b/homeassistant/components/kodi/translations/pl.json @@ -23,7 +23,7 @@ }, "discovery_confirm": { "description": "Czy chcesz doda\u0107 Kodi (\"{name}\") do Home Assistanta?", - "title": "Wykryte urz\u0105dzenia Kodi" + "title": "Wykryto urz\u0105dzenia Kodi" }, "user": { "data": { diff --git a/homeassistant/components/kodi/translations/tr.json b/homeassistant/components/kodi/translations/tr.json index 54ad8e0b6fd..a8d11ddcb9b 100644 --- a/homeassistant/components/kodi/translations/tr.json +++ b/homeassistant/components/kodi/translations/tr.json @@ -4,6 +4,7 @@ "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "no_uuid": "Kodi \u00f6rne\u011finin benzersiz bir kimli\u011fi yok. Bu b\u00fcy\u00fck olas\u0131l\u0131kla eski bir Kodi s\u00fcr\u00fcm\u00fcnden (17.x veya alt\u0131) kaynaklanmaktad\u0131r. Entegrasyonu manuel olarak yap\u0131land\u0131rabilir veya daha yeni bir Kodi s\u00fcr\u00fcm\u00fcne y\u00fckseltebilirsiniz.", "unknown": "Beklenmeyen hata" }, "error": { @@ -11,6 +12,7 @@ "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "unknown": "Beklenmeyen hata" }, + "flow_title": "{name}", "step": { "credentials": { "data": { @@ -19,17 +21,30 @@ }, "description": "L\u00fctfen Kodi kullan\u0131c\u0131 ad\u0131n\u0131z\u0131 ve \u015fifrenizi girin. Bunlar Sistem / Ayarlar / A\u011f / Hizmetler'de bulunabilir." }, + "discovery_confirm": { + "description": "Ev Asistan\u0131'na Kodi \"{name}\" eklemek istiyor musunuz?", + "title": "Ke\u015ffedilen Kodi" + }, "user": { "data": { - "host": "Ana Bilgisayar", - "port": "Port" - } + "host": "Ana bilgisayar", + "port": "Port", + "ssl": "SSL sertifikas\u0131 kullan\u0131r" + }, + "description": "Kodi ba\u011flant\u0131 bilgileri. L\u00fctfen Sistem/Ayarlar/A\u011f/Hizmetler'de \"Kodi'nin HTTP \u00fczerinden denetimine izin ver\" se\u00e7ene\u011fini etkinle\u015ftirdi\u011finizden emin olun." }, "ws_port": { "data": { "ws_port": "Port" - } + }, + "description": "WebSocket ba\u011flant\u0131 noktas\u0131 (bazen Kodi'de TCP ba\u011flant\u0131 noktas\u0131 olarak adland\u0131r\u0131l\u0131r). WebSocket \u00fczerinden ba\u011flanmak i\u00e7in Sistem/Ayarlar/A\u011f/Hizmetler'de \"Programlar\u0131n Kodi'yi kontrol etmesine izin ver\" se\u00e7ene\u011fini etkinle\u015ftirmeniz gerekir. WebSocket etkin de\u011filse, ba\u011flant\u0131 noktas\u0131n\u0131 kald\u0131r\u0131n ve bo\u015f b\u0131rak\u0131n." } } + }, + "device_automation": { + "trigger_type": { + "turn_off": "{entity_name} nin kapat\u0131lmas\u0131 istendi", + "turn_on": "{entity_name} nin a\u00e7\u0131lmas\u0131 istendi" + } } } \ No newline at end of file diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py index 4cc53c9069a..230a40f35b9 100644 --- a/homeassistant/components/konnected/config_flow.py +++ b/homeassistant/components/konnected/config_flow.py @@ -9,11 +9,11 @@ from urllib.parse import urlparse import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import ssdp from homeassistant.components.binary_sensor import ( DEVICE_CLASS_DOOR, DEVICE_CLASSES_SCHEMA, ) -from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER, ATTR_UPNP_MODEL_NAME from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_BINARY_SENSORS, @@ -29,6 +29,7 @@ from homeassistant.const import ( CONF_ZONE, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv from .const import ( @@ -238,7 +239,7 @@ class KonnectedFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) return await self.async_step_user() - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered konnected panel. This flow is triggered by the SSDP component. It will check if the @@ -247,16 +248,16 @@ class KonnectedFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.debug(discovery_info) try: - if discovery_info[ATTR_UPNP_MANUFACTURER] != KONN_MANUFACTURER: + if discovery_info.upnp[ssdp.ATTR_UPNP_MANUFACTURER] != KONN_MANUFACTURER: return self.async_abort(reason="not_konn_panel") if not any( - name in discovery_info[ATTR_UPNP_MODEL_NAME] + name in discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] for name in KONN_PANEL_MODEL_NAMES ): _LOGGER.warning( "Discovered unrecognized Konnected device %s", - discovery_info.get(ATTR_UPNP_MODEL_NAME, "Unknown"), + discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "Unknown"), ) return self.async_abort(reason="not_konn_panel") @@ -266,11 +267,29 @@ class KonnectedFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.error("Malformed Konnected SSDP info") else: # extract host/port from ssdp_location - netloc = urlparse(discovery_info["ssdp_location"]).netloc.split(":") - return await self.async_step_user( - user_input={CONF_HOST: netloc[0], CONF_PORT: int(netloc[1])} + netloc = urlparse(discovery_info.ssdp_location).netloc.split(":") + self._async_abort_entries_match( + {CONF_HOST: netloc[0], CONF_PORT: int(netloc[1])} ) + try: + status = await get_status(self.hass, netloc[0], int(netloc[1])) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + else: + self.data[CONF_HOST] = netloc[0] + self.data[CONF_PORT] = int(netloc[1]) + self.data[CONF_ID] = status.get( + "chipId", status["mac"].replace(":", "") + ) + self.data[CONF_MODEL] = status.get("model", KONN_MODEL) + + KonnectedFlowHandler.discovered_hosts[self.data[CONF_ID]] = { + CONF_HOST: self.data[CONF_HOST], + CONF_PORT: self.data[CONF_PORT], + } + return await self.async_step_confirm() + return self.async_abort(reason="unknown") async def async_step_user(self, user_input=None): diff --git a/homeassistant/components/konnected/panel.py b/homeassistant/components/konnected/panel.py index 137fdada8c5..d02b4c31c33 100644 --- a/homeassistant/components/konnected/panel.py +++ b/homeassistant/components/konnected/panel.py @@ -151,7 +151,7 @@ class AlarmPanel: self.port, ) - device_registry = await dr.async_get_registry(self.hass) + device_registry = dr.async_get(self.hass) device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, self.status.get("mac"))}, @@ -304,7 +304,9 @@ class AlarmPanel: def async_ds18b20_sensor_configuration(self): """Return the configuration map for syncing DS18B20 sensors.""" return [ - self.format_zone(sensor[CONF_ZONE]) + self.format_zone( + sensor[CONF_ZONE], {CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]} + ) for sensor in self.stored_configuration[CONF_SENSORS] if sensor[CONF_TYPE] == "ds18b20" ] diff --git a/homeassistant/components/konnected/strings.json b/homeassistant/components/konnected/strings.json index e53838ad0d7..905597035d5 100644 --- a/homeassistant/components/konnected/strings.json +++ b/homeassistant/components/konnected/strings.json @@ -24,7 +24,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "not_konn_panel": "Not a recognized Konnected.io device" + "not_konn_panel": "Not a recognized Konnected.io device", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, "options": { diff --git a/homeassistant/components/konnected/translations/bg.json b/homeassistant/components/konnected/translations/bg.json index 6b2ccdd56ec..1c804131ae8 100644 --- a/homeassistant/components/konnected/translations/bg.json +++ b/homeassistant/components/konnected/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/konnected/translations/ca.json b/homeassistant/components/konnected/translations/ca.json index 878ecf32d09..c9bf5414d86 100644 --- a/homeassistant/components/konnected/translations/ca.json +++ b/homeassistant/components/konnected/translations/ca.json @@ -3,6 +3,7 @@ "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", "not_konn_panel": "No s'ha reconegut com a un dispositiu Konnected.io", "unknown": "Error inesperat" }, diff --git a/homeassistant/components/konnected/translations/de.json b/homeassistant/components/konnected/translations/de.json index fd307e90f20..937425be1dc 100644 --- a/homeassistant/components/konnected/translations/de.json +++ b/homeassistant/components/konnected/translations/de.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "cannot_connect": "Verbindung fehlgeschlagen", "not_konn_panel": "Kein anerkanntes Konnected.io-Ger\u00e4t", "unknown": "Unerwarteter Fehler" }, @@ -32,9 +33,7 @@ "not_konn_panel": "Kein anerkanntes Konnected.io-Ger\u00e4t" }, "error": { - "bad_host": "Ung\u00fcltige Override-API-Host-URL", - "one": "eins", - "other": "andere" + "bad_host": "Ung\u00fcltige Override-API-Host-URL" }, "step": { "options_binary": { diff --git a/homeassistant/components/konnected/translations/en.json b/homeassistant/components/konnected/translations/en.json index 32cf120e8af..b5e6340b562 100644 --- a/homeassistant/components/konnected/translations/en.json +++ b/homeassistant/components/konnected/translations/en.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Device is already configured", "already_in_progress": "Configuration flow is already in progress", + "cannot_connect": "Failed to connect", "not_konn_panel": "Not a recognized Konnected.io device", "unknown": "Unexpected error" }, diff --git a/homeassistant/components/konnected/translations/et.json b/homeassistant/components/konnected/translations/et.json index 34c361fd9c6..ddddea5c800 100644 --- a/homeassistant/components/konnected/translations/et.json +++ b/homeassistant/components/konnected/translations/et.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Seade on juba h\u00e4\u00e4lestatud", "already_in_progress": "Seadistamine on juba k\u00e4imas", + "cannot_connect": "\u00dchendamine nurjus", "not_konn_panel": "Tuvastamata Konnected.io seade", "unknown": "Tundmatu viga" }, diff --git a/homeassistant/components/konnected/translations/he.json b/homeassistant/components/konnected/translations/he.json index 5bfc5453409..f07caab4ee1 100644 --- a/homeassistant/components/konnected/translations/he.json +++ b/homeassistant/components/konnected/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", "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", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "error": { diff --git a/homeassistant/components/konnected/translations/hu.json b/homeassistant/components/konnected/translations/hu.json index 65a1c88b8d5..9b65090505b 100644 --- a/homeassistant/components/konnected/translations/hu.json +++ b/homeassistant/components/konnected/translations/hu.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van", + "cannot_connect": "Sikertelen csatlakoz\u00e1s", "not_konn_panel": "Nem felismert Konnected.io eszk\u00f6z", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, diff --git a/homeassistant/components/konnected/translations/id.json b/homeassistant/components/konnected/translations/id.json index b80b86c25c9..f2e2035ca06 100644 --- a/homeassistant/components/konnected/translations/id.json +++ b/homeassistant/components/konnected/translations/id.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Perangkat sudah dikonfigurasi", "already_in_progress": "Alur konfigurasi sedang berlangsung", + "cannot_connect": "Gagal terhubung", "not_konn_panel": "Bukan perangkat Konnected.io yang dikenali", "unknown": "Kesalahan yang tidak diharapkan" }, diff --git a/homeassistant/components/konnected/translations/ja.json b/homeassistant/components/konnected/translations/ja.json new file mode 100644 index 00000000000..5572841b3f2 --- /dev/null +++ b/homeassistant/components/konnected/translations/ja.json @@ -0,0 +1,109 @@ +{ + "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", + "not_konn_panel": "\u8a8d\u8b58\u3055\u308c\u305f\u3001Konnected.io\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u3042\u308a\u307e\u305b\u3093", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "confirm": { + "description": "\u30e2\u30c7\u30eb: {model}\n ID: {id}\n\u30db\u30b9\u30c8: {host}\n\u30dd\u30fc\u30c8: {port} \n\nKonnected Alarm Panel\u306e\u8a2d\u5b9a\u3067\u3001IO\u3068\u30d1\u30cd\u30eb\u306e\u52d5\u4f5c\u3092\u8a2d\u5b9a\u3067\u304d\u307e\u3059\u3002", + "title": "Konnected\u30c7\u30d0\u30a4\u30b9\u306e\u6e96\u5099\u5b8c\u4e86" + }, + "import_confirm": { + "description": "ID {id} \u306eKonnected\u30a2\u30e9\u30fc\u30e0\u30d1\u30cd\u30eb\u304c\u8a2d\u5b9a\u3067\u691c\u51fa\u3055\u308c\u307e\u3057\u305f\u3002\u3053\u306e\u30d5\u30ed\u30fc\u3092\u4f7f\u7528\u3059\u308b\u3068\u3001\u8a2d\u5b9a\u30a8\u30f3\u30c8\u30ea\u30fc\u306b\u30a4\u30f3\u30dd\u30fc\u30c8\u3067\u304d\u307e\u3059\u3002", + "title": "Konnected Device\u306e\u30a4\u30f3\u30dd\u30fc\u30c8" + }, + "user": { + "data": { + "host": "IP\u30a2\u30c9\u30ec\u30b9", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "Konnected Panel\u306e\u30db\u30b9\u30c8\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + }, + "options": { + "abort": { + "not_konn_panel": "\u8a8d\u8b58\u3055\u308c\u305f\u3001Konnected.io\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u3042\u308a\u307e\u305b\u3093" + }, + "error": { + "bad_host": "\u7121\u52b9\u306a\u4e0a\u66f8\u304d(Override)API\u30db\u30b9\u30c8URL" + }, + "step": { + "options_binary": { + "data": { + "inverse": "\u958b\u9589\u72b6\u614b\u3092\u53cd\u8ee2", + "name": "\u540d\u524d(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "type": "\u30d0\u30a4\u30ca\u30ea\u30fc\u30bb\u30f3\u30b5\u30fc \u30bf\u30a4\u30d7" + }, + "description": "{zone} \u30aa\u30d7\u30b7\u30e7\u30f3", + "title": "\u30d0\u30a4\u30ca\u30ea\u30fc\u30bb\u30f3\u30b5\u30fc\u306e\u8a2d\u5b9a" + }, + "options_digital": { + "data": { + "name": "\u540d\u524d(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "poll_interval": "\u30dd\u30fc\u30ea\u30f3\u30b0\u9593\u9694(\u5206)(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "type": "\u30bb\u30f3\u30b5\u30fc\u30bf\u30a4\u30d7" + }, + "description": "{zone} \u30aa\u30d7\u30b7\u30e7\u30f3", + "title": "\u30c7\u30b8\u30bf\u30eb\u30bb\u30f3\u30b5\u30fc\u306e\u8a2d\u5b9a" + }, + "options_io": { + "data": { + "1": "\u30be\u30fc\u30f31", + "2": "\u30be\u30fc\u30f32", + "3": "\u30be\u30fc\u30f33", + "4": "\u30be\u30fc\u30f34", + "5": "\u30be\u30fc\u30f35", + "6": "\u30be\u30fc\u30f36", + "7": "\u30be\u30fc\u30f37", + "out": "OUT" + }, + "description": "{model} \u3067 {host} \u3092\u767a\u898b\u3057\u307e\u3057\u305f\u3002\u5404I/O\u306e\u57fa\u672c\u69cb\u6210\u3092\u4ee5\u4e0b\u304b\u3089\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044\u3002I/O\u306b\u5fdc\u3058\u3066\u3001\u30d0\u30a4\u30ca\u30ea\u30bb\u30f3\u30b5\u30fc(\u30aa\u30fc\u30d7\u30f3(\u958b)/\u30af\u30ed\u30fc\u30ba(\u9589)\u63a5\u70b9)\u3001\u30c7\u30b8\u30bf\u30eb\u30bb\u30f3\u30b5\u30fc(dht\u304a\u3088\u3073ds18b20)\u3001\u307e\u305f\u306f\u5207\u308a\u66ff\u3048\u53ef\u80fd\u306a\u51fa\u529b(switchable outputs)\u304c\u53ef\u80fd\u3067\u3059\u3002\u6b21\u306e\u30b9\u30c6\u30c3\u30d7\u3067\u3001\u8a73\u7d30\u306a\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a\u3067\u304d\u307e\u3059\u3002", + "title": "I/O\u306e\u8a2d\u5b9a" + }, + "options_io_ext": { + "data": { + "10": "\u30be\u30fc\u30f310", + "11": "\u30be\u30fc\u30f311", + "12": "\u30be\u30fc\u30f312", + "8": "\u30be\u30fc\u30f38", + "9": "\u30be\u30fc\u30f39", + "alarm1": "\u30a2\u30e9\u30fc\u30e01", + "alarm2_out2": "OUT2/ALARM2", + "out1": "OUT1" + }, + "description": "\u6b8b\u308a\u306eI/O\u306e\u69cb\u6210\u3092\u4ee5\u4e0b\u304b\u3089\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u6b21\u306e\u30b9\u30c6\u30c3\u30d7\u3067\u8a73\u7d30\u306a\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a\u3059\u308b\u3053\u3068\u304c\u3067\u304d\u307e\u3059\u3002", + "title": "\u62e1\u5f35I/O\u306e\u8a2d\u5b9a" + }, + "options_misc": { + "data": { + "api_host": "API\u30db\u30b9\u30c8\u306eURL\u3092\u4e0a\u66f8\u304d\u3059\u308b(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "blink": "\u72b6\u614b\u5909\u66f4\u3092\u9001\u4fe1\u3059\u308b\u3068\u304d\u306b\u3001\u30d1\u30cd\u30eb\u306eLED\u3092\u70b9\u6ec5\u3055\u305b\u308b", + "discovery": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306e\u691c\u51fa(discovery)\u8981\u6c42\u306b\u5fdc\u7b54\u3059\u308b", + "override_api_host": "\u30c7\u30d5\u30a9\u30eb\u30c8\u306eHome Assistant API\u30db\u30b9\u30c8\u30d1\u30cd\u30eb\u306eURL\u3092\u4e0a\u66f8\u304d\u3059\u308b" + }, + "description": "\u30d1\u30cd\u30eb\u306b\u5fc5\u8981\u306a\u52d5\u4f5c\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "\u305d\u306e\u4ed6\u306e\u8a2d\u5b9a" + }, + "options_switch": { + "data": { + "activation": "\u30aa\u30f3\u306e\u3068\u304d\u306b\u51fa\u529b", + "momentary": "\u30d1\u30eb\u30b9\u6301\u7d9a\u6642\u9593(ms)(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "more_states": "\u3053\u306e\u30be\u30fc\u30f3\u306e\u8ffd\u52a0\u72b6\u614b\u306e\u8a2d\u5b9a", + "name": "\u540d\u524d(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "pause": "\u30d1\u30eb\u30b9\u9593\u306e\u4e00\u6642\u505c\u6b62(ms)(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "repeat": "\u7e70\u308a\u8fd4\u3059\u6642\u9593(-1 =\u7121\u9650)(\u30aa\u30d7\u30b7\u30e7\u30f3)" + }, + "description": "{zone}\u30aa\u30d7\u30b7\u30e7\u30f3 : \u72b6\u614b{state}", + "title": "\u5207\u308a\u66ff\u3048\u53ef\u80fd\u306a\u51fa\u529b\u306e\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/translations/nl.json b/homeassistant/components/konnected/translations/nl.json index 0387dc8c7b0..1680431a35b 100644 --- a/homeassistant/components/konnected/translations/nl.json +++ b/homeassistant/components/konnected/translations/nl.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Apparaat is al geconfigureerd", "already_in_progress": "De configuratiestroom is al aan de gang", + "cannot_connect": "Kan geen verbinding maken", "not_konn_panel": "Geen herkend Konnected.io apparaat", "unknown": "Onverwachte fout" }, diff --git a/homeassistant/components/konnected/translations/no.json b/homeassistant/components/konnected/translations/no.json index 47be4c20bf0..92d3efc4633 100644 --- a/homeassistant/components/konnected/translations/no.json +++ b/homeassistant/components/konnected/translations/no.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Enheten er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", + "cannot_connect": "Tilkobling mislyktes", "not_konn_panel": "Ikke en anerkjent Konnected.io-enhet", "unknown": "Uventet feil" }, diff --git a/homeassistant/components/konnected/translations/pl.json b/homeassistant/components/konnected/translations/pl.json index f6e9a2dbfbc..7352bc82344 100644 --- a/homeassistant/components/konnected/translations/pl.json +++ b/homeassistant/components/konnected/translations/pl.json @@ -3,6 +3,7 @@ "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", "not_konn_panel": "Nie rozpoznano urz\u0105dzenia Konnected.io", "unknown": "Nieoczekiwany b\u0142\u0105d" }, diff --git a/homeassistant/components/konnected/translations/ru.json b/homeassistant/components/konnected/translations/ru.json index 4357c924572..79cc78e6a9a 100644 --- a/homeassistant/components/konnected/translations/ru.json +++ b/homeassistant/components/konnected/translations/ru.json @@ -3,6 +3,7 @@ "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.", "not_konn_panel": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Konnected.io \u043d\u0435 \u0440\u0430\u0441\u043f\u043e\u0437\u043d\u0430\u043d\u043e.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, diff --git a/homeassistant/components/konnected/translations/sl.json b/homeassistant/components/konnected/translations/sl.json index 88e0b696416..928d47c389d 100644 --- a/homeassistant/components/konnected/translations/sl.json +++ b/homeassistant/components/konnected/translations/sl.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Naprava je \u017ee konfigurirana", "already_in_progress": "Konfiguracijski tok za napravo je \u017ee v teku.", + "cannot_connect": "Povezava ni uspela", "not_konn_panel": "Ni prepoznana kot Konnected.io naprava", "unknown": "Pri\u0161lo je do neznane napake" }, diff --git a/homeassistant/components/konnected/translations/tr.json b/homeassistant/components/konnected/translations/tr.json index f86f09eeea7..3c23b26ab0d 100644 --- a/homeassistant/components/konnected/translations/tr.json +++ b/homeassistant/components/konnected/translations/tr.json @@ -3,6 +3,8 @@ "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", + "not_konn_panel": "Bilinen bir Konnected.io cihaz\u0131 de\u011fil", "unknown": "Beklenmeyen hata" }, "error": { @@ -10,38 +12,63 @@ }, "step": { "confirm": { - "description": "Model: {model}\nID: {id}\nSunucu: {host}\nPort: {port}\n\nIO ve panel davran\u0131\u015f\u0131n\u0131 Ba\u011fl\u0131 Alarm Paneli ayarlar\u0131nda yap\u0131land\u0131rabilirsiniz." + "description": "Model: {model}\nID: {id}\nSunucu: {host}\nPort: {port}\n\nIO ve panel davran\u0131\u015f\u0131n\u0131 Ba\u011fl\u0131 Alarm Paneli ayarlar\u0131nda yap\u0131land\u0131rabilirsiniz.", + "title": "Konnected Cihaz\u0131 Haz\u0131r" }, "import_confirm": { - "description": "configuration.yaml'de {id} kimli\u011fine sahip Ba\u011fl\u0131 bir Alarm Paneli ke\u015ffedildi. Bu ak\u0131\u015f, onu bir yap\u0131land\u0131rma giri\u015fine aktarman\u0131za olanak tan\u0131r." + "description": "configuration.yaml'de {id} kimli\u011fine sahip Ba\u011fl\u0131 bir Alarm Paneli ke\u015ffedildi. Bu ak\u0131\u015f, onu bir yap\u0131land\u0131rma giri\u015fine aktarman\u0131za olanak tan\u0131r.", + "title": "Konnected Ayg\u0131t\u0131n\u0131 \u0130\u00e7eri Aktar" }, "user": { "data": { - "host": "\u0130p Adresi", + "host": "IP Adresi", "port": "Port" - } + }, + "description": "L\u00fctfen Konnected Paneliniz i\u00e7in ana bilgisayar bilgilerini girin." } } }, "options": { + "abort": { + "not_konn_panel": "Bilinen bir Konnected.io cihaz\u0131 de\u011fil" + }, "error": { - "bad_host": "Ge\u00e7ersiz, Ge\u00e7ersiz K\u0131lma API ana makine url'si" + "bad_host": "Ge\u00e7ersiz, Ge\u00e7ersiz K\u0131lma API ana makine url'si", + "one": "Bo\u015f", + "other": "Bo\u015f" }, "step": { "options_binary": { "data": { - "inverse": "A\u00e7\u0131k / kapal\u0131 durumunu tersine \u00e7evirin" - } + "inverse": "A\u00e7\u0131k / kapal\u0131 durumunu tersine \u00e7evirin", + "name": "Ad (iste\u011fe ba\u011fl\u0131)", + "type": "\u0130kili Sens\u00f6r Tipi" + }, + "description": "{zone} se\u00e7enekleri", + "title": "\u0130kili Sens\u00f6r\u00fc Yap\u0131land\u0131r" + }, + "options_digital": { + "data": { + "name": "Ad (iste\u011fe ba\u011fl\u0131)", + "poll_interval": "Yoklama Aral\u0131\u011f\u0131 (dakika) (iste\u011fe ba\u011fl\u0131)", + "type": "Sens\u00f6r Tipi" + }, + "description": "{zone} se\u00e7enekleri", + "title": "Dijital Sens\u00f6r\u00fc Yap\u0131land\u0131r" }, "options_io": { "data": { + "1": "B\u00f6lge 1", + "2": "B\u00f6lge 2", "3": "B\u00f6lge 3", "4": "B\u00f6lge 4", "5": "B\u00f6lge 5", "6": "B\u00f6lge 6", "7": "B\u00f6lge 7", - "out": "OUT" - } + "out": "DI\u015eARI" + }, + "description": "{host} bir {model} ke\u015ffetti. A\u015fa\u011f\u0131dan her G/\u00c7'\u0131n temel yap\u0131land\u0131rmas\u0131n\u0131 se\u00e7in - G/\u00c7'a ba\u011fl\u0131 olarak ikili sens\u00f6rlere (a\u00e7\u0131k/kapal\u0131 kontaklar), dijital sens\u00f6rlere (dht ve ds18b20) veya de\u011fi\u015ftirilebilir \u00e7\u0131k\u0131\u015flara izin verebilir. Sonraki ad\u0131mlarda ayr\u0131nt\u0131l\u0131 se\u00e7enekleri yap\u0131land\u0131rabileceksiniz.", + "title": "G/\u00c7'\u0131 yap\u0131land\u0131r" }, "options_io_ext": { "data": { @@ -51,15 +78,33 @@ "8": "B\u00f6lge 8", "9": "B\u00f6lge 9", "alarm1": "ALARM1", - "alarm2_out2": "OUT2/ALARM2", - "out1": "OUT1" - } + "alarm2_out2": "\u00c7IKI\u015e2/ALARM2", + "out1": "\u00c7IKI\u015e1" + }, + "description": "A\u015fa\u011f\u0131da kalan G/\u00c7'nin yap\u0131land\u0131rmas\u0131n\u0131 se\u00e7in. Sonraki ad\u0131mlarda ayr\u0131nt\u0131l\u0131 se\u00e7enekleri yap\u0131land\u0131rabileceksiniz.", + "title": "Geni\u015fletilmi\u015f G/\u00c7'yi Yap\u0131land\u0131r" }, "options_misc": { "data": { "api_host": "API ana makine URL'sini ge\u00e7ersiz k\u0131l (iste\u011fe ba\u011fl\u0131)", + "blink": "Durum de\u011fi\u015fikli\u011fi g\u00f6nderilirken panelin LED'ini yan\u0131p s\u00f6nd\u00fcr", + "discovery": "A\u011f\u0131n\u0131zdaki ke\u015fif isteklerine yan\u0131t verin", "override_api_host": "Varsay\u0131lan Home Assistant API ana bilgisayar paneli URL'sini ge\u00e7ersiz k\u0131l" - } + }, + "description": "L\u00fctfen paneliniz i\u00e7in istedi\u011finiz davran\u0131\u015f\u0131 se\u00e7in", + "title": "\u00c7e\u015fitli Yap\u0131land\u0131rma" + }, + "options_switch": { + "data": { + "activation": "A\u00e7\u0131kken \u00e7\u0131kt\u0131", + "momentary": "Darbe s\u00fcresi (ms) (iste\u011fe ba\u011fl\u0131)", + "more_states": "Bu b\u00f6lge i\u00e7in ek durumlar yap\u0131land\u0131r\u0131n", + "name": "Ad (iste\u011fe ba\u011fl\u0131)", + "pause": "Darbeler aras\u0131nda duraklama (ms) (iste\u011fe ba\u011fl\u0131)", + "repeat": "Tekrarlanacak zamanlar (-1=sonsuz) (iste\u011fe ba\u011fl\u0131)" + }, + "description": "{zone} se\u00e7enekleri: durum {state}", + "title": "De\u011fi\u015ftirilebilir \u00c7\u0131k\u0131\u015f\u0131 Yap\u0131land\u0131r" } } } diff --git a/homeassistant/components/konnected/translations/zh-Hant.json b/homeassistant/components/konnected/translations/zh-Hant.json index ad85d9c3060..523bb10b969 100644 --- a/homeassistant/components/konnected/translations/zh-Hant.json +++ b/homeassistant/components/konnected/translations/zh-Hant.json @@ -3,6 +3,7 @@ "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", "not_konn_panel": "\u4e26\u975e\u53ef\u8b58\u5225 Konnected.io \u88dd\u7f6e", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, diff --git a/homeassistant/components/kostal_plenticore/__init__.py b/homeassistant/components/kostal_plenticore/__init__.py index f00e6ee1327..f5c973cc499 100644 --- a/homeassistant/components/kostal_plenticore/__init__.py +++ b/homeassistant/components/kostal_plenticore/__init__.py @@ -11,7 +11,7 @@ from .helper import Plenticore _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor"] +PLATFORMS = ["select", "sensor", "switch"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/kostal_plenticore/const.py b/homeassistant/components/kostal_plenticore/const.py index 68c2baffbdb..ebed1ddcb74 100644 --- a/homeassistant/components/kostal_plenticore/const.py +++ b/homeassistant/components/kostal_plenticore/const.py @@ -1,4 +1,5 @@ """Constants for the Kostal Plenticore Solar Inverter integration.""" +from typing import NamedTuple from homeassistant.components.sensor import ( ATTR_STATE_CLASS, @@ -688,11 +689,70 @@ SENSOR_SETTINGS_DATA = [ {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, ATTR_ICON: "mdi:battery-negative"}, "format_round", ), - ( +] + + +class SwitchData(NamedTuple): + """Representation of a SelectData tuple.""" + + module_id: str + data_id: str + name: str + is_on: str + on_value: str + on_label: str + off_value: str + off_label: str + + +# Defines all entities for switches. +# +# Each entry is defined with a tuple of these values: +# - module id (str) +# - process data id (str) +# - entity name suffix (str) +# - on Value (str) +# - on Label (str) +# - off Value (str) +# - off Label (str) +SWITCH_SETTINGS_DATA = [ + SwitchData( "devices:local", "Battery:Strategy", - "Battery Strategy", - {}, - "format_round", + "Battery Strategy:", + "1", + "1", + "Automatic", + "2", + "Automatic economical", ), ] + + +class SelectData(NamedTuple): + """Representation of a SelectData tuple.""" + + module_id: str + data_id: str + name: str + options: list + is_on: str + + +# Defines all entities for select widgets. +# +# Each entry is defined with a tuple of these values: +# - module id (str) +# - process data id (str) +# - entity name suffix (str) +# - options +# - entity is enabled by default (bool) +SELECT_SETTINGS_DATA = [ + SelectData( + "devices:local", + "battery_charge", + "Battery Charging / Usage mode", + ["None", "Battery:SmartBatteryControl:Enable", "Battery:TimeControl:Enable"], + "1", + ) +] diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index 32dfc9b2fd9..fd367230c6c 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -3,11 +3,16 @@ from __future__ import annotations import asyncio from collections import defaultdict +from collections.abc import Iterable from datetime import datetime, timedelta import logging from aiohttp.client_exceptions import ClientError -from kostal.plenticore import PlenticoreApiClient, PlenticoreAuthenticationException +from kostal.plenticore import ( + PlenticoreApiClient, + PlenticoreApiException, + PlenticoreAuthenticationException, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant @@ -112,6 +117,38 @@ class Plenticore: _LOGGER.debug("Logged out from %s", self.host) +class DataUpdateCoordinatorMixin: + """Base implementation for read and write data.""" + + async def async_read_data(self, module_id: str, data_id: str) -> list[str, bool]: + """Write settings back to Plenticore.""" + client = self._plenticore.client + + if client is None: + return False + + try: + val = await client.get_setting_values(module_id, data_id) + except PlenticoreApiException: + return False + else: + return val + + async def async_write_data(self, module_id: str, value: dict[str, str]) -> bool: + """Write settings back to Plenticore.""" + client = self._plenticore.client + + if client is None: + return False + + try: + await client.set_setting_values(module_id, value) + except PlenticoreApiException: + return False + else: + return True + + class PlenticoreUpdateCoordinator(DataUpdateCoordinator): """Base implementation of DataUpdateCoordinator for Plenticore data.""" @@ -171,7 +208,9 @@ class ProcessDataUpdateCoordinator(PlenticoreUpdateCoordinator): } -class SettingDataUpdateCoordinator(PlenticoreUpdateCoordinator): +class SettingDataUpdateCoordinator( + PlenticoreUpdateCoordinator, DataUpdateCoordinatorMixin +): """Implementation of PlenticoreUpdateCoordinator for settings data.""" async def _async_update_data(self) -> dict[str, dict[str, str]]: @@ -183,9 +222,83 @@ class SettingDataUpdateCoordinator(PlenticoreUpdateCoordinator): _LOGGER.debug("Fetching %s for %s", self.name, self._fetch) fetched_data = await client.get_setting_values(self._fetch) + return fetched_data + + +class PlenticoreSelectUpdateCoordinator(DataUpdateCoordinator): + """Base implementation of DataUpdateCoordinator for Plenticore data.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + name: str, + update_inverval: timedelta, + plenticore: Plenticore, + ) -> None: + """Create a new update coordinator for plenticore data.""" + super().__init__( + hass=hass, + logger=logger, + name=name, + update_interval=update_inverval, + ) + # data ids to poll + self._fetch = defaultdict(list) + self._plenticore = plenticore + + def start_fetch_data(self, module_id: str, data_id: str, all_options: str) -> None: + """Start fetching the given data (module-id and entry-id).""" + self._fetch[module_id].append(data_id) + self._fetch[module_id].append(all_options) + + # Force an update of all data. Multiple refresh calls + # are ignored by the debouncer. + async def force_refresh(event_time: datetime) -> None: + await self.async_request_refresh() + + async_call_later(self.hass, 2, force_refresh) + + def stop_fetch_data(self, module_id: str, data_id: str, all_options: str) -> None: + """Stop fetching the given data (module-id and entry-id).""" + self._fetch[module_id].remove(all_options) + self._fetch[module_id].remove(data_id) + + +class SelectDataUpdateCoordinator( + PlenticoreSelectUpdateCoordinator, DataUpdateCoordinatorMixin +): + """Implementation of PlenticoreUpdateCoordinator for select data.""" + + async def _async_update_data(self) -> dict[str, dict[str, str]]: + client = self._plenticore.client + + if client is None: + return {} + + _LOGGER.debug("Fetching select %s for %s", self.name, self._fetch) + + fetched_data = await self._async_get_current_option(self._fetch) return fetched_data + async def _async_get_current_option( + self, + module_id: str | dict[str, Iterable[str]], + ) -> dict[str, dict[str, str]]: + """Get current option.""" + for mid, pids in module_id.items(): + all_options = pids[1] + for all_option in all_options: + if all_option != "None": + val = await self.async_read_data(mid, all_option) + for option in val.values(): + if option[all_option] == "1": + fetched = {mid: {pids[0]: all_option}} + return fetched + + return {mid: {pids[0]: "None"}} + class PlenticoreDataFormatter: """Provides method to format values of process or settings data.""" diff --git a/homeassistant/components/kostal_plenticore/select.py b/homeassistant/components/kostal_plenticore/select.py new file mode 100644 index 00000000000..4e594095f7e --- /dev/null +++ b/homeassistant/components/kostal_plenticore/select.py @@ -0,0 +1,127 @@ +"""Platform for Kostal Plenticore select widgets.""" +from __future__ import annotations + +from abc import ABC +from datetime import timedelta +import logging + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, SELECT_SETTINGS_DATA +from .helper import Plenticore, SelectDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add kostal plenticore Select widget.""" + plenticore: Plenticore = hass.data[DOMAIN][entry.entry_id] + select_data_update_coordinator = SelectDataUpdateCoordinator( + hass, + _LOGGER, + "Settings Data", + timedelta(seconds=30), + plenticore, + ) + + async_add_entities( + PlenticoreDataSelect( + select_data_update_coordinator, + entry_id=entry.entry_id, + platform_name=entry.title, + device_class="kostal_plenticore__battery", + module_id=select.module_id, + data_id=select.data_id, + name=select.name, + current_option="None", + options=select.options, + is_on=select.is_on, + device_info=plenticore.device_info, + unique_id=f"{entry.entry_id}_{select.module_id}", + ) + for select in SELECT_SETTINGS_DATA + ) + + +class PlenticoreDataSelect(CoordinatorEntity, SelectEntity, ABC): + """Representation of a Plenticore Select.""" + + def __init__( + self, + coordinator, + entry_id: str, + platform_name: str, + device_class: str | None, + module_id: str, + data_id: str, + name: str, + current_option: str | None, + options: list[str], + is_on: str, + device_info: DeviceInfo, + unique_id: str, + ) -> None: + """Create a new Select Entity for Plenticore process data.""" + super().__init__(coordinator) + self.entry_id = entry_id + self.platform_name = platform_name + self._attr_device_class = device_class + self.module_id = module_id + self.data_id = data_id + self._attr_options = options + self.all_options = options + self._attr_current_option = current_option + self._is_on = is_on + self._device_info = device_info + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_unique_id = unique_id + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available + and self.coordinator.data is not None + and self.module_id in self.coordinator.data + and self.data_id in self.coordinator.data[self.module_id] + ) + + async def async_added_to_hass(self) -> None: + """Register this entity on the Update Coordinator.""" + await super().async_added_to_hass() + self.coordinator.start_fetch_data( + self.module_id, self.data_id, self.all_options + ) + + async def async_will_remove_from_hass(self) -> None: + """Unregister this entity from the Update Coordinator.""" + self.coordinator.stop_fetch_data(self.module_id, self.data_id, self.all_options) + await super().async_will_remove_from_hass() + + async def async_select_option(self, option: str) -> None: + """Update the current selected option.""" + self._attr_current_option = option + for all_option in self._attr_options: + if all_option != "None": + await self.coordinator.async_write_data( + self.module_id, {all_option: "0"} + ) + if option != "None": + await self.coordinator.async_write_data(self.module_id, {option: "1"}) + self.async_write_ha_state() + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + if self.available: + return self.coordinator.data[self.module_id][self.data_id] + + return None diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py new file mode 100644 index 00000000000..b3b1ba29e84 --- /dev/null +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -0,0 +1,159 @@ +"""Platform for Kostal Plenticore switches.""" +from __future__ import annotations + +from abc import ABC +from datetime import timedelta +import logging + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, SWITCH_SETTINGS_DATA +from .helper import SettingDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +): + """Add kostal plenticore Switch.""" + plenticore = hass.data[DOMAIN][entry.entry_id] + + entities = [] + + available_settings_data = await plenticore.client.get_settings() + settings_data_update_coordinator = SettingDataUpdateCoordinator( + hass, + _LOGGER, + "Settings Data", + timedelta(seconds=30), + plenticore, + ) + for switch in SWITCH_SETTINGS_DATA: + if switch.module_id not in available_settings_data or switch.data_id not in ( + setting.id for setting in available_settings_data[switch.module_id] + ): + _LOGGER.debug( + "Skipping non existing setting data %s/%s", + switch.module_id, + switch.data_id, + ) + continue + + entities.append( + PlenticoreDataSwitch( + settings_data_update_coordinator, + entry.entry_id, + entry.title, + switch.module_id, + switch.data_id, + switch.name, + switch.is_on, + switch.on_value, + switch.on_label, + switch.off_value, + switch.off_label, + plenticore.device_info, + f"{entry.title} {switch.name}", + f"{entry.entry_id}_{switch.module_id}_{switch.data_id}", + ) + ) + + async_add_entities(entities) + + +class PlenticoreDataSwitch(CoordinatorEntity, SwitchEntity, ABC): + """Representation of a Plenticore Switch.""" + + def __init__( + self, + coordinator, + entry_id: str, + platform_name: str, + module_id: str, + data_id: str, + name: str, + is_on: str, + on_value: str, + on_label: str, + off_value: str, + off_label: str, + device_info: DeviceInfo, + attr_name: str, + attr_unique_id: str, + ): + """Create a new Switch Entity for Plenticore process data.""" + super().__init__(coordinator) + self.entry_id = entry_id + self.platform_name = platform_name + self.module_id = module_id + self.data_id = data_id + self._name = name + self._is_on = is_on + self._attr_name = attr_name + self.on_value = on_value + self.on_label = on_label + self.off_value = off_value + self.off_label = off_label + self._attr_unique_id = attr_unique_id + + self._device_info = device_info + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available + and self.coordinator.data is not None + and self.module_id in self.coordinator.data + and self.data_id in self.coordinator.data[self.module_id] + ) + + async def async_added_to_hass(self) -> None: + """Register this entity on the Update Coordinator.""" + await super().async_added_to_hass() + self.coordinator.start_fetch_data(self.module_id, self.data_id) + + async def async_will_remove_from_hass(self) -> None: + """Unregister this entity from the Update Coordinator.""" + self.coordinator.stop_fetch_data(self.module_id, self.data_id) + await super().async_will_remove_from_hass() + + async def async_turn_on(self, **kwargs) -> None: + """Turn device on.""" + if await self.coordinator.async_write_data( + self.module_id, {self.data_id: self.on_value} + ): + self.coordinator.name = f"{self.platform_name} {self._name} {self.on_label}" + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs) -> None: + """Turn device off.""" + if await self.coordinator.async_write_data( + self.module_id, {self.data_id: self.off_value} + ): + self.coordinator.name = ( + f"{self.platform_name} {self._name} {self.off_label}" + ) + await self.coordinator.async_request_refresh() + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return self._device_info + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + if self.coordinator.data[self.module_id][self.data_id] == self._is_on: + self.coordinator.name = f"{self.platform_name} {self._name} {self.on_label}" + else: + self.coordinator.name = ( + f"{self.platform_name} {self._name} {self.off_label}" + ) + return bool(self.coordinator.data[self.module_id][self.data_id] == self._is_on) diff --git a/homeassistant/components/kostal_plenticore/translations/ja.json b/homeassistant/components/kostal_plenticore/translations/ja.json new file mode 100644 index 00000000000..d4f08a1dd5c --- /dev/null +++ b/homeassistant/components/kostal_plenticore/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + } + } + } + }, + "title": "Kostal Plenticore\u30bd\u30fc\u30e9\u30fc\u30a4\u30f3\u30d0\u30fc\u30bf\u30fc" +} \ No newline at end of file diff --git a/homeassistant/components/kostal_plenticore/translations/tr.json b/homeassistant/components/kostal_plenticore/translations/tr.json new file mode 100644 index 00000000000..573047e61b5 --- /dev/null +++ b/homeassistant/components/kostal_plenticore/translations/tr.json @@ -0,0 +1,21 @@ +{ + "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": "Ana bilgisayar", + "password": "Parola" + } + } + } + }, + "title": "Kostal Plenticore Solar \u0130nverter" +} \ No newline at end of file diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index f18d0e78d94..297435b3d1d 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -121,7 +121,7 @@ class KrakenSensor(CoordinatorEntity[Optional[KrakenResponse]], SensorEntity): self._available = True self._attr_device_info = DeviceInfo( - entry_type="service", + entry_type=device_registry.DeviceEntryType.SERVICE, identifiers={(DOMAIN, f"{source_asset}_{self._target_asset}")}, manufacturer="Kraken.com", name=self._device_name, diff --git a/homeassistant/components/kraken/translations/id.json b/homeassistant/components/kraken/translations/id.json index a436ac4aee5..438a69f54fe 100644 --- a/homeassistant/components/kraken/translations/id.json +++ b/homeassistant/components/kraken/translations/id.json @@ -8,5 +8,15 @@ "description": "Ingin memulai penyiapan?" } } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Interval pembaruan", + "tracked_asset_pairs": "Pasangan Aset Terlacak" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/kraken/translations/ja.json b/homeassistant/components/kraken/translations/ja.json new file mode 100644 index 00000000000..1d581131252 --- /dev/null +++ b/homeassistant/components/kraken/translations/ja.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "user": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u9593\u9694", + "tracked_asset_pairs": "\u8ffd\u8de1\u3055\u308c\u305f\u30a2\u30bb\u30c3\u30c8\u30da\u30a2" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kraken/translations/tr.json b/homeassistant/components/kraken/translations/tr.json new file mode 100644 index 00000000000..fa3decd322e --- /dev/null +++ b/homeassistant/components/kraken/translations/tr.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + }, + "error": { + "one": "Bo\u015f", + "other": "Bo\u015f" + }, + "step": { + "user": { + "data": { + "one": "Bo\u015f", + "other": "" + }, + "description": "Kuruluma ba\u015flamak ister misiniz?" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "G\u00fcncelle\u015ftirme aral\u0131\u011f\u0131", + "tracked_asset_pairs": "\u0130zlenen Varl\u0131k \u00c7iftleri" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kulersky/translations/ja.json b/homeassistant/components/kulersky/translations/ja.json new file mode 100644 index 00000000000..d1234b69652 --- /dev/null +++ b/homeassistant/components/kulersky/translations/ja.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "confirm": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/__init__.py b/homeassistant/components/lametric/__init__.py index 797f0982f00..f51a34aafc1 100644 --- a/homeassistant/components/lametric/__init__.py +++ b/homeassistant/components/lametric/__init__.py @@ -34,8 +34,7 @@ def setup(hass, config): hlmn = HassLaMetricManager( client_id=conf[CONF_CLIENT_ID], client_secret=conf[CONF_CLIENT_SECRET] ) - devices = hlmn.manager.get_devices() - if not devices: + if not (devices := hlmn.manager.get_devices()): _LOGGER.error("No LaMetric devices found") return False diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 66f05c5d34d..10623924c90 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -86,20 +86,17 @@ class LastfmSensor(SensorEntity): self._cover = self._user.get_image() self._playcount = self._user.get_playcount() - recent_tracks = self._user.get_recent_tracks(limit=2) - if recent_tracks: + if recent_tracks := self._user.get_recent_tracks(limit=2): last = recent_tracks[0] self._lastplayed = f"{last.track.artist} - {last.track.title}" - top_tracks = self._user.get_top_tracks(limit=1) - if top_tracks: + if top_tracks := self._user.get_top_tracks(limit=1): top = top_tracks[0] toptitle = re.search("', '(.+?)',", str(top)) topartist = re.search("'(.+?)',", str(top)) self._topplayed = f"{topartist.group(1)} - {toptitle.group(1)}" - now_playing = self._user.get_now_playing() - if now_playing is None: + if (now_playing := self._user.get_now_playing()) is None: self._state = STATE_NOT_SCROBBLING return diff --git a/homeassistant/components/launch_library/manifest.json b/homeassistant/components/launch_library/manifest.json index f7820a1d408..fee5aeea0fe 100644 --- a/homeassistant/components/launch_library/manifest.json +++ b/homeassistant/components/launch_library/manifest.json @@ -2,7 +2,7 @@ "domain": "launch_library", "name": "Launch Library", "documentation": "https://www.home-assistant.io/integrations/launch_library", - "requirements": ["pylaunches==1.0.0"], + "requirements": ["pylaunches==1.2.0"], "codeowners": ["@ludeeus"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 48a63a50fa9..d019c156f37 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -8,6 +8,8 @@ import pypck from homeassistant import config_entries from homeassistant.const import ( + CONF_ADDRESS, + CONF_DOMAIN, CONF_IP_ADDRESS, CONF_NAME, CONF_PASSWORD, @@ -16,16 +18,27 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.typing import ConfigType -from .const import CONF_DIM_MODE, CONF_SK_NUM_TRIES, CONNECTION, DOMAIN, PLATFORMS +from .const import ( + CONF_DIM_MODE, + CONF_DOMAIN_DATA, + CONF_SK_NUM_TRIES, + CONNECTION, + DOMAIN, + PLATFORMS, +) from .helpers import ( + AddressType, DeviceConnectionType, InputType, + async_update_config_entry, generate_unique_id, + get_device_model, import_lcn_config, + register_lcn_address_devices, + register_lcn_host_device, ) from .schemas import CONFIG_SCHEMA # noqa: F401 from .services import SERVICES @@ -96,12 +109,12 @@ async def async_setup_entry( hass.data[DOMAIN][config_entry.entry_id] = { CONNECTION: lcn_connection, } + # Update config_entry with LCN device serials + await async_update_config_entry(hass, config_entry) - # remove orphans from entity registry which are in ConfigEntry but were removed - # from configuration.yaml - if config_entry.source == config_entries.SOURCE_IMPORT: - entity_registry = await er.async_get_registry(hass) - entity_registry.async_clear_config_entry(config_entry.entry_id) + # register/update devices for host, modules and groups in device registry + register_lcn_host_device(hass, config_entry) + register_lcn_address_devices(hass, config_entry) # forward config_entry to components hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) @@ -150,17 +163,38 @@ class LcnEntity(Entity): self._unregister_for_inputs: Callable | None = None self._name: str = config[CONF_NAME] + @property + def address(self) -> AddressType: + """Return LCN address.""" + return ( + self.device_connection.seg_id, + self.device_connection.addr_id, + self.device_connection.is_group, + ) + @property def unique_id(self) -> str: """Return a unique ID.""" - unique_device_id = generate_unique_id( - ( - self.device_connection.seg_id, - self.device_connection.addr_id, - self.device_connection.is_group, - ) + return generate_unique_id( + self.entry_id, self.address, self.config[CONF_RESOURCE] ) - return f"{self.entry_id}-{unique_device_id}-{self.config[CONF_RESOURCE]}" + + @property + def device_info(self) -> DeviceInfo | None: + """Return device specific attributes.""" + address = f"{'g' if self.address[2] else 'm'}{self.address[0]:03d}{self.address[1]:03d}" + model = f"LCN {get_device_model(self.config[CONF_DOMAIN], self.config[CONF_DOMAIN_DATA])}" + + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": f"{address}.{self.config[CONF_RESOURCE]}", + "model": model, + "manufacturer": "Issendorff", + "via_device": ( + DOMAIN, + generate_unique_id(self.entry_id, self.config[CONF_ADDRESS]), + ), + } @property def should_poll(self) -> bool: diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index 905da4d005c..9316d4309c9 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -15,6 +15,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType from .const import CONF_DIM_MODE, CONF_SK_NUM_TRIES, DOMAIN @@ -90,9 +91,16 @@ class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="connection_timeout") # check if we already have a host with the same address configured - entry = get_config_entry(self.hass, data) - if entry: + if entry := get_config_entry(self.hass, data): entry.source = config_entries.SOURCE_IMPORT + + # Cleanup entity and device registry, if we imported from configuration.yaml to + # remove orphans when entities were removed from configuration + entity_registry = er.async_get(self.hass) + entity_registry.async_clear_config_entry(entry.entry_id) + device_registry = dr.async_get(self.hass) + device_registry.async_clear_config_entry(entry.entry_id) + self.hass.config_entries.async_update_entry(entry, data=data) return self.async_abort(reason="existing_configuration_updated") diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index b62f8474470..b879c2d3f72 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -1,6 +1,9 @@ """Helpers for LCN component.""" from __future__ import annotations +import asyncio +from copy import deepcopy +from itertools import chain import re from typing import Tuple, Type, Union, cast @@ -22,19 +25,23 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_SENSORS, + CONF_SOURCE, CONF_SWITCHES, CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import ConfigType from .const import ( + BINSENSOR_PORTS, CONF_CLIMATES, CONF_CONNECTIONS, CONF_DIM_MODE, CONF_DOMAIN_DATA, CONF_HARDWARE_SERIAL, CONF_HARDWARE_TYPE, + CONF_OUTPUT, CONF_RESOURCE, CONF_SCENES, CONF_SK_NUM_TRIES, @@ -42,6 +49,13 @@ from .const import ( CONNECTION, DEFAULT_NAME, DOMAIN, + LED_PORTS, + LOGICOP_PORTS, + OUTPUT_PORTS, + S0_INPUTS, + SETPOINTS, + THRESHOLDS, + VARIABLES, ) # typing @@ -92,10 +106,43 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str: raise ValueError("Unknown domain") -def generate_unique_id(address: AddressType) -> str: +def get_device_model(domain_name: str, domain_data: ConfigType) -> str: + """Return the model for the specified domain_data.""" + if domain_name in ("switch", "light"): + return "Output" if domain_data[CONF_OUTPUT] in OUTPUT_PORTS else "Relay" + if domain_name in ("binary_sensor", "sensor"): + if domain_data[CONF_SOURCE] in BINSENSOR_PORTS: + return "Binary Sensor" + if domain_data[CONF_SOURCE] in chain( + VARIABLES, SETPOINTS, THRESHOLDS, S0_INPUTS + ): + return "Variable" + if domain_data[CONF_SOURCE] in LED_PORTS: + return "Led" + if domain_data[CONF_SOURCE] in LOGICOP_PORTS: + return "Logical Operation" + return "Key" + if domain_name == "cover": + return "Motor" + if domain_name == "climate": + return "Regulator" + if domain_name == "scene": + return "Scene" + raise ValueError("Unknown domain") + + +def generate_unique_id( + entry_id: str, + address: AddressType, + resource: str | None = None, +) -> str: """Generate a unique_id from the given parameters.""" + unique_id = entry_id is_group = "g" if address[2] else "m" - return f"{is_group}{address[0]:03d}{address[1]:03d}" + unique_id += f"-{is_group}{address[0]:03d}{address[1]:03d}" + if resource: + unique_id += f"-{resource}".lower() + return unique_id def import_lcn_config(lcn_config: ConfigType) -> list[ConfigType]: @@ -200,6 +247,110 @@ def import_lcn_config(lcn_config: ConfigType) -> list[ConfigType]: return list(data.values()) +def register_lcn_host_device(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Register LCN host for given config_entry in device registry.""" + device_registry = dr.async_get(hass) + + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="Issendorff", + name=config_entry.title, + model="PCHK", + ) + + +def register_lcn_address_devices( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Register LCN modules and groups defined in config_entry as devices in device registry. + + The name of all given device_connections is collected and the devices + are updated. + """ + device_registry = dr.async_get(hass) + + host_identifiers = (DOMAIN, config_entry.entry_id) + + for device_config in config_entry.data[CONF_DEVICES]: + address = device_config[CONF_ADDRESS] + device_name = device_config[CONF_NAME] + identifiers = {(DOMAIN, generate_unique_id(config_entry.entry_id, address))} + + if device_config[CONF_ADDRESS][2]: # is group + device_model = f"LCN group (g{address[0]:03d}{address[1]:03d})" + sw_version = None + else: # is module + hardware_type = device_config[CONF_HARDWARE_TYPE] + if hardware_type in pypck.lcn_defs.HARDWARE_DESCRIPTIONS: + hardware_name = pypck.lcn_defs.HARDWARE_DESCRIPTIONS[hardware_type] + else: + hardware_name = pypck.lcn_defs.HARDWARE_DESCRIPTIONS[-1] + device_model = f"{hardware_name} (m{address[0]:03d}{address[1]:03d})" + sw_version = f"{device_config[CONF_SOFTWARE_SERIAL]:06X}" + + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers=identifiers, + via_device=host_identifiers, + manufacturer="Issendorff", + sw_version=sw_version, + name=device_name, + model=device_model, + ) + + +async def async_update_device_config( + device_connection: DeviceConnectionType, device_config: ConfigType +) -> None: + """Fill missing values in device_config with infos from LCN bus.""" + # fetch serial info if device is module + if not (is_group := device_config[CONF_ADDRESS][2]): # is module + await device_connection.serial_known + if device_config[CONF_HARDWARE_SERIAL] == -1: + device_config[CONF_HARDWARE_SERIAL] = device_connection.hardware_serial + if device_config[CONF_SOFTWARE_SERIAL] == -1: + device_config[CONF_SOFTWARE_SERIAL] = device_connection.software_serial + if device_config[CONF_HARDWARE_TYPE] == -1: + device_config[CONF_HARDWARE_TYPE] = device_connection.hardware_type.value + + # fetch name if device is module + if device_config[CONF_NAME] != "": + return + + device_name = "" + if not is_group: + device_name = await device_connection.request_name() + if is_group or device_name == "": + module_type = "Group" if is_group else "Module" + device_name = ( + f"{module_type} " + f"{device_config[CONF_ADDRESS][0]:03d}/" + f"{device_config[CONF_ADDRESS][1]:03d}" + ) + device_config[CONF_NAME] = device_name + + +async def async_update_config_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Fill missing values in config_entry with infos from LCN bus.""" + device_configs = deepcopy(config_entry.data[CONF_DEVICES]) + coros = [] + for device_config in device_configs: + device_connection = get_device_connection( + hass, device_config[CONF_ADDRESS], config_entry + ) + coros.append(async_update_device_config(device_connection, device_config)) + + await asyncio.gather(*coros) + + new_data = {**config_entry.data, CONF_DEVICES: device_configs} + + # schedule config_entry for save + hass.config_entries.async_update_entry(config_entry, data=new_data) + + def has_unique_host_names(hosts: list[ConfigType]) -> list[ConfigType]: """Validate that all connection names are unique. @@ -232,8 +383,7 @@ def is_address(value: str) -> tuple[AddressType, str]: myhome.0.g11 myhome.s0.g11 """ - matcher = PATTERN_ADDRESS.match(value) - if matcher: + if matcher := PATTERN_ADDRESS.match(value): is_group = matcher.group("type") == "g" addr = (int(matcher.group("seg_id")), int(matcher.group("id")), is_group) conn_id = matcher.group("conn_id") diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 965e9626f66..66321c79a1b 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -1,6 +1,7 @@ """Support for LCN sensors.""" from __future__ import annotations +from itertools import chain from typing import cast import pypck @@ -38,9 +39,8 @@ def create_lcn_sensor_entity( hass, entity_config[CONF_ADDRESS], config_entry ) - if ( - entity_config[CONF_DOMAIN_DATA][CONF_SOURCE] - in VARIABLES + SETPOINTS + THRESHOLDS + S0_INPUTS + if entity_config[CONF_DOMAIN_DATA][CONF_SOURCE] in chain( + VARIABLES, SETPOINTS, THRESHOLDS, S0_INPUTS ): return LcnVariableSensor( entity_config, config_entry.entry_id, device_connection diff --git a/homeassistant/components/lg_netcast/const.py b/homeassistant/components/lg_netcast/const.py new file mode 100644 index 00000000000..6cb44a5a3f8 --- /dev/null +++ b/homeassistant/components/lg_netcast/const.py @@ -0,0 +1,2 @@ +"""Constants for the lg_netcast component.""" +DOMAIN = "lg_netcast" diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py index 5b5ce313689..5938fc8d616 100644 --- a/homeassistant/components/lg_netcast/media_player.py +++ b/homeassistant/components/lg_netcast/media_player.py @@ -31,6 +31,8 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.script import Script +from .const import DOMAIN + DEFAULT_NAME = "LG TV Remote" CONF_ON_ACTION = "turn_on_action" @@ -69,8 +71,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): on_action = config.get(CONF_ON_ACTION) client = LgNetCastClient(host, access_token) - domain = __name__.split(".")[-2] - on_action_script = Script(hass, on_action, name, domain) if on_action else None + on_action_script = Script(hass, on_action, name, DOMAIN) if on_action else None add_entities([LgTVDevice(client, name, on_action_script)], True) diff --git a/homeassistant/components/life360/translations/ja.json b/homeassistant/components/life360/translations/ja.json new file mode 100644 index 00000000000..772b44b31d8 --- /dev/null +++ b/homeassistant/components/life360/translations/ja.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "create_entry": { + "default": "\u8a73\u7d30\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a\u3059\u308b\u306b\u306f\u3001[Life360\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8]({docs_url}) \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "error": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_username": "\u7121\u52b9\u306a\u30e6\u30fc\u30b6\u30fc\u540d", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u8a73\u7d30\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a\u3059\u308b\u306b\u306f\u3001[Life360\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8]({docs_url}) \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u8ffd\u52a0\u3059\u308b\u524d\u306b\u884c\u3046\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "title": "Life360\u30a2\u30ab\u30a6\u30f3\u30c8\u60c5\u5831" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/translations/tr.json b/homeassistant/components/life360/translations/tr.json index e1e57b39737..1304151d948 100644 --- a/homeassistant/components/life360/translations/tr.json +++ b/homeassistant/components/life360/translations/tr.json @@ -2,20 +2,25 @@ "config": { "abort": { "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", - "unknown": "Beklenmedik hata" + "unknown": "Beklenmeyen hata" + }, + "create_entry": { + "default": "Geli\u015fmi\u015f se\u00e7enekleri ayarlamak i\u00e7in [Life360 belgelerine]( {docs_url} ) bak\u0131n." }, "error": { - "already_configured": "Hesap zaten konfig\u00fcre edilmi\u015fi durumda", + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "invalid_username": "Ge\u00e7ersiz kullan\u0131c\u0131 ad\u0131", - "unknown": "Beklenmedik hata" + "unknown": "Beklenmeyen hata" }, "step": { "user": { "data": { "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "description": "Geli\u015fmi\u015f se\u00e7enekleri ayarlamak i\u00e7in [Life360 belgelerine]( {docs_url} ) bak\u0131n.\n Bunu hesap eklemeden \u00f6nce yapmak isteyebilirsiniz.", + "title": "Life360 Hesap Bilgileri" } } } diff --git a/homeassistant/components/lifx/translations/ja.json b/homeassistant/components/lifx/translations/ja.json new file mode 100644 index 00000000000..6cfa33a7ace --- /dev/null +++ b/homeassistant/components/lifx/translations/ja.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "confirm": { + "description": "LIFX\u306e\u8a2d\u5b9a\u3092\u3057\u307e\u3059\u304b\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lifx/translations/tr.json b/homeassistant/components/lifx/translations/tr.json index fc7532a1e34..ca4cfa92020 100644 --- a/homeassistant/components/lifx/translations/tr.json +++ b/homeassistant/components/lifx/translations/tr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, "step": { diff --git a/homeassistant/components/lifx_cloud/scene.py b/homeassistant/components/lifx_cloud/scene.py index ec2aca00aa9..c91feeaef82 100644 --- a/homeassistant/components/lifx_cloud/scene.py +++ b/homeassistant/components/lifx_cloud/scene.py @@ -38,7 +38,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= try: httpsession = async_get_clientsession(hass) - with async_timeout.timeout(timeout): + async with async_timeout.timeout(timeout): scenes_resp = await httpsession.get(url, headers=headers) except (asyncio.TimeoutError, aiohttp.ClientError): @@ -81,7 +81,7 @@ class LifxCloudScene(Scene): try: httpsession = async_get_clientsession(self.hass) - with async_timeout.timeout(self._timeout): + async with async_timeout.timeout(self._timeout): await httpsession.put(url, headers=self._headers) except (asyncio.TimeoutError, aiohttp.ClientError): diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index c5ae88eaaa0..dcf4972706a 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -252,16 +252,14 @@ def preprocess_turn_on_alternatives(hass, params): if ATTR_PROFILE in params: hass.data[DATA_PROFILES].apply_profile(params.pop(ATTR_PROFILE), params) - color_name = params.pop(ATTR_COLOR_NAME, None) - if color_name is not None: + if (color_name := params.pop(ATTR_COLOR_NAME, None)) is not None: try: params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name) except ValueError: _LOGGER.warning("Got unknown color %s, falling back to white", color_name) params[ATTR_RGB_COLOR] = (255, 255, 255) - kelvin = params.pop(ATTR_KELVIN, None) - if kelvin is not None: + 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) diff --git a/homeassistant/components/light/device_condition.py b/homeassistant/components/light/device_condition.py index e5ff8a83ba3..12e86c1e23d 100644 --- a/homeassistant/components/light/device_condition.py +++ b/homeassistant/components/light/device_condition.py @@ -20,12 +20,10 @@ CONDITION_SCHEMA = toggle_entity.CONDITION_SCHEMA.extend( @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> ConditionCheckerType: """Evaluate state based on configuration.""" - if config_validation: - config = CONDITION_SCHEMA(config) - return toggle_entity.async_condition_from_config(config) + return toggle_entity.async_condition_from_config(hass, config) async def async_get_conditions( diff --git a/homeassistant/components/light/translations/ca.json b/homeassistant/components/light/translations/ca.json index 788c4c3aa5c..1e91f5005ce 100644 --- a/homeassistant/components/light/translations/ca.json +++ b/homeassistant/components/light/translations/ca.json @@ -19,8 +19,8 @@ }, "state": { "_": { - "off": "off", - "on": "on" + "off": "OFF", + "on": "ON" } }, "title": "Llum" diff --git a/homeassistant/components/light/translations/hu.json b/homeassistant/components/light/translations/hu.json index 1ac835fd1af..7a82050c5a1 100644 --- a/homeassistant/components/light/translations/hu.json +++ b/homeassistant/components/light/translations/hu.json @@ -4,8 +4,8 @@ "brightness_decrease": "{entity_name} f\u00e9nyerej\u00e9nek cs\u00f6kkent\u00e9se", "brightness_increase": "{entity_name} f\u00e9nyerej\u00e9nek n\u00f6vel\u00e9se", "flash": "Vaku {entity_name}", - "toggle": "{entity_name} fel/lekapcsol\u00e1sa", - "turn_off": "{entity_name} lekapcsol\u00e1sa", + "toggle": "{entity_name} be/kikapcsol\u00e1sa", + "turn_off": "{entity_name} kikapcsol\u00e1sa", "turn_on": "{entity_name} felkapcsol\u00e1sa" }, "condition_type": { diff --git a/homeassistant/components/light/translations/ja.json b/homeassistant/components/light/translations/ja.json index d4ac27ea526..c7d3c968bcb 100644 --- a/homeassistant/components/light/translations/ja.json +++ b/homeassistant/components/light/translations/ja.json @@ -1,4 +1,22 @@ { + "device_automation": { + "action_type": { + "brightness_decrease": "{entity_name} \u660e\u308b\u3055\u3092\u4e0b\u3052\u308b", + "brightness_increase": "{entity_name} \u660e\u308b\u3055\u3092\u4e0a\u3052\u308b", + "flash": "\u30d5\u30e9\u30c3\u30b7\u30e5 {entity_name}", + "toggle": "\u30c8\u30b0\u30eb {entity_name}", + "turn_off": "\u30aa\u30d5\u306b\u3059\u308b {entity_name}", + "turn_on": "\u30aa\u30f3\u306b\u3059\u308b {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u306f\u30aa\u30d5\u3067\u3059", + "is_on": "{entity_name} \u304c\u30aa\u30f3\u3067\u3059" + }, + "trigger_type": { + "turned_off": "{entity_name} \u30aa\u30d5\u306b\u306a\u308a\u307e\u3057\u305f", + "turned_on": "{entity_name} \u30aa\u30f3\u306b\u306a\u3063\u3066\u3044\u307e\u3059" + } + }, "state": { "_": { "off": "\u30aa\u30d5", diff --git a/homeassistant/components/light/translations/tr.json b/homeassistant/components/light/translations/tr.json index 6fa71e8f339..21de5074bbd 100644 --- a/homeassistant/components/light/translations/tr.json +++ b/homeassistant/components/light/translations/tr.json @@ -1,4 +1,22 @@ { + "device_automation": { + "action_type": { + "brightness_decrease": "{entity_name} parlakl\u0131\u011f\u0131n\u0131 azalt", + "brightness_increase": "{entity_name} parlakl\u0131\u011f\u0131n\u0131 art\u0131r\u0131n", + "flash": "Fla\u015f {entity_name}", + "toggle": "{entity_name} de\u011fi\u015ftir", + "turn_off": "{entity_name} kapat", + "turn_on": "{entity_name} a\u00e7\u0131n" + }, + "condition_type": { + "is_off": "{entity_name} kapal\u0131", + "is_on": "{entity_name} a\u00e7\u0131k" + }, + "trigger_type": { + "turned_off": "{entity_name} kapat\u0131ld\u0131", + "turned_on": "{entity_name} a\u00e7\u0131ld\u0131" + } + }, "state": { "_": { "off": "Kapal\u0131", diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index ac307f68d08..41b8f446541 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -231,8 +231,7 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity): async def async_added_to_hass(self): """Handle entity about to be added to hass event.""" await super().async_added_to_hass() - last_state = await self.async_get_last_state() - if last_state: + if last_state := await self.async_get_last_state(): self._is_on = last_state.state == STATE_ON self._brightness = last_state.attributes.get("brightness") self._temperature = last_state.attributes.get("color_temp") diff --git a/homeassistant/components/linode/binary_sensor.py b/homeassistant/components/linode/binary_sensor.py index 6769d72594b..9e482283042 100644 --- a/homeassistant/components/linode/binary_sensor.py +++ b/homeassistant/components/linode/binary_sensor.py @@ -38,8 +38,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): dev = [] for node in nodes: - node_id = linode.get_node_id(node) - if node_id is None: + if (node_id := linode.get_node_id(node)) is None: _LOGGER.error("Node %s is not available", node) return dev.append(LinodeBinarySensor(linode, node_id)) @@ -52,7 +51,7 @@ class LinodeBinarySensor(BinarySensorEntity): _attr_device_class = DEVICE_CLASS_MOVING - def __init__(self, li, node_id): + def __init__(self, li, node_id): # pylint: disable=invalid-name """Initialize a new Linode sensor.""" self._linode = li self._node_id = node_id diff --git a/homeassistant/components/linode/switch.py b/homeassistant/components/linode/switch.py index 9002cb7bd11..cd2efe9c4da 100644 --- a/homeassistant/components/linode/switch.py +++ b/homeassistant/components/linode/switch.py @@ -35,8 +35,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): dev = [] for node in nodes: - node_id = linode.get_node_id(node) - if node_id is None: + if (node_id := linode.get_node_id(node)) is None: _LOGGER.error("Node %s is not available", node) return dev.append(LinodeSwitch(linode, node_id)) @@ -47,7 +46,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class LinodeSwitch(SwitchEntity): """Representation of a Linode Node switch.""" - def __init__(self, li, node_id): + def __init__(self, li, node_id): # pylint: disable=invalid-name """Initialize a new Linode sensor.""" self._linode = li self._node_id = node_id diff --git a/homeassistant/components/litejet/light.py b/homeassistant/components/litejet/light.py index 172e46c441a..742cc341754 100644 --- a/homeassistant/components/litejet/light.py +++ b/homeassistant/components/litejet/light.py @@ -34,7 +34,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class LiteJetLight(LightEntity): """Representation of a single LiteJet light.""" - def __init__(self, config_entry, lj, i, name): + def __init__(self, config_entry, lj, i, name): # pylint: disable=invalid-name """Initialize a LiteJet light.""" self._config_entry = config_entry self._lj = lj diff --git a/homeassistant/components/litejet/scene.py b/homeassistant/components/litejet/scene.py index 2f2ab244e1e..8b285d58ef1 100644 --- a/homeassistant/components/litejet/scene.py +++ b/homeassistant/components/litejet/scene.py @@ -26,7 +26,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class LiteJetScene(Scene): """Representation of a single LiteJet scene.""" - def __init__(self, entry_id, lj, i, name): + def __init__(self, entry_id, lj, i, name): # pylint: disable=invalid-name """Initialize the scene.""" self._entry_id = entry_id self._lj = lj diff --git a/homeassistant/components/litejet/switch.py b/homeassistant/components/litejet/switch.py index 343d8393f1c..0feabfbf864 100644 --- a/homeassistant/components/litejet/switch.py +++ b/homeassistant/components/litejet/switch.py @@ -28,7 +28,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class LiteJetSwitch(SwitchEntity): """Representation of a single LiteJet switch.""" - def __init__(self, entry_id, lj, i, name): + def __init__(self, entry_id, lj, i, name): # pylint: disable=invalid-name """Initialize a LiteJet switch.""" self._entry_id = entry_id self._lj = lj diff --git a/homeassistant/components/litejet/translations/id.json b/homeassistant/components/litejet/translations/id.json index 690692ca4cc..332355f8fa9 100644 --- a/homeassistant/components/litejet/translations/id.json +++ b/homeassistant/components/litejet/translations/id.json @@ -15,5 +15,15 @@ "title": "Hubungkan ke LiteJet" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Waktu Transisi Default (detik)" + }, + "title": "Konfigurasikan LiteJet" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/ja.json b/homeassistant/components/litejet/translations/ja.json new file mode 100644 index 00000000000..c26dc073113 --- /dev/null +++ b/homeassistant/components/litejet/translations/ja.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "open_failed": "\u6307\u5b9a\u3055\u308c\u305f\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8\u3092\u958b\u304f\u3053\u3068\u304c\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "user": { + "data": { + "port": "\u30dd\u30fc\u30c8" + }, + "description": "LiteJet\u306eRS232-2\u30dd\u30fc\u30c8\u3092\u30b3\u30f3\u30d4\u30e5\u30fc\u30bf\u30fc\u306b\u63a5\u7d9a\u3057\u3001\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8\u30c7\u30d0\u30a4\u30b9\u3078\u306e\u30d1\u30b9\u3092\u5165\u529b\u3057\u307e\u3059\u3002 \n\n LiteJet MCP\u306f\u300119.2 K\u30dc\u30fc\u30018\u30c7\u30fc\u30bf\u30d3\u30c3\u30c8\u30011\u30b9\u30c8\u30c3\u30d7\u30d3\u30c3\u30c8\u3001\u30d1\u30ea\u30c6\u30a3\u306a\u3057\u3001\u5404\u5fdc\u7b54\u306e\u5f8c\u306b 'CR' \u3092\u9001\u4fe1\u3059\u308b\u3088\u3046\u306b\u8a2d\u5b9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "title": "LiteJet\u306b\u63a5\u7d9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "\u30c7\u30d5\u30a9\u30eb\u30c8 \u30c8\u30e9\u30f3\u30b8\u30b7\u30e7\u30f3(\u79d2)" + }, + "title": "LiteJet\u306e\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/tr.json b/homeassistant/components/litejet/translations/tr.json index de4ea12cb6f..55bc5a35195 100644 --- a/homeassistant/components/litejet/translations/tr.json +++ b/homeassistant/components/litejet/translations/tr.json @@ -1,9 +1,29 @@ { "config": { + "abort": { + "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + }, + "error": { + "open_failed": "Belirtilen seri ba\u011flant\u0131 noktas\u0131 a\u00e7\u0131lam\u0131yor." + }, "step": { "user": { + "data": { + "port": "Port" + }, + "description": "LiteJet'in RS232-2 ba\u011flant\u0131 noktas\u0131n\u0131 bilgisayar\u0131n\u0131za ba\u011flay\u0131n ve seri ba\u011flant\u0131 noktas\u0131 ayg\u0131t\u0131n\u0131n yolunu girin. \n\n LiteJet MCP 19.2 K baud, 8 veri biti, 1 durdurma biti, e\u015fliksiz ve her yan\u0131ttan sonra bir 'CR' iletecek \u015fekilde yap\u0131land\u0131r\u0131lmal\u0131d\u0131r.", "title": "LiteJet'e Ba\u011flan\u0131n" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Varsay\u0131lan Ge\u00e7i\u015f (saniye)" + }, + "title": "LiteJet'i yap\u0131land\u0131r\u0131n" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index 04a98a5bf60..d972ecc79d9 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -2,6 +2,11 @@ from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -9,7 +14,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .hub import LitterRobotHub -PLATFORMS = ["sensor", "switch", "vacuum"] +PLATFORMS = [BUTTON_DOMAIN, SELECT_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN, VACUUM_DOMAIN] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/litterrobot/button.py b/homeassistant/components/litterrobot/button.py new file mode 100644 index 00000000000..3b8be295731 --- /dev/null +++ b/homeassistant/components/litterrobot/button.py @@ -0,0 +1,43 @@ +"""Support for Litter-Robot button.""" +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_CONFIG +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import LitterRobotEntity +from .hub import LitterRobotHub + +TYPE_RESET_WASTE_DRAWER = "Reset Waste Drawer" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Litter-Robot cleaner using config entry.""" + hub: LitterRobotHub = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + LitterRobotResetWasteDrawerButton( + robot=robot, entity_type=TYPE_RESET_WASTE_DRAWER, hub=hub + ) + for robot in hub.account.robots + ] + ) + + +class LitterRobotResetWasteDrawerButton(LitterRobotEntity, ButtonEntity): + """Litter-Robot reset waste drawer button.""" + + _attr_icon = "mdi:delete-variant" + _attr_entity_category = ENTITY_CATEGORY_CONFIG + + async def async_press(self) -> None: + """Press the button.""" + await self.robot.reset_waste_drawer() + self.coordinator.async_set_updated_data(True) diff --git a/homeassistant/components/litterrobot/entity.py b/homeassistant/components/litterrobot/entity.py index fbcd129411a..206358201c3 100644 --- a/homeassistant/components/litterrobot/entity.py +++ b/homeassistant/components/litterrobot/entity.py @@ -9,6 +9,7 @@ from typing import Any from pylitterbot import Robot from pylitterbot.exceptions import InvalidCommandException +from homeassistant.const import ENTITY_CATEGORY_CONFIG from homeassistant.core import callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.event import async_call_later @@ -66,19 +67,21 @@ class LitterRobotControlEntity(LitterRobotEntity): self, action: MethodType, *args: Any, **kwargs: Any ) -> bool: """Perform an action and initiates a refresh of the robot data after a few seconds.""" + success = False try: - await action(*args, **kwargs) + success = await action(*args, **kwargs) except InvalidCommandException as ex: # pragma: no cover # this exception should only occur if the underlying API for commands changes _LOGGER.error(ex) - return False + success = False - self.async_cancel_refresh_callback() - self._refresh_callback = async_call_later( - self.hass, REFRESH_WAIT_TIME_SECONDS, self.async_call_later_callback - ) - return True + if success: + self.async_cancel_refresh_callback() + self._refresh_callback = async_call_later( + self.hass, REFRESH_WAIT_TIME_SECONDS, self.async_call_later_callback + ) + return success async def async_call_later_callback(self, *_) -> None: """Perform refresh request on callback.""" @@ -99,9 +102,7 @@ class LitterRobotControlEntity(LitterRobotEntity): @staticmethod def parse_time_at_default_timezone(time_str: str) -> time | None: """Parse a time string and add default timezone.""" - parsed_time = dt_util.parse_time(time_str) - - if parsed_time is None: + if (parsed_time := dt_util.parse_time(time_str)) is None: return None return ( @@ -113,3 +114,22 @@ class LitterRobotControlEntity(LitterRobotEntity): ) .timetz() ) + + +class LitterRobotConfigEntity(LitterRobotControlEntity): + """A Litter-Robot entity that can control configuration of the unit.""" + + _attr_entity_category = ENTITY_CATEGORY_CONFIG + + def __init__(self, robot: Robot, entity_type: str, hub: LitterRobotHub) -> None: + """Init a Litter-Robot control entity.""" + super().__init__(robot=robot, entity_type=entity_type, hub=hub) + self._assumed_state: Any = None + + async def perform_action_and_assume_state( + self, action: MethodType, assumed_state: Any + ) -> bool: + """Perform an action and assume the state passed in if call is successful.""" + if await self.perform_action_and_refresh(action, assumed_state): + self._assumed_state = assumed_state + self.async_write_ha_state() diff --git a/homeassistant/components/litterrobot/hub.py b/homeassistant/components/litterrobot/hub.py index 6a9155b9eaf..3aa86dcc93a 100644 --- a/homeassistant/components/litterrobot/hub.py +++ b/homeassistant/components/litterrobot/hub.py @@ -13,7 +13,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -UPDATE_INTERVAL_SECONDS = 10 +UPDATE_INTERVAL_SECONDS = 20 class LitterRobotHub: diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 7b864948569..0d76e8df09c 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -3,7 +3,11 @@ "name": "Litter-Robot", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/litterrobot", - "requirements": ["pylitterbot==2021.10.1"], - "codeowners": ["@natekspencer"], + "requirements": [ + "pylitterbot==2021.11.0" + ], + "codeowners": [ + "@natekspencer" + ], "iot_class": "cloud_polling" -} +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py new file mode 100644 index 00000000000..c1a0718510d --- /dev/null +++ b/homeassistant/components/litterrobot/select.py @@ -0,0 +1,53 @@ +"""Support for Litter-Robot selects.""" +from __future__ import annotations + +from pylitterbot.robot import VALID_WAIT_TIMES + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import LitterRobotConfigEntity +from .hub import LitterRobotHub + +TYPE_CLEAN_CYCLE_WAIT_TIME_MINUTES = "Clean Cycle Wait Time Minutes" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Litter-Robot selects using config entry.""" + hub: LitterRobotHub = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + [ + LitterRobotSelect( + robot=robot, entity_type=TYPE_CLEAN_CYCLE_WAIT_TIME_MINUTES, hub=hub + ) + for robot in hub.account.robots + ] + ) + + +class LitterRobotSelect(LitterRobotConfigEntity, SelectEntity): + """Litter-Robot Select.""" + + _attr_icon = "mdi:timer-outline" + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return str(self.robot.clean_cycle_wait_time_minutes) + + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + return [str(minute) for minute in VALID_WAIT_TIMES] + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.perform_action_and_refresh(self.robot.set_wait_time, int(option)) diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index cbcb75c0b23..6b4dc1b3300 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -1,9 +1,11 @@ """Support for Litter-Robot sensors.""" from __future__ import annotations +from datetime import datetime + from pylitterbot.robot import Robot -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, StateType from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_CLASS_TIMESTAMP, PERCENTAGE from homeassistant.core import HomeAssistant @@ -36,7 +38,7 @@ class LitterRobotPropertySensor(LitterRobotEntity, SensorEntity): self.sensor_attribute = sensor_attribute @property - def native_value(self) -> str: + def native_value(self) -> StateType | datetime: """Return the state.""" return getattr(self.robot, self.sensor_attribute) @@ -59,10 +61,10 @@ class LitterRobotSleepTimeSensor(LitterRobotPropertySensor): """Litter-Robot sleep time sensor.""" @property - def native_value(self) -> str | None: + def native_value(self) -> StateType | datetime: """Return the state.""" if self.robot.sleep_mode_enabled: - return super().native_value.isoformat() + return super().native_value return None @property @@ -98,4 +100,4 @@ async def async_setup_entry( ) ) - async_add_entities(entities, True) + async_add_entities(entities) diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index 3c4e4bb9937..4d302a0d4ae 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -9,16 +9,18 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .entity import LitterRobotControlEntity +from .entity import LitterRobotConfigEntity from .hub import LitterRobotHub -class LitterRobotNightLightModeSwitch(LitterRobotControlEntity, SwitchEntity): +class LitterRobotNightLightModeSwitch(LitterRobotConfigEntity, SwitchEntity): """Litter-Robot Night Light Mode Switch.""" @property def is_on(self) -> bool: """Return true if switch is on.""" + if self._refresh_callback is not None: + return self._assumed_state return self.robot.night_light_mode_enabled @property @@ -28,19 +30,21 @@ class LitterRobotNightLightModeSwitch(LitterRobotControlEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self.perform_action_and_refresh(self.robot.set_night_light, True) + await self.perform_action_and_assume_state(self.robot.set_night_light, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self.perform_action_and_refresh(self.robot.set_night_light, False) + await self.perform_action_and_assume_state(self.robot.set_night_light, False) -class LitterRobotPanelLockoutSwitch(LitterRobotControlEntity, SwitchEntity): +class LitterRobotPanelLockoutSwitch(LitterRobotConfigEntity, SwitchEntity): """Litter-Robot Panel Lockout Switch.""" @property def is_on(self) -> bool: """Return true if switch is on.""" + if self._refresh_callback is not None: + return self._assumed_state return self.robot.panel_lock_enabled @property @@ -50,14 +54,14 @@ class LitterRobotPanelLockoutSwitch(LitterRobotControlEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self.perform_action_and_refresh(self.robot.set_panel_lockout, True) + await self.perform_action_and_assume_state(self.robot.set_panel_lockout, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self.perform_action_and_refresh(self.robot.set_panel_lockout, False) + await self.perform_action_and_assume_state(self.robot.set_panel_lockout, False) -ROBOT_SWITCHES: list[tuple[type[LitterRobotControlEntity], str]] = [ +ROBOT_SWITCHES: list[tuple[type[LitterRobotConfigEntity], str]] = [ (LitterRobotNightLightModeSwitch, "Night Light Mode"), (LitterRobotPanelLockoutSwitch, "Panel Lockout"), ] @@ -76,4 +80,4 @@ async def async_setup_entry( for switch_class, switch_type in ROBOT_SWITCHES: entities.append(switch_class(robot=robot, entity_type=switch_type, hub=hub)) - async_add_entities(entities, True) + async_add_entities(entities) diff --git a/homeassistant/components/litterrobot/translations/ja.json b/homeassistant/components/litterrobot/translations/ja.json new file mode 100644 index 00000000000..b4c39a6b251 --- /dev/null +++ b/homeassistant/components/litterrobot/translations/ja.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/pl.json b/homeassistant/components/litterrobot/translations/pl.json index 8558125c057..41654933a6f 100644 --- a/homeassistant/components/litterrobot/translations/pl.json +++ b/homeassistant/components/litterrobot/translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "[%key::common::config_flow::abort::already_configured_account%]" + "already_configured": "Konto jest ju\u017c skonfigurowane" }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", diff --git a/homeassistant/components/litterrobot/translations/tr.json b/homeassistant/components/litterrobot/translations/tr.json new file mode 100644 index 00000000000..a83e1936fb4 --- /dev/null +++ b/homeassistant/components/litterrobot/translations/tr.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap 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": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index e40a971f43f..5af6c5b5ef3 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -1,6 +1,7 @@ """Support for Litter-Robot "Vacuum".""" from __future__ import annotations +import logging from typing import Any from pylitterbot.enums import LitterBoxStatus @@ -29,6 +30,8 @@ from .const import DOMAIN from .entity import LitterRobotControlEntity from .hub import LitterRobotHub +_LOGGER = logging.getLogger(__name__) + SUPPORT_LITTERROBOT = ( SUPPORT_START | SUPPORT_STATE | SUPPORT_STATUS | SUPPORT_TURN_OFF | SUPPORT_TURN_ON ) @@ -47,13 +50,12 @@ async def async_setup_entry( """Set up Litter-Robot cleaner using config entry.""" hub: LitterRobotHub = hass.data[DOMAIN][entry.entry_id] - entities = [] - for robot in hub.account.robots: - entities.append( + async_add_entities( + [ LitterRobotCleaner(robot=robot, entity_type=TYPE_LITTER_BOX, hub=hub) - ) - - async_add_entities(entities, True) + for robot in hub.account.robots + ] + ) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -122,6 +124,13 @@ class LitterRobotCleaner(LitterRobotControlEntity, StateVacuumEntity): async def async_reset_waste_drawer(self) -> None: """Reset the waste drawer level.""" + # The Litter-Robot reset waste drawer service has been replaced by a + # dedicated button entity and marked as deprecated + _LOGGER.warning( + "The 'litterrobot.reset_waste_drawer' service is deprecated and " + "replaced by a dedicated reset waste drawer button entity; Please " + "use that entity to reset the waste drawer instead" + ) await self.robot.reset_waste_drawer() self.coordinator.async_set_updated_data(True) @@ -137,6 +146,13 @@ class LitterRobotCleaner(LitterRobotControlEntity, StateVacuumEntity): async def async_set_wait_time(self, minutes: int) -> None: """Set the wait time.""" + # The Litter-Robot set wait time service has been replaced by a + # dedicated select entity and marked as deprecated + _LOGGER.warning( + "The 'litterrobot.set_wait_time' service is deprecated and " + "replaced by a dedicated set wait time select entity; Please " + "use that entity to set the wait time instead" + ) await self.perform_action_and_refresh(self.robot.set_wait_time, minutes) @property @@ -149,4 +165,5 @@ class LitterRobotCleaner(LitterRobotControlEntity, StateVacuumEntity): "power_status": self.robot.power_status, "status_code": self.robot.status_code, "last_seen": self.robot.last_seen, + "status": self.status, } diff --git a/homeassistant/components/local_ip/sensor.py b/homeassistant/components/local_ip/sensor.py index bd1b3d54fac..56c1fac7c8f 100644 --- a/homeassistant/components/local_ip/sensor.py +++ b/homeassistant/components/local_ip/sensor.py @@ -1,7 +1,6 @@ """Sensor platform for local_ip.""" from homeassistant.components.network import async_get_source_ip -from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME @@ -33,6 +32,4 @@ class IPSensor(SensorEntity): async def async_update(self) -> None: """Fetch new state data for the sensor.""" - self._attr_native_value = await async_get_source_ip( - self.hass, target_ip=PUBLIC_TARGET_IP - ) + self._attr_native_value = await async_get_source_ip(self.hass) diff --git a/homeassistant/components/local_ip/translations/ja.json b/homeassistant/components/local_ip/translations/ja.json new file mode 100644 index 00000000000..f5d2efd6613 --- /dev/null +++ b/homeassistant/components/local_ip/translations/ja.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "user": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f", + "title": "\u30ed\u30fc\u30ab\u30ebIP\u30a2\u30c9\u30ec\u30b9" + } + } + }, + "title": "\u30ed\u30fc\u30ab\u30ebIP\u30a2\u30c9\u30ec\u30b9" +} \ No newline at end of file diff --git a/homeassistant/components/locative/translations/ja.json b/homeassistant/components/locative/translations/ja.json new file mode 100644 index 00000000000..89003e78a9d --- /dev/null +++ b/homeassistant/components/locative/translations/ja.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "webhook_not_internet_accessible": "Webhook\u30e1\u30c3\u30bb\u30fc\u30b8\u3092\u53d7\u4fe1\u3059\u308b\u306b\u306f\u3001Home Assistant\u306e\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306b\u3001\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u304b\u3089\u30a2\u30af\u30bb\u30b9\u3067\u304d\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "create_entry": { + "default": "Home Assistant\u306b\u5834\u6240\u3092\u9001\u4fe1\u3059\u308b\u306b\u306f\u3001Locative app\u3067webhook\u6a5f\u80fd\u3092\u8a2d\u5b9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\n\n\u6b21\u306e\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044:\n\n- URL: `{webhook_url}`\n- Method(\u65b9\u5f0f): POST\n\n\u8a73\u7d30\u306f[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8]({docs_url}) \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "user": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f", + "title": "Locative Webhook\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/locative/translations/tr.json b/homeassistant/components/locative/translations/tr.json index 84adcdf8225..906abd1b2e5 100644 --- a/homeassistant/components/locative/translations/tr.json +++ b/homeassistant/components/locative/translations/tr.json @@ -3,6 +3,15 @@ "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr.", "webhook_not_internet_accessible": "Webhook mesajlar\u0131n\u0131 alabilmek i\u00e7in Home Assistant \u00f6rne\u011finize internetten eri\u015filebilmelidir." + }, + "create_entry": { + "default": "Konumlar\u0131 Home Assistant'a g\u00f6ndermek i\u00e7in Locative uygulamas\u0131nda webhook \u00f6zelli\u011fini ayarlaman\u0131z gerekir. \n\n A\u015fa\u011f\u0131daki bilgileri doldurun: \n\n - URL: ` {webhook_url} `\n - Y\u00f6ntem: POST \n\n Daha fazla ayr\u0131nt\u0131 i\u00e7in [belgelere]( {docs_url}" + }, + "step": { + "user": { + "description": "Kuruluma ba\u015flamak ister misiniz?", + "title": "Konum Belirleyici Webhook'u ayarlay\u0131n" + } } } } \ No newline at end of file diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index aa3662da0c8..1f2e87fc864 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -162,8 +162,7 @@ class LockEntity(Entity): """Return the state attributes.""" state_attr = {} for prop, attr in PROP_TO_ATTR.items(): - value = getattr(self, prop) - if value is not None: + if (value := getattr(self, prop)) is not None: state_attr[attr] = value return state_attr diff --git a/homeassistant/components/lock/device_condition.py b/homeassistant/components/lock/device_condition.py index 74b55a1a89c..a818a2b5fa4 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -68,11 +68,9 @@ async def async_get_conditions( @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" - if config_validation: - config = CONDITION_SCHEMA(config) if config[CONF_TYPE] == "is_jammed": state = STATE_JAMMED elif config[CONF_TYPE] == "is_locking": diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index cbdab7abb3d..75415bbf3e1 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -104,7 +104,7 @@ async def async_attach_trigger( } if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] - state_config = state_trigger.TRIGGER_SCHEMA(state_config) + state_config = await state_trigger.async_validate_trigger_config(hass, state_config) return await state_trigger.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) diff --git a/homeassistant/components/lock/translations/ja.json b/homeassistant/components/lock/translations/ja.json new file mode 100644 index 00000000000..53138d5d8d4 --- /dev/null +++ b/homeassistant/components/lock/translations/ja.json @@ -0,0 +1,24 @@ +{ + "device_automation": { + "action_type": { + "lock": "\u30ed\u30c3\u30af {entity_name}", + "open": "\u30aa\u30fc\u30d7\u30f3 {entity_name}", + "unlock": "\u30a2\u30f3\u30ed\u30c3\u30af {entity_name}" + }, + "condition_type": { + "is_locked": "{entity_name} \u306f\u30ed\u30c3\u30af\u3055\u308c\u3066\u3044\u307e\u3059", + "is_unlocked": "{entity_name} \u306e\u30ed\u30c3\u30af\u306f\u89e3\u9664\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "trigger_type": { + "locked": "{entity_name} \u306f\u30ed\u30c3\u30af\u3055\u308c\u3066\u3044\u307e\u3059", + "unlocked": "{entity_name} \u306e\u30ed\u30c3\u30af\u304c\u89e3\u9664\u3055\u308c\u307e\u3057\u305f" + } + }, + "state": { + "_": { + "locked": "\u65bd\u9320\u4e2d", + "unlocked": "\u30ed\u30c3\u30af\u89e3\u9664" + } + }, + "title": "\u30ed\u30c3\u30af" +} \ No newline at end of file diff --git a/homeassistant/components/lock/translations/tr.json b/homeassistant/components/lock/translations/tr.json index ea6ff1a157d..a6e12ea5a98 100644 --- a/homeassistant/components/lock/translations/tr.json +++ b/homeassistant/components/lock/translations/tr.json @@ -1,5 +1,14 @@ { "device_automation": { + "action_type": { + "lock": "{entity_name} kilitle", + "open": "{entity_name} a\u00e7\u0131n", + "unlock": "{entity_name} kilidini a\u00e7\u0131" + }, + "condition_type": { + "is_locked": "{entity_name} kilitli", + "is_unlocked": "{entity_name} kilidi a\u00e7\u0131ld\u0131" + }, "trigger_type": { "locked": "{entity_name} kilitlendi", "unlocked": "{entity_name} kilidi a\u00e7\u0131ld\u0131" diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index a758f850b93..89e70f346ed 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -193,9 +193,7 @@ class LogbookView(HomeAssistantView): async def get(self, request, datetime=None): """Retrieve logbook entries.""" if datetime: - datetime = dt_util.parse_datetime(datetime) - - if datetime is None: + if (datetime := dt_util.parse_datetime(datetime)) is None: return self.json_message("Invalid datetime", HTTPStatus.BAD_REQUEST) else: datetime = dt_util.start_of_local_day() @@ -219,8 +217,7 @@ class LogbookView(HomeAssistantView): end_day = start_day + timedelta(days=period) else: start_day = datetime - end_day = dt_util.parse_datetime(end_time) - if end_day is None: + if (end_day := dt_util.parse_datetime(end_time)) is None: return self.json_message("Invalid end_time", HTTPStatus.BAD_REQUEST) hass = request.app["hass"] diff --git a/homeassistant/components/logi_circle/__init__.py b/homeassistant/components/logi_circle/__init__.py index d9060b10080..45b34928a30 100644 --- a/homeassistant/components/logi_circle/__init__.py +++ b/homeassistant/components/logi_circle/__init__.py @@ -147,7 +147,7 @@ async def async_setup_entry(hass, entry): return False try: - with async_timeout.timeout(_TIMEOUT): + async with async_timeout.timeout(_TIMEOUT): # Ensure the cameras property returns the same Camera objects for # all devices. Performs implicit login and session validation. await logi_circle.synchronize_cameras() diff --git a/homeassistant/components/logi_circle/config_flow.py b/homeassistant/components/logi_circle/config_flow.py index 9054b476332..7453fe27e18 100644 --- a/homeassistant/components/logi_circle/config_flow.py +++ b/homeassistant/components/logi_circle/config_flow.py @@ -158,7 +158,7 @@ class LogiCircleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) try: - with async_timeout.timeout(_TIMEOUT): + async with async_timeout.timeout(_TIMEOUT): await logi_session.authorize(code) except AuthorizationFailed: (self.hass.data[DATA_FLOW_IMPL][DOMAIN][EXTERNAL_ERRORS]) = "invalid_auth" diff --git a/homeassistant/components/logi_circle/translations/ja.json b/homeassistant/components/logi_circle/translations/ja.json new file mode 100644 index 00000000000..8f611814597 --- /dev/null +++ b/homeassistant/components/logi_circle/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "external_error": "\u5225\u306e\u30d5\u30ed\u30fc\u3067\u4f8b\u5916\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002", + "external_setup": "Logi Circle\u306f\u5225\u306e\u30d5\u30ed\u30fc\u304b\u3089\u6b63\u5e38\u306b\u69cb\u6210\u3055\u308c\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" + }, + "error": { + "authorize_url_timeout": "\u8a8d\u8a3cURL\u306e\u751f\u6210\u304c\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002", + "follow_link": "\u9001\u4fe1(submit) \u3092\u62bc\u3059\u524d\u306b\u3001\u30ea\u30f3\u30af\u3092\u305f\u3069\u3063\u3066\u8a8d\u8a3c\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "auth": { + "description": "\u4ee5\u4e0b\u306e\u30ea\u30f3\u30af\u3092\u30af\u30ea\u30c3\u30af\u3057\u3066\u3001Logi Circle\u30a2\u30ab\u30a6\u30f3\u30c8\u3078\u306e\u30a2\u30af\u30bb\u30b9\u3092 **\u627f\u8a8d(Accept)** \u3057\u3066\u304b\u3089\u3001\u623b\u3063\u3066\u304d\u3066\u4ee5\u4e0b\u306e **\u9001\u4fe1(submit)** \u3092\u62bc\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n\n[\u30ea\u30f3\u30af]({authorization_url})", + "title": "Logi Circle\u3067\u8a8d\u8a3c\u3059\u308b" + }, + "user": { + "data": { + "flow_impl": "\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc" + }, + "description": "Logi Circle\u3067\u8a8d\u8a3c\u3059\u308b\u305f\u3081\u306e\u8a8d\u8a3c\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc\u3092\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "\u8a8d\u8a3c\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/logi_circle/translations/tr.json b/homeassistant/components/logi_circle/translations/tr.json index 0b0f58116c2..5073dad1b62 100644 --- a/homeassistant/components/logi_circle/translations/tr.json +++ b/homeassistant/components/logi_circle/translations/tr.json @@ -1,10 +1,28 @@ { "config": { "abort": { - "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "external_error": "Ba\u015fka bir ak\u0131\u015ftan \u00f6zel durum olu\u015ftu.", + "external_setup": "Logi Circle, ba\u015fka bir ak\u0131\u015ftan ba\u015far\u0131yla yap\u0131land\u0131r\u0131ld\u0131.", + "missing_configuration": "Bile\u015fen yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin." }, "error": { + "authorize_url_timeout": "Yetkilendirme URL'si olu\u015ftururken zaman a\u015f\u0131m\u0131.", + "follow_link": "L\u00fctfen ba\u011flant\u0131y\u0131 takip edin ve G\u00f6nder'e basmadan \u00f6nce kimli\u011finizi do\u011frulay\u0131n.", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + }, + "step": { + "auth": { + "description": "L\u00fctfen a\u015fa\u011f\u0131daki ba\u011flant\u0131y\u0131 takip edin ve Logi Circle hesab\u0131n\u0131za eri\u015fimi **Kabul** edin, ard\u0131ndan geri d\u00f6n\u00fcn ve a\u015fa\u011f\u0131daki **G\u00f6nder**'e bas\u0131n. \n\n [Ba\u011flant\u0131]( {authorization_url} )", + "title": "Logi Circle ile kimlik do\u011frulamas\u0131" + }, + "user": { + "data": { + "flow_impl": "Sa\u011flay\u0131c\u0131" + }, + "description": "Logi Circle ile kimlik do\u011frulamas\u0131 yapmak istedi\u011finiz kimlik do\u011frulama sa\u011flay\u0131c\u0131s\u0131n\u0131 se\u00e7in.", + "title": "Kimlik Do\u011frulama Sa\u011flay\u0131c\u0131s\u0131" + } } } } \ No newline at end of file diff --git a/homeassistant/components/london_air/sensor.py b/homeassistant/components/london_air/sensor.py index 4c9e7a4c4fb..fb522054c5f 100644 --- a/homeassistant/components/london_air/sensor.py +++ b/homeassistant/components/london_air/sensor.py @@ -94,10 +94,10 @@ class AirSensor(SensorEntity): ICON = "mdi:cloud-outline" - def __init__(self, name, APIdata): + def __init__(self, name, api_data): """Initialize the sensor.""" self._name = name - self._api_data = APIdata + self._api_data = api_data self._site_data = None self._state = None self._updated = None diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index f749621beaf..5e603027a50 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -1,6 +1,7 @@ """The lookin integration.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging @@ -37,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: lookin_device = await lookin_protocol.get_info() devices = await lookin_protocol.get_devices() - except aiohttp.ClientError as ex: + except (asyncio.TimeoutError, aiohttp.ClientError) as ex: raise ConfigEntryNotReady from ex meteo_coordinator: DataUpdateCoordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/lookin/climate.py b/homeassistant/components/lookin/climate.py index 7bb48350eef..356b57453bc 100644 --- a/homeassistant/components/lookin/climate.py +++ b/homeassistant/components/lookin/climate.py @@ -1,15 +1,16 @@ """The lookin integration climate platform.""" from __future__ import annotations -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine from datetime import timedelta import logging -from typing import Any, Callable, Final, cast +from typing import Any, Final, cast from aiolookin import Climate, MeteoSensor, SensorID from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( + ATTR_HVAC_MODE, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -151,6 +152,28 @@ class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity): if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return self._climate.temp_celsius = int(temperature) + lookin_index = LOOKIN_HVAC_MODE_IDX_TO_HASS + if hvac_mode := kwargs.get(ATTR_HVAC_MODE): + self._climate.hvac_mode = HASS_TO_LOOKIN_HVAC_MODE[hvac_mode] + elif self._climate.hvac_mode == lookin_index.index(HVAC_MODE_OFF): + # + # If the device is off, and the user didn't specify an HVAC mode + # (which is the default when using the HA UI), the device won't turn + # on without having an HVAC mode passed. + # + # We picked the hvac mode based on the current temp if its available + # since only some units support auto, but most support either heat + # or cool otherwise we set auto since we don't have a way to make + # an educated guess. + # + meteo_data: MeteoSensor = self._meteo_coordinator.data + current_temp = meteo_data.temperature + if not current_temp: + self._climate.hvac_mode = lookin_index.index(HVAC_MODE_AUTO) + elif current_temp >= self._climate.temp_celsius: + self._climate.hvac_mode = lookin_index.index(HVAC_MODE_COOL) + else: + self._climate.hvac_mode = lookin_index.index(HVAC_MODE_HEAT) await self._async_update_conditioner() async def async_set_fan_mode(self, fan_mode: str) -> None: diff --git a/homeassistant/components/lookin/config_flow.py b/homeassistant/components/lookin/config_flow.py index 14e4b517b5b..2b4df9cc027 100644 --- a/homeassistant/components/lookin/config_flow.py +++ b/homeassistant/components/lookin/config_flow.py @@ -9,10 +9,10 @@ from aiolookin import Device, LookInHttpProtocol, NoUsableService import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import DiscoveryInfoType from .const import DOMAIN @@ -28,11 +28,11 @@ class LookinFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._name: str | None = None async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Start a discovery flow from zeroconf.""" - uid: str = discovery_info["hostname"][: -len(".local.")] - host: str = discovery_info["host"] + uid: str = discovery_info.hostname[: -len(".local.")] + host: str = discovery_info.host await self.async_set_unique_id(uid.upper()) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) diff --git a/homeassistant/components/lookin/translations/he.json b/homeassistant/components/lookin/translations/he.json new file mode 100644 index 00000000000..3110857a512 --- /dev/null +++ b/homeassistant/components/lookin/translations/he.json @@ -0,0 +1,28 @@ +{ + "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" + }, + "error": { + "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} ({host})", + "step": { + "device_name": { + "data": { + "name": "\u05e9\u05dd" + } + }, + "user": { + "data": { + "ip_address": "\u05db\u05ea\u05d5\u05d1\u05ea IP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lookin/translations/id.json b/homeassistant/components/lookin/translations/id.json new file mode 100644 index 00000000000..bb3c4bd7c07 --- /dev/null +++ b/homeassistant/components/lookin/translations/id.json @@ -0,0 +1,31 @@ +{ + "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" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "flow_title": "{name} ({host})", + "step": { + "device_name": { + "data": { + "name": "Nama" + } + }, + "discovery_confirm": { + "description": "Ingin menyiapkan {name} ({host})?" + }, + "user": { + "data": { + "ip_address": "Alamat IP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lookin/translations/ja.json b/homeassistant/components/lookin/translations/ja.json new file mode 100644 index 00000000000..345a338d19d --- /dev/null +++ b/homeassistant/components/lookin/translations/ja.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "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" + }, + "error": { + "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} ({host})", + "step": { + "device_name": { + "data": { + "name": "\u540d\u524d" + } + }, + "discovery_confirm": { + "description": "{name} ({host}) \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "ip_address": "IP\u30a2\u30c9\u30ec\u30b9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lookin/translations/pl.json b/homeassistant/components/lookin/translations/pl.json index d6edb1d50da..8f1bb63725e 100644 --- a/homeassistant/components/lookin/translations/pl.json +++ b/homeassistant/components/lookin/translations/pl.json @@ -2,6 +2,7 @@ "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" }, @@ -10,12 +11,16 @@ "no_devices_found": "Nie znaleziono urz\u0105dze\u0144 w sieci", "unknown": "Nieoczekiwany b\u0142\u0105d" }, + "flow_title": "{name} ({host})", "step": { "device_name": { "data": { "name": "Nazwa" } }, + "discovery_confirm": { + "description": "Czy chcesz skonfigurowa\u0107 {name} ({host})?" + }, "user": { "data": { "ip_address": "Adres IP" diff --git a/homeassistant/components/lookin/translations/sl.json b/homeassistant/components/lookin/translations/sl.json new file mode 100644 index 00000000000..8bcb70a23d3 --- /dev/null +++ b/homeassistant/components/lookin/translations/sl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana", + "cannot_connect": "Povezava ni uspela", + "no_devices_found": "V omre\u017eju ni mogo\u010de najti nobene naprave" + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "no_devices_found": "V omre\u017eju ni mogo\u010de najti nobene naprave", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "ip_address": "IP naslov" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lookin/translations/tr.json b/homeassistant/components/lookin/translations/tr.json new file mode 100644 index 00000000000..b1751d5d413 --- /dev/null +++ b/homeassistant/components/lookin/translations/tr.json @@ -0,0 +1,31 @@ +{ + "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" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "{name} ({host})", + "step": { + "device_name": { + "data": { + "name": "Ad" + } + }, + "discovery_confirm": { + "description": "{name} ( {host} ) kurulumu yapmak istiyor musunuz?" + }, + "user": { + "data": { + "ip_address": "IP Adresi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/loopenergy/__init__.py b/homeassistant/components/loopenergy/__init__.py deleted file mode 100644 index 4e963f2828a..00000000000 --- a/homeassistant/components/loopenergy/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The loopenergy component.""" diff --git a/homeassistant/components/loopenergy/manifest.json b/homeassistant/components/loopenergy/manifest.json deleted file mode 100644 index 01a18dc01db..00000000000 --- a/homeassistant/components/loopenergy/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "loopenergy", - "name": "Loop Energy", - "documentation": "https://www.home-assistant.io/integrations/loopenergy", - "requirements": ["pyloopenergy==0.2.1"], - "codeowners": ["@pavoni"], - "iot_class": "cloud_push" -} diff --git a/homeassistant/components/loopenergy/sensor.py b/homeassistant/components/loopenergy/sensor.py deleted file mode 100644 index 05d7f79ebfd..00000000000 --- a/homeassistant/components/loopenergy/sensor.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Support for Loop Energy sensors.""" -import logging - -import pyloopenergy -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, - EVENT_HOMEASSISTANT_STOP, -) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -CONF_ELEC = "electricity" -CONF_GAS = "gas" - -CONF_ELEC_SERIAL = "electricity_serial" -CONF_ELEC_SECRET = "electricity_secret" - -CONF_GAS_SERIAL = "gas_serial" -CONF_GAS_SECRET = "gas_secret" -CONF_GAS_CALORIFIC = "gas_calorific" - -CONF_GAS_TYPE = "gas_type" - -DEFAULT_CALORIFIC = 39.11 -DEFAULT_UNIT = "kW" - -ELEC_SCHEMA = vol.Schema( - { - vol.Required(CONF_ELEC_SERIAL): cv.string, - vol.Required(CONF_ELEC_SECRET): cv.string, - } -) - -GAS_TYPE_SCHEMA = vol.In([CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL]) - -GAS_SCHEMA = vol.Schema( - { - vol.Required(CONF_GAS_SERIAL): cv.string, - vol.Required(CONF_GAS_SECRET): cv.string, - vol.Optional(CONF_GAS_TYPE, default=CONF_UNIT_SYSTEM_METRIC): GAS_TYPE_SCHEMA, - vol.Optional(CONF_GAS_CALORIFIC, default=DEFAULT_CALORIFIC): vol.Coerce(float), - } -) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_ELEC): ELEC_SCHEMA, vol.Optional(CONF_GAS): GAS_SCHEMA} -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Loop Energy sensors.""" - elec_config = config.get(CONF_ELEC) - gas_config = config.get(CONF_GAS, {}) - - controller = pyloopenergy.LoopEnergy( - elec_config.get(CONF_ELEC_SERIAL), - elec_config.get(CONF_ELEC_SECRET), - gas_config.get(CONF_GAS_SERIAL), - gas_config.get(CONF_GAS_SECRET), - gas_config.get(CONF_GAS_TYPE), - gas_config.get(CONF_GAS_CALORIFIC), - ) - - def stop_loopenergy(event): - """Shutdown loopenergy thread on exit.""" - _LOGGER.info("Shutting down loopenergy") - controller.terminate() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_loopenergy) - - sensors = [LoopEnergyElec(controller)] - - if gas_config.get(CONF_GAS_SERIAL): - sensors.append(LoopEnergyGas(controller)) - - add_entities(sensors) - - -class LoopEnergySensor(SensorEntity): - """Implementation of an Loop Energy base sensor.""" - - def __init__(self, controller): - """Initialize the sensor.""" - self._state = None - self._unit_of_measurement = DEFAULT_UNIT - self._controller = controller - self._name = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - def _callback(self): - self.schedule_update_ha_state(True) - - -class LoopEnergyElec(LoopEnergySensor): - """Implementation of an Loop Energy Electricity sensor.""" - - def __init__(self, controller): - """Initialize the sensor.""" - super().__init__(controller) - self._name = "Power Usage" - - async def async_added_to_hass(self): - """Subscribe to updates.""" - self._controller.subscribe_elecricity(self._callback) - - def update(self): - """Get the cached Loop energy reading.""" - self._state = round(self._controller.electricity_useage, 2) - - -class LoopEnergyGas(LoopEnergySensor): - """Implementation of an Loop Energy Gas sensor.""" - - def __init__(self, controller): - """Initialize the sensor.""" - super().__init__(controller) - self._name = "Gas Usage" - - async def async_added_to_hass(self): - """Subscribe to updates.""" - self._controller.subscribe_gas(self._callback) - - def update(self): - """Get the cached Loop gas reading.""" - self._state = round(self._controller.gas_useage, 2) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index d8fe591a0ba..6f5de83fd30 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -140,6 +140,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DOMAIN] = { # We store a dictionary mapping url_path: config. None is the default. + "mode": mode, "dashboards": {None: default_config}, "resources": resource_collection, "yaml_dashboards": config[DOMAIN].get(CONF_DASHBOARDS, {}), diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index 3c9fb03d863..c02a65cc425 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -235,9 +235,7 @@ class DashboardsCollection(collection.StorageCollection): async def _async_load_data(self) -> dict | None: """Load the data.""" - data = await self.store.async_load() - - if data is None: + if (data := await self.store.async_load()) is None: return cast(Optional[dict], data) updated = False diff --git a/homeassistant/components/lovelace/resources.py b/homeassistant/components/lovelace/resources.py index 2a098361962..22297c54d6c 100644 --- a/homeassistant/components/lovelace/resources.py +++ b/homeassistant/components/lovelace/resources.py @@ -70,9 +70,7 @@ class ResourceStorageCollection(collection.StorageCollection): async def _async_load_data(self) -> dict | None: """Load the data.""" - data = await self.store.async_load() - - if data is not None: + if (data := await self.store.async_load()) is not None: return cast(Optional[dict], data) # Import it from config. diff --git a/homeassistant/components/lovelace/system_health.py b/homeassistant/components/lovelace/system_health.py index 29b53251f21..96ae2f47540 100644 --- a/homeassistant/components/lovelace/system_health.py +++ b/homeassistant/components/lovelace/system_health.py @@ -38,7 +38,9 @@ async def system_health_info(hass): else: health_info[key] = dashboard[key] - if MODE_STORAGE in modes: + if hass.data[DOMAIN][CONF_MODE] == MODE_YAML: + health_info[CONF_MODE] = MODE_YAML + elif MODE_STORAGE in modes: health_info[CONF_MODE] = MODE_STORAGE elif MODE_YAML in modes: health_info[CONF_MODE] = MODE_YAML diff --git a/homeassistant/components/lovelace/translations/ja.json b/homeassistant/components/lovelace/translations/ja.json new file mode 100644 index 00000000000..85b5e71cebc --- /dev/null +++ b/homeassistant/components/lovelace/translations/ja.json @@ -0,0 +1,10 @@ +{ + "system_health": { + "info": { + "dashboards": "\u30c0\u30c3\u30b7\u30e5 \u30dc\u30fc\u30c9", + "mode": "\u30e2\u30fc\u30c9", + "resources": "\u30ea\u30bd\u30fc\u30b9", + "views": "\u30d3\u30e5\u30fc" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/luci/manifest.json b/homeassistant/components/luci/manifest.json index 6feac638637..705bb7ecb4b 100644 --- a/homeassistant/components/luci/manifest.json +++ b/homeassistant/components/luci/manifest.json @@ -2,7 +2,7 @@ "domain": "luci", "name": "OpenWRT (luci)", "documentation": "https://www.home-assistant.io/integrations/luci", - "requirements": ["openwrt-luci-rpc==1.1.8"], + "requirements": ["openwrt-luci-rpc==1.1.11"], "codeowners": ["@mzdrale"], "iot_class": "local_polling" } diff --git a/homeassistant/components/luftdaten/__init__.py b/homeassistant/components/luftdaten/__init__.py index ea09c9208ee..f8a67fff2f3 100644 --- a/homeassistant/components/luftdaten/__init__.py +++ b/homeassistant/components/luftdaten/__init__.py @@ -19,12 +19,11 @@ from homeassistant.const import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, - PRESSURE_HPA, + PRESSURE_PA, TEMP_CELSIUS, ) from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady -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.event import async_track_time_interval @@ -68,14 +67,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key=SENSOR_PRESSURE, name="Pressure", icon="mdi:arrow-down-bold", - native_unit_of_measurement=PRESSURE_HPA, + native_unit_of_measurement=PRESSURE_PA, device_class=DEVICE_CLASS_PRESSURE, ), SensorEntityDescription( key=SENSOR_PRESSURE_AT_SEALEVEL, name="Pressure at sealevel", icon="mdi:download", - native_unit_of_measurement=PRESSURE_HPA, + native_unit_of_measurement=PRESSURE_PA, device_class=DEVICE_CLASS_PRESSURE, ), SensorEntityDescription( @@ -175,11 +174,9 @@ async def async_setup_entry(hass, config_entry): hass.async_create_task(hass.config_entries.async_remove(config_entry.entry_id)) return False - session = async_get_clientsession(hass) - try: luftdaten = LuftDatenData( - Luftdaten(config_entry.data[CONF_SENSOR_ID], hass.loop, session), + Luftdaten(config_entry.data[CONF_SENSOR_ID]), config_entry.data.get(CONF_SENSORS, {}).get( CONF_MONITORED_CONDITIONS, SENSOR_KEYS ), diff --git a/homeassistant/components/luftdaten/config_flow.py b/homeassistant/components/luftdaten/config_flow.py index f13fcc831dc..56dee86e9fb 100644 --- a/homeassistant/components/luftdaten/config_flow.py +++ b/homeassistant/components/luftdaten/config_flow.py @@ -13,7 +13,6 @@ from homeassistant.const import ( CONF_SHOW_ON_MAP, ) from homeassistant.core import callback -from homeassistant.helpers import aiohttp_client import homeassistant.helpers.config_validation as cv from .const import CONF_SENSOR_ID, DEFAULT_SCAN_INTERVAL, DOMAIN @@ -69,8 +68,7 @@ class LuftDatenFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if sensor_id in configured_sensors(self.hass): return self._show_form({CONF_SENSOR_ID: "already_configured"}) - session = aiohttp_client.async_get_clientsession(self.hass) - luftdaten = Luftdaten(user_input[CONF_SENSOR_ID], self.hass.loop, session) + luftdaten = Luftdaten(user_input[CONF_SENSOR_ID]) try: await luftdaten.get_data() valid = await luftdaten.validate_sensor() diff --git a/homeassistant/components/luftdaten/manifest.json b/homeassistant/components/luftdaten/manifest.json index f296093b556..fd355bd8d3c 100644 --- a/homeassistant/components/luftdaten/manifest.json +++ b/homeassistant/components/luftdaten/manifest.json @@ -3,7 +3,7 @@ "name": "Luftdaten", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/luftdaten", - "requirements": ["luftdaten==0.6.5"], + "requirements": ["luftdaten==0.7.1"], "codeowners": ["@fabaff"], "quality_scale": "gold", "iot_class": "cloud_polling" diff --git a/homeassistant/components/luftdaten/translations/ja.json b/homeassistant/components/luftdaten/translations/ja.json new file mode 100644 index 00000000000..15dc417c156 --- /dev/null +++ b/homeassistant/components/luftdaten/translations/ja.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_sensor": "\u30bb\u30f3\u30b5\u30fc\u304c\u5229\u7528\u3067\u304d\u306a\u3044\u304b\u3001\u7121\u52b9\u3067\u3059" + }, + "step": { + "user": { + "data": { + "show_on_map": "\u5730\u56f3\u306b\u8868\u793a", + "station_id": "Luftdaten\u30bb\u30f3\u30b5\u30fcID" + }, + "title": "Luftdaten\u306e\u5b9a\u7fa9" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/translations/tr.json b/homeassistant/components/luftdaten/translations/tr.json index 04565de3d28..decde0caff5 100644 --- a/homeassistant/components/luftdaten/translations/tr.json +++ b/homeassistant/components/luftdaten/translations/tr.json @@ -2,7 +2,17 @@ "config": { "error": { "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_sensor": "Sens\u00f6r mevcut de\u011fil veya ge\u00e7ersiz" + }, + "step": { + "user": { + "data": { + "show_on_map": "Haritada g\u00f6ster", + "station_id": "Luftdaten Sens\u00f6r Kimli\u011fi" + }, + "title": "Luftdaten'i tan\u0131mlay\u0131n" + } } } } \ No newline at end of file diff --git a/homeassistant/components/lupusec/__init__.py b/homeassistant/components/lupusec/__init__.py index 3ae07bd8105..734c7affe90 100644 --- a/homeassistant/components/lupusec/__init__.py +++ b/homeassistant/components/lupusec/__init__.py @@ -1,4 +1,5 @@ """Support for Lupusec Home Security system.""" +# pylint: disable=import-error import logging import lupupy diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py index 963c82da5fa..9668b06b0ef 100644 --- a/homeassistant/components/lupusec/binary_sensor.py +++ b/homeassistant/components/lupusec/binary_sensor.py @@ -1,4 +1,5 @@ """Support for Lupusec Security System binary sensors.""" +# pylint: disable=import-error from datetime import timedelta import lupupy.constants as CONST diff --git a/homeassistant/components/lupusec/manifest.json b/homeassistant/components/lupusec/manifest.json index 6541925a5e4..ce200fe196a 100644 --- a/homeassistant/components/lupusec/manifest.json +++ b/homeassistant/components/lupusec/manifest.json @@ -1,4 +1,5 @@ { + "disabled": "Library has incompatible requirements.", "domain": "lupusec", "name": "Lupus Electronics LUPUSEC", "documentation": "https://www.home-assistant.io/integrations/lupusec", diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index f35322eb773..5321d1b4f25 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -1,4 +1,5 @@ """Support for Lupusec Security System switches.""" +# pylint: disable=import-error from datetime import timedelta import lupupy.constants as CONST diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 786e21f2d0b..e1a93385d31 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -123,7 +123,7 @@ async def async_setup_entry(hass, config_entry): devices = bridge.get_devices() bridge_device = devices[BRIDGE_DEVICE_ID] - await _async_register_bridge_device(hass, config_entry.entry_id, bridge_device) + _async_register_bridge_device(hass, config_entry.entry_id, bridge_device) # Store this bridge (keyed by entry_id) so it can be retrieved by the # platforms we're setting up. hass.data[DOMAIN][config_entry.entry_id] = { @@ -164,7 +164,7 @@ async def async_setup_lip(hass, config_entry, lip_devices): _LOGGER.debug("Connected to Lutron Caseta bridge via LIP at %s:23", host) button_devices_by_lip_id = _async_merge_lip_leap_data(lip_devices, bridge) - button_devices_by_dr_id = await _async_register_button_devices( + button_devices_by_dr_id = _async_register_button_devices( hass, config_entry_id, bridge_device, button_devices_by_lip_id ) _async_subscribe_pico_remote_events(hass, lip, button_devices_by_lip_id) @@ -200,9 +200,10 @@ def _async_merge_lip_leap_data(lip_devices, bridge): return button_devices_by_id -async def _async_register_bridge_device(hass, config_entry_id, bridge_device): +@callback +def _async_register_bridge_device(hass, config_entry_id, bridge_device): """Register the bridge device in the device registry.""" - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( name=bridge_device["name"], manufacturer=MANUFACTURER, @@ -212,11 +213,12 @@ async def _async_register_bridge_device(hass, config_entry_id, bridge_device): ) -async def _async_register_button_devices( +@callback +def _async_register_button_devices( hass, config_entry_id, bridge_device, button_devices_by_id ): """Register button devices (Pico Remotes) in the device registry.""" - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) button_devices_by_dr_id = {} for device in button_devices_by_id.values(): @@ -338,6 +340,7 @@ class LutronCasetaDevice(Entity): name=self.name, suggested_area=self._device["name"].split("_")[0], via_device=(DOMAIN, self._bridge_device["serial"]), + configuration_url="https://device-login.lutron.com", ) @property diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index 9d028e97c87..b198d5ddbee 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -10,8 +10,10 @@ from pylutron_caseta.smartbridge import Smartbridge import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import ( ABORT_REASON_CANNOT_CONNECT, @@ -61,16 +63,18 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA_USER) - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle a flow initialized by zeroconf discovery.""" - hostname = discovery_info["hostname"] - if hostname is None or not hostname.startswith("lutron-"): + hostname = discovery_info.hostname + if hostname is None or not hostname.lower().startswith("lutron-"): return self.async_abort(reason="not_lutron_device") self.lutron_id = hostname.split("-")[1].replace(".local.", "") await self.async_set_unique_id(self.lutron_id) - host = discovery_info[CONF_HOST] + host = discovery_info.host self._abort_if_unique_id_configured({CONF_HOST: host}) self.data[CONF_HOST] = host @@ -80,7 +84,9 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } return await self.async_step_link() - async def async_step_homekit(self, discovery_info): + async def async_step_homekit( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle a flow initialized by homekit discovery.""" return await self.async_step_zeroconf(discovery_info) diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index 857ef9b56c5..ce50923f2f5 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -235,8 +235,7 @@ async def async_get_triggers( """List device triggers for lutron caseta devices.""" triggers = [] - device = get_button_device_by_dr_id(hass, device_id) - if not device: + if not (device := get_button_device_by_dr_id(hass, device_id)): raise InvalidDeviceAutomationConfig(f"Device not found: {device_id}") valid_buttons = DEVICE_TYPE_SUBTYPE_MAP.get(device["type"], []) diff --git a/homeassistant/components/lutron_caseta/translations/ca.json b/homeassistant/components/lutron_caseta/translations/ca.json index b56f0976119..de714fea726 100644 --- a/homeassistant/components/lutron_caseta/translations/ca.json +++ b/homeassistant/components/lutron_caseta/translations/ca.json @@ -48,8 +48,8 @@ "lower_3": "Baixa 3", "lower_4": "Baixa 4", "lower_all": "Baixa-ho tot", - "off": "off", - "on": "on", + "off": "OFF", + "on": "ON", "open_1": "Obre 1", "open_2": "Obre 2", "open_3": "Obre 3", diff --git a/homeassistant/components/lutron_caseta/translations/id.json b/homeassistant/components/lutron_caseta/translations/id.json index 409cea59060..7789d784d23 100644 --- a/homeassistant/components/lutron_caseta/translations/id.json +++ b/homeassistant/components/lutron_caseta/translations/id.json @@ -55,6 +55,12 @@ "open_3": "Buka 3", "open_4": "Buka 4", "open_all": "Buka semua", + "raise": "Angkat", + "raise_1": "Angkat 1", + "raise_2": "Angkat 2", + "raise_3": "Angkat 3", + "raise_4": "Angkat 4", + "raise_all": "Angkat semua", "stop": "Hentikan (favorit)", "stop_1": "Hentikan 1", "stop_2": "Hentikan 2", diff --git a/homeassistant/components/lutron_caseta/translations/ja.json b/homeassistant/components/lutron_caseta/translations/ja.json new file mode 100644 index 00000000000..f32e47e9942 --- /dev/null +++ b/homeassistant/components/lutron_caseta/translations/ja.json @@ -0,0 +1,76 @@ +{ + "config": { + "abort": { + "already_configured": "\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", + "not_lutron_device": "\u691c\u51fa\u3055\u308c\u305f\u30c7\u30d0\u30a4\u30b9\u306f\u3001Lutron\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u3042\u308a\u307e\u305b\u3093" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name} ({host})", + "step": { + "import_failed": { + "description": "configuration.yaml\u304b\u3089\u30a4\u30f3\u30dd\u30fc\u30c8\u3055\u308c\u305fbridge (host: {host})\u3001\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002", + "title": "Cas\u00e9ta bridge\u69cb\u6210\u306e\u30a4\u30f3\u30dd\u30fc\u30c8\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002" + }, + "link": { + "description": "{name} ({host}) \u3068\u30da\u30a2\u30ea\u30f3\u30b0\u3059\u308b\u306b\u306f\u3001\u3053\u306e\u30d5\u30a9\u30fc\u30e0\u3092\u9001\u4fe1(submit)\u3057\u305f\u5f8c\u3001\u30d6\u30ea\u30c3\u30b8\u306e\u80cc\u9762\u306b\u3042\u308b\u9ed2\u3044\u30dc\u30bf\u30f3\u3092\u62bc\u3057\u307e\u3059\u3002", + "title": "\u30d6\u30ea\u30c3\u30b8\u3068\u30da\u30a2" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "\u30c7\u30d0\u30a4\u30b9\u306eIP\u30a2\u30c9\u30ec\u30b9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u81ea\u52d5\u7684\u306b\u30d6\u30ea\u30c3\u30b8\u306b\u63a5\u7d9a" + } + } + }, + "device_automation": { + "trigger_subtype": { + "button_1": "1\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "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", + "close_1": "\u30af\u30ed\u30fc\u30ba1", + "close_2": "\u30af\u30ed\u30fc\u30ba2", + "close_3": "\u30af\u30ed\u30fc\u30ba3", + "close_4": "\u30af\u30ed\u30fc\u30ba4", + "close_all": "\u3059\u3079\u3066\u30af\u30ed\u30fc\u30ba", + "group_1_button_1": "\u6700\u521d\u306e\u30b0\u30eb\u30fc\u30d7\u306e\u6700\u521d\u306e\u30dc\u30bf\u30f3", + "group_1_button_2": "\u6700\u521d\u306e\u30b0\u30eb\u30fc\u30d7\u306e2\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "group_2_button_1": "2\u756a\u76ee\u306e\u30b0\u30eb\u30fc\u30d7\u306e\u6700\u521d\u306e\u30dc\u30bf\u30f3", + "group_2_button_2": "2\u756a\u76ee\u306e\u30b0\u30eb\u30fc\u30d7\u306e2\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "lower": "\u4e0b\u3052\u308b", + "lower_1": "\u4e0b1", + "lower_2": "\u4e0b2", + "lower_3": "\u4e0b3", + "lower_4": "\u4e0b4", + "lower_all": "\u3059\u3079\u3066\u4e0b\u3052\u308b", + "off": "\u30aa\u30d5", + "on": "\u30aa\u30f3", + "open_1": "\u30aa\u30fc\u30d7\u30f31", + "open_2": "\u30aa\u30fc\u30d7\u30f32", + "open_3": "\u30aa\u30fc\u30d7\u30f33", + "open_4": "\u30aa\u30fc\u30d7\u30f34", + "open_all": "\u3059\u3079\u3066\u958b\u304f", + "raise": "\u4e0a\u3052\u308b", + "raise_1": "\u4e0a1", + "raise_2": "\u4e0a2", + "raise_3": "\u4e0a3", + "raise_4": "\u4e0a4", + "raise_all": "\u3059\u3079\u3066\u4e0a\u3052\u308b", + "stop": "\u505c\u6b62(\u304a\u6c17\u306b\u5165\u308a)", + "stop_1": "\u505c\u6b62 1", + "stop_2": "\u505c\u6b62 2", + "stop_3": "\u505c\u6b62 3", + "stop_4": "\u505c\u6b62 4", + "stop_all": "\u3059\u3079\u3066\u505c\u6b62" + }, + "trigger_type": { + "press": "\"{subtype}\" \u304c\u3001\u62bc\u3055\u308c\u307e\u3057\u305f", + "release": "\"{subtype}\" \u96e2\u3059" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lutron_caseta/translations/pl.json b/homeassistant/components/lutron_caseta/translations/pl.json index b890cf3323a..bc4f9d61f39 100644 --- a/homeassistant/components/lutron_caseta/translations/pl.json +++ b/homeassistant/components/lutron_caseta/translations/pl.json @@ -15,7 +15,7 @@ "title": "Nie uda\u0142o si\u0119 zaimportowa\u0107 konfiguracji mostka Cas\u00e9ta." }, "link": { - "description": "Aby sparowa\u0107 z {name} ({host}), po przes\u0142aniu tego formularza naci\u015bnij czarny przycisk z ty\u0142u mostka.", + "description": "Aby sparowa\u0107 z {name} ({host}), po zatwierdzeniu tego formularza naci\u015bnij czarny przycisk z ty\u0142u mostka.", "title": "Sparuj z mostkiem" }, "user": { diff --git a/homeassistant/components/lutron_caseta/translations/tr.json b/homeassistant/components/lutron_caseta/translations/tr.json index 06b46972264..54c5530224c 100644 --- a/homeassistant/components/lutron_caseta/translations/tr.json +++ b/homeassistant/components/lutron_caseta/translations/tr.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, - "flow_title": "Lutron Cas\u00e9ta {name} ( {host} )", + "flow_title": "{name} ({host})", "step": { "import_failed": { "description": "Configuration.yaml'den i\u00e7e aktar\u0131lan k\u00f6pr\u00fc (ana bilgisayar: {host}) kurulamad\u0131.", diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index 8b80fa61d2b..a9d5cbdec7d 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -93,6 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_data() -> Lyric: """Fetch data from Lyric.""" + await oauth_session.async_ensure_token_valid() try: async with async_timeout.timeout(60): await lyric.get_locations() diff --git a/homeassistant/components/lyric/manifest.json b/homeassistant/components/lyric/manifest.json index fbcc9567c3a..c45d7fb38e9 100644 --- a/homeassistant/components/lyric/manifest.json +++ b/homeassistant/components/lyric/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lyric", "dependencies": ["http"], - "requirements": ["aiolyric==1.0.7"], + "requirements": ["aiolyric==1.0.8"], "codeowners": ["@timmo001"], "quality_scale": "silver", "dhcp": [ diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index 6f550813ad8..be156594524 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -47,7 +47,7 @@ LYRIC_SETPOINT_STATUS_NAMES = { class LyricSensorEntityDescription(SensorEntityDescription): """Class describing Honeywell Lyric sensor entities.""" - value: Callable[[LyricDevice], StateType] = round + value: Callable[[LyricDevice], StateType | datetime] = round def get_datetime_from_future_time(time: str) -> datetime: @@ -133,7 +133,7 @@ async def async_setup_entry( device_class=DEVICE_CLASS_TIMESTAMP, value=lambda device: get_datetime_from_future_time( device.changeableValues.nextPeriodTime - ).isoformat(), + ), ), location, device, diff --git a/homeassistant/components/lyric/translations/id.json b/homeassistant/components/lyric/translations/id.json index f1057fc7cb2..a519e2e5f9e 100644 --- a/homeassistant/components/lyric/translations/id.json +++ b/homeassistant/components/lyric/translations/id.json @@ -13,6 +13,7 @@ "title": "Pilih Metode Autentikasi" }, "reauth_confirm": { + "description": "Integrasi Lyric perlu mengautentikasi ulang akun Anda", "title": "Autentikasi Ulang Integrasi" } } diff --git a/homeassistant/components/lyric/translations/ja.json b/homeassistant/components/lyric/translations/ja.json new file mode 100644 index 00000000000..98b2a11c5ff --- /dev/null +++ b/homeassistant/components/lyric/translations/ja.json @@ -0,0 +1,21 @@ +{ + "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", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "create_entry": { + "default": "\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f" + }, + "step": { + "pick_implementation": { + "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" + }, + "reauth_confirm": { + "description": "(\u6b4c\u8a5e)Lyric\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u306f\u3001\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u518d\u8a8d\u8a3c\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lyric/translations/tr.json b/homeassistant/components/lyric/translations/tr.json index 773577271d2..b910327ed7e 100644 --- a/homeassistant/components/lyric/translations/tr.json +++ b/homeassistant/components/lyric/translations/tr.json @@ -1,15 +1,20 @@ { "config": { "abort": { - "authorize_url_timeout": "Yetki URL'si olu\u015fturulurken zaman a\u015f\u0131m\u0131 olu\u015ftu.", - "missing_configuration": "Bile\u015fen yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin." + "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.", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "create_entry": { "default": "Ba\u015far\u0131yla do\u011fruland\u0131" }, "step": { "pick_implementation": { - "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7in" + "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7" + }, + "reauth_confirm": { + "description": "Lyric entegrasyonunun hesab\u0131n\u0131z\u0131 yeniden do\u011frulamas\u0131 gerekiyor.", + "title": "Entegrasyonu Yeniden Do\u011frula" } } } diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 0c473367fe9..98dd0f45d2b 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -250,7 +250,7 @@ class MailboxMediaView(MailboxView): mailbox = self.get_mailbox(platform) with suppress(asyncio.CancelledError, asyncio.TimeoutError): - with async_timeout.timeout(10): + async with async_timeout.timeout(10): try: stream = await mailbox.async_get_media(msgid) except StreamError as err: diff --git a/homeassistant/components/mailgun/translations/ja.json b/homeassistant/components/mailgun/translations/ja.json new file mode 100644 index 00000000000..cacb7e92502 --- /dev/null +++ b/homeassistant/components/mailgun/translations/ja.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "webhook_not_internet_accessible": "Webhook\u30e1\u30c3\u30bb\u30fc\u30b8\u3092\u53d7\u4fe1\u3059\u308b\u306b\u306f\u3001Home Assistant\u306e\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306b\u3001\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u304b\u3089\u30a2\u30af\u30bb\u30b9\u3067\u304d\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "create_entry": { + "default": "Home Assistant\u306b\u30a4\u30d9\u30f3\u30c8\u3092\u9001\u4fe1\u3059\u308b\u306b\u306f\u3001[Webhooks with Mailgun]({mailgun_url})\u3092\u8a2d\u5b9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\n\n\u4ee5\u4e0b\u306e\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n\n- URL: `{webhook_url}`\n- Method(\u65b9\u5f0f): POST\n- Content Type: application/x-www-fjsorm-urlencoded\n\n\u53d7\u4fe1\u30c7\u30fc\u30bf\u3092\u51e6\u7406\u3059\u308b\u305f\u3081\u306b\u30aa\u30fc\u30c8\u30e1\u30fc\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a\u3059\u308b\u65b9\u6cd5\u306b\u3064\u3044\u3066\u306f\u3001[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8]({docs_url})\u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "user": { + "description": "Mailgun\u3092\u8a2d\u5b9a\u3057\u3066\u3082\u3088\u308d\u3057\u3044\u3067\u3059\u304b\uff1f", + "title": "Mailgun Webhook\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/translations/tr.json b/homeassistant/components/mailgun/translations/tr.json index 84adcdf8225..3918614af2e 100644 --- a/homeassistant/components/mailgun/translations/tr.json +++ b/homeassistant/components/mailgun/translations/tr.json @@ -3,6 +3,15 @@ "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr.", "webhook_not_internet_accessible": "Webhook mesajlar\u0131n\u0131 alabilmek i\u00e7in Home Assistant \u00f6rne\u011finize internetten eri\u015filebilmelidir." + }, + "create_entry": { + "default": "Etkinlikleri Home Assistant'a g\u00f6ndermek i\u00e7in [Webhooks with Mailgun]( {mailgun_url} ) kurman\u0131z gerekir. \n\n A\u015fa\u011f\u0131daki bilgileri doldurun: \n\n - URL: ` {webhook_url} `\n - Y\u00f6ntem: POST\n - \u0130\u00e7erik T\u00fcr\u00fc: uygulama/json \n\n Gelen verileri i\u015flemek i\u00e7in otomasyonlar\u0131n nas\u0131l yap\u0131land\u0131r\u0131laca\u011f\u0131 hakk\u0131nda [belgelere]( {docs_url}" + }, + "step": { + "user": { + "description": "Mailgun'u kurmak istedi\u011finizden emin misiniz?", + "title": "Mailgun Webhook'u kurun" + } } } } \ No newline at end of file diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index d8b1ed088e3..39fc032b4b7 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -427,8 +427,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): async def async_added_to_hass(self): """Run when entity about to be added to hass.""" await super().async_added_to_hass() - state = await self.async_get_last_state() - if state: + if state := await self.async_get_last_state(): if ( state.state in (STATE_ALARM_PENDING, STATE_ALARM_ARMING) and hasattr(state, "attributes") diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index a94b1013782..4eb30028e8b 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -452,6 +452,6 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): """Publish state change to MQTT.""" if (new_state := event.data.get("new_state")) is None: return - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._state_topic, new_state.state, self._qos, True ) diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py index 2b2395acfc6..cba13ac5f09 100644 --- a/homeassistant/components/maxcube/climate.py +++ b/homeassistant/components/maxcube/climate.py @@ -68,29 +68,22 @@ class MaxCubeClimate(ClimateEntity): def __init__(self, handler, device): """Initialize MAX! Cube ClimateEntity.""" room = handler.cube.room_by_id(device.room_id) - self._name = f"{room.name} {device.name}" + self._attr_name = f"{room.name} {device.name}" self._cubehandle = handler self._device = device - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS - - @property - def should_poll(self): - """Return the polling state.""" - return True - - @property - def name(self): - """Return the name of the climate device.""" - return self._name - - @property - def unique_id(self): - """Return a unique ID.""" - return self._device.serial + self._attr_supported_features = SUPPORT_FLAGS + self._attr_should_poll = True + self._attr_unique_id = self._device.serial + self._attr_temperature_unit = TEMP_CELSIUS + self._attr_hvac_modes = [HVAC_MODE_OFF, HVAC_MODE_AUTO, HVAC_MODE_HEAT] + self._attr_preset_modes = [ + PRESET_NONE, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + PRESET_AWAY, + PRESET_ON, + ] @property def min_temp(self): @@ -105,11 +98,6 @@ class MaxCubeClimate(ClimateEntity): """Return the maximum temperature.""" return self._device.max_temperature or MAX_TEMPERATURE - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - @property def current_temperature(self): """Return the current temperature.""" @@ -129,11 +117,6 @@ class MaxCubeClimate(ClimateEntity): return HVAC_MODE_HEAT - @property - def hvac_modes(self): - """Return the list of available operation modes.""" - return [HVAC_MODE_OFF, HVAC_MODE_AUTO, HVAC_MODE_HEAT] - def set_hvac_mode(self, hvac_mode: str): """Set new target hvac mode.""" if hvac_mode == HVAC_MODE_OFF: @@ -222,18 +205,6 @@ class MaxCubeClimate(ClimateEntity): return PRESET_AWAY return PRESET_NONE - @property - def preset_modes(self): - """Return available preset modes.""" - return [ - PRESET_NONE, - PRESET_BOOST, - PRESET_COMFORT, - PRESET_ECO, - PRESET_AWAY, - PRESET_ON, - ] - def set_preset_mode(self, preset_mode): """Set new operation mode.""" if preset_mode == PRESET_COMFORT: diff --git a/homeassistant/components/mazda/translations/ja.json b/homeassistant/components/mazda/translations/ja.json new file mode 100644 index 00000000000..3bf5b7f88b3 --- /dev/null +++ b/homeassistant/components/mazda/translations/ja.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "account_locked": "\u30a2\u30ab\u30a6\u30f3\u30c8\u304c\u30ed\u30c3\u30af\u3055\u308c\u307e\u3057\u305f\u3002\u5f8c\u3067\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "region": "\u30ea\u30fc\u30b8\u30e7\u30f3" + }, + "description": "MyMazda\u30e2\u30d0\u30a4\u30eb\u30a2\u30d7\u30ea\u306b\u30ed\u30b0\u30a4\u30f3\u3059\u308b\u969b\u306b\u4f7f\u7528\u3059\u308b\u30e1\u30fc\u30eb\u30a2\u30c9\u30ec\u30b9\u3068\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30de\u30c4\u30c0 \u30b3\u30cd\u30af\u30c6\u30c3\u30c9\u30b5\u30fc\u30d3\u30b9 - \u30a2\u30ab\u30a6\u30f3\u30c8\u306e\u8ffd\u52a0" + } + } + }, + "title": "\u30de\u30c4\u30c0 \u30b3\u30cd\u30af\u30c6\u30c3\u30c9\u30b5\u30fc\u30d3\u30b9" +} \ No newline at end of file diff --git a/homeassistant/components/mazda/translations/tr.json b/homeassistant/components/mazda/translations/tr.json new file mode 100644 index 00000000000..6c574d1de0b --- /dev/null +++ b/homeassistant/components/mazda/translations/tr.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "account_locked": "Hesap kilitlendi. L\u00fctfen daha sonra tekrar deneyiniz.", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "email": "E-posta", + "password": "Parola", + "region": "B\u00f6lge" + }, + "description": "L\u00fctfen MyMazda mobil uygulamas\u0131na giri\u015f yapmak i\u00e7in kulland\u0131\u011f\u0131n\u0131z e-posta adresini ve \u015fifreyi giriniz.", + "title": "Mazda Connected Services - Hesap Ekle" + } + } + }, + "title": "Mazda Connected Services" +} \ No newline at end of file diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index a223385d8e8..ded5e3e265e 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -89,9 +89,7 @@ class MediaExtractor: "Could not retrieve data for the URL: %s", self.get_media_url() ) else: - entities = self.get_entities() - - if not entities: + if not (entities := self.get_entities()): self.call_media_player_service(stream_selector, None) for entity_id in entities: diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 5f15270563a..d1d51f525e4 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -22,6 +22,7 @@ import async_timeout import voluptuous as vol from yarl import URL +from homeassistant.backports.enum import StrEnum from homeassistant.components import websocket_api from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.websocket_api.const import ( @@ -140,13 +141,24 @@ ENTITY_IMAGE_CACHE = {CACHE_IMAGES: collections.OrderedDict(), CACHE_MAXSIZE: 16 SCAN_INTERVAL = dt.timedelta(seconds=10) -DEVICE_CLASS_TV = "tv" -DEVICE_CLASS_SPEAKER = "speaker" -DEVICE_CLASS_RECEIVER = "receiver" -DEVICE_CLASSES = [DEVICE_CLASS_TV, DEVICE_CLASS_SPEAKER, DEVICE_CLASS_RECEIVER] +class MediaPlayerDeviceClass(StrEnum): + """Device class for media players.""" -DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) + TV = "tv" + SPEAKER = "speaker" + RECEIVER = "receiver" + + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(MediaPlayerDeviceClass)) + + +# DEVICE_CLASS* below are deprecated as of 2021.12 +# use the MediaPlayerDeviceClass enum instead. +DEVICE_CLASSES = [cls.value for cls in MediaPlayerDeviceClass] +DEVICE_CLASS_TV = MediaPlayerDeviceClass.TV.value +DEVICE_CLASS_SPEAKER = MediaPlayerDeviceClass.SPEAKER.value +DEVICE_CLASS_RECEIVER = MediaPlayerDeviceClass.RECEIVER.value MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = { @@ -373,6 +385,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class MediaPlayerEntityDescription(EntityDescription): """A class that describes media player entities.""" + device_class: MediaPlayerDeviceClass | str | None = None + class MediaPlayerEntity(Entity): """ABC for media player entities.""" @@ -382,6 +396,7 @@ class MediaPlayerEntity(Entity): _attr_app_id: str | None = None _attr_app_name: str | None = None + _attr_device_class: MediaPlayerDeviceClass | str | None _attr_group_members: list[str] | None = None _attr_is_volume_muted: bool | None = None _attr_media_album_artist: str | None = None @@ -413,6 +428,15 @@ class MediaPlayerEntity(Entity): _attr_volume_level: float | None = None # Implement these for your media player + @property + def device_class(self) -> MediaPlayerDeviceClass | str | None: + """Return the class of this entity.""" + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class + return None + @property def state(self) -> str | None: """State of the player.""" @@ -905,8 +929,7 @@ class MediaPlayerEntity(Entity): return state_attr for attr in ATTR_TO_PROPERTY: - value = getattr(self, attr) - if value is not None: + if (value := getattr(self, attr)) is not None: state_attr[attr] = value if self.media_image_remotely_accessible: @@ -1026,8 +1049,7 @@ class MediaPlayerImageView(HomeAssistantView): media_content_id: str | None = None, ) -> web.Response: """Start a get request.""" - player = self.component.get_entity(entity_id) - if player is None: + if (player := self.component.get_entity(entity_id)) is None: status = ( HTTPStatus.NOT_FOUND if request[KEY_AUTHENTICATED] @@ -1071,9 +1093,8 @@ async def websocket_handle_thumbnail(hass, connection, msg): Async friendly. """ component = hass.data[DOMAIN] - player = component.get_entity(msg["entity_id"]) - if player is None: + if (player := component.get_entity(msg["entity_id"])) is None: connection.send_message( websocket_api.error_message(msg["id"], ERR_NOT_FOUND, "Entity not found") ) diff --git a/homeassistant/components/media_player/device_condition.py b/homeassistant/components/media_player/device_condition.py index e392c274f33..d099eb9a8a4 100644 --- a/homeassistant/components/media_player/device_condition.py +++ b/homeassistant/components/media_player/device_condition.py @@ -60,11 +60,9 @@ async def async_get_conditions( @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" - if config_validation: - config = CONDITION_SCHEMA(config) if config[CONF_TYPE] == "is_playing": state = STATE_PLAYING elif config[CONF_TYPE] == "is_idle": diff --git a/homeassistant/components/media_player/device_trigger.py b/homeassistant/components/media_player/device_trigger.py index 9aa75ab935c..d48a657794b 100644 --- a/homeassistant/components/media_player/device_trigger.py +++ b/homeassistant/components/media_player/device_trigger.py @@ -104,7 +104,7 @@ async def async_attach_trigger( } if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] - state_config = state_trigger.TRIGGER_SCHEMA(state_config) + state_config = await state_trigger.async_validate_trigger_config(hass, state_config) return await state_trigger.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) diff --git a/homeassistant/components/media_player/translations/ca.json b/homeassistant/components/media_player/translations/ca.json index 5887685e119..e1fce334053 100644 --- a/homeassistant/components/media_player/translations/ca.json +++ b/homeassistant/components/media_player/translations/ca.json @@ -18,8 +18,8 @@ "state": { "_": { "idle": "Inactiu", - "off": "off", - "on": "on", + "off": "OFF", + "on": "ON", "paused": "Pausat/ada", "playing": "Reproduint", "standby": "En espera" diff --git a/homeassistant/components/media_player/translations/ja.json b/homeassistant/components/media_player/translations/ja.json index 459da77a6f9..b60a48d3279 100644 --- a/homeassistant/components/media_player/translations/ja.json +++ b/homeassistant/components/media_player/translations/ja.json @@ -1,11 +1,28 @@ { + "device_automation": { + "condition_type": { + "is_idle": "{entity_name} \u306f\u3001\u30a2\u30a4\u30c9\u30eb\u72b6\u614b\u3067\u3059", + "is_off": "{entity_name} \u306f\u30aa\u30d5\u3067\u3059", + "is_on": "{entity_name} \u304c\u30aa\u30f3\u3067\u3059", + "is_paused": "{entity_name} \u306f\u3001\u4e00\u6642\u505c\u6b62\u3057\u3066\u3044\u307e\u3059", + "is_playing": "{entity_name} \u304c\u518d\u751f\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "trigger_type": { + "idle": "{entity_name} \u304c\u30a2\u30a4\u30c9\u30eb\u72b6\u614b\u306b\u306a\u308a\u307e\u3059", + "paused": "{entity_name} \u306f\u3001\u4e00\u6642\u505c\u6b62\u3057\u3066\u3044\u307e\u3059", + "playing": "{entity_name} \u304c\u518d\u751f\u3092\u958b\u59cb\u3057\u307e\u3059", + "turned_off": "{entity_name} \u30aa\u30d5\u306b\u306a\u308a\u307e\u3057\u305f", + "turned_on": "{entity_name} \u30aa\u30f3\u306b\u306a\u3063\u3066\u3044\u307e\u3059" + } + }, "state": { "_": { "idle": "\u30a2\u30a4\u30c9\u30eb", "off": "\u30aa\u30d5", "on": "\u30aa\u30f3", "paused": "\u4e00\u6642\u505c\u6b62", - "playing": "\u518d\u751f\u4e2d" + "playing": "\u518d\u751f\u4e2d", + "standby": "\u30b9\u30bf\u30f3\u30d0\u30a4" } }, "title": "\u30e1\u30c7\u30a3\u30a2\u30d7\u30ec\u30fc\u30e4\u30fc" diff --git a/homeassistant/components/media_player/translations/tr.json b/homeassistant/components/media_player/translations/tr.json index f7b9be9da53..0e9eb8eab55 100644 --- a/homeassistant/components/media_player/translations/tr.json +++ b/homeassistant/components/media_player/translations/tr.json @@ -2,11 +2,17 @@ "device_automation": { "condition_type": { "is_idle": "{entity_name} bo\u015fta", - "is_off": "{entity_name} kapal\u0131" + "is_off": "{entity_name} kapal\u0131", + "is_on": "{entity_name} a\u00e7\u0131k", + "is_paused": "{entity_name} duraklat\u0131ld\u0131", + "is_playing": "{entity_name} oynat\u0131l\u0131yor" }, "trigger_type": { + "idle": "{entity_name} bo\u015fta", + "paused": "{entity_name} duraklat\u0131ld\u0131", "playing": "{entity_name} oynamaya ba\u015flar", - "turned_off": "{entity_name} kapat\u0131ld\u0131" + "turned_off": "{entity_name} kapat\u0131ld\u0131", + "turned_on": "{entity_name} a\u00e7\u0131ld\u0131" } }, "state": { diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index 32f0070176f..b48ee784c23 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -93,9 +93,7 @@ class MediaSourceItem: @classmethod def from_uri(cls, hass: HomeAssistant, uri: str) -> MediaSourceItem: """Create an item from a uri.""" - match = URI_SCHEME_REGEX.match(uri) - - if not match: + if not (match := URI_SCHEME_REGEX.match(uri)): raise ValueError("Invalid media source URI") domain = match.group("domain") diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 3ab3f603dbd..af34498aba2 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -130,8 +130,7 @@ class MelCloudDevice: def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" model = None - unit_infos = self.device.units - if unit_infos is not None: + if (unit_infos := self.device.units) is not None: model = ", ".join([x["model"] for x in unit_infos if x["model"]]) return DeviceInfo( connections={(CONNECTION_NETWORK_MAC, self.device.mac)}, @@ -146,7 +145,7 @@ async def mel_devices_setup(hass, token) -> list[MelCloudDevice]: """Query connected devices from MELCloud.""" session = hass.helpers.aiohttp_client.async_get_clientsession() try: - with timeout(10): + async with timeout(10): all_devices = await get_devices( token, session, diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 9c15f5ec242..139a4e8e44d 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -42,7 +42,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) try: - with timeout(10): + async with timeout(10): if (acquired_token := token) is None: acquired_token = await pymelcloud.login( username, diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index 5c8f7d7ca2c..f875984453d 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -3,7 +3,7 @@ "name": "MELCloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/melcloud", - "requirements": ["pymelcloud==2.5.4"], + "requirements": ["pymelcloud==2.5.5"], "codeowners": ["@vilppuvuorinen"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/melcloud/translations/ja.json b/homeassistant/components/melcloud/translations/ja.json new file mode 100644 index 00000000000..b8f2d14e5cd --- /dev/null +++ b/homeassistant/components/melcloud/translations/ja.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u3053\u306e\u30e1\u30fc\u30eb\u306b\u306f\u3059\u3067\u306b\u3001MELCloud\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3\u304c\u66f4\u65b0\u3055\u308c\u307e\u3057\u305f\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "E\u30e1\u30fc\u30eb" + }, + "description": "MELCloud\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u4f7f\u7528\u3057\u3066\u63a5\u7d9a\u3057\u307e\u3059\u3002", + "title": "MELCloud\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/translations/tr.json b/homeassistant/components/melcloud/translations/tr.json index 6bce50f3de6..dd03f27f953 100644 --- a/homeassistant/components/melcloud/translations/tr.json +++ b/homeassistant/components/melcloud/translations/tr.json @@ -13,7 +13,9 @@ "data": { "password": "Parola", "username": "E-posta" - } + }, + "description": "MELCloud hesab\u0131n\u0131z\u0131 kullanarak ba\u011flan\u0131n.", + "title": "MELCloud'a ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index dd932a75957..47573a76151 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -1,10 +1,16 @@ """The met component.""" +from __future__ import annotations + +from collections.abc import Callable from datetime import timedelta import logging from random import randrange +from types import MappingProxyType +from typing import Any import metno +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ELEVATION, CONF_LATITUDE, @@ -13,6 +19,7 @@ from homeassistant.const import ( LENGTH_FEET, LENGTH_METERS, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.distance import convert as convert_distance @@ -32,7 +39,7 @@ PLATFORMS = ["weather"] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Met as config entry.""" # Don't setup if tracking home location and latitude or longitude isn't set. # Also, filters out our onboarding default location. @@ -62,7 +69,7 @@ async def async_setup_entry(hass, config_entry): return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS @@ -77,9 +84,9 @@ async def async_unload_entry(hass, config_entry): class MetDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching Met data.""" - def __init__(self, hass, config_entry): + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize global Met data updater.""" - self._unsub_track_home = None + self._unsub_track_home: Callable | None = None self.weather = MetWeatherData( hass, config_entry.data, hass.config.units.is_metric ) @@ -89,19 +96,19 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator): super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - async def _async_update_data(self): + async def _async_update_data(self) -> MetWeatherData: """Fetch data from Met.""" try: return await self.weather.fetch_data() except Exception as err: raise UpdateFailed(f"Update failed: {err}") from err - def track_home(self): + def track_home(self) -> None: """Start tracking changes to HA home setting.""" if self._unsub_track_home: return - async def _async_update_weather_data(_event=None): + async def _async_update_weather_data(_event: str | None = None) -> None: """Update weather data.""" if self.weather.set_coordinates(): await self.async_refresh() @@ -110,7 +117,7 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator): EVENT_CORE_CONFIG_UPDATE, _async_update_weather_data ) - def untrack_home(self): + def untrack_home(self) -> None: """Stop tracking changes to HA home setting.""" if self._unsub_track_home: self._unsub_track_home() @@ -120,18 +127,20 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator): class MetWeatherData: """Keep data for Met.no weather entities.""" - def __init__(self, hass, config, is_metric): + def __init__( + self, hass: HomeAssistant, config: MappingProxyType[str, Any], is_metric: bool + ) -> None: """Initialise the weather entity data.""" self.hass = hass self._config = config self._is_metric = is_metric - self._weather_data = None - self.current_weather_data = {} + self._weather_data: metno.MetWeatherData + self.current_weather_data: dict = {} self.daily_forecast = None self.hourly_forecast = None - self._coordinates = None + self._coordinates: dict[str, str] | None = None - def set_coordinates(self): + def set_coordinates(self) -> bool: """Weather data inialization - set the coordinates.""" if self._config.get(CONF_TRACK_HOME, False): latitude = self.hass.config.latitude @@ -161,7 +170,7 @@ class MetWeatherData: ) return True - async def fetch_data(self): + async def fetch_data(self) -> MetWeatherData: """Fetch data from API - (current weather and forecast).""" await self._weather_data.fetching_data() self.current_weather_data = self._weather_data.get_current_weather() diff --git a/homeassistant/components/met/manifest.json b/homeassistant/components/met/manifest.json index 4ebbdd3b1e7..b6c3e565dc0 100644 --- a/homeassistant/components/met/manifest.json +++ b/homeassistant/components/met/manifest.json @@ -3,7 +3,7 @@ "name": "Meteorologisk institutt (Met.no)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/met", - "requirements": ["pyMetno==0.8.4"], + "requirements": ["pyMetno==0.9.0"], "codeowners": ["@danielhiversen", "@thimic"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/met/translations/ja.json b/homeassistant/components/met/translations/ja.json new file mode 100644 index 00000000000..aea2493206c --- /dev/null +++ b/homeassistant/components/met/translations/ja.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "no_home": "Home Assistant\u306e\u8a2d\u5b9a\u3067\u3001\u5bb6\u306e\u5ea7\u6a19\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093" + }, + "error": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "step": { + "user": { + "data": { + "elevation": "\u6a19\u9ad8(Elevation)", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "name": "\u540d\u524d" + }, + "description": "Meteorologisk institutt", + "title": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/met/translations/tr.json b/homeassistant/components/met/translations/tr.json index d256711728c..9de6ab715fa 100644 --- a/homeassistant/components/met/translations/tr.json +++ b/homeassistant/components/met/translations/tr.json @@ -1,14 +1,21 @@ { "config": { + "abort": { + "no_home": "Home Assistant yap\u0131land\u0131rmas\u0131nda hi\u00e7bir ev koordinat\u0131 ayarlanmad\u0131" + }, "error": { "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, "step": { "user": { "data": { + "elevation": "Y\u00fckseklik", "latitude": "Enlem", - "longitude": "Boylam" - } + "longitude": "Boylam", + "name": "Ad" + }, + "description": "Meteoroloji Enstit\u00fcs\u00fc", + "title": "Konum" } } } diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 9cec6f93279..53c372030f1 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -1,5 +1,9 @@ """Support for Met.no weather service.""" +from __future__ import annotations + import logging +from types import MappingProxyType +from typing import Any import voluptuous as vol @@ -13,27 +17,37 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, PLATFORM_SCHEMA, + Forecast, WeatherEntity, ) -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, LENGTH_INCHES, - LENGTH_KILOMETERS, - LENGTH_MILES, LENGTH_MILLIMETERS, PRESSURE_HPA, PRESSURE_INHG, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, 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.update_coordinator import CoordinatorEntity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + T, +) from homeassistant.util.distance import convert as convert_distance from homeassistant.util.pressure import convert as convert_pressure +from homeassistant.util.speed import convert as convert_speed from .const import ( ATTR_FORECAST_PRECIPITATION, @@ -67,7 +81,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Met.no weather platform.""" _LOGGER.warning("Loading Met.no via platform config is deprecated") @@ -84,7 +103,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Add a weather entity from a config_entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( @@ -110,7 +133,13 @@ def format_condition(condition: str) -> str: class MetWeather(CoordinatorEntity, WeatherEntity): """Implementation of a Met.no weather condition.""" - def __init__(self, coordinator, config, is_metric, hourly): + def __init__( + self, + coordinator: DataUpdateCoordinator[T], + config: MappingProxyType[str, Any], + is_metric: bool, + hourly: bool, + ) -> None: """Initialise the platform with a data instance and site.""" super().__init__(coordinator) self._config = config @@ -118,12 +147,12 @@ class MetWeather(CoordinatorEntity, WeatherEntity): self._hourly = hourly @property - def track_home(self): + def track_home(self) -> (Any | bool): """Return if we are tracking home.""" return self._config.get(CONF_TRACK_HOME, False) @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID.""" name_appendix = "" if self._hourly: @@ -134,7 +163,7 @@ class MetWeather(CoordinatorEntity, WeatherEntity): return f"{self._config[CONF_LATITUDE]}-{self._config[CONF_LONGITUDE]}{name_appendix}" @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" name = self._config.get(CONF_NAME) name_appendix = "" @@ -155,25 +184,25 @@ class MetWeather(CoordinatorEntity, WeatherEntity): return not self._hourly @property - def condition(self): + def condition(self) -> str | None: """Return the current condition.""" condition = self.coordinator.data.current_weather_data.get("condition") return format_condition(condition) @property - def temperature(self): + def temperature(self) -> float | None: """Return the temperature.""" return self.coordinator.data.current_weather_data.get( ATTR_MAP[ATTR_WEATHER_TEMPERATURE] ) @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement.""" return TEMP_CELSIUS @property - def pressure(self): + def pressure(self) -> float | None: """Return the pressure.""" pressure_hpa = self.coordinator.data.current_weather_data.get( ATTR_MAP[ATTR_WEATHER_PRESSURE] @@ -184,14 +213,14 @@ class MetWeather(CoordinatorEntity, WeatherEntity): return round(convert_pressure(pressure_hpa, PRESSURE_HPA, PRESSURE_INHG), 2) @property - def humidity(self): + def humidity(self) -> float | None: """Return the humidity.""" return self.coordinator.data.current_weather_data.get( ATTR_MAP[ATTR_WEATHER_HUMIDITY] ) @property - def wind_speed(self): + def wind_speed(self) -> float | None: """Return the wind speed.""" speed_km_h = self.coordinator.data.current_weather_data.get( ATTR_MAP[ATTR_WEATHER_WIND_SPEED] @@ -199,30 +228,32 @@ class MetWeather(CoordinatorEntity, WeatherEntity): if self._is_metric or speed_km_h is None: return speed_km_h - speed_mi_h = convert_distance(speed_km_h, LENGTH_KILOMETERS, LENGTH_MILES) + speed_mi_h = convert_speed( + speed_km_h, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR + ) return int(round(speed_mi_h)) @property - def wind_bearing(self): + def wind_bearing(self) -> float | str | None: """Return the wind direction.""" return self.coordinator.data.current_weather_data.get( ATTR_MAP[ATTR_WEATHER_WIND_BEARING] ) @property - def attribution(self): + def attribution(self) -> str: """Return the attribution.""" return ATTRIBUTION @property - def forecast(self): + def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" if self._hourly: met_forecast = self.coordinator.data.hourly_forecast else: met_forecast = self.coordinator.data.daily_forecast required_keys = {ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME} - ha_forecast = [] + ha_forecast: list[Forecast] = [] for met_item in met_forecast: if not set(met_item).issuperset(required_keys): continue @@ -232,26 +263,28 @@ class MetWeather(CoordinatorEntity, WeatherEntity): if met_item.get(v) is not None } if not self._is_metric and ATTR_FORECAST_PRECIPITATION in ha_item: - precip_inches = convert_distance( - ha_item[ATTR_FORECAST_PRECIPITATION], - LENGTH_MILLIMETERS, - LENGTH_INCHES, - ) + if ha_item[ATTR_FORECAST_PRECIPITATION] is not None: + precip_inches = convert_distance( + ha_item[ATTR_FORECAST_PRECIPITATION], + LENGTH_MILLIMETERS, + LENGTH_INCHES, + ) ha_item[ATTR_FORECAST_PRECIPITATION] = round(precip_inches, 2) if ha_item.get(ATTR_FORECAST_CONDITION): ha_item[ATTR_FORECAST_CONDITION] = format_condition( ha_item[ATTR_FORECAST_CONDITION] ) - ha_forecast.append(ha_item) + ha_forecast.append(ha_item) # type: ignore[arg-type] return ha_forecast @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info.""" return DeviceInfo( default_name="Forecast", - entry_type="service", - identifiers={(DOMAIN,)}, + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN,)}, # type: ignore[arg-type] manufacturer="Met.no", model="Forecast", + configuration_url="https://www.met.no/en", ) diff --git a/homeassistant/components/met_eireann/translations/ja.json b/homeassistant/components/met_eireann/translations/ja.json new file mode 100644 index 00000000000..db065c8c15b --- /dev/null +++ b/homeassistant/components/met_eireann/translations/ja.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "step": { + "user": { + "data": { + "elevation": "\u6a19\u9ad8(Elevation)", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "name": "\u540d\u524d" + }, + "description": "Met \u00c9ireann Public Weather Forecast API\u306e\u6c17\u8c61\u30c7\u30fc\u30bf\u3092\u4f7f\u7528\u3059\u308b\u5834\u6240\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/met_eireann/translations/tr.json b/homeassistant/components/met_eireann/translations/tr.json new file mode 100644 index 00000000000..689a539e885 --- /dev/null +++ b/homeassistant/components/met_eireann/translations/tr.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "step": { + "user": { + "data": { + "elevation": "Y\u00fckseklik", + "latitude": "Enlem", + "longitude": "Boylam", + "name": "Ad" + }, + "description": "Met \u00c9ireann Public Weather Forecast API'sinden gelen hava durumu verilerini kullanmak i\u00e7in konumunuzu girin", + "title": "Konum" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index 3a8eed0d7be..ae09bc5845e 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -13,18 +13,20 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_NAME, LENGTH_INCHES, - LENGTH_METERS, - LENGTH_MILES, LENGTH_MILLIMETERS, PRESSURE_HPA, PRESSURE_INHG, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, TEMP_CELSIUS, ) +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from homeassistant.util.distance import convert as convert_distance from homeassistant.util.pressure import convert as convert_pressure +from homeassistant.util.speed import convert as convert_speed from .const import ATTRIBUTION, CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP @@ -130,8 +132,9 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): if self._is_metric or speed_m_s is None: return speed_m_s - speed_mi_s = convert_distance(speed_m_s, LENGTH_METERS, LENGTH_MILES) - speed_mi_h = speed_mi_s / 3600.0 + speed_mi_h = convert_speed( + speed_m_s, SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR + ) return int(round(speed_mi_h)) @property @@ -185,8 +188,9 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): """Device info.""" return DeviceInfo( default_name="Forecast", - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN,)}, manufacturer="Met Éireann", model="Forecast", + configuration_url="https://www.met.ie", ) diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 27203aab298..2f47aee9c02 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -60,23 +60,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an Meteo-France account from a config entry.""" hass.data.setdefault(DOMAIN, {}) - latitude = entry.data.get(CONF_LATITUDE) - client = MeteoFranceClient() - # Migrate from previous config - if not latitude: - places = await hass.async_add_executor_job( - client.search_places, entry.data[CONF_CITY] - ) - hass.config_entries.async_update_entry( - entry, - title=f"{places[0]}", - data={ - CONF_LATITUDE: places[0].latitude, - CONF_LONGITUDE: places[0].longitude, - }, - ) - latitude = entry.data[CONF_LATITUDE] longitude = entry.data[CONF_LONGITUDE] diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 3cbd56bf94e..7f4e3e0a77b 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -8,6 +8,7 @@ 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 from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -96,7 +97,7 @@ class MeteoFranceSensor(CoordinatorEntity, SensorEntity): def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self.platform.config_entry.unique_id)}, manufacturer=MANUFACTURER, model=MODEL, @@ -141,11 +142,7 @@ class MeteoFranceRainSensor(MeteoFranceSensor): (cadran for cadran in self.coordinator.data.forecast if cadran["rain"] > 1), None, ) - return ( - dt_util.utc_from_timestamp(next_rain["dt"]).isoformat() - if next_rain - else None - ) + return dt_util.utc_from_timestamp(next_rain["dt"]) if next_rain else None @property def extra_state_attributes(self): diff --git a/homeassistant/components/meteo_france/translations/bg.json b/homeassistant/components/meteo_france/translations/bg.json index c8c6c11aa02..9a8d591cd77 100644 --- a/homeassistant/components/meteo_france/translations/bg.json +++ b/homeassistant/components/meteo_france/translations/bg.json @@ -1,6 +1,13 @@ { "config": { "step": { + "cities": { + "data": { + "city": "\u0413\u0440\u0430\u0434" + }, + "description": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0432\u0430\u0448\u0438\u044f \u0433\u0440\u0430\u0434 \u043e\u0442 \u0441\u043f\u0438\u0441\u044a\u043a\u0430", + "title": "M\u00e9t\u00e9o-France" + }, "user": { "data": { "city": "\u0413\u0440\u0430\u0434" diff --git a/homeassistant/components/meteo_france/translations/ja.json b/homeassistant/components/meteo_france/translations/ja.json new file mode 100644 index 00000000000..2fa1f60225e --- /dev/null +++ b/homeassistant/components/meteo_france/translations/ja.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "empty": "\u90fd\u5e02\u691c\u7d22\u306e\u7d50\u679c\u306f\u3042\u308a\u307e\u305b\u3093: \u90fd\u5e02\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "step": { + "cities": { + "data": { + "city": "\u90fd\u5e02" + }, + "description": "\u30ea\u30b9\u30c8\u304b\u3089\u3042\u306a\u305f\u306e\u90fd\u5e02\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "M\u00e9t\u00e9o-France" + }, + "user": { + "data": { + "city": "\u90fd\u5e02" + }, + "description": "\u90f5\u4fbf\u756a\u53f7(\u30d5\u30e9\u30f3\u30b9\u306e\u307f\u3001\u63a8\u5968) \u307e\u305f\u306f\u3001\u5e02\u533a\u753a\u6751\u540d\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "M\u00e9t\u00e9o-France" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "\u4e88\u6e2c\u30e2\u30fc\u30c9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/translations/tr.json b/homeassistant/components/meteo_france/translations/tr.json index 59c3886a900..4793dbb4fe7 100644 --- a/homeassistant/components/meteo_france/translations/tr.json +++ b/homeassistant/components/meteo_france/translations/tr.json @@ -8,10 +8,18 @@ "empty": "\u015eehir aramas\u0131nda sonu\u00e7 yok: l\u00fctfen \u015fehir alan\u0131n\u0131 kontrol edin" }, "step": { + "cities": { + "data": { + "city": "\u015eehir" + }, + "description": "Listeden \u015fehrinizi se\u00e7in", + "title": "M\u00e9t\u00e9o-Fransa" + }, "user": { "data": { "city": "\u015eehir" }, + "description": "Posta kodunu (yaln\u0131zca Fransa i\u00e7in, \u00f6nerilir) veya \u015fehir ad\u0131n\u0131 girin", "title": "M\u00e9t\u00e9o-Fransa" } } diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 5892944cf6a..8158d6564cf 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -15,6 +15,7 @@ from homeassistant.components.weather import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MODE, TEMP_CELSIUS from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -90,7 +91,7 @@ class MeteoFranceWeather(CoordinatorEntity, WeatherEntity): def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self.platform.config_entry.unique_id)}, manufacturer=MANUFACTURER, model=MODEL, diff --git a/homeassistant/components/meteoalarm/binary_sensor.py b/homeassistant/components/meteoalarm/binary_sensor.py index ce0fa97ecb9..079747afd3b 100644 --- a/homeassistant/components/meteoalarm/binary_sensor.py +++ b/homeassistant/components/meteoalarm/binary_sensor.py @@ -89,8 +89,7 @@ class MeteoAlertBinarySensor(BinarySensorEntity): self._attributes = {} self._state = False - alert = self._api.get_alert() - if alert: + if alert := self._api.get_alert(): expiration_date = dt_util.parse_datetime(alert["expires"]) now = dt_util.utcnow() diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index 8e4e9f8fe13..603940b4cc8 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -3,6 +3,7 @@ from homeassistant.components.sensor import SensorEntity, SensorEntityDescriptio 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.update_coordinator import ( CoordinatorEntity, @@ -43,7 +44,7 @@ class MeteoclimaticSensor(CoordinatorEntity, SensorEntity): def device_info(self): """Return the device info.""" return DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self.platform.config_entry.unique_id)}, manufacturer=MANUFACTURER, model=MODEL, diff --git a/homeassistant/components/meteoclimatic/translations/id.json b/homeassistant/components/meteoclimatic/translations/id.json index 81dddee653f..4e23dad8dc4 100644 --- a/homeassistant/components/meteoclimatic/translations/id.json +++ b/homeassistant/components/meteoclimatic/translations/id.json @@ -6,6 +6,15 @@ }, "error": { "not_found": "Tidak ada perangkat yang ditemukan di jaringan" + }, + "step": { + "user": { + "data": { + "code": "Kode stasiun" + }, + "description": "Masukkan kode stasiun Meteoclimatic (misalnya, ESCAT4300000043206B)", + "title": "Meteoclimatic" + } } } } \ No newline at end of file diff --git a/homeassistant/components/meteoclimatic/translations/ja.json b/homeassistant/components/meteoclimatic/translations/ja.json new file mode 100644 index 00000000000..0274ff70e88 --- /dev/null +++ b/homeassistant/components/meteoclimatic/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", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "not_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "step": { + "user": { + "data": { + "code": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u30b3\u30fc\u30c9" + }, + "description": "Meteoclimatic\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u30b3\u30fc\u30c9\u3092\u5165\u529b(\u4f8b: ESCAT4300000043206B)", + "title": "Meteoclimatic" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/meteoclimatic/translations/tr.json b/homeassistant/components/meteoclimatic/translations/tr.json new file mode 100644 index 00000000000..a56fe984e2e --- /dev/null +++ b/homeassistant/components/meteoclimatic/translations/tr.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "unknown": "Beklenmeyen hata" + }, + "error": { + "not_found": "A\u011fda cihaz bulunamad\u0131" + }, + "step": { + "user": { + "data": { + "code": "\u0130stasyon kodu" + }, + "description": "Meteoclimatic istasyon kodunu girin (\u00f6rne\u011fin, ESCAT4300000043206B)", + "title": "Meteoclimatic" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/meteoclimatic/weather.py b/homeassistant/components/meteoclimatic/weather.py index 36c3229777f..4faecdaa3ac 100644 --- a/homeassistant/components/meteoclimatic/weather.py +++ b/homeassistant/components/meteoclimatic/weather.py @@ -5,6 +5,7 @@ from homeassistant.components.weather import WeatherEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import TEMP_CELSIUS from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( @@ -57,7 +58,7 @@ class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): def device_info(self): """Return the device info.""" return DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self.platform.config_entry.unique_id)}, manufacturer=MANUFACTURER, model=MODEL, diff --git a/homeassistant/components/metoffice/__init__.py b/homeassistant/components/metoffice/__init__.py index 4c187a606c7..5537af31540 100644 --- a/homeassistant/components/metoffice/__init__.py +++ b/homeassistant/components/metoffice/__init__.py @@ -9,6 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( @@ -98,3 +100,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) return unload_ok + + +def get_device_info(coordinates: str, name: str) -> DeviceInfo: + """Return device registry information.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, coordinates)}, + manufacturer="Met Office", + name=f"Met Office {name}", + ) diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 4919e36bd58..4f2ed842086 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import get_device_info from .const import ( ATTRIBUTION, CONDITION_CLASSES, @@ -181,6 +182,9 @@ class MetOfficeCurrentSensor(CoordinatorEntity, SensorEntity): self.entity_description = description mode_label = MODE_3HOURLY_LABEL if use_3hourly else MODE_DAILY_LABEL + self._attr_device_info = get_device_info( + coordinates=hass_data[METOFFICE_COORDINATES], name=hass_data[METOFFICE_NAME] + ) self._attr_name = f"{hass_data[METOFFICE_NAME]} {description.name} {mode_label}" self._attr_unique_id = f"{description.name}_{hass_data[METOFFICE_COORDINATES]}" if not use_3hourly: diff --git a/homeassistant/components/metoffice/translations/bg.json b/homeassistant/components/metoffice/translations/bg.json new file mode 100644 index 00000000000..a45f3015f93 --- /dev/null +++ b/homeassistant/components/metoffice/translations/bg.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u043b\u0443\u0433\u0430\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430" + }, + "error": { + "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": { + "api_key": "API \u043a\u043b\u044e\u0447", + "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/metoffice/translations/ja.json b/homeassistant/components/metoffice/translations/ja.json new file mode 100644 index 00000000000..a64d35406de --- /dev/null +++ b/homeassistant/components/metoffice/translations/ja.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6" + }, + "description": "\u7def\u5ea6\u3068\u7d4c\u5ea6\u306f\u3001\u6700\u3082\u8fd1\u3044\u30a6\u30a7\u30b6\u30fc\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u3092\u898b\u3064\u3051\u308b\u305f\u3081\u306b\u4f7f\u7528\u3055\u308c\u307e\u3059\u3002", + "title": "\u82f1\u56fd\u6c17\u8c61\u5e81(UK Met Office)\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/metoffice/translations/tr.json b/homeassistant/components/metoffice/translations/tr.json index 55064a139ef..0e1891ab77b 100644 --- a/homeassistant/components/metoffice/translations/tr.json +++ b/homeassistant/components/metoffice/translations/tr.json @@ -14,7 +14,8 @@ "latitude": "Enlem", "longitude": "Boylam" }, - "description": "Enlem ve boylam, en yak\u0131n hava istasyonunu bulmak i\u00e7in kullan\u0131lacakt\u0131r." + "description": "Enlem ve boylam, en yak\u0131n hava istasyonunu bulmak i\u00e7in kullan\u0131lacakt\u0131r.", + "title": "\u0130ngiltere Met Office'e ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py index b02539f0e31..79363db3667 100644 --- a/homeassistant/components/metoffice/weather.py +++ b/homeassistant/components/metoffice/weather.py @@ -1,18 +1,19 @@ """Support for UK Met Office weather service.""" from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) -from homeassistant.const import LENGTH_KILOMETERS, TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import get_device_info from .const import ( ATTRIBUTION, CONDITION_CLASSES, @@ -51,7 +52,7 @@ def _build_forecast_data(timestep): if timestep.weather: data[ATTR_FORECAST_CONDITION] = _get_weather_condition(timestep.weather.value) if timestep.precipitation: - data[ATTR_FORECAST_PRECIPITATION] = timestep.precipitation.value + data[ATTR_FORECAST_PRECIPITATION_PROBABILITY] = timestep.precipitation.value if timestep.temperature: data[ATTR_FORECAST_TEMP] = timestep.temperature.value if timestep.wind_direction: @@ -76,20 +77,13 @@ class MetOfficeWeather(CoordinatorEntity, WeatherEntity): super().__init__(coordinator) mode_label = MODE_3HOURLY_LABEL if use_3hourly else MODE_DAILY_LABEL - self._name = f"{DEFAULT_NAME} {hass_data[METOFFICE_NAME]} {mode_label}" - self._unique_id = hass_data[METOFFICE_COORDINATES] + self._attr_device_info = get_device_info( + coordinates=hass_data[METOFFICE_COORDINATES], name=hass_data[METOFFICE_NAME] + ) + self._attr_name = f"{DEFAULT_NAME} {hass_data[METOFFICE_NAME]} {mode_label}" + self._attr_unique_id = hass_data[METOFFICE_COORDINATES] if not use_3hourly: - self._unique_id = f"{self._unique_id}_{MODE_DAILY}" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return the unique of the sensor.""" - return self._unique_id + self._attr_unique_id = f"{self._attr_unique_id}_{MODE_DAILY}" @property def condition(self): @@ -123,11 +117,6 @@ class MetOfficeWeather(CoordinatorEntity, WeatherEntity): _visibility = f"{visibility_class} - {visibility_distance}" return _visibility - @property - def visibility_unit(self): - """Return the unit of measurement.""" - return LENGTH_KILOMETERS - @property def pressure(self): """Return the mean sea-level pressure.""" diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index 9f7131d1935..d0f08427b51 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -299,7 +299,7 @@ class MicrosoftFace: payload = None try: - with async_timeout.timeout(self.timeout): + async with async_timeout.timeout(self.timeout): response = await getattr(self.websession, method)( url, data=payload, headers=headers, params=params ) diff --git a/homeassistant/components/mikrotik/manifest.json b/homeassistant/components/mikrotik/manifest.json index fdb6774f4b6..eff9d26103d 100644 --- a/homeassistant/components/mikrotik/manifest.json +++ b/homeassistant/components/mikrotik/manifest.json @@ -3,7 +3,7 @@ "name": "Mikrotik", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mikrotik", - "requirements": ["librouteros==3.0.0"], + "requirements": ["librouteros==3.2.0"], "codeowners": ["@engrbm87"], "iot_class": "local_polling" } diff --git a/homeassistant/components/mikrotik/translations/bg.json b/homeassistant/components/mikrotik/translations/bg.json index 9d19b786d10..d81e97f2d68 100644 --- a/homeassistant/components/mikrotik/translations/bg.json +++ b/homeassistant/components/mikrotik/translations/bg.json @@ -1,5 +1,12 @@ { "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", + "name_exists": "\u0418\u043c\u0435\u0442\u043e \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/mikrotik/translations/ja.json b/homeassistant/components/mikrotik/translations/ja.json new file mode 100644 index 00000000000..93cde1c8391 --- /dev/null +++ b/homeassistant/components/mikrotik/translations/ja.json @@ -0,0 +1,36 @@ +{ + "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "name_exists": "\u540d\u524d\u304c\u5b58\u5728\u3057\u307e\u3059" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d", + "verify_ssl": "SSL\u3092\u4f7f\u7528" + }, + "title": "Mikrotik\u30eb\u30fc\u30bf\u30fc\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "ARP ping\u3092\u6709\u52b9\u306b\u3059\u308b", + "detection_time": "\u30db\u30fc\u30e0\u30a4\u30f3\u30bf\u30fc\u30d0\u30eb\u3092\u691c\u8a0e", + "force_dhcp": "DHCP\u3092\u4f7f\u7528\u3057\u305f\u5f37\u5236\u30b9\u30ad\u30e3\u30f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/translations/tr.json b/homeassistant/components/mikrotik/translations/tr.json index cffcc65151c..f1f825290b2 100644 --- a/homeassistant/components/mikrotik/translations/tr.json +++ b/homeassistant/components/mikrotik/translations/tr.json @@ -5,15 +5,30 @@ }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", - "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "name_exists": "Bu ad zaten var" }, "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", + "name": "Ad", "password": "Parola", "port": "Port", - "username": "Kullan\u0131c\u0131 Ad\u0131" + "username": "Kullan\u0131c\u0131 Ad\u0131", + "verify_ssl": "SSL kullan" + }, + "title": "Mikrotik Router'\u0131 kurun" + } + } + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "ARP pingini etkinle\u015ftir", + "detection_time": "Ev aral\u0131\u011f\u0131n\u0131 g\u00f6z \u00f6n\u00fcnde bulundurun", + "force_dhcp": "DHCP kullanarak taramay\u0131 zorla" } } } diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index 73480563c29..c087fe0d853 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -1,16 +1,19 @@ """The mill component.""" +from __future__ import annotations + from datetime import timedelta import logging from mill import Mill +from mill_local import Mill as MillLocal -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME 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 DOMAIN +from .const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL _LOGGER = logging.getLogger(__name__) @@ -23,8 +26,9 @@ class MillDataUpdateCoordinator(DataUpdateCoordinator): def __init__( self, hass: HomeAssistant, + update_interval: timedelta | None = None, *, - mill_data_connection: Mill, + mill_data_connection: Mill | MillLocal, ) -> None: """Initialize global Mill data updater.""" self.mill_data_connection = mill_data_connection @@ -34,26 +38,42 @@ class MillDataUpdateCoordinator(DataUpdateCoordinator): _LOGGER, name=DOMAIN, update_method=mill_data_connection.fetch_heater_and_sensor_data, - update_interval=timedelta(seconds=30), + update_interval=update_interval, ) async def async_setup_entry(hass, entry): """Set up the Mill heater.""" - mill_data_connection = Mill( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - websession=async_get_clientsession(hass), - ) + hass.data.setdefault(DOMAIN, {LOCAL: {}, CLOUD: {}}) + + if entry.data.get(CONNECTION_TYPE) == LOCAL: + mill_data_connection = MillLocal( + entry.data[CONF_IP_ADDRESS], + websession=async_get_clientsession(hass), + ) + update_interval = timedelta(seconds=15) + key = entry.data[CONF_IP_ADDRESS] + conn_type = LOCAL + else: + mill_data_connection = Mill( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + websession=async_get_clientsession(hass), + ) + update_interval = timedelta(seconds=30) + key = entry.data[CONF_USERNAME] + conn_type = CLOUD + if not await mill_data_connection.connect(): raise ConfigEntryNotReady - - hass.data[DOMAIN] = MillDataUpdateCoordinator( + data_coordinator = MillDataUpdateCoordinator( hass, mill_data_connection=mill_data_connection, + update_interval=update_interval, ) - await hass.data[DOMAIN].async_config_entry_first_refresh() + hass.data[DOMAIN][conn_type][key] = data_coordinator + await data_coordinator.async_config_entry_first_refresh() hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 3cc8d58abda..91ba10618f0 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -12,7 +12,13 @@ from homeassistant.components.climate.const import ( SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, TEMP_CELSIUS +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_IP_ADDRESS, + CONF_USERNAME, + PRECISION_WHOLE, + TEMP_CELSIUS, +) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import DeviceInfo @@ -23,15 +29,16 @@ from .const import ( ATTR_COMFORT_TEMP, ATTR_ROOM_NAME, ATTR_SLEEP_TEMP, + CLOUD, + CONNECTION_TYPE, DOMAIN, + LOCAL, MANUFACTURER, MAX_TEMP, MIN_TEMP, SERVICE_SET_ROOM_TEMP, ) -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE - SET_ROOM_TEMP_SCHEMA = vol.Schema( { vol.Required(ATTR_ROOM_NAME): cv.string, @@ -44,8 +51,12 @@ SET_ROOM_TEMP_SCHEMA = vol.Schema( async def async_setup_entry(hass, entry, async_add_entities): """Set up the Mill climate.""" + if entry.data.get(CONNECTION_TYPE) == LOCAL: + mill_data_coordinator = hass.data[DOMAIN][LOCAL][entry.data[CONF_IP_ADDRESS]] + async_add_entities([LocalMillHeater(mill_data_coordinator)]) + return - mill_data_coordinator = hass.data[DOMAIN] + mill_data_coordinator = hass.data[DOMAIN][CLOUD][entry.data[CONF_USERNAME]] entities = [ MillHeater(mill_data_coordinator, mill_device) @@ -75,7 +86,6 @@ class MillHeater(CoordinatorEntity, ClimateEntity): _attr_fan_modes = [FAN_ON, HVAC_MODE_OFF] _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP - _attr_supported_features = SUPPORT_FLAGS _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = TEMP_CELSIUS @@ -92,13 +102,21 @@ class MillHeater(CoordinatorEntity, ClimateEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, heater.device_id)}, manufacturer=MANUFACTURER, - model=f"generation {1 if heater.is_gen1 else 2}", + model=f"Generation {heater.generation}", name=self.name, ) - if heater.is_gen1: + if heater.is_gen1 or heater.is_gen3: self._attr_hvac_modes = [HVAC_MODE_HEAT] else: self._attr_hvac_modes = [HVAC_MODE_HEAT, HVAC_MODE_OFF] + + if heater.generation < 3: + self._attr_supported_features = ( + SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE + ) + else: + self._attr_supported_features = SUPPORT_TARGET_TEMPERATURE + self._update_attr(heater) async def async_set_temperature(self, **kwargs): @@ -151,7 +169,7 @@ class MillHeater(CoordinatorEntity, ClimateEntity): "open_window": heater.open_window, "heating": heater.is_heating, "controlled_by_tibber": heater.tibber_control, - "heater_generation": 1 if heater.is_gen1 else 2, + "heater_generation": heater.generation, } if heater.room: self._attr_extra_state_attributes["room"] = heater.room.name @@ -169,3 +187,47 @@ class MillHeater(CoordinatorEntity, ClimateEntity): self._attr_hvac_mode = HVAC_MODE_HEAT else: self._attr_hvac_mode = HVAC_MODE_OFF + + +class LocalMillHeater(CoordinatorEntity, ClimateEntity): + """Representation of a Mill Thermostat device.""" + + _attr_hvac_mode = HVAC_MODE_HEAT + _attr_hvac_modes = [HVAC_MODE_HEAT] + _attr_max_temp = MAX_TEMP + _attr_min_temp = MIN_TEMP + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE + _attr_target_temperature_step = PRECISION_WHOLE + _attr_temperature_unit = TEMP_CELSIUS + + def __init__(self, coordinator): + """Initialize the thermostat.""" + super().__init__(coordinator) + self._attr_name = coordinator.mill_data_connection.name + self._update_attr() + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + return + await self.coordinator.mill_data_connection.set_target_temperature( + int(temperature) + ) + await self.coordinator.async_request_refresh() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attr() + self.async_write_ha_state() + + @callback + def _update_attr(self) -> None: + data = self.coordinator.data + self._attr_target_temperature = data["set_temperature"] + self._attr_current_temperature = data["ambient_temperature"] + + if data["current_power"] > 0: + self._attr_hvac_action = CURRENT_HVAC_HEAT + else: + self._attr_hvac_action = CURRENT_HVAC_IDLE diff --git a/homeassistant/components/mill/config_flow.py b/homeassistant/components/mill/config_flow.py index 7970e2946f2..9f7dd5d5cdb 100644 --- a/homeassistant/components/mill/config_flow.py +++ b/homeassistant/components/mill/config_flow.py @@ -1,16 +1,13 @@ """Adds config flow for Mill integration.""" from mill import Mill +from mill_local import Mill as MillLocal import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN - -DATA_SCHEMA = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} -) +from .const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL class MillConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -20,10 +17,68 @@ class MillConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Handle the initial step.""" + data_schema = vol.Schema( + { + vol.Required(CONNECTION_TYPE, default=CLOUD): vol.In( + ( + CLOUD, + LOCAL, + ) + ) + } + ) + if user_input is None: return self.async_show_form( step_id="user", - data_schema=DATA_SCHEMA, + data_schema=data_schema, + ) + + if user_input[CONNECTION_TYPE] == LOCAL: + return await self.async_step_local() + return await self.async_step_cloud() + + async def async_step_local(self, user_input=None): + """Handle the local step.""" + data_schema = vol.Schema({vol.Required(CONF_IP_ADDRESS): str}) + if user_input is None: + return self.async_show_form( + step_id="local", + data_schema=data_schema, + ) + + mill_data_connection = MillLocal( + user_input[CONF_IP_ADDRESS], + websession=async_get_clientsession(self.hass), + ) + + await self.async_set_unique_id(mill_data_connection.device_ip) + self._abort_if_unique_id_configured() + + if not await mill_data_connection.connect(): + return self.async_show_form( + step_id="local", + data_schema=data_schema, + errors={"base": "cannot_connect"}, + ) + + return self.async_create_entry( + title=user_input[CONF_IP_ADDRESS], + data={ + CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS], + CONNECTION_TYPE: LOCAL, + }, + ) + + async def async_step_cloud(self, user_input=None): + """Handle the cloud step.""" + data_schema = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ) + if user_input is None: + return self.async_show_form( + step_id="cloud", + data_schema=data_schema, errors={}, ) @@ -39,10 +94,10 @@ class MillConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if not await mill_data_connection.connect(): - errors["cannot_connect"] = "cannot_connect" + errors["base"] = "cannot_connect" return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, + step_id="cloud", + data_schema=data_schema, errors=errors, ) @@ -53,5 +108,9 @@ class MillConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=unique_id, - data={CONF_USERNAME: username, CONF_PASSWORD: password}, + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONNECTION_TYPE: CLOUD, + }, ) diff --git a/homeassistant/components/mill/const.py b/homeassistant/components/mill/const.py index 25a57117a65..c42747920bf 100644 --- a/homeassistant/components/mill/const.py +++ b/homeassistant/components/mill/const.py @@ -5,11 +5,14 @@ ATTR_COMFORT_TEMP = "comfort_temp" ATTR_ROOM_NAME = "room_name" ATTR_SLEEP_TEMP = "sleep_temp" BATTERY = "battery" +CLOUD = "Cloud" +CONNECTION_TYPE = "connection_type" CONSUMPTION_TODAY = "day_consumption" CONSUMPTION_YEAR = "year_consumption" DOMAIN = "mill" ECO2 = "eco2" HUMIDITY = "humidity" +LOCAL = "Local" MANUFACTURER = "Mill" MAX_TEMP = 35 MIN_TEMP = 5 diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 75b6464c9c1..9cb220b06dc 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -2,8 +2,8 @@ "domain": "mill", "name": "Mill", "documentation": "https://www.home-assistant.io/integrations/mill", - "requirements": ["millheater==0.7.4"], + "requirements": ["millheater==0.9.0", "mill-local==0.1.0"], "codeowners": ["@danielhiversen"], "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "local_polling" } diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index 1b9e84eafa8..f82d3dbcc34 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -4,19 +4,15 @@ from __future__ import annotations import mill from homeassistant.components.sensor import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CO2, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, + CONF_USERNAME, ENERGY_KILO_WATT_HOUR, ENTITY_CATEGORY_DIAGNOSTIC, PERCENTAGE, @@ -28,11 +24,14 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( BATTERY, + CLOUD, + CONNECTION_TYPE, CONSUMPTION_TODAY, CONSUMPTION_YEAR, DOMAIN, ECO2, HUMIDITY, + LOCAL, MANUFACTURER, TEMPERATURE, TVOC, @@ -41,16 +40,16 @@ from .const import ( HEATER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=CONSUMPTION_YEAR, - device_class=DEVICE_CLASS_ENERGY, + device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, name="Year consumption", ), SensorEntityDescription( key=CONSUMPTION_TODAY, - device_class=DEVICE_CLASS_ENERGY, + device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, name="Day consumption", ), ) @@ -58,29 +57,29 @@ HEATER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=TEMPERATURE, - device_class=DEVICE_CLASS_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=TEMP_CELSIUS, name="Temperature", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=HUMIDITY, - device_class=DEVICE_CLASS_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, name="Humidity", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=BATTERY, - device_class=DEVICE_CLASS_BATTERY, + device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, name="Battery", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), SensorEntityDescription( key=ECO2, - device_class=DEVICE_CLASS_CO2, + device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, name="Estimated CO2", ), @@ -88,15 +87,17 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key=TVOC, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, name="TVOC", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), ) async def async_setup_entry(hass, entry, async_add_entities): """Set up the Mill sensor.""" + if entry.data.get(CONNECTION_TYPE) == LOCAL: + return - mill_data_coordinator = hass.data[DOMAIN] + mill_data_coordinator = hass.data[DOMAIN][CLOUD][entry.data[CONF_USERNAME]] entities = [ MillSensor( diff --git a/homeassistant/components/mill/strings.json b/homeassistant/components/mill/strings.json index ab09f3f59b6..5f4cec1336e 100644 --- a/homeassistant/components/mill/strings.json +++ b/homeassistant/components/mill/strings.json @@ -8,10 +8,22 @@ }, "step": { "user": { + "data": { + "connection_type": "Select connection type" + }, + "description": "Select connection type. Local requires generation 3 heaters" + }, + "cloud": { "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "local": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + }, + "description": "Local IP address of the device." } } } diff --git a/homeassistant/components/mill/translations/bg.json b/homeassistant/components/mill/translations/bg.json index a93b899406a..43611df6920 100644 --- a/homeassistant/components/mill/translations/bg.json +++ b/homeassistant/components/mill/translations/bg.json @@ -7,6 +7,18 @@ "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" }, "step": { + "cloud": { + "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" + } + }, + "local": { + "data": { + "ip_address": "IP \u0430\u0434\u0440\u0435\u0441" + }, + "description": "\u041b\u043e\u043a\u0430\u043b\u0435\u043d IP \u0430\u0434\u0440\u0435\u0441 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e." + }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", diff --git a/homeassistant/components/mill/translations/ca.json b/homeassistant/components/mill/translations/ca.json index 309e5ccc41c..ffb567fc218 100644 --- a/homeassistant/components/mill/translations/ca.json +++ b/homeassistant/components/mill/translations/ca.json @@ -7,11 +7,25 @@ "cannot_connect": "Ha fallat la connexi\u00f3" }, "step": { - "user": { + "cloud": { "data": { "password": "Contrasenya", "username": "Nom d'usuari" } + }, + "local": { + "data": { + "ip_address": "Adre\u00e7a IP" + }, + "description": "Adre\u00e7a IP local del dispositiu." + }, + "user": { + "data": { + "connection_type": "Selecciona el tipus de connexi\u00f3", + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Selecciona el tipus de connexi\u00f3. La local necessita escalfadors de generaci\u00f3 3" } } } diff --git a/homeassistant/components/mill/translations/de.json b/homeassistant/components/mill/translations/de.json index 44d9c2448e6..1688dddb321 100644 --- a/homeassistant/components/mill/translations/de.json +++ b/homeassistant/components/mill/translations/de.json @@ -7,11 +7,25 @@ "cannot_connect": "Verbindung fehlgeschlagen" }, "step": { - "user": { + "cloud": { "data": { "password": "Passwort", "username": "Benutzername" } + }, + "local": { + "data": { + "ip_address": "IP-Adresse" + }, + "description": "Lokale IP-Adresse des Ger\u00e4ts." + }, + "user": { + "data": { + "connection_type": "Verbindungstyp ausw\u00e4hlen", + "password": "Passwort", + "username": "Benutzername" + }, + "description": "W\u00e4hle die Anschlussart. Lokal erfordert Heizger\u00e4te der Generation 3" } } } diff --git a/homeassistant/components/mill/translations/en.json b/homeassistant/components/mill/translations/en.json index bb7d67f03b4..20291847893 100644 --- a/homeassistant/components/mill/translations/en.json +++ b/homeassistant/components/mill/translations/en.json @@ -7,11 +7,25 @@ "cannot_connect": "Failed to connect" }, "step": { - "user": { + "cloud": { "data": { "password": "Password", "username": "Username" } + }, + "local": { + "data": { + "ip_address": "IP Address" + }, + "description": "Local IP address of the device." + }, + "user": { + "data": { + "connection_type": "Select connection type", + "password": "Password", + "username": "Username" + }, + "description": "Select connection type. Local requires generation 3 heaters" } } } diff --git a/homeassistant/components/mill/translations/et.json b/homeassistant/components/mill/translations/et.json index c9f9d5dbd98..dad66420441 100644 --- a/homeassistant/components/mill/translations/et.json +++ b/homeassistant/components/mill/translations/et.json @@ -7,11 +7,25 @@ "cannot_connect": "\u00dchendus nurjus" }, "step": { - "user": { + "cloud": { "data": { "password": "Salas\u00f5na", "username": "Kasutajanimi" } + }, + "local": { + "data": { + "ip_address": "IP aadress" + }, + "description": "Seadme kohalik IP-aadress." + }, + "user": { + "data": { + "connection_type": "Vali \u00fchenduse t\u00fc\u00fcp", + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "Vali \u00fchenduse t\u00fc\u00fcp. Kohalik vajab 3. p\u00f5lvkonna k\u00fctteseadmeid" } } } diff --git a/homeassistant/components/mill/translations/he.json b/homeassistant/components/mill/translations/he.json index 40170220ad7..b551a319b2b 100644 --- a/homeassistant/components/mill/translations/he.json +++ b/homeassistant/components/mill/translations/he.json @@ -7,6 +7,17 @@ "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" }, "step": { + "cloud": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, + "local": { + "data": { + "ip_address": "\u05db\u05ea\u05d5\u05d1\u05ea IP" + } + }, "user": { "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", diff --git a/homeassistant/components/mill/translations/hu.json b/homeassistant/components/mill/translations/hu.json index 74a6f9abbac..8a77a822fc8 100644 --- a/homeassistant/components/mill/translations/hu.json +++ b/homeassistant/components/mill/translations/hu.json @@ -7,11 +7,25 @@ "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "step": { - "user": { + "cloud": { "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } + }, + "local": { + "data": { + "ip_address": "IP c\u00edm" + }, + "description": "Az eszk\u00f6z helyi IP-c\u00edme." + }, + "user": { + "data": { + "connection_type": "V\u00e1lassza ki a kapcsolat t\u00edpus\u00e1t", + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "V\u00e1lassza ki a kapcsolat t\u00edpus\u00e1t. A helyi kapcsolat 3. gener\u00e1ci\u00f3s f\u0171t\u0151berendez\u00e9seket ig\u00e9nyel" } } } diff --git a/homeassistant/components/mill/translations/id.json b/homeassistant/components/mill/translations/id.json index ab929d3d7c8..92670be251a 100644 --- a/homeassistant/components/mill/translations/id.json +++ b/homeassistant/components/mill/translations/id.json @@ -7,11 +7,25 @@ "cannot_connect": "Gagal terhubung" }, "step": { - "user": { + "cloud": { "data": { "password": "Kata Sandi", "username": "Nama Pengguna" } + }, + "local": { + "data": { + "ip_address": "Alamat IP" + }, + "description": "Alamat IP lokal perangkat." + }, + "user": { + "data": { + "connection_type": "Pilih jenis koneksi", + "password": "Kata Sandi", + "username": "Nama Pengguna" + }, + "description": "Pilih jenis koneksi. Lokal membutuhkan pemanas generasi 3" } } } diff --git a/homeassistant/components/mill/translations/ja.json b/homeassistant/components/mill/translations/ja.json new file mode 100644 index 00000000000..9250a503b58 --- /dev/null +++ b/homeassistant/components/mill/translations/ja.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\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": { + "cloud": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + }, + "local": { + "data": { + "ip_address": "IP\u30a2\u30c9\u30ec\u30b9" + }, + "description": "\u30c7\u30d0\u30a4\u30b9\u306e\u30ed\u30fc\u30ab\u30ebIP\u30a2\u30c9\u30ec\u30b9\u3002" + }, + "user": { + "data": { + "connection_type": "\u63a5\u7d9a\u30bf\u30a4\u30d7\u306e\u9078\u629e", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u63a5\u7d9a\u30bf\u30a4\u30d7\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u30ed\u30fc\u30ab\u30eb\u306b\u306f\u7b2c3\u4e16\u4ee3\u306e\u30d2\u30fc\u30bf\u30fc\u304c\u5fc5\u8981\u3067\u3059" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mill/translations/nl.json b/homeassistant/components/mill/translations/nl.json index fff0a8232e4..f37a5cf0758 100644 --- a/homeassistant/components/mill/translations/nl.json +++ b/homeassistant/components/mill/translations/nl.json @@ -7,11 +7,25 @@ "cannot_connect": "Kan geen verbinding maken" }, "step": { - "user": { + "cloud": { "data": { "password": "Wachtwoord", "username": "Gebruikersnaam" } + }, + "local": { + "data": { + "ip_address": "IP-adres" + }, + "description": "Lokaal IP-adres van het apparaat." + }, + "user": { + "data": { + "connection_type": "Selecteer verbindingstype", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Selecteer verbindingstype. Lokaal vereist generatie 3 kachels" } } } diff --git a/homeassistant/components/mill/translations/no.json b/homeassistant/components/mill/translations/no.json index ef481a449ae..d5306fd090d 100644 --- a/homeassistant/components/mill/translations/no.json +++ b/homeassistant/components/mill/translations/no.json @@ -7,11 +7,25 @@ "cannot_connect": "Tilkobling mislyktes" }, "step": { - "user": { + "cloud": { "data": { "password": "Passord", "username": "Brukernavn" } + }, + "local": { + "data": { + "ip_address": "IP adresse" + }, + "description": "Lokal IP-adresse til enheten." + }, + "user": { + "data": { + "connection_type": "Velg tilkoblingstype", + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Velg tilkoblingstype. Lokal krever generasjon 3 varmeovner" } } } diff --git a/homeassistant/components/mill/translations/pl.json b/homeassistant/components/mill/translations/pl.json index a9ec418ddb3..73abdedbfbc 100644 --- a/homeassistant/components/mill/translations/pl.json +++ b/homeassistant/components/mill/translations/pl.json @@ -7,11 +7,25 @@ "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" }, "step": { - "user": { + "cloud": { "data": { "password": "Has\u0142o", "username": "Nazwa u\u017cytkownika" } + }, + "local": { + "data": { + "ip_address": "Adres IP" + }, + "description": "Lokalny adres IP urz\u0105dzenia." + }, + "user": { + "data": { + "connection_type": "Wybierz typ po\u0142\u0105czenia", + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Wybierz typ po\u0142\u0105czenia. Lokalnie wymaga grzejnik\u00f3w 3 generacji" } } } diff --git a/homeassistant/components/mill/translations/ru.json b/homeassistant/components/mill/translations/ru.json index eac6c63c559..ab09ec1fdaa 100644 --- a/homeassistant/components/mill/translations/ru.json +++ b/homeassistant/components/mill/translations/ru.json @@ -7,11 +7,25 @@ "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": { + "cloud": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" } + }, + "local": { + "data": { + "ip_address": "IP-\u0430\u0434\u0440\u0435\u0441" + }, + "description": "\u041b\u043e\u043a\u0430\u043b\u044c\u043d\u044b\u0439 IP-\u0430\u0434\u0440\u0435\u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430." + }, + "user": { + "data": { + "connection_type": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0442\u0438\u043f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f", + "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\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0442\u0438\u043f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f. \u0414\u043b\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f \u043e\u043f\u0446\u0438\u0438 Local \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043e\u0431\u043e\u0433\u0440\u0435\u0432\u0430\u0442\u0435\u043b\u044c \u0442\u0440\u0435\u0442\u044c\u0435\u0433\u043e \u043f\u043e\u043a\u043e\u043b\u0435\u043d\u0438\u044f." } } } diff --git a/homeassistant/components/mill/translations/sl.json b/homeassistant/components/mill/translations/sl.json new file mode 100644 index 00000000000..4c08cf164a8 --- /dev/null +++ b/homeassistant/components/mill/translations/sl.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "cloud": { + "data": { + "password": "Geslo", + "username": "Uporabni\u0161ko ime" + } + }, + "local": { + "data": { + "ip_address": "IP naslov" + }, + "description": "Lokalni naslov IP naprave." + }, + "user": { + "data": { + "connection_type": "Izberite vrsto povezave" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mill/translations/tr.json b/homeassistant/components/mill/translations/tr.json index 0f14728873a..63ddc4ee7b1 100644 --- a/homeassistant/components/mill/translations/tr.json +++ b/homeassistant/components/mill/translations/tr.json @@ -7,11 +7,25 @@ "cannot_connect": "Ba\u011flanma hatas\u0131" }, "step": { - "user": { + "cloud": { "data": { "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" } + }, + "local": { + "data": { + "ip_address": "IP Adresi" + }, + "description": "Cihaz\u0131n yerel IP adresi." + }, + "user": { + "data": { + "connection_type": "Ba\u011flant\u0131 t\u00fcr\u00fcn\u00fc se\u00e7in", + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "Ba\u011flant\u0131 t\u00fcr\u00fcn\u00fc se\u00e7in. Yerel, 3. nesil \u0131s\u0131t\u0131c\u0131lar gerektirir" } } } diff --git a/homeassistant/components/mill/translations/zh-Hant.json b/homeassistant/components/mill/translations/zh-Hant.json index 179100726da..a77203a0495 100644 --- a/homeassistant/components/mill/translations/zh-Hant.json +++ b/homeassistant/components/mill/translations/zh-Hant.json @@ -7,11 +7,25 @@ "cannot_connect": "\u9023\u7dda\u5931\u6557" }, "step": { - "user": { + "cloud": { "data": { "password": "\u5bc6\u78bc", "username": "\u4f7f\u7528\u8005\u540d\u7a31" } + }, + "local": { + "data": { + "ip_address": "IP \u4f4d\u5740" + }, + "description": "\u88dd\u7f6e IP \u4f4d\u5740\u3002" + }, + "user": { + "data": { + "connection_type": "\u9078\u64c7\u9023\u7dda\u985e\u578b", + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u9078\u64c7\u9023\u7dda\u985e\u578b\u3002\u672c\u5730\u7aef\u5c07\u9700\u8981\u7b2c\u4e09\u4ee3\u52a0\u71b1\u5668" } } } diff --git a/homeassistant/components/minecraft_server/translations/bg.json b/homeassistant/components/minecraft_server/translations/bg.json index a051d6ca487..43e9a8a20d0 100644 --- a/homeassistant/components/minecraft_server/translations/bg.json +++ b/homeassistant/components/minecraft_server/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u0423\u0441\u043b\u0443\u0433\u0430\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/minecraft_server/translations/ja.json b/homeassistant/components/minecraft_server/translations/ja.json new file mode 100644 index 00000000000..e12d8967aab --- /dev/null +++ b/homeassistant/components/minecraft_server/translations/ja.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u30b5\u30fc\u30d0\u30fc\u3078\u306e\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u30db\u30b9\u30c8\u3068\u30dd\u30fc\u30c8\u3092\u78ba\u8a8d\u3057\u3066\u3001\u3082\u3046\u4e00\u5ea6\u3084\u308a\u76f4\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u307e\u305f\u3001\u30b5\u30fc\u30d0\u30fc\u3067Minecraft\u306e\u30d0\u30fc\u30b8\u30e7\u30f31.7\u4ee5\u4e0a\u306e\u3082\u306e\u3092\u5b9f\u884c\u3057\u3066\u3044\u308b\u3053\u3068\u3082\u3042\u308f\u305b\u3066\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "invalid_ip": "IP\u30a2\u30c9\u30ec\u30b9\u304c\u7121\u52b9\u3067\u3059(MAC\u30a2\u30c9\u30ec\u30b9\u3092\u7279\u5b9a\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f)\u3002\u4fee\u6b63\u3057\u3066\u3001\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", + "invalid_port": "\u30dd\u30fc\u30c8\u306f1024\u301c65535\u306e\u7bc4\u56f2\u5185\u306b\u3042\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\u4fee\u6b63\u3057\u3066\u3082\u3046\u4e00\u5ea6\u8a66\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d" + }, + "description": "\u30e2\u30cb\u30bf\u30ea\u30f3\u30b0\u304c\u3067\u304d\u308b\u3088\u3046\u306b\u3001Minecraft Server\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u3002", + "title": "\u3042\u306a\u305f\u306eMinecraft\u30b5\u30fc\u30d0\u30fc\u3092\u30ea\u30f3\u30af" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/translations/tr.json b/homeassistant/components/minecraft_server/translations/tr.json index 422dab32a01..f7bc8de5a61 100644 --- a/homeassistant/components/minecraft_server/translations/tr.json +++ b/homeassistant/components/minecraft_server/translations/tr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Host zaten ayarlanm\u0131\u015f." + "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, "error": { "cannot_connect": "Server ile ba\u011flant\u0131 kurulamad\u0131. L\u00fctfen host ve port ayarlar\u0131n\u0131 kontrol et ve tekrar dene. Ayr\u0131ca, serverda en az Minecraft s\u00fcr\u00fcm 1.7 \u00e7al\u0131\u015ft\u0131rd\u0131\u011f\u0131ndan emin ol.", @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "name": "Ad" }, "description": "G\u00f6zetmeye izin vermek i\u00e7in Minecraft server nesnesini ayarla.", diff --git a/homeassistant/components/minio/manifest.json b/homeassistant/components/minio/manifest.json index 45ba422c331..ba5ba4cd0a8 100644 --- a/homeassistant/components/minio/manifest.json +++ b/homeassistant/components/minio/manifest.json @@ -2,7 +2,7 @@ "domain": "minio", "name": "Minio", "documentation": "https://www.home-assistant.io/integrations/minio", - "requirements": ["minio==4.0.9"], + "requirements": ["minio==5.0.10"], "codeowners": ["@tkislan"], "iot_class": "cloud_push" } diff --git a/homeassistant/components/minio/minio_helper.py b/homeassistant/components/minio/minio_helper.py index b7fb3157c71..4f10da10998 100644 --- a/homeassistant/components/minio/minio_helper.py +++ b/homeassistant/components/minio/minio_helper.py @@ -22,8 +22,7 @@ def normalize_metadata(metadata: dict) -> dict: """Normalize object metadata by stripping the prefix.""" new_metadata = {} for meta_key, meta_value in metadata.items(): - match = _METADATA_RE.match(meta_key) - if not match: + if not (match := _METADATA_RE.match(meta_key)): continue new_metadata[match.group(1).lower()] = meta_value diff --git a/homeassistant/components/mitemp_bt/manifest.json b/homeassistant/components/mitemp_bt/manifest.json index 8c5906ae439..f0465315cef 100644 --- a/homeassistant/components/mitemp_bt/manifest.json +++ b/homeassistant/components/mitemp_bt/manifest.json @@ -2,7 +2,7 @@ "domain": "mitemp_bt", "name": "Xiaomi Mijia BLE Temperature and Humidity Sensor", "documentation": "https://www.home-assistant.io/integrations/mitemp_bt", - "requirements": ["mitemp_bt==0.0.3"], + "requirements": ["mitemp_bt==0.0.5"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index d486f78d334..85f0c21f90c 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -122,7 +122,7 @@ class MjpegCamera(Camera): websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl) try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): response = await websession.get(self._still_image_url, auth=self._auth) image = await response.read() diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 73775f23e6d..a83931bab23 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -37,8 +37,7 @@ PLATFORMS = "sensor", "binary_sensor", "device_tracker" async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the mobile app component.""" store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) - app_config = await store.async_load() - if app_config is None: + if (app_config := await store.async_load()) is None: app_config = { DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], @@ -77,7 +76,7 @@ async def async_setup_entry(hass, entry): hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id] = entry - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py index 4e2822a4c3c..edce0b8f456 100644 --- a/homeassistant/components/mobile_app/binary_sensor.py +++ b/homeassistant/components/mobile_app/binary_sensor.py @@ -28,14 +28,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): webhook_id = config_entry.data[CONF_WEBHOOK_ID] - entity_registry = await er.async_get_registry(hass) + entity_registry = er.async_get(hass) entries = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) for entry in entries: if entry.domain != ENTITY_TYPE or entry.disabled_by: continue config = { ATTR_SENSOR_ATTRIBUTES: {}, - ATTR_SENSOR_DEVICE_CLASS: entry.device_class, + ATTR_SENSOR_DEVICE_CLASS: entry.device_class or entry.original_device_class, ATTR_SENSOR_ICON: entry.original_icon, ATTR_SENSOR_NAME: entry.original_name, ATTR_SENSOR_STATE: None, diff --git a/homeassistant/components/mobile_app/config_flow.py b/homeassistant/components/mobile_app/config_flow.py index e8efbd92898..246c433672b 100644 --- a/homeassistant/components/mobile_app/config_flow.py +++ b/homeassistant/components/mobile_app/config_flow.py @@ -35,7 +35,7 @@ class MobileAppFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): user_input[ATTR_DEVICE_ID] = str(uuid.uuid4()).replace("-", "") # Register device tracker entity and add to person registering app - entity_registry = await er.async_get_registry(self.hass) + entity_registry = er.async_get(self.hass) devt_entry = entity_registry.async_get_or_create( "device_tracker", DOMAIN, diff --git a/homeassistant/components/mobile_app/device_action.py b/homeassistant/components/mobile_app/device_action.py index 193c25e482c..3ad43098225 100644 --- a/homeassistant/components/mobile_app/device_action.py +++ b/homeassistant/components/mobile_app/device_action.py @@ -45,9 +45,7 @@ async def async_call_action_from_config( "Unable to resolve webhook ID from the device ID" ) - service_name = get_notify_service(hass, webhook_id) - - if service_name is None: + if (service_name := get_notify_service(hass, webhook_id)) is None: raise InvalidDeviceAutomationConfig( "Unable to find notify service for webhook ID" ) diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py index f4b5866eacd..28f5e13c4fd 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -119,9 +119,7 @@ class MobileAppEntity(TrackerEntity, RestoreEntity): if self._data is not None: return - state = await self.async_get_last_state() - - if state is None: + if (state := await self.async_get_last_state()) is None: self._data = {} return diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index 0cdec984f55..0c26533b7cb 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -1,6 +1,12 @@ """A entity class for mobile_app.""" from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ICON, CONF_NAME, CONF_UNIQUE_ID, CONF_WEBHOOK_ID +from homeassistant.const import ( + ATTR_ICON, + CONF_NAME, + CONF_UNIQUE_ID, + CONF_WEBHOOK_ID, + STATE_UNAVAILABLE, +) from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -44,9 +50,8 @@ class MobileAppEntity(RestoreEntity): self.hass, SIGNAL_SENSOR_UPDATE, self._handle_update ) ) - state = await self.async_get_last_state() - if state is None: + if (state := await self.async_get_last_state()) is None: return self.async_restore_last_state(state) @@ -102,6 +107,11 @@ class MobileAppEntity(RestoreEntity): """Return device registry information for this entity.""" return device_info(self._registration) + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._config.get(ATTR_SENSOR_STATE) != STATE_UNAVAILABLE + @callback def _handle_update(self, data): """Handle async event updates.""" diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index bd5f1354ad3..e1dada100f9 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -149,7 +149,7 @@ class MobileAppNotificationService(BaseNotificationService): target_data["registration_info"] = reg_info try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): response = await async_get_clientsession(self._hass).post( push_url, json=target_data ) diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index 89c8d437628..0631f8f72aa 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -2,10 +2,18 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, CONF_WEBHOOK_ID +from homeassistant.const import ( + CONF_NAME, + CONF_UNIQUE_ID, + CONF_WEBHOOK_ID, + DEVICE_CLASS_DATE, + DEVICE_CLASS_TIMESTAMP, + STATE_UNKNOWN, +) from homeassistant.core import callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util import dt as dt_util from .const import ( ATTR_DEVICE_NAME, @@ -32,14 +40,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): webhook_id = config_entry.data[CONF_WEBHOOK_ID] - entity_registry = await er.async_get_registry(hass) + entity_registry = er.async_get(hass) entries = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) for entry in entries: if entry.domain != ENTITY_TYPE or entry.disabled_by: continue config = { ATTR_SENSOR_ATTRIBUTES: {}, - ATTR_SENSOR_DEVICE_CLASS: entry.device_class, + ATTR_SENSOR_DEVICE_CLASS: entry.device_class or entry.original_device_class, ATTR_SENSOR_ICON: entry.original_icon, ATTR_SENSOR_NAME: entry.original_name, ATTR_SENSOR_STATE: None, @@ -81,7 +89,22 @@ class MobileAppSensor(MobileAppEntity, SensorEntity): @property def native_value(self): """Return the state of the sensor.""" - return self._config[ATTR_SENSOR_STATE] + if (state := self._config[ATTR_SENSOR_STATE]) in (None, STATE_UNKNOWN): + return None + + if ( + self.device_class + in ( + DEVICE_CLASS_DATE, + DEVICE_CLASS_TIMESTAMP, + ) + and (timestamp := dt_util.parse_datetime(state)) is not None + ): + if self.device_class == DEVICE_CLASS_DATE: + return timestamp.date() + return timestamp + + return state @property def native_unit_of_measurement(self): diff --git a/homeassistant/components/mobile_app/translations/ja.json b/homeassistant/components/mobile_app/translations/ja.json new file mode 100644 index 00000000000..6a5de20ec8d --- /dev/null +++ b/homeassistant/components/mobile_app/translations/ja.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "install_app": "Mobile app\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u958b\u3044\u3066\u3001Home Assistant\u3068\u306e\u8a2d\u5b9a\u3057\u307e\u3059\u3002\u4e92\u63db\u6027\u306e\u3042\u308b\u30a2\u30d7\u30ea\u306e\u4e00\u89a7\u306f\u3001[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8]({apps_url}) \u3092\u3054\u89a7\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "confirm": { + "description": "\u30e2\u30d0\u30a4\u30eb\u30a2\u30d7\u30ea\u306e\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u304b\uff1f" + } + } + }, + "device_automation": { + "action_type": { + "notify": "\u901a\u77e5\u306e\u9001\u4fe1" + } + }, + "title": "\u30e2\u30d0\u30a4\u30eb\u30a2\u30d7\u30ea" +} \ No newline at end of file diff --git a/homeassistant/components/mobile_app/translations/tr.json b/homeassistant/components/mobile_app/translations/tr.json index 10d79751ec1..af35741315f 100644 --- a/homeassistant/components/mobile_app/translations/tr.json +++ b/homeassistant/components/mobile_app/translations/tr.json @@ -1,7 +1,18 @@ { + "config": { + "abort": { + "install_app": "Home Assistant ile entegrasyonu ayarlamak i\u00e7in mobil uygulamay\u0131 a\u00e7\u0131n. Uyumlu uygulamalar\u0131n listesi i\u00e7in [belgelere]( {apps_url}" + }, + "step": { + "confirm": { + "description": "Mobil Uygulama bile\u015fenini kurmak istiyor musunuz?" + } + } + }, "device_automation": { "action_type": { "notify": "Bildirim g\u00f6nder" } - } + }, + "title": "Mobil uygulama" } \ No newline at end of file diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index fd6bd81fb6c..ebba383636b 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -344,7 +344,7 @@ async def webhook_update_registration(hass, config_entry, data): """Handle an update registration webhook.""" new_registration = {**config_entry.data, **data} - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -433,7 +433,7 @@ async def webhook_register_sensor(hass, config_entry, data): device_name = config_entry.data[ATTR_DEVICE_NAME] unique_store_key = f"{config_entry.data[CONF_WEBHOOK_ID]}_{unique_id}" - entity_registry = await er.async_get_registry(hass) + entity_registry = er.async_get(hass) existing_sensor = entity_registry.async_get_entity_id( entity_type, DOMAIN, unique_store_key ) @@ -518,7 +518,7 @@ async def webhook_update_sensor_states(hass, config_entry, data): unique_store_key = f"{config_entry.data[CONF_WEBHOOK_ID]}_{unique_id}" - entity_registry = await er.async_get_registry(hass) + entity_registry = er.async_get(hass) if not entity_registry.async_get_entity_id( entity_type, DOMAIN, unique_store_key ): diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index c962f298586..682b3430807 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -254,8 +254,7 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() - state = await self.async_get_last_state() - if state: + if state := await self.async_get_last_state(): self._attr_is_on = state.state == STATE_ON async def async_turn(self, command: int) -> None: diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 171486639f4..07756b0f207 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -41,8 +41,7 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() - state = await self.async_get_last_state() - if state: + if state := await self.async_get_last_state(): self._attr_is_on = state.state == STATE_ON async def async_update(self, now: datetime | None = None) -> None: diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 152239e88c7..1a9a7a82e9c 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -100,8 +100,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() - state = await self.async_get_last_state() - if state: + if state := await self.async_get_last_state(): convert = { STATE_CLOSED: self._state_closed, STATE_CLOSING: self._state_closing, diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 6911098dd94..e4249594940 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -53,8 +53,7 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreEntity, SensorEntity): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() - state = await self.async_get_last_state() - if state: + if state := await self.async_get_last_state(): self._attr_native_value = state.state async def async_update(self, now: datetime | None = None) -> None: diff --git a/homeassistant/components/modem_callerid/config_flow.py b/homeassistant/components/modem_callerid/config_flow.py index da552b26beb..2bc857a16f4 100644 --- a/homeassistant/components/modem_callerid/config_flow.py +++ b/homeassistant/components/modem_callerid/config_flow.py @@ -1,10 +1,9 @@ """Config flow for Modem Caller ID integration.""" from __future__ import annotations -import logging from typing import Any -from phone_modem import DEFAULT_PORT, PhoneModem +from phone_modem import PhoneModem import serial.tools.list_ports from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol @@ -16,8 +15,6 @@ from homeassistant.data_entry_flow import FlowResult from .const import DEFAULT_NAME, DOMAIN, EXCEPTIONS -_LOGGER = logging.getLogger(__name__) - DATA_SCHEMA = vol.Schema({"name": str, "device": str}) @@ -33,12 +30,12 @@ class PhoneModemFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Set up flow instance.""" self._device: str | None = None - async def async_step_usb(self, discovery_info: dict[str, str]) -> FlowResult: + async def async_step_usb(self, discovery_info: usb.UsbServiceInfo) -> FlowResult: """Handle USB Discovery.""" - device = discovery_info["device"] + device = discovery_info.device dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) - unique_id = f"{discovery_info['vid']}:{discovery_info['pid']}_{discovery_info['serial_number']}_{discovery_info['manufacturer']}_{discovery_info['description']}" + unique_id = f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}" if ( await self.validate_device_errors(dev_path=dev_path, unique_id=unique_id) is None @@ -102,30 +99,6 @@ class PhoneModemFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(unused_ports)}) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - if self._async_current_entries(): - _LOGGER.warning( - "Loading Modem_callerid via platform setup is deprecated; Please remove it from your configuration" - ) - if CONF_DEVICE not in config: - config[CONF_DEVICE] = DEFAULT_PORT - ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) - for port in ports: - if port.device == config[CONF_DEVICE]: - if ( - await self.validate_device_errors( - dev_path=port.device, - unique_id=_generate_unique_id(port), - ) - is None - ): - return self.async_create_entry( - title=config.get(CONF_NAME, DEFAULT_NAME), - data={CONF_DEVICE: port.device}, - ) - return self.async_abort(reason="cannot_connect") - async def validate_device_errors( self, dev_path: str, unique_id: str ) -> dict[str, str] | None: diff --git a/homeassistant/components/modem_callerid/const.py b/homeassistant/components/modem_callerid/const.py index b05623f8d8b..0b01c3b761f 100644 --- a/homeassistant/components/modem_callerid/const.py +++ b/homeassistant/components/modem_callerid/const.py @@ -5,7 +5,6 @@ from phone_modem import exceptions from serial import SerialException DATA_KEY_API = "api" -DATA_KEY_COORDINATOR = "coordinator" DEFAULT_NAME = "Phone Modem" DOMAIN = "modem_callerid" ICON = "mdi:phone-classic" diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index a1df80f6bcb..94c7c51ee80 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -1,48 +1,15 @@ """A sensor for incoming calls using a USB modem that supports caller ID.""" from __future__ import annotations -from phone_modem import DEFAULT_PORT, PhoneModem -import voluptuous as vol +from phone_modem import PhoneModem -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_DEVICE, - CONF_NAME, - EVENT_HOMEASSISTANT_STOP, - STATE_IDLE, -) +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP, STATE_IDLE from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.helpers import entity_platform -from .const import CID, DATA_KEY_API, DEFAULT_NAME, DOMAIN, ICON, SERVICE_REJECT_CALL - -# Deprecated in Home Assistant 2021.10 -PLATFORM_SCHEMA = cv.deprecated( - vol.All( - PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_DEVICE, default=DEFAULT_PORT): cv.string, - } - ) - ) -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Modem Caller ID component.""" - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) +from .const import CID, DATA_KEY_API, DOMAIN, ICON, SERVICE_REJECT_CALL async def async_setup_entry( diff --git a/homeassistant/components/modem_callerid/translations/fr.json b/homeassistant/components/modem_callerid/translations/fr.json new file mode 100644 index 00000000000..5847c82af6a --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/fr.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion" + }, + "step": { + "user": { + "data": { + "name": "Nom", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/id.json b/homeassistant/components/modem_callerid/translations/id.json index 9e8fc6738b9..3aa455d5f4c 100644 --- a/homeassistant/components/modem_callerid/translations/id.json +++ b/homeassistant/components/modem_callerid/translations/id.json @@ -2,17 +2,23 @@ "config": { "abort": { "already_configured": "Perangkat sudah dikonfigurasi", - "already_in_progress": "Alur konfigurasi sedang berlangsung" + "already_in_progress": "Alur konfigurasi sedang berlangsung", + "no_devices_found": "Tidak ada perangkat yang tersisa yang ditemukan" }, "error": { "cannot_connect": "Gagal terhubung" }, "step": { + "usb_confirm": { + "description": "Integrasi ini adalah integrasi untuk panggilan darat menggunakan modem suara CX93001. Integrasi dapat mengambil informasi ID pemanggil dengan opsi untuk menolak panggilan masuk.", + "title": "Modem Telepon" + }, "user": { "data": { "name": "Nama", "port": "Port" }, + "description": "Integrasi ini adalah integrasi untuk panggilan darat menggunakan modem suara CX93001. Integrasi dapat mengambil informasi ID pemanggil dengan opsi untuk menolak panggilan masuk.", "title": "Modem Telepon" } } diff --git a/homeassistant/components/modem_callerid/translations/ja.json b/homeassistant/components/modem_callerid/translations/ja.json new file mode 100644 index 00000000000..ab5ab732931 --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/ja.json @@ -0,0 +1,26 @@ +{ + "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": "\u6b8b\u308a\u306e\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "usb_confirm": { + "description": "\u3053\u308c\u306f\u3001CX93001\u97f3\u58f0\u30e2\u30c7\u30e0\u3092\u4f7f\u7528\u3057\u305f\u56fa\u5b9a\u96fb\u8a71\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u3059\u3002\u767a\u4fe1\u8005\u756a\u53f7\u60c5\u5831\u3092\u53d6\u5f97\u3059\u308b\u3053\u3068\u3067\u3001\u7740\u4fe1\u3092\u62d2\u5426\u3059\u308b\u30aa\u30d7\u30b7\u30e7\u30f3\u3082\u3042\u308a\u307e\u3059\u3002", + "title": "Phone\u30e2\u30c7\u30e0" + }, + "user": { + "data": { + "name": "\u540d\u524d", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "\u3053\u308c\u306f\u3001CX93001\u97f3\u58f0\u30e2\u30c7\u30e0\u3092\u4f7f\u7528\u3057\u305f\u56fa\u5b9a\u96fb\u8a71\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u3059\u3002\u767a\u4fe1\u8005\u756a\u53f7\u60c5\u5831\u3092\u53d6\u5f97\u3059\u308b\u3053\u3068\u3067\u3001\u7740\u4fe1\u3092\u62d2\u5426\u3059\u308b\u30aa\u30d7\u30b7\u30e7\u30f3\u3082\u3042\u308a\u307e\u3059\u3002", + "title": "Phone\u30e2\u30c7\u30e0" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modem_callerid/translations/pl.json b/homeassistant/components/modem_callerid/translations/pl.json index b608a467395..5101fc574a0 100644 --- a/homeassistant/components/modem_callerid/translations/pl.json +++ b/homeassistant/components/modem_callerid/translations/pl.json @@ -10,14 +10,16 @@ }, "step": { "usb_confirm": { - "description": "Jest to integracja dla po\u0142\u0105cze\u0144 stacjonarnych przy u\u017cyciu modemu g\u0142osowego CX93001. Mo\u017ce ona pobra\u0107 informacje o identyfikatorze dzwoni\u0105cego z opcj\u0105 odrzucenia po\u0142\u0105czenia przychodz\u0105cego." + "description": "Jest to integracja dla po\u0142\u0105cze\u0144 stacjonarnych przy u\u017cyciu modemu g\u0142osowego CX93001. Mo\u017ce ona pobra\u0107 informacje o identyfikatorze dzwoni\u0105cego z opcj\u0105 odrzucenia po\u0142\u0105czenia przychodz\u0105cego.", + "title": "Modem telefoniczny" }, "user": { "data": { "name": "Nazwa", "port": "Port" }, - "description": "Jest to integracja dla po\u0142\u0105cze\u0144 stacjonarnych przy u\u017cyciu modemu g\u0142osowego CX93001. Mo\u017ce ona pobra\u0107 informacje o identyfikatorze dzwoni\u0105cego z opcj\u0105 odrzucenia po\u0142\u0105czenia przychodz\u0105cego." + "description": "Jest to integracja dla po\u0142\u0105cze\u0144 stacjonarnych przy u\u017cyciu modemu g\u0142osowego CX93001. Mo\u017ce ona pobra\u0107 informacje o identyfikatorze dzwoni\u0105cego z opcj\u0105 odrzucenia po\u0142\u0105czenia przychodz\u0105cego.", + "title": "Modem telefoniczny" } } } diff --git a/homeassistant/components/modem_callerid/translations/tr.json b/homeassistant/components/modem_callerid/translations/tr.json new file mode 100644 index 00000000000..eec011d8c6f --- /dev/null +++ b/homeassistant/components/modem_callerid/translations/tr.json @@ -0,0 +1,26 @@ +{ + "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": "Kalan cihaz bulunamad\u0131" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "step": { + "usb_confirm": { + "description": "Bu, CX93001 sesli modem kullanan sabit hat aramalar\u0131 i\u00e7in bir entegrasyondur. Bu, gelen bir aramay\u0131 reddetme se\u00e7ene\u011fi ile arayan kimli\u011fi bilgilerini alabilir.", + "title": "Telefon Modemi" + }, + "user": { + "data": { + "name": "Ad", + "port": "Port" + }, + "description": "Bu, CX93001 sesli modem kullanan sabit hat aramalar\u0131 i\u00e7in bir entegrasyondur. Bu, gelen bir aramay\u0131 reddetme se\u00e7ene\u011fi ile arayan kimli\u011fi bilgilerini alabilir.", + "title": "Telefon Modemi" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/modern_forms/config_flow.py b/homeassistant/components/modern_forms/config_flow.py index e8b557f7bc5..f8f3e2f1dc1 100644 --- a/homeassistant/components/modern_forms/config_flow.py +++ b/homeassistant/components/modern_forms/config_flow.py @@ -6,11 +6,11 @@ from typing import Any from aiomodernforms import ModernFormsConnectionError, ModernFormsDevice import voluptuous as vol +from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigFlow from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import DiscoveryInfoType from .const import DOMAIN @@ -27,23 +27,23 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): return await self._handle_config_flow(user_input) async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" - host = discovery_info["hostname"].rstrip(".") + host = discovery_info.hostname.rstrip(".") name, _ = host.rsplit(".") self.context.update( { - CONF_HOST: discovery_info["host"], + CONF_HOST: discovery_info.host, CONF_NAME: name, - CONF_MAC: discovery_info["properties"].get(CONF_MAC), + CONF_MAC: discovery_info.properties.get(CONF_MAC), "title_placeholders": {"name": name}, } ) # Prepare configuration flow - return await self._handle_config_flow(discovery_info, True) + return await self._handle_config_flow({}, True) async def async_step_zeroconf_confirm( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/modern_forms/sensor.py b/homeassistant/components/modern_forms/sensor.py index 1e51ec9a1ae..5c9e0a18575 100644 --- a/homeassistant/components/modern_forms/sensor.py +++ b/homeassistant/components/modern_forms/sensor.py @@ -73,7 +73,7 @@ class ModernFormsLightTimerRemainingTimeSensor(ModernFormsSensor): self._attr_device_class = DEVICE_CLASS_TIMESTAMP @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" sleep_time: datetime = dt_util.utc_from_timestamp( self.coordinator.data.state.light_sleep_timer @@ -83,7 +83,7 @@ class ModernFormsLightTimerRemainingTimeSensor(ModernFormsSensor): or (sleep_time - dt_util.utcnow()).total_seconds() < 0 ): return None - return sleep_time.isoformat() + return sleep_time class ModernFormsFanTimerRemainingTimeSensor(ModernFormsSensor): @@ -103,7 +103,7 @@ class ModernFormsFanTimerRemainingTimeSensor(ModernFormsSensor): self._attr_device_class = DEVICE_CLASS_TIMESTAMP @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" sleep_time: datetime = dt_util.utc_from_timestamp( self.coordinator.data.state.fan_sleep_timer @@ -115,4 +115,4 @@ class ModernFormsFanTimerRemainingTimeSensor(ModernFormsSensor): ): return None - return sleep_time.isoformat() + return sleep_time diff --git a/homeassistant/components/modern_forms/translations/bg.json b/homeassistant/components/modern_forms/translations/bg.json index a6e2f383b1a..4200524546f 100644 --- a/homeassistant/components/modern_forms/translations/bg.json +++ b/homeassistant/components/modern_forms/translations/bg.json @@ -2,13 +2,16 @@ "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\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" }, "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" }, "flow_title": "{name}", "step": { + "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\u0432\u0430\u043d\u0435\u0442\u043e?" + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442" diff --git a/homeassistant/components/modern_forms/translations/id.json b/homeassistant/components/modern_forms/translations/id.json index 8b2f9fcfa1d..c85667c4449 100644 --- a/homeassistant/components/modern_forms/translations/id.json +++ b/homeassistant/components/modern_forms/translations/id.json @@ -15,8 +15,14 @@ "user": { "data": { "host": "Host" - } + }, + "description": "Siapkan kipas Modern Forms untuk diintegrasikan dengan Home Assistant." + }, + "zeroconf_confirm": { + "description": "Ingin menambahkan kipas Modern Forms `{name}` ke Home Assistant?", + "title": "Perangkat kipas Modern Forms yang Ditemukan" } } - } + }, + "title": "Modern Forms" } \ No newline at end of file diff --git a/homeassistant/components/modern_forms/translations/ja.json b/homeassistant/components/modern_forms/translations/ja.json new file mode 100644 index 00000000000..bf36d307a09 --- /dev/null +++ b/homeassistant/components/modern_forms/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\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" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "Modern Forms fan\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3001Home Assistant\u3068\u9023\u643a\u3059\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002" + }, + "zeroconf_confirm": { + "description": "Home Assistant\u306b `{name}` \u3068\u3044\u3046\u540d\u524d\u306eModern Forms fan\u3092\u8ffd\u52a0\u3057\u307e\u3059\u304b?", + "title": "Modern Forms fan device\u3092\u767a\u898b" + } + } + }, + "title": "Modern Forms" +} \ No newline at end of file diff --git a/homeassistant/components/modern_forms/translations/ru.json b/homeassistant/components/modern_forms/translations/ru.json index 9bfba30bf33..68b46c57f47 100644 --- a/homeassistant/components/modern_forms/translations/ru.json +++ b/homeassistant/components/modern_forms/translations/ru.json @@ -16,7 +16,7 @@ "data": { "host": "\u0425\u043e\u0441\u0442" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 \u0432\u0435\u043d\u0442\u0438\u043b\u044f\u0442\u043e\u0440\u043e\u043c Modern Forms." + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 \u0432\u0435\u043d\u0442\u0438\u043b\u044f\u0442\u043e\u0440\u043e\u043c Modern Forms." }, "zeroconf_confirm": { "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0432\u0435\u043d\u0442\u0438\u043b\u044f\u0442\u043e\u0440 Modern Forms `{name}`?", diff --git a/homeassistant/components/modern_forms/translations/tr.json b/homeassistant/components/modern_forms/translations/tr.json new file mode 100644 index 00000000000..b0f884ef68f --- /dev/null +++ b/homeassistant/components/modern_forms/translations/tr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Kuruluma ba\u015flamak ister misiniz?" + }, + "user": { + "data": { + "host": "Ana bilgisayar" + }, + "description": "Modern Forms fan\u0131n\u0131z\u0131 Home Assistant ile entegre olacak \u015fekilde ayarlay\u0131n." + }, + "zeroconf_confirm": { + "description": "\"{name}\" adl\u0131 Modern Formlar fan\u0131n\u0131 Ev Asistan\u0131'na eklemek istiyor musunuz?", + "title": "Ke\u015ffedilen Modern Formlar fan cihaz\u0131" + } + } + }, + "title": "Modern Formlar" +} \ No newline at end of file diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index c57903ce5b7..c178d5e2360 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -235,10 +235,7 @@ class MoldIndicator(SensorEntity): ) return None - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - hum = util.convert(state.state, float) - - if hum is None: + if (hum := util.convert(state.state, float)) is None: _LOGGER.error( "Unable to parse humidity sensor %s, state: %s", state.entity_id, @@ -246,7 +243,7 @@ class MoldIndicator(SensorEntity): ) return None - if unit != PERCENTAGE: + if (unit := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) != PERCENTAGE: _LOGGER.error( "Humidity sensor %s has unsupported unit: %s %s", state.entity_id, diff --git a/homeassistant/components/monoprice/translations/ja.json b/homeassistant/components/monoprice/translations/ja.json new file mode 100644 index 00000000000..b456edabd26 --- /dev/null +++ b/homeassistant/components/monoprice/translations/ja.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "port": "\u30dd\u30fc\u30c8", + "source_1": "\u30bd\u30fc\u30b9#1\u306e\u540d\u524d", + "source_2": "\u30bd\u30fc\u30b9#2\u306e\u540d\u524d", + "source_3": "\u30bd\u30fc\u30b9#3\u306e\u540d\u524d", + "source_4": "\u30bd\u30fc\u30b9#4\u306e\u540d\u524d", + "source_5": "\u30bd\u30fc\u30b9#5\u306e\u540d\u524d", + "source_6": "\u30bd\u30fc\u30b9#6\u306e\u540d\u524d" + }, + "title": "\u30c7\u30d0\u30a4\u30b9\u306b\u63a5\u7d9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "source_1": "\u30bd\u30fc\u30b9#1\u306e\u540d\u524d", + "source_2": "\u30bd\u30fc\u30b9#2\u306e\u540d\u524d", + "source_3": "\u30bd\u30fc\u30b9#3\u306e\u540d\u524d", + "source_4": "\u30bd\u30fc\u30b9#4\u306e\u540d\u524d", + "source_5": "\u30bd\u30fc\u30b9#5\u306e\u540d\u524d", + "source_6": "\u30bd\u30fc\u30b9#6\u306e\u540d\u524d" + }, + "title": "\u30bd\u30fc\u30b9\u306e\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/monoprice/translations/tr.json b/homeassistant/components/monoprice/translations/tr.json index 7c622a3cb4a..bb9728f18a7 100644 --- a/homeassistant/components/monoprice/translations/tr.json +++ b/homeassistant/components/monoprice/translations/tr.json @@ -21,5 +21,20 @@ "title": "Cihaza ba\u011flan\u0131n" } } + }, + "options": { + "step": { + "init": { + "data": { + "source_1": "Kaynak #1 ad\u0131", + "source_2": "Kaynak #2 ad\u0131", + "source_3": "Kaynak #3 ad\u0131", + "source_4": "Kaynak #4 ad\u0131", + "source_5": "Kaynak #5 ad\u0131", + "source_6": "Kaynak #6 ad\u0131" + }, + "title": "Kaynaklar\u0131 yap\u0131land\u0131r" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/moon/translations/sensor.ja.json b/homeassistant/components/moon/translations/sensor.ja.json new file mode 100644 index 00000000000..9df6ceab2af --- /dev/null +++ b/homeassistant/components/moon/translations/sensor.ja.json @@ -0,0 +1,14 @@ +{ + "state": { + "moon__phase": { + "first_quarter": "\u4e0a\u5f26\u306e\u6708", + "full_moon": "\u6e80\u6708", + "last_quarter": "\u4e0b\u5f26\u306e\u6708", + "new_moon": "\u65b0\u6708", + "waning_crescent": "\u4e8c\u5341\u516d\u591c", + "waning_gibbous": "\u5341\u516b\u591c", + "waxing_crescent": "\u4e09\u65e5\u6708", + "waxing_gibbous": "\u5341\u4e09\u591c" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/moon/translations/sensor.tr.json b/homeassistant/components/moon/translations/sensor.tr.json new file mode 100644 index 00000000000..487f1de9d85 --- /dev/null +++ b/homeassistant/components/moon/translations/sensor.tr.json @@ -0,0 +1,14 @@ +{ + "state": { + "moon__phase": { + "first_quarter": "\u0130lk D\u00f6rd\u00fcn", + "full_moon": "Dolunay", + "last_quarter": "Son D\u00f6rd\u00fcn", + "new_moon": "Yeni Ay", + "waning_crescent": "Azalan Hilal", + "waning_gibbous": "\u015ei\u015fkin Ay", + "waxing_crescent": "Hilal", + "waxing_gibbous": "\u015ei\u015fkin Ay" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 14bdeae817b..904e5cecbbe 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -157,7 +157,7 @@ async def async_setup_entry( else: version = f"Protocol: {motion_gateway.protocol}" - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, motion_gateway.mac)}, diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index 7ff87c1b58b..96951f78a5b 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -3,7 +3,7 @@ "name": "Motion Blinds", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/motion_blinds", - "requirements": ["motionblinds==0.5.7"], + "requirements": ["motionblinds==0.5.8"], "dependencies": ["network"], "codeowners": ["@starkillerOG"], "iot_class": "local_push" diff --git a/homeassistant/components/motion_blinds/translations/id.json b/homeassistant/components/motion_blinds/translations/id.json index 9248531a751..269470110ec 100644 --- a/homeassistant/components/motion_blinds/translations/id.json +++ b/homeassistant/components/motion_blinds/translations/id.json @@ -6,13 +6,15 @@ "connection_error": "Gagal terhubung" }, "error": { - "discovery_error": "Gagal menemukan Motion Gateway" + "discovery_error": "Gagal menemukan Motion Gateway", + "invalid_interface": "Antarmuka jaringan tidak valid" }, "flow_title": "Motion Blinds", "step": { "connect": { "data": { - "api_key": "Kunci API" + "api_key": "Kunci API", + "interface": "Antarmuka jaringan yang akan digunakan" }, "description": "Anda akan memerlukan Kunci API 16 karakter, baca petunjuknya di https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key", "title": "Motion Blinds" @@ -33,5 +35,16 @@ "title": "Motion Blinds" } } + }, + "options": { + "step": { + "init": { + "data": { + "wait_for_push": "Tunggu push multicast pada pembaruan" + }, + "description": "Tentukan pengaturan opsional", + "title": "Motion Blinds" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/motion_blinds/translations/ja.json b/homeassistant/components/motion_blinds/translations/ja.json new file mode 100644 index 00000000000..b81cfa365c3 --- /dev/null +++ b/homeassistant/components/motion_blinds/translations/ja.json @@ -0,0 +1,50 @@ +{ + "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", + "connection_error": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "error": { + "discovery_error": "Motion Gateway\u306e\u691c\u51fa\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_interface": "\u7121\u52b9\u306a\u30cd\u30c3\u30c8\u30ef\u30fc\u30af \u30a4\u30f3\u30bf\u30fc\u30d5\u30a7\u30a4\u30b9" + }, + "flow_title": "\u30e2\u30fc\u30b7\u30e7\u30f3\u30d6\u30e9\u30a4\u30f3\u30c9", + "step": { + "connect": { + "data": { + "api_key": "API\u30ad\u30fc", + "interface": "\u4f7f\u7528\u3059\u308b\u30cd\u30c3\u30c8\u30ef\u30fc\u30af \u30a4\u30f3\u30bf\u30fc\u30d5\u30a7\u30a4\u30b9" + }, + "description": "16\u6587\u5b57\u306eAPI\u30ad\u30fc\u304c\u5fc5\u8981\u3067\u3059\u3002\u624b\u9806\u306f\u3001https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30e2\u30fc\u30b7\u30e7\u30f3\u30d6\u30e9\u30a4\u30f3\u30c9" + }, + "select": { + "data": { + "select_ip": "IP\u30a2\u30c9\u30ec\u30b9" + }, + "description": "\u8ffd\u52a0\u306eMotion Gateway\u3092\u63a5\u7d9a\u3059\u308b\u5834\u5408\u306f\u3001\u518d\u5ea6\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u5b9f\u884c\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u63a5\u7d9a\u3057\u305f\u3044Motion Gateway\u3092\u9078\u629e\u3057\u307e\u3059\u3002" + }, + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "host": "IP\u30a2\u30c9\u30ec\u30b9" + }, + "description": "Motion Gateway\u306b\u63a5\u7d9a\u3057\u307e\u3059\u3002IP\u30a2\u30c9\u30ec\u30b9\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u306a\u3044\u5834\u5408\u306f\u3001\u81ea\u52d5\u691c\u51fa\u304c\u4f7f\u7528\u3055\u308c\u307e\u3059", + "title": "\u30e2\u30fc\u30b7\u30e7\u30f3\u30d6\u30e9\u30a4\u30f3\u30c9" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "wait_for_push": "\u66f4\u65b0\u6642\u306b\u30de\u30eb\u30c1\u30ad\u30e3\u30b9\u30c8 \u30d7\u30c3\u30b7\u30e5\u3092\u5f85\u6a5f\u3059\u308b" + }, + "description": "\u30aa\u30d7\u30b7\u30e7\u30f3\u8a2d\u5b9a\u306e\u6307\u5b9a", + "title": "\u30e2\u30fc\u30b7\u30e7\u30f3\u30d6\u30e9\u30a4\u30f3\u30c9" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/motion_blinds/translations/pl.json b/homeassistant/components/motion_blinds/translations/pl.json index 61e9d22c3cf..6161b6aa3da 100644 --- a/homeassistant/components/motion_blinds/translations/pl.json +++ b/homeassistant/components/motion_blinds/translations/pl.json @@ -6,13 +6,15 @@ "connection_error": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" }, "error": { - "discovery_error": "Nie uda\u0142o si\u0119 wykry\u0107 bramki ruchu" + "discovery_error": "Nie uda\u0142o si\u0119 wykry\u0107 bramki ruchu", + "invalid_interface": "Nieprawid\u0142owy interfejs sieciowy" }, "flow_title": "Rolety Motion", "step": { "connect": { "data": { - "api_key": "Klucz API" + "api_key": "Klucz API", + "interface": "Interfejs sieciowy" }, "description": "B\u0119dziesz potrzebowa\u0142 16-znakowego klucza API, instrukcje znajdziesz na https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key", "title": "Motion Blinds" @@ -33,5 +35,16 @@ "title": "Rolety Motion" } } + }, + "options": { + "step": { + "init": { + "data": { + "wait_for_push": "Poczekaj na aktualizacj\u0119 multicast push" + }, + "description": "Okre\u015bl opcjonalne ustawienia", + "title": "Rolety Motion" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/motion_blinds/translations/sl.json b/homeassistant/components/motion_blinds/translations/sl.json index bb61b035201..80ea8b24fbb 100644 --- a/homeassistant/components/motion_blinds/translations/sl.json +++ b/homeassistant/components/motion_blinds/translations/sl.json @@ -1,6 +1,14 @@ { "config": { + "error": { + "invalid_interface": "Neveljaven omre\u017eni vmesnik" + }, "step": { + "connect": { + "data": { + "interface": "Omre\u017eni vmesnik za uporabo" + } + }, "user": { "title": "Motion Blinds" } diff --git a/homeassistant/components/motion_blinds/translations/tr.json b/homeassistant/components/motion_blinds/translations/tr.json index 194608780c9..c9a0efe2538 100644 --- a/homeassistant/components/motion_blinds/translations/tr.json +++ b/homeassistant/components/motion_blinds/translations/tr.json @@ -5,25 +5,44 @@ "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", "connection_error": "Ba\u011flanma hatas\u0131" }, + "error": { + "discovery_error": "Motion Gateway bulunamad\u0131", + "invalid_interface": "Ge\u00e7ersiz a\u011f aray\u00fcz\u00fc" + }, "flow_title": "Hareketli Panjurlar", "step": { "connect": { "data": { - "api_key": "API Anahtar\u0131" - } + "api_key": "API Anahtar\u0131", + "interface": "Kullan\u0131lacak a\u011f aray\u00fcz\u00fc" + }, + "description": "16 karakterlik API Anahtar\u0131na ihtiyac\u0131n\u0131z olacak, talimatlar i\u00e7in https://www.home-assistant.io/integrations/motion_blinds/#retriving-the-key adresine bak\u0131n.", + "title": "Hareketli Perdeler" }, "select": { "data": { - "select_ip": "\u0130p Adresi" + "select_ip": "IP Adresi" }, + "description": "Motion Gateway'e ba\u011flamak istiyorsan\u0131z kurulumu tekrar \u00e7al\u0131\u015ft\u0131r\u0131n", "title": "Ba\u011flamak istedi\u011finiz Hareket A\u011f Ge\u00e7idini se\u00e7in" }, "user": { "data": { "api_key": "API Anahtar\u0131", - "host": "IP adresi" + "host": "IP Adresi" }, "description": "Motion Gateway'inize ba\u011flan\u0131n, IP adresi ayarlanmad\u0131ysa, otomatik ke\u015fif kullan\u0131l\u0131r", + "title": "Hareketli Perdeler" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "wait_for_push": "G\u00fcncellemede \u00e7ok noktaya yay\u0131n i\u00e7in bekleyin" + }, + "description": "\u0130ste\u011fe ba\u011fl\u0131 ayarlar\u0131 belirtin", "title": "Hareketli Panjurlar" } } diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index ec501f9f112..37a15931920 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -3,9 +3,11 @@ from __future__ import annotations import asyncio from collections.abc import Callable +import contextlib from http import HTTPStatus import json import logging +import os from types import MappingProxyType from typing import Any from urllib.parse import urlencode, urljoin @@ -15,13 +17,17 @@ from motioneye_client.client import ( MotionEyeClient, MotionEyeClientError, MotionEyeClientInvalidAuthError, + MotionEyeClientPathError, ) from motioneye_client.const import ( KEY_CAMERAS, KEY_HTTP_METHOD_POST_JSON, KEY_ID, KEY_NAME, + KEY_ROOT_DIRECTORY, KEY_WEB_HOOK_CONVERSION_SPECIFIERS, + KEY_WEB_HOOK_CS_FILE_PATH, + KEY_WEB_HOOK_CS_FILE_TYPE, KEY_WEB_HOOK_NOTIFICATIONS_ENABLED, KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD, KEY_WEB_HOOK_NOTIFICATIONS_URL, @@ -31,6 +37,8 @@ from motioneye_client.const import ( ) from homeassistant.components.camera.const import DOMAIN as CAMERA_DOMAIN +from homeassistant.components.media_source.const import URI_SCHEME +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.webhook import ( async_generate_id, @@ -73,6 +81,8 @@ from .const import ( DOMAIN, EVENT_FILE_STORED, EVENT_FILE_STORED_KEYS, + EVENT_FILE_URL, + EVENT_MEDIA_CONTENT_ID, EVENT_MOTION_DETECTED, EVENT_MOTION_DETECTED_KEYS, MOTIONEYE_MANUFACTURER, @@ -82,7 +92,7 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) -PLATFORMS = [CAMERA_DOMAIN, SWITCH_DOMAIN] +PLATFORMS = [CAMERA_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN] def create_motioneye_client( @@ -100,6 +110,20 @@ def get_motioneye_device_identifier( return (DOMAIN, f"{config_entry_id}_{camera_id}") +def split_motioneye_device_identifier( + identifier: tuple[str, str] +) -> tuple[str, str, int] | None: + """Get the identifiers for a motionEye device.""" + if len(identifier) != 2 or identifier[0] != DOMAIN or "_" not in identifier[1]: + return None + config_id, camera_id_str = identifier[1].split("_", 1) + try: + camera_id = int(camera_id_str) + except ValueError: + return None + return (DOMAIN, config_id, camera_id) + + def get_motioneye_entity_unique_id( config_entry_id: str, camera_id: int, entity_type: str ) -> str: @@ -325,7 +349,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } current_cameras: set[tuple[str, str]] = set() - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) @callback def _async_process_motioneye_cameras() -> None: @@ -427,6 +451,21 @@ async def handle_webhook( status=HTTPStatus.BAD_REQUEST, ) + if KEY_WEB_HOOK_CS_FILE_PATH in data and KEY_WEB_HOOK_CS_FILE_TYPE in data: + try: + event_file_type = int(data[KEY_WEB_HOOK_CS_FILE_TYPE]) + except ValueError: + pass + else: + data.update( + _get_media_event_data( + hass, + device, + data[KEY_WEB_HOOK_CS_FILE_PATH], + event_file_type, + ) + ) + hass.bus.async_fire( f"{DOMAIN}.{event_type}", { @@ -439,6 +478,69 @@ async def handle_webhook( return None +def _get_media_event_data( + hass: HomeAssistant, + device: dr.DeviceEntry, + event_file_path: str, + event_file_type: int, +) -> dict[str, str]: + config_entry_id = next(iter(device.config_entries), None) + if not config_entry_id or config_entry_id not in hass.data[DOMAIN]: + return {} + + config_entry_data = hass.data[DOMAIN][config_entry_id] + client = config_entry_data[CONF_CLIENT] + coordinator = config_entry_data[CONF_COORDINATOR] + + for identifier in device.identifiers: + data = split_motioneye_device_identifier(identifier) + if data is not None: + camera_id = data[2] + camera = get_camera_from_cameras(camera_id, coordinator.data) + break + else: + return {} + + root_directory = camera.get(KEY_ROOT_DIRECTORY) if camera else None + if root_directory is None: + return {} + + kind = "images" if client.is_file_type_image(event_file_type) else "movies" + + # The file_path in the event is the full local filesystem path to the + # media. To convert that to the media path that motionEye will + # understand, we need to strip the root directory from the path. + if os.path.commonprefix([root_directory, event_file_path]) != root_directory: + return {} + + file_path = "/" + os.path.relpath(event_file_path, root_directory) + output = { + EVENT_MEDIA_CONTENT_ID: ( + f"{URI_SCHEME}{DOMAIN}/{config_entry_id}#{device.id}#{kind}#{file_path}" + ), + } + url = get_media_url( + client, + camera_id, + file_path, + kind == "images", + ) + if url: + output[EVENT_FILE_URL] = url + return output + + +def get_media_url( + client: MotionEyeClient, camera_id: int, path: str, image: bool +) -> str | None: + """Get the URL for a motionEye media item.""" + with contextlib.suppress(MotionEyeClientPathError): + if image: + return client.get_image_url(camera_id, path) + return client.get_movie_url(camera_id, path) + return None + + class MotionEyeEntity(CoordinatorEntity): """Base class for motionEye entities.""" @@ -478,3 +580,8 @@ class MotionEyeEntity(CoordinatorEntity): def device_info(self) -> DeviceInfo: """Return the device information.""" return DeviceInfo(identifiers={self._device_identifier}) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self._camera is not None and super().available diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index 3e8df99e0aa..7d9be209c4e 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -5,13 +5,24 @@ from types import MappingProxyType from typing import Any import aiohttp -from motioneye_client.client import MotionEyeClient +from jinja2 import Template +from motioneye_client.client import MotionEyeClient, MotionEyeClientURLParseError from motioneye_client.const import ( DEFAULT_SURVEILLANCE_USERNAME, + KEY_ACTION_SNAPSHOT, KEY_MOTION_DETECTION, KEY_NAME, KEY_STREAMING_AUTH_MODE, + KEY_TEXT_OVERLAY_CAMERA_NAME, + KEY_TEXT_OVERLAY_CUSTOM_TEXT, + KEY_TEXT_OVERLAY_CUSTOM_TEXT_LEFT, + KEY_TEXT_OVERLAY_CUSTOM_TEXT_RIGHT, + KEY_TEXT_OVERLAY_DISABLED, + KEY_TEXT_OVERLAY_LEFT, + KEY_TEXT_OVERLAY_RIGHT, + KEY_TEXT_OVERLAY_TIMESTAMP, ) +import voluptuous as vol from homeassistant.components.mjpeg.camera import ( CONF_MJPEG_URL, @@ -29,6 +40,7 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -39,17 +51,49 @@ from . import ( listen_for_new_cameras, ) from .const import ( + CONF_ACTION, CONF_CLIENT, CONF_COORDINATOR, + CONF_STREAM_URL_TEMPLATE, CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_USERNAME, DOMAIN, MOTIONEYE_MANUFACTURER, + SERVICE_ACTION, + SERVICE_SET_TEXT_OVERLAY, + SERVICE_SNAPSHOT, TYPE_MOTIONEYE_MJPEG_CAMERA, ) PLATFORMS = ["camera"] +SCHEMA_TEXT_OVERLAY = vol.In( + [ + KEY_TEXT_OVERLAY_DISABLED, + KEY_TEXT_OVERLAY_TIMESTAMP, + KEY_TEXT_OVERLAY_CUSTOM_TEXT, + KEY_TEXT_OVERLAY_CAMERA_NAME, + ] +) +SCHEMA_SERVICE_SET_TEXT = vol.Schema( + vol.All( + cv.make_entity_service_schema( + { + vol.Optional(KEY_TEXT_OVERLAY_LEFT): SCHEMA_TEXT_OVERLAY, + vol.Optional(KEY_TEXT_OVERLAY_CUSTOM_TEXT_LEFT): cv.string, + vol.Optional(KEY_TEXT_OVERLAY_RIGHT): SCHEMA_TEXT_OVERLAY, + vol.Optional(KEY_TEXT_OVERLAY_CUSTOM_TEXT_RIGHT): cv.string, + }, + ), + cv.has_at_least_one_key( + KEY_TEXT_OVERLAY_LEFT, + KEY_TEXT_OVERLAY_CUSTOM_TEXT_LEFT, + KEY_TEXT_OVERLAY_RIGHT, + KEY_TEXT_OVERLAY_CUSTOM_TEXT_RIGHT, + ), + ), +) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -78,6 +122,23 @@ async def async_setup_entry( listen_for_new_cameras(hass, entry, camera_add) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_SET_TEXT_OVERLAY, + SCHEMA_SERVICE_SET_TEXT, + "async_set_text_overlay", + ) + platform.async_register_entity_service( + SERVICE_ACTION, + {vol.Required(CONF_ACTION): cv.string}, + "async_request_action", + ) + platform.async_register_entity_service( + SERVICE_SNAPSHOT, + {}, + "async_request_snapshot", + ) + class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): """motionEye mjpeg camera.""" @@ -98,7 +159,7 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): self._motion_detection_enabled: bool = camera.get(KEY_MOTION_DETECTION, False) # motionEye cameras are always streaming or unavailable. - self.is_streaming = True + self._attr_is_streaming = True MotionEyeEntity.__init__( self, @@ -129,11 +190,24 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): ): auth = camera[KEY_STREAMING_AUTH_MODE] + streaming_template = self._options.get(CONF_STREAM_URL_TEMPLATE, "").strip() + streaming_url = None + + if streaming_template: + # Note: Can't use homeassistant.helpers.template as it requires hass + # which is not available during entity construction. + streaming_url = Template(streaming_template).render(**camera) + else: + try: + streaming_url = self._client.get_camera_stream_url(camera) + except MotionEyeClientURLParseError: + pass + return { CONF_NAME: camera[KEY_NAME], CONF_USERNAME: self._surveillance_username if auth is not None else None, CONF_PASSWORD: self._surveillance_password if auth is not None else None, - CONF_MJPEG_URL: self._client.get_camera_stream_url(camera) or "", + CONF_MJPEG_URL: streaming_url or "", CONF_STILL_IMAGE_URL: self._client.get_camera_snapshot_url(camera), CONF_AUTHENTICATION: auth, } @@ -186,3 +260,38 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" return self._motion_detection_enabled + + async def async_set_text_overlay( + self, + left_text: str = None, + right_text: str = None, + custom_left_text: str = None, + custom_right_text: str = None, + ) -> None: + """Set text overlay for a camera.""" + # Fetch the very latest camera config to reduce the risk of updating with a + # stale configuration. + camera = await self._client.async_get_camera(self._camera_id) + if not camera: + return + if left_text is not None: + camera[KEY_TEXT_OVERLAY_LEFT] = left_text + if right_text is not None: + camera[KEY_TEXT_OVERLAY_RIGHT] = right_text + if custom_left_text is not None: + camera[KEY_TEXT_OVERLAY_CUSTOM_TEXT_LEFT] = custom_left_text.encode( + "unicode_escape" + ).decode("UTF-8") + if custom_right_text is not None: + camera[KEY_TEXT_OVERLAY_CUSTOM_TEXT_RIGHT] = custom_right_text.encode( + "unicode_escape" + ).decode("UTF-8") + await self._client.async_set_camera(self._camera_id, camera) + + async def async_request_action(self, action: str) -> None: + """Call a motionEye action on a camera.""" + await self._client.async_action(self._camera_id, action) + + async def async_request_snapshot(self) -> None: + """Request a motionEye snapshot be saved.""" + await self.async_request_action(KEY_ACTION_SNAPSHOT) diff --git a/homeassistant/components/motioneye/config_flow.py b/homeassistant/components/motioneye/config_flow.py index a767cf7ecad..e90a6068ebc 100644 --- a/homeassistant/components/motioneye/config_flow.py +++ b/homeassistant/components/motioneye/config_flow.py @@ -10,6 +10,7 @@ from motioneye_client.client import ( ) import voluptuous as vol +from homeassistant.components.hassio import HassioServiceInfo from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, @@ -26,6 +27,7 @@ from . import create_motioneye_client from .const import ( CONF_ADMIN_PASSWORD, CONF_ADMIN_USERNAME, + CONF_STREAM_URL_TEMPLATE, CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_USERNAME, CONF_WEBHOOK_SET, @@ -161,9 +163,9 @@ class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a reauthentication flow.""" return await self.async_step_user(config_data) - async def async_step_hassio(self, discovery_info: dict[str, Any]) -> FlowResult: + async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Handle Supervisor discovery.""" - self._hassio_discovery = discovery_info + self._hassio_discovery = discovery_info.config await self._async_handle_discovery_without_unique_id() return await self.async_step_hassio_confirm() @@ -218,4 +220,19 @@ class MotionEyeOptionsFlow(OptionsFlow): ): bool, } + if self.show_advanced_options: + # The input URL is not validated as being a URL, to allow for the possibility + # the template input won't be a valid URL until after it's rendered. + schema.update( + { + vol.Required( + CONF_STREAM_URL_TEMPLATE, + default=self._config_entry.options.get( + CONF_STREAM_URL_TEMPLATE, + "", + ), + ): str + } + ) + return self.async_show_form(step_id="init", data_schema=vol.Schema(schema)) diff --git a/homeassistant/components/motioneye/const.py b/homeassistant/components/motioneye/const.py index 41fb2c18d63..37e751236da 100644 --- a/homeassistant/components/motioneye/const.py +++ b/homeassistant/components/motioneye/const.py @@ -28,10 +28,12 @@ DOMAIN: Final = "motioneye" ATTR_EVENT_TYPE: Final = "event_type" ATTR_WEBHOOK_ID: Final = "webhook_id" +CONF_ACTION: Final = "action" CONF_CLIENT: Final = "client" CONF_COORDINATOR: Final = "coordinator" CONF_ADMIN_PASSWORD: Final = "admin_password" CONF_ADMIN_USERNAME: Final = "admin_username" +CONF_STREAM_URL_TEMPLATE: Final = "stream_url_template" CONF_SURVEILLANCE_USERNAME: Final = "surveillance_username" CONF_SURVEILLANCE_PASSWORD: Final = "surveillance_password" CONF_WEBHOOK_SET: Final = "webhook_set" @@ -78,11 +80,19 @@ EVENT_FILE_STORED_KEYS: Final = [ KEY_WEB_HOOK_CS_MOTION_VERSION, ] +EVENT_FILE_URL: Final = "file_url" +EVENT_MEDIA_CONTENT_ID: Final = "media_content_id" + MOTIONEYE_MANUFACTURER: Final = "motionEye" +SERVICE_SET_TEXT_OVERLAY: Final = "set_text_overlay" +SERVICE_ACTION: Final = "action" +SERVICE_SNAPSHOT: Final = "snapshot" + SIGNAL_CAMERA_ADD: Final = f"{DOMAIN}_camera_add_signal." "{}" SIGNAL_CAMERA_REMOVE: Final = f"{DOMAIN}_camera_remove_signal." "{}" +TYPE_MOTIONEYE_ACTION_SENSOR = f"{DOMAIN}_action_sensor" TYPE_MOTIONEYE_MJPEG_CAMERA: Final = "motioneye_mjpeg_camera" TYPE_MOTIONEYE_SWITCH_BASE: Final = f"{DOMAIN}_switch" diff --git a/homeassistant/components/motioneye/manifest.json b/homeassistant/components/motioneye/manifest.json index 9be95c21162..e01cae08511 100644 --- a/homeassistant/components/motioneye/manifest.json +++ b/homeassistant/components/motioneye/manifest.json @@ -5,10 +5,11 @@ "config_flow": true, "dependencies": [ "http", + "media_source", "webhook" ], "requirements": [ - "motioneye-client==0.3.11" + "motioneye-client==0.3.12" ], "codeowners": [ "@dermotduffy" diff --git a/homeassistant/components/motioneye/media_source.py b/homeassistant/components/motioneye/media_source.py new file mode 100644 index 00000000000..a24c9e7ab26 --- /dev/null +++ b/homeassistant/components/motioneye/media_source.py @@ -0,0 +1,356 @@ +"""motionEye Media Source Implementation.""" +from __future__ import annotations + +import logging +from pathlib import PurePath +from typing import Optional, Tuple, cast + +from motioneye_client.const import KEY_MEDIA_LIST, KEY_MIME_TYPE, KEY_PATH + +from homeassistant.components.media_player.const import ( + MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_IMAGE, + MEDIA_CLASS_VIDEO, + MEDIA_TYPE_IMAGE, + MEDIA_TYPE_VIDEO, +) +from homeassistant.components.media_source.error import MediaSourceError, Unresolvable +from homeassistant.components.media_source.models import ( + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr + +from . import get_media_url, split_motioneye_device_identifier +from .const import CONF_CLIENT, DOMAIN + +MIME_TYPE_MAP = { + "movies": "video/mp4", + "images": "image/jpeg", +} + +MEDIA_CLASS_MAP = { + "movies": MEDIA_CLASS_VIDEO, + "images": MEDIA_CLASS_IMAGE, +} + +_LOGGER = logging.getLogger(__name__) + + +# Hierarchy: +# +# url (e.g. http://my-motioneye-1, http://my-motioneye-2) +# -> Camera (e.g. "Office", "Kitchen") +# -> kind (e.g. Images, Movies) +# -> path hierarchy as configured on motionEye + + +async def async_get_media_source(hass: HomeAssistant) -> MotionEyeMediaSource: + """Set up motionEye media source.""" + return MotionEyeMediaSource(hass) + + +class MotionEyeMediaSource(MediaSource): + """Provide motionEye stills and videos as media sources.""" + + name: str = "motionEye Media" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize MotionEyeMediaSource.""" + super().__init__(DOMAIN) + self.hass = hass + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media to a url.""" + config_id, device_id, kind, path = self._parse_identifier(item.identifier) + + if not config_id or not device_id or not kind or not path: + raise Unresolvable( + f"Incomplete media identifier specified: {item.identifier}" + ) + + config = self._get_config_or_raise(config_id) + device = self._get_device_or_raise(device_id) + self._verify_kind_or_raise(kind) + + url = get_media_url( + self.hass.data[DOMAIN][config.entry_id][CONF_CLIENT], + self._get_camera_id_or_raise(config, device), + self._get_path_or_raise(path), + kind == "images", + ) + if not url: + raise Unresolvable(f"Could not resolve media item: {item.identifier}") + + return PlayMedia(url, MIME_TYPE_MAP[kind]) + + @callback + @classmethod + def _parse_identifier( + cls, identifier: str + ) -> tuple[str | None, str | None, str | None, str | None]: + base = [None] * 4 + data = identifier.split("#", 3) + return cast( + Tuple[Optional[str], Optional[str], Optional[str], Optional[str]], + tuple(data + base)[:4], # type: ignore[operator] + ) + + async def async_browse_media( + self, + item: MediaSourceItem, + ) -> BrowseMediaSource: + """Return media.""" + if item.identifier: + config_id, device_id, kind, path = self._parse_identifier(item.identifier) + config = device = None + if config_id: + config = self._get_config_or_raise(config_id) + if device_id: + device = self._get_device_or_raise(device_id) + if kind: + self._verify_kind_or_raise(kind) + path = self._get_path_or_raise(path) + + if config and device and kind: + return await self._build_media_path(config, device, kind, path) + if config and device: + return self._build_media_kinds(config, device) + if config: + return self._build_media_devices(config) + return self._build_media_configs() + + def _get_config_or_raise(self, config_id: str) -> ConfigEntry: + """Get a config entry from a URL.""" + entry = self.hass.config_entries.async_get_entry(config_id) + if not entry: + raise MediaSourceError(f"Unable to find config entry with id: {config_id}") + return entry + + def _get_device_or_raise(self, device_id: str) -> dr.DeviceEntry: + """Get a config entry from a URL.""" + device_registry = dr.async_get(self.hass) + device = device_registry.async_get(device_id) + if not device: + raise MediaSourceError(f"Unable to find device with id: {device_id}") + return device + + @classmethod + def _verify_kind_or_raise(cls, kind: str) -> None: + """Verify kind is an expected value.""" + if kind in MEDIA_CLASS_MAP: + return + raise MediaSourceError(f"Unknown media type: {kind}") + + @classmethod + def _get_path_or_raise(cls, path: str | None) -> str: + """Verify path is a valid motionEye path.""" + if not path: + return "/" + if PurePath(path).root == "/": + return path + raise MediaSourceError( + f"motionEye media path must start with '/', received: {path}" + ) + + @classmethod + def _get_camera_id_or_raise( + cls, config: ConfigEntry, device: dr.DeviceEntry + ) -> int: + """Get a config entry from a URL.""" + for identifier in device.identifiers: + data = split_motioneye_device_identifier(identifier) + if data is not None: + return data[2] + raise MediaSourceError(f"Could not find camera id for device id: {device.id}") + + @classmethod + def _build_media_config(cls, config: ConfigEntry) -> BrowseMediaSource: + return BrowseMediaSource( + domain=DOMAIN, + identifier=config.entry_id, + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type="", + title=config.title, + can_play=False, + can_expand=True, + children_media_class=MEDIA_CLASS_DIRECTORY, + ) + + def _build_media_configs(self) -> BrowseMediaSource: + """Build the media sources for config entries.""" + return BrowseMediaSource( + domain=DOMAIN, + identifier="", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type="", + title="motionEye Media", + can_play=False, + can_expand=True, + children=[ + self._build_media_config(entry) + for entry in self.hass.config_entries.async_entries(DOMAIN) + ], + children_media_class=MEDIA_CLASS_DIRECTORY, + ) + + @classmethod + def _build_media_device( + cls, + config: ConfigEntry, + device: dr.DeviceEntry, + full_title: bool = True, + ) -> BrowseMediaSource: + return BrowseMediaSource( + domain=DOMAIN, + identifier=f"{config.entry_id}#{device.id}", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type="", + title=f"{config.title} {device.name}" if full_title else device.name, + can_play=False, + can_expand=True, + children_media_class=MEDIA_CLASS_DIRECTORY, + ) + + def _build_media_devices(self, config: ConfigEntry) -> BrowseMediaSource: + """Build the media sources for device entries.""" + device_registry = dr.async_get(self.hass) + devices = dr.async_entries_for_config_entry(device_registry, config.entry_id) + + base = self._build_media_config(config) + base.children = [ + self._build_media_device(config, device, full_title=False) + for device in devices + ] + return base + + @classmethod + def _build_media_kind( + cls, + config: ConfigEntry, + device: dr.DeviceEntry, + kind: str, + full_title: bool = True, + ) -> BrowseMediaSource: + return BrowseMediaSource( + domain=DOMAIN, + identifier=f"{config.entry_id}#{device.id}#{kind}", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=( + MEDIA_TYPE_VIDEO if kind == "movies" else MEDIA_TYPE_IMAGE + ), + title=( + f"{config.title} {device.name} {kind.title()}" + if full_title + else kind.title() + ), + can_play=False, + can_expand=True, + children_media_class=( + MEDIA_CLASS_VIDEO if kind == "movies" else MEDIA_CLASS_IMAGE + ), + ) + + def _build_media_kinds( + self, config: ConfigEntry, device: dr.DeviceEntry + ) -> BrowseMediaSource: + base = self._build_media_device(config, device) + base.children = [ + self._build_media_kind(config, device, kind, full_title=False) + for kind in MEDIA_CLASS_MAP + ] + return base + + async def _build_media_path( + self, + config: ConfigEntry, + device: dr.DeviceEntry, + kind: str, + path: str, + ) -> BrowseMediaSource: + """Build the media sources for media kinds.""" + base = self._build_media_kind(config, device, kind) + + parsed_path = PurePath(path) + if path != "/": + base.title += f" {PurePath(*parsed_path.parts[1:])}" + + base.children = [] + + client = self.hass.data[DOMAIN][config.entry_id][CONF_CLIENT] + camera_id = self._get_camera_id_or_raise(config, device) + + if kind == "movies": + resp = await client.async_get_movies(camera_id) + else: + resp = await client.async_get_images(camera_id) + + sub_dirs: set[str] = set() + parts = parsed_path.parts + for media in resp.get(KEY_MEDIA_LIST, []): + if ( + KEY_PATH not in media + or KEY_MIME_TYPE not in media + or media[KEY_MIME_TYPE] not in MIME_TYPE_MAP.values() + ): + continue + + # Example path: '/2021-04-21/21-13-10.mp4' + parts_media = PurePath(media[KEY_PATH]).parts + + if parts_media[: len(parts)] == parts and len(parts_media) > len(parts): + full_child_path = str(PurePath(*parts_media[: len(parts) + 1])) + display_child_path = parts_media[len(parts)] + + # Child is a media file. + if len(parts) + 1 == len(parts_media): + if kind == "movies": + thumbnail_url = client.get_movie_url( + camera_id, full_child_path, preview=True + ) + else: + thumbnail_url = client.get_image_url( + camera_id, full_child_path, preview=True + ) + + base.children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{config.entry_id}#{device.id}#{kind}#{full_child_path}", + media_class=MEDIA_CLASS_MAP[kind], + media_content_type=media[KEY_MIME_TYPE], + title=display_child_path, + can_play=(kind == "movies"), + can_expand=False, + thumbnail=thumbnail_url, + ) + ) + + # Child is a subdirectory. + elif len(parts) + 1 < len(parts_media): + if full_child_path not in sub_dirs: + sub_dirs.add(full_child_path) + base.children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=( + f"{config.entry_id}#{device.id}" + f"#{kind}#{full_child_path}" + ), + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=( + MEDIA_TYPE_VIDEO + if kind == "movies" + else MEDIA_TYPE_IMAGE + ), + title=display_child_path, + can_play=False, + can_expand=True, + children_media_class=MEDIA_CLASS_DIRECTORY, + ) + ) + return base diff --git a/homeassistant/components/motioneye/sensor.py b/homeassistant/components/motioneye/sensor.py new file mode 100644 index 00000000000..c8b7679149c --- /dev/null +++ b/homeassistant/components/motioneye/sensor.py @@ -0,0 +1,94 @@ +"""Sensor platform for motionEye.""" +from __future__ import annotations + +import logging +from types import MappingProxyType +from typing import Any + +from motioneye_client.client import MotionEyeClient +from motioneye_client.const import KEY_ACTIONS, KEY_NAME + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +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 DataUpdateCoordinator + +from . import MotionEyeEntity, get_camera_from_cameras, listen_for_new_cameras +from .const import CONF_CLIENT, CONF_COORDINATOR, DOMAIN, TYPE_MOTIONEYE_ACTION_SENSOR + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up motionEye from a config entry.""" + entry_data = hass.data[DOMAIN][entry.entry_id] + + @callback + def camera_add(camera: dict[str, Any]) -> None: + """Add a new motionEye camera.""" + async_add_entities( + [ + MotionEyeActionSensor( + entry.entry_id, + camera, + entry_data[CONF_CLIENT], + entry_data[CONF_COORDINATOR], + entry.options, + ) + ] + ) + + listen_for_new_cameras(hass, entry, camera_add) + + +class MotionEyeActionSensor(MotionEyeEntity, SensorEntity): + """motionEye action sensor camera.""" + + def __init__( + self, + config_entry_id: str, + camera: dict[str, Any], + client: MotionEyeClient, + coordinator: DataUpdateCoordinator, + options: MappingProxyType[str, str], + ) -> None: + """Initialize an action sensor.""" + super().__init__( + config_entry_id, + TYPE_MOTIONEYE_ACTION_SENSOR, + camera, + client, + coordinator, + options, + SensorEntityDescription( + key=TYPE_MOTIONEYE_ACTION_SENSOR, entity_registry_enabled_default=False + ), + ) + + @property + def name(self) -> str: + """Return the name of the sensor.""" + camera_prepend = f"{self._camera[KEY_NAME]} " if self._camera else "" + return f"{camera_prepend}Actions" + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return len(self._camera.get(KEY_ACTIONS, [])) if self._camera else 0 + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Add actions as attribute.""" + if actions := (self._camera.get(KEY_ACTIONS) if self._camera else None): + return {KEY_ACTIONS: actions} + return None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._camera = get_camera_from_cameras(self._camera_id, self.coordinator.data) + super()._handle_coordinator_update() diff --git a/homeassistant/components/motioneye/services.yaml b/homeassistant/components/motioneye/services.yaml new file mode 100644 index 00000000000..2970124c000 --- /dev/null +++ b/homeassistant/components/motioneye/services.yaml @@ -0,0 +1,110 @@ +set_text_overlay: + name: Set Text Overlay + description: Sets the text overlay for a camera. + target: + device: + integration: motioneye + entity: + integration: motioneye + fields: + left_text: + name: Left Text Overlay + description: Text to display on the left + required: false + advanced: false + example: "timestamp" + default: "" + selector: + select: + options: + - "disabled" + - "camera-name" + - "timestamp" + - "custom-text" + custom_left_text: + name: Left Custom Text + description: Custom text to display on the left + required: false + advanced: false + example: "Hello on the left!" + default: "" + selector: + text: + multiline: true + right_text: + name: Right Text Overlay + description: Text to display on the right + required: false + advanced: false + example: "timestamp" + default: "" + selector: + select: + options: + - "disabled" + - "camera-name" + - "timestamp" + - "custom-text" + custom_right_text: + name: Right Custom Text + description: Custom text to display on the right + required: false + advanced: false + example: "Hello on the right!" + default: "" + selector: + text: + multiline: true + +action: + name: Action + description: Trigger a motionEye action + target: + device: + integration: motioneye + entity: + integration: motioneye + fields: + action: + name: Action + description: Action to trigger + required: true + advanced: false + example: "snapshot" + default: "" + selector: + select: + options: + - "snapshot" + - "record_start" + - "record_stop" + - "lock" + - "unlock" + - "light_on" + - "light_off" + - "alarm_on" + - "alarm_off" + - "up" + - "right" + - "down" + - "left" + - "zoom_in" + - "zoom_out" + - "preset1" + - "preset2" + - "preset3" + - "preset4" + - "preset5" + - "preset6" + - "preset7" + - "preset8" + - "preset9" + +snapshot: + name: Snapshot + description: Trigger a motionEye still snapshot + target: + device: + integration: motioneye + entity: + integration: motioneye diff --git a/homeassistant/components/motioneye/strings.json b/homeassistant/components/motioneye/strings.json index 9763e1caf34..0f17699e652 100644 --- a/homeassistant/components/motioneye/strings.json +++ b/homeassistant/components/motioneye/strings.json @@ -31,9 +31,10 @@ "init": { "data": { "webhook_set": "Configure motionEye webhooks to report events to Home Assistant", - "webhook_set_overwrite": "Overwrite unrecognized webhooks" + "webhook_set_overwrite": "Overwrite unrecognized webhooks", + "stream_url_template": "Stream URL template" } } } } -} +} \ No newline at end of file diff --git a/homeassistant/components/motioneye/switch.py b/homeassistant/components/motioneye/switch.py index 9a6fe27441f..695cde842a7 100644 --- a/homeassistant/components/motioneye/switch.py +++ b/homeassistant/components/motioneye/switch.py @@ -17,8 +17,8 @@ from motioneye_client.const import ( from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_CONFIG from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import ENTITY_CATEGORY_CONFIG from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator diff --git a/homeassistant/components/motioneye/translations/ca.json b/homeassistant/components/motioneye/translations/ca.json index 85477627f38..5139cd22229 100644 --- a/homeassistant/components/motioneye/translations/ca.json +++ b/homeassistant/components/motioneye/translations/ca.json @@ -30,6 +30,7 @@ "step": { "init": { "data": { + "stream_url_template": "Plantilla URL de flux de reproducci\u00f3", "webhook_set": "Configura els webhooks de motionEye per enviar esdeveniments a Home Assistant", "webhook_set_overwrite": "Sobreescriu els webhooks no reconeguts" } diff --git a/homeassistant/components/motioneye/translations/de.json b/homeassistant/components/motioneye/translations/de.json index d565329b7cd..e4d72b07398 100644 --- a/homeassistant/components/motioneye/translations/de.json +++ b/homeassistant/components/motioneye/translations/de.json @@ -30,6 +30,7 @@ "step": { "init": { "data": { + "stream_url_template": "Stream-URL-Vorlage", "webhook_set": "MotionEye-Webhooks konfigurieren, um Ereignisse an Home Assistant zu melden", "webhook_set_overwrite": "\u00dcberschreiben von nicht bekannten Webhooks" } diff --git a/homeassistant/components/motioneye/translations/en.json b/homeassistant/components/motioneye/translations/en.json index 6c24b7850d4..2b12c978f54 100644 --- a/homeassistant/components/motioneye/translations/en.json +++ b/homeassistant/components/motioneye/translations/en.json @@ -30,6 +30,7 @@ "step": { "init": { "data": { + "stream_url_template": "Stream URL template", "webhook_set": "Configure motionEye webhooks to report events to Home Assistant", "webhook_set_overwrite": "Overwrite unrecognized webhooks" } diff --git a/homeassistant/components/motioneye/translations/et.json b/homeassistant/components/motioneye/translations/et.json index b3e3919123c..89fe7a60d5d 100644 --- a/homeassistant/components/motioneye/translations/et.json +++ b/homeassistant/components/motioneye/translations/et.json @@ -30,6 +30,7 @@ "step": { "init": { "data": { + "stream_url_template": "Voo URL-i mall", "webhook_set": "Seadista motionEye veebihaagid, et teatada s\u00fcndmustest Home Assistanti'le", "webhook_set_overwrite": "Kirjuta tundmatud veebihaagid \u00fcle" } diff --git a/homeassistant/components/motioneye/translations/hu.json b/homeassistant/components/motioneye/translations/hu.json index 0acc46509a4..60afb07d52b 100644 --- a/homeassistant/components/motioneye/translations/hu.json +++ b/homeassistant/components/motioneye/translations/hu.json @@ -30,6 +30,7 @@ "step": { "init": { "data": { + "stream_url_template": "Stream URL-sablon", "webhook_set": "\u00c1ll\u00edtsa be a motionEye webhookokat az esem\u00e9nyek jelent\u00e9s\u00e9nek Home Assistant sz\u00e1m\u00e1ra", "webhook_set_overwrite": "Fel\u00fcl\u00edrja a fel nem ismert webhookokat" } diff --git a/homeassistant/components/motioneye/translations/id.json b/homeassistant/components/motioneye/translations/id.json index 0278ac26195..efce25eb1d7 100644 --- a/homeassistant/components/motioneye/translations/id.json +++ b/homeassistant/components/motioneye/translations/id.json @@ -11,13 +11,30 @@ "unknown": "Kesalahan yang tidak diharapkan" }, "step": { + "hassio_confirm": { + "description": "Ingin mengonfigurasi Home Assistant untuk terhubung ke layanan motionEye yang disediakan oleh add-on: {addon}?", + "title": "motionEye melalui add-on Home Assistant" + }, "user": { "data": { "admin_password": "Kata Sandi Admin", "admin_username": "Nama Pengguna Admin", + "surveillance_password": "Kata Sandi Surveillance", + "surveillance_username": "Nama Pengguna Surveillance", "url": "URL" } } } + }, + "options": { + "step": { + "init": { + "data": { + "stream_url_template": "Templat URL streaming", + "webhook_set": "Konfigurasikan webhook motionEye untuk melaporkan peristiwa ke Home Assistant", + "webhook_set_overwrite": "Timpa webhook yang tidak dikenal" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/it.json b/homeassistant/components/motioneye/translations/it.json index 77307be07dd..114fdfc6052 100644 --- a/homeassistant/components/motioneye/translations/it.json +++ b/homeassistant/components/motioneye/translations/it.json @@ -30,6 +30,7 @@ "step": { "init": { "data": { + "stream_url_template": "Modello URL streaming", "webhook_set": "Configura i webhooks di motionEye per segnalare gli eventi a Home Assistant", "webhook_set_overwrite": "Sovrascrivi webhook non riconosciuti" } diff --git a/homeassistant/components/motioneye/translations/ja.json b/homeassistant/components/motioneye/translations/ja.json new file mode 100644 index 00000000000..8f9d963019f --- /dev/null +++ b/homeassistant/components/motioneye/translations/ja.json @@ -0,0 +1,40 @@ +{ + "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", + "invalid_url": "\u7121\u52b9\u306aURL", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "hassio_confirm": { + "description": "\u30a2\u30c9\u30aa\u30f3 {addon} \u304c\u3001\u63d0\u4f9b\u3059\u308bmotionEye service\u306b\u63a5\u7d9a\u3059\u308b\u3088\u3046\u306bHome Assistant\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u304b\uff1f", + "title": "Home Assistant\u30a2\u30c9\u30aa\u30f3\u7d4c\u7531\u306emotionEye" + }, + "user": { + "data": { + "admin_password": "\u7ba1\u7406\u8005(Admin)\u30d1\u30b9\u30ef\u30fc\u30c9", + "admin_username": "\u7ba1\u7406\u8005\u30e6\u30fc\u30b6\u30fc\u540d", + "surveillance_password": "\u76e3\u8996(Surveillance)\u30d1\u30b9\u30ef\u30fc\u30c9", + "surveillance_username": "\u76e3\u8996\u30e6\u30fc\u30b6\u30fc\u540d", + "url": "URL" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "stream_url_template": "Stream URL\u30c6\u30f3\u30d7\u30ec\u30fc\u30c8", + "webhook_set": "Home Assistant\u306b\u30a4\u30d9\u30f3\u30c8\u3092\u5831\u544a\u3059\u308b\u3088\u3046\u306b\u3001motionEye webhooks\u3092\u8a2d\u5b9a\u3059\u308b", + "webhook_set_overwrite": "\u8a8d\u8b58\u3055\u308c\u3066\u3044\u306a\u3044Webhooks\u3092\u4e0a\u66f8\u304d\u3059\u308b" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/nl.json b/homeassistant/components/motioneye/translations/nl.json index 81bb0365c33..dce66fcb5d1 100644 --- a/homeassistant/components/motioneye/translations/nl.json +++ b/homeassistant/components/motioneye/translations/nl.json @@ -30,6 +30,7 @@ "step": { "init": { "data": { + "stream_url_template": "Stream-URL template", "webhook_set": "MotionEye-webhooks configureren om gebeurtenissen aan Home Assistant te melden", "webhook_set_overwrite": "Overschrijf niet-herkende webhooks" } diff --git a/homeassistant/components/motioneye/translations/no.json b/homeassistant/components/motioneye/translations/no.json index c0fd5e881c2..1d7f3bab29f 100644 --- a/homeassistant/components/motioneye/translations/no.json +++ b/homeassistant/components/motioneye/translations/no.json @@ -30,6 +30,7 @@ "step": { "init": { "data": { + "stream_url_template": "Stream URL-mal", "webhook_set": "Konfigurer motionEye webhooks for \u00e5 rapportere hendelser til Home Assistant", "webhook_set_overwrite": "Overskriv ukjente webhooks" } diff --git a/homeassistant/components/motioneye/translations/pl.json b/homeassistant/components/motioneye/translations/pl.json index a34af5491ec..82bc7e8dcaa 100644 --- a/homeassistant/components/motioneye/translations/pl.json +++ b/homeassistant/components/motioneye/translations/pl.json @@ -20,7 +20,7 @@ "admin_password": "Has\u0142o administratora", "admin_username": "Nazwa u\u017cytkownika administratora", "surveillance_password": "Has\u0142o podgl\u0105du", - "surveillance_username": "[%key::common::config_flow::data::username%] podgl\u0105du", + "surveillance_username": "Nazwa u\u017cytkownika podgl\u0105du", "url": "URL" } } @@ -30,6 +30,7 @@ "step": { "init": { "data": { + "stream_url_template": "Szablon adresu URL strumienia", "webhook_set": "Skonfiguruj webhook motionEye, aby zg\u0142asza\u0107 zdarzenia do Home Assistanta", "webhook_set_overwrite": "Nadpisz nierozpoznane webhooki" } diff --git a/homeassistant/components/motioneye/translations/pt-BR.json b/homeassistant/components/motioneye/translations/pt-BR.json index cbc33e7c1c4..ec20df02074 100644 --- a/homeassistant/components/motioneye/translations/pt-BR.json +++ b/homeassistant/components/motioneye/translations/pt-BR.json @@ -8,5 +8,14 @@ "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", "unknown": "Erro inesperado" } + }, + "options": { + "step": { + "init": { + "data": { + "stream_url_template": "Modelo de URL de fluxo" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/ru.json b/homeassistant/components/motioneye/translations/ru.json index fbda6e7abdc..f21feba5f9a 100644 --- a/homeassistant/components/motioneye/translations/ru.json +++ b/homeassistant/components/motioneye/translations/ru.json @@ -30,6 +30,7 @@ "step": { "init": { "data": { + "stream_url_template": "\u0428\u0430\u0431\u043b\u043e\u043d URL-\u0430\u0434\u0440\u0435\u0441\u0430 \u043f\u043e\u0442\u043e\u043a\u0430", "webhook_set": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Webhook motionEye \u0434\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u043e\u0442\u0447\u0435\u0442\u043e\u0432 \u043e \u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0445 \u0432 Home Assistant", "webhook_set_overwrite": "\u041f\u0435\u0440\u0435\u0437\u0430\u043f\u0438\u0441\u044b\u0432\u0430\u0442\u044c \u043d\u0435\u0440\u0430\u0441\u043f\u043e\u0437\u043d\u0430\u043d\u043d\u044b\u0435 Webhook" } diff --git a/homeassistant/components/motioneye/translations/tr.json b/homeassistant/components/motioneye/translations/tr.json new file mode 100644 index 00000000000..a2a909a062b --- /dev/null +++ b/homeassistant/components/motioneye/translations/tr.json @@ -0,0 +1,40 @@ +{ + "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", + "invalid_url": "Ge\u00e7ersiz URL", + "unknown": "Beklenmeyen hata" + }, + "step": { + "hassio_confirm": { + "description": "{addon} taraf\u0131ndan sa\u011flanan motionEye hizmetine ba\u011flanmak i\u00e7in Home Assistant'\u0131 yap\u0131land\u0131rmak istiyor musunuz?", + "title": "Home Assistant eklentisi arac\u0131l\u0131\u011f\u0131yla motionEye" + }, + "user": { + "data": { + "admin_password": "Y\u00f6netici Parola", + "admin_username": "Y\u00f6netici Kullan\u0131c\u0131 Ad\u0131", + "surveillance_password": "G\u00f6zetim Parola", + "surveillance_username": "G\u00f6zetim Kullan\u0131c\u0131 Ad\u0131", + "url": "URL" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "stream_url_template": "Ak\u0131\u015f URL \u015fablonu", + "webhook_set": "Olaylar\u0131 Home Assistant'a bildirmek i\u00e7in motionEye webhooklar\u0131n\u0131 yap\u0131land\u0131r\u0131n", + "webhook_set_overwrite": "Tan\u0131nmayan webhooklar\u0131n \u00fczerine yaz" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/zh-Hant.json b/homeassistant/components/motioneye/translations/zh-Hant.json index a443ee6954b..84e508b7737 100644 --- a/homeassistant/components/motioneye/translations/zh-Hant.json +++ b/homeassistant/components/motioneye/translations/zh-Hant.json @@ -30,6 +30,7 @@ "step": { "init": { "data": { + "stream_url_template": "\u4e32\u6d41 URL \u6a21\u677f", "webhook_set": "\u8a2d\u5b9a motionEye webhooks \u4ee5\u56de\u5831\u4e8b\u4ef6\u81f3 Home Assistant", "webhook_set_overwrite": "\u8986\u84cb\u7121\u6cd5\u8fa8\u8b58\u7684 Webhooks" } diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 994ae2d108a..cdf37cd6381 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass +import datetime as dt from functools import lru_cache, partial, wraps import inspect from itertools import groupby @@ -38,9 +40,11 @@ from homeassistant.core import ( ServiceCall, callback, ) +from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.exceptions import HomeAssistantError, TemplateError, Unauthorized from homeassistant.helpers import config_validation as cv, event, template from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send +from homeassistant.helpers.frame import report from homeassistant.helpers.typing import ConfigType, ServiceDataType from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util @@ -57,13 +61,16 @@ from .const import ( CONF_BIRTH_MESSAGE, CONF_BROKER, CONF_COMMAND_TOPIC, + CONF_ENCODING, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, + CONF_TOPIC, CONF_WILL_MESSAGE, DATA_MQTT_CONFIG, DEFAULT_BIRTH, DEFAULT_DISCOVERY, + DEFAULT_ENCODING, DEFAULT_PREFIX, DEFAULT_QOS, DEFAULT_RETAIN, @@ -106,6 +113,7 @@ DEFAULT_KEEPALIVE = 60 DEFAULT_PROTOCOL = PROTOCOL_311 DEFAULT_TLS_PROTOCOL = "auto" +ATTR_TOPIC_TEMPLATE = "topic_template" ATTR_PAYLOAD_TEMPLATE = "payload_template" MAX_RECONNECT_WAIT = 300 # seconds @@ -198,7 +206,10 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -SCHEMA_BASE = {vol.Optional(CONF_QOS, default=DEFAULT_QOS): _VALID_QOS_SCHEMA} +SCHEMA_BASE = { + vol.Optional(CONF_QOS, default=DEFAULT_QOS): _VALID_QOS_SCHEMA, + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, +} MQTT_BASE_PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(SCHEMA_BASE) @@ -220,21 +231,55 @@ MQTT_RW_PLATFORM_SCHEMA = MQTT_BASE_PLATFORM_SCHEMA.extend( ) # Service call validation schema -MQTT_PUBLISH_SCHEMA = vol.Schema( - { - vol.Required(ATTR_TOPIC): valid_publish_topic, - vol.Exclusive(ATTR_PAYLOAD, CONF_PAYLOAD): cv.string, - vol.Exclusive(ATTR_PAYLOAD_TEMPLATE, CONF_PAYLOAD): cv.string, - vol.Optional(ATTR_QOS, default=DEFAULT_QOS): _VALID_QOS_SCHEMA, - vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean, - }, - required=True, +MQTT_PUBLISH_SCHEMA = vol.All( + vol.Schema( + { + vol.Exclusive(ATTR_TOPIC, CONF_TOPIC): valid_publish_topic, + vol.Exclusive(ATTR_TOPIC_TEMPLATE, CONF_TOPIC): cv.string, + vol.Exclusive(ATTR_PAYLOAD, CONF_PAYLOAD): cv.string, + vol.Exclusive(ATTR_PAYLOAD_TEMPLATE, CONF_PAYLOAD): cv.string, + vol.Optional(ATTR_QOS, default=DEFAULT_QOS): _VALID_QOS_SCHEMA, + vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + }, + required=True, + ), + cv.has_at_least_one_key(ATTR_TOPIC, ATTR_TOPIC_TEMPLATE), ) SubscribePayloadType = Union[str, bytes] # Only bytes if encoding is None +@dataclass +class MqttServiceInfo(BaseServiceInfo): + """Prepared info from mqtt entries.""" + + topic: str + payload: ReceivePayloadType + qos: int + retain: bool + subscribed_topic: str + timestamp: dt.datetime + + # Used to prevent log flooding. To be removed in 2022.6 + _warning_logged: bool = False + + def __getitem__(self, name: str) -> Any: + """ + Allow property access by name for compatibility reason. + + Deprecated, and will be removed in version 2022.6. + """ + if not self._warning_logged: + report( + f"accessed discovery_info['{name}'] instead of discovery_info.{name}; this will fail in version 2022.6", + exclude_integrations={"mqtt"}, + error_if_core=False, + ) + self._warning_logged = True + return getattr(self, name) + + def _build_publish_data(topic: Any, qos: int, retain: bool) -> ServiceDataType: """Build the arguments for the publish service without the payload.""" data = {ATTR_TOPIC: topic} @@ -245,39 +290,16 @@ def _build_publish_data(topic: Any, qos: int, retain: bool) -> ServiceDataType: return data -@bind_hass -def publish(hass: HomeAssistant, topic, payload, qos=None, retain=None) -> None: +def publish(hass: HomeAssistant, topic, payload, qos=0, retain=False) -> None: """Publish message to an MQTT topic.""" hass.add_job(async_publish, hass, topic, payload, qos, retain) -@callback -@bind_hass -def async_publish( - hass: HomeAssistant, topic: Any, payload, qos=None, retain=None +async def async_publish( + hass: HomeAssistant, topic: Any, payload, qos=0, retain=False ) -> None: """Publish message to an MQTT topic.""" - data = _build_publish_data(topic, qos, retain) - data[ATTR_PAYLOAD] = payload - hass.async_create_task(hass.services.async_call(DOMAIN, SERVICE_PUBLISH, data)) - - -@bind_hass -def publish_template( - hass: HomeAssistant, topic, payload_template, qos=None, retain=None -) -> None: - """Publish message to an MQTT topic.""" - hass.add_job(async_publish_template, hass, topic, payload_template, qos, retain) - - -@bind_hass -def async_publish_template( - hass: HomeAssistant, topic, payload_template, qos=None, retain=None -) -> None: - """Publish message to an MQTT topic using a template payload.""" - data = _build_publish_data(topic, qos, retain) - data[ATTR_PAYLOAD_TEMPLATE] = payload_template - hass.async_create_task(hass.services.async_call(DOMAIN, SERVICE_PUBLISH, data)) + await hass.data[DATA_MQTT].async_publish(topic, str(payload), qos, retain) AsyncDeprecatedMessageCallbackType = Callable[ @@ -473,11 +495,36 @@ async def async_setup_entry(hass, entry): async def async_publish_service(call: ServiceCall): """Handle MQTT publish service calls.""" - msg_topic: str = call.data[ATTR_TOPIC] + msg_topic = call.data.get(ATTR_TOPIC) + msg_topic_template = call.data.get(ATTR_TOPIC_TEMPLATE) payload = call.data.get(ATTR_PAYLOAD) payload_template = call.data.get(ATTR_PAYLOAD_TEMPLATE) qos: int = call.data[ATTR_QOS] retain: bool = call.data[ATTR_RETAIN] + if msg_topic_template is not None: + try: + rendered_topic = template.Template( + msg_topic_template, hass + ).async_render(parse_result=False) + msg_topic = valid_publish_topic(rendered_topic) + except (template.jinja2.TemplateError, TemplateError) as exc: + _LOGGER.error( + "Unable to publish: rendering topic template of %s " + "failed because %s", + msg_topic_template, + exc, + ) + return + except vol.Invalid as err: + _LOGGER.error( + "Unable to publish: topic template '%s' produced an " + "invalid topic '%s' after rendering (%s)", + msg_topic_template, + rendered_topic, + err, + ) + return + if payload_template is not None: try: payload = template.Template(payload_template, hass).async_render( diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 155667b00cb..c5c70ad33a4 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -10,6 +10,7 @@ ABBREVIATIONS = { "avty": "availability", "avty_mode": "availability_mode", "avty_t": "availability_topic", + "avty_tpl": "availability_template", "away_mode_cmd_t": "away_mode_command_topic", "away_mode_stat_tpl": "away_mode_state_template", "away_mode_stat_t": "away_mode_state_topic", @@ -39,12 +40,14 @@ ABBREVIATIONS = { "cmd_tpl": "command_template", "cod_arm_req": "code_arm_required", "cod_dis_req": "code_disarm_required", + "cod_trig_req": "code_trigger_required", "curr_temp_t": "current_temperature_topic", "curr_temp_tpl": "current_temperature_template", "dev": "device", "dev_cla": "device_class", "dock_t": "docked_topic", "dock_tpl": "docked_template", + "e": "encoding", "en": "enabled_by_default", "err_t": "error_topic", "err_tpl": "error_template", @@ -97,6 +100,7 @@ ABBREVIATIONS = { "mode_stat_tpl": "mode_state_template", "modes": "modes", "name": "name", + "obj_id": "object_id", "off_dly": "off_delay", "on_cmd_type": "on_command_type", "ops": "options", @@ -134,6 +138,7 @@ ABBREVIATIONS = { "pl_osc_off": "payload_oscillation_off", "pl_osc_on": "payload_oscillation_on", "pl_paus": "payload_pause", + "pl_prs": "payload_press", "pl_rst": "payload_reset", "pl_rst_hum": "payload_reset_humidity", "pl_rst_mode": "payload_reset_mode", @@ -145,6 +150,7 @@ ABBREVIATIONS = { "pl_ret": "payload_return_to_base", "pl_toff": "payload_turn_off", "pl_ton": "payload_turn_on", + "pl_trig": "payload_trigger", "pl_unlk": "payload_unlock", "pos_clsd": "position_closed", "pos_open": "position_open", diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index dfdf2dbfb26..3c324c0789b 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -12,6 +12,7 @@ from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, SUPPORT_ALARM_ARM_VACATION, + SUPPORT_ALARM_TRIGGER, ) from homeassistant.const import ( CONF_CODE, @@ -43,12 +44,14 @@ _LOGGER = logging.getLogger(__name__) CONF_CODE_ARM_REQUIRED = "code_arm_required" CONF_CODE_DISARM_REQUIRED = "code_disarm_required" +CONF_CODE_TRIGGER_REQUIRED = "code_trigger_required" CONF_PAYLOAD_DISARM = "payload_disarm" CONF_PAYLOAD_ARM_HOME = "payload_arm_home" CONF_PAYLOAD_ARM_AWAY = "payload_arm_away" CONF_PAYLOAD_ARM_NIGHT = "payload_arm_night" CONF_PAYLOAD_ARM_VACATION = "payload_arm_vacation" CONF_PAYLOAD_ARM_CUSTOM_BYPASS = "payload_arm_custom_bypass" +CONF_PAYLOAD_TRIGGER = "payload_trigger" CONF_COMMAND_TEMPLATE = "command_template" MQTT_ALARM_ATTRIBUTES_BLOCKED = frozenset( @@ -66,6 +69,7 @@ DEFAULT_ARM_AWAY = "ARM_AWAY" DEFAULT_ARM_HOME = "ARM_HOME" DEFAULT_ARM_CUSTOM_BYPASS = "ARM_CUSTOM_BYPASS" DEFAULT_DISARM = "DISARM" +DEFAULT_TRIGGER = "TRIGGER" DEFAULT_NAME = "MQTT Alarm" REMOTE_CODE = "REMOTE_CODE" @@ -76,6 +80,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( vol.Optional(CONF_CODE): cv.string, vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, vol.Optional(CONF_CODE_DISARM_REQUIRED, default=True): cv.boolean, + vol.Optional(CONF_CODE_TRIGGER_REQUIRED, default=True): cv.boolean, vol.Optional( CONF_COMMAND_TEMPLATE, default=DEFAULT_COMMAND_TEMPLATE ): cv.template, @@ -91,6 +96,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( CONF_PAYLOAD_ARM_CUSTOM_BYPASS, default=DEFAULT_ARM_CUSTOM_BYPASS ): cv.string, vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string, + vol.Optional(CONF_PAYLOAD_TRIGGER, default=DEFAULT_TRIGGER): cv.string, vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean, vol.Required(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, @@ -126,6 +132,7 @@ async def _async_setup_entity( class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): """Representation of a MQTT alarm status.""" + _entity_id_format = alarm.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_ALARM_ATTRIBUTES_BLOCKED def __init__(self, hass, config, config_entry, discovery_data): @@ -202,6 +209,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): | SUPPORT_ALARM_ARM_NIGHT | SUPPORT_ALARM_ARM_VACATION | SUPPORT_ALARM_ARM_CUSTOM_BYPASS + | SUPPORT_ALARM_TRIGGER ) @property @@ -228,7 +236,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): if code_required and not self._validate_code(code, "disarming"): return payload = self._config[CONF_PAYLOAD_DISARM] - self._publish(code, payload) + await self._publish(code, payload) async def async_alarm_arm_home(self, code=None): """Send arm home command. @@ -239,7 +247,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): if code_required and not self._validate_code(code, "arming home"): return action = self._config[CONF_PAYLOAD_ARM_HOME] - self._publish(code, action) + await self._publish(code, action) async def async_alarm_arm_away(self, code=None): """Send arm away command. @@ -250,7 +258,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): if code_required and not self._validate_code(code, "arming away"): return action = self._config[CONF_PAYLOAD_ARM_AWAY] - self._publish(code, action) + await self._publish(code, action) async def async_alarm_arm_night(self, code=None): """Send arm night command. @@ -261,7 +269,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): if code_required and not self._validate_code(code, "arming night"): return action = self._config[CONF_PAYLOAD_ARM_NIGHT] - self._publish(code, action) + await self._publish(code, action) async def async_alarm_arm_vacation(self, code=None): """Send arm vacation command. @@ -272,7 +280,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): if code_required and not self._validate_code(code, "arming vacation"): return action = self._config[CONF_PAYLOAD_ARM_VACATION] - self._publish(code, action) + await self._publish(code, action) async def async_alarm_arm_custom_bypass(self, code=None): """Send arm custom bypass command. @@ -283,14 +291,25 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): if code_required and not self._validate_code(code, "arming custom bypass"): return action = self._config[CONF_PAYLOAD_ARM_CUSTOM_BYPASS] - self._publish(code, action) + await self._publish(code, action) - def _publish(self, code, action): + async def async_alarm_trigger(self, code=None): + """Send trigger command. + + This method is a coroutine. + """ + code_required = self._config[CONF_CODE_TRIGGER_REQUIRED] + if code_required and not self._validate_code(code, "triggering"): + return + action = self._config[CONF_PAYLOAD_TRIGGER] + await self._publish(code, action) + + async def _publish(self, code, action): """Publish via mqtt.""" command_template = self._config[CONF_COMMAND_TEMPLATE] values = {"action": action, "code": code} payload = command_template.async_render(**values, parse_result=False) - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._config[CONF_COMMAND_TOPIC], payload, diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 213aabdb006..3d988079c6f 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -28,7 +28,7 @@ from homeassistant.util import dt as dt_util from . import PLATFORMS, subscription from .. import mqtt -from .const import CONF_QOS, CONF_STATE_TOPIC, DOMAIN +from .const import CONF_ENCODING, CONF_QOS, CONF_STATE_TOPIC, DOMAIN from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, @@ -87,6 +87,8 @@ async def _async_setup_entity( class MqttBinarySensor(MqttEntity, BinarySensorEntity): """Representation a binary sensor that is updated by MQTT.""" + _entity_id_format = binary_sensor.ENTITY_ID_FORMAT + def __init__(self, hass, config, config_entry, discovery_data): """Initialize the MQTT binary sensor.""" self._state = None @@ -198,6 +200,7 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity): "topic": self._config[CONF_STATE_TOPIC], "msg_callback": state_message_received, "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, } }, ) diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py new file mode 100644 index 00000000000..4b8931375b9 --- /dev/null +++ b/homeassistant/components/mqtt/button.py @@ -0,0 +1,94 @@ +"""Support for MQTT buttons.""" +from __future__ import annotations + +import functools + +import voluptuous as vol + +from homeassistant.components import button +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.typing import ConfigType + +from . import PLATFORMS +from .. import mqtt +from .const import CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, DOMAIN +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper + +CONF_PAYLOAD_PRESS = "payload_press" +DEFAULT_NAME = "MQTT Button" +DEFAULT_PAYLOAD_PRESS = "PRESS" + +PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_DEVICE_CLASS): button.DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PAYLOAD_PRESS, default=DEFAULT_PAYLOAD_PRESS): cv.string, + vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean, + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +DISCOVERY_SCHEMA = PLATFORM_SCHEMA.extend({}, extra=vol.REMOVE_EXTRA) + + +async def async_setup_platform( + hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None +): + """Set up MQTT button through configuration.yaml.""" + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + await _async_setup_entity(hass, async_add_entities, config) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up MQTT button dynamically through MQTT discovery.""" + setup = functools.partial( + _async_setup_entity, hass, async_add_entities, config_entry=config_entry + ) + await async_setup_entry_helper(hass, button.DOMAIN, setup, DISCOVERY_SCHEMA) + + +async def _async_setup_entity( + hass, async_add_entities, config, config_entry=None, discovery_data=None +): + """Set up the MQTT button.""" + async_add_entities([MqttButton(hass, config, config_entry, discovery_data)]) + + +class MqttButton(MqttEntity, ButtonEntity): + """Representation of a switch that can be toggled using MQTT.""" + + _entity_id_format = button.ENTITY_ID_FORMAT + + def __init__(self, hass, config, config_entry, discovery_data): + """Initialize the MQTT button.""" + MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + + @staticmethod + def config_schema(): + """Return the config schema.""" + return DISCOVERY_SCHEMA + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + + @property + def device_class(self) -> ButtonDeviceClass | None: + """Return the device class of the sensor.""" + return self._config.get(CONF_DEVICE_CLASS) + + async def async_press(self, **kwargs): + """Turn the device on. + + This method is a coroutine. + """ + await mqtt.async_publish( + self.hass, + self._config[CONF_COMMAND_TOPIC], + self._config[CONF_PAYLOAD_PRESS], + self._config[CONF_QOS], + self._config[CONF_RETAIN], + ) diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 39457dbd629..a9fdfb96a6c 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -66,6 +66,7 @@ async def _async_setup_entity( class MqttCamera(MqttEntity, Camera): """representation of a MQTT camera.""" + _entity_id_format = camera.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_CAMERA_ATTRIBUTES_BLOCKED def __init__(self, hass, config, config_entry, discovery_data): diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 16d4ae695c1..e1f63252495 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -13,6 +13,7 @@ from homeassistant.components.climate.const import ( ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_ACTIONS, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, FAN_AUTO, @@ -297,6 +298,7 @@ async def _async_setup_entity( class MqttClimate(MqttEntity, ClimateEntity): """Representation of an MQTT climate device.""" + _entity_id_format = climate.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED def __init__(self, hass, config, config_entry, discovery_data): @@ -404,9 +406,15 @@ class MqttClimate(MqttEntity, ClimateEntity): def handle_action_received(msg): """Handle receiving action via MQTT.""" payload = render_template(msg, CONF_ACTION_TEMPLATE) - - self._action = payload - self.async_write_ha_state() + if payload in CURRENT_HVAC_ACTIONS: + self._action = payload + self.async_write_ha_state() + else: + _LOGGER.warning( + "Invalid %s action: %s", + CURRENT_HVAC_ACTIONS, + payload, + ) add_subscription(topics, CONF_ACTION_TOPIC, handle_action_received) @@ -659,9 +667,9 @@ class MqttClimate(MqttEntity, ClimateEntity): """Return the list of available fan modes.""" return self._config[CONF_FAN_MODE_LIST] - def _publish(self, topic, payload): + async def _publish(self, topic, payload): if self._topic[topic] is not None: - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._topic[topic], payload, @@ -669,7 +677,9 @@ class MqttClimate(MqttEntity, ClimateEntity): self._config[CONF_RETAIN], ) - def _set_temperature(self, temp, cmnd_topic, cmnd_template, state_topic, attr): + async def _set_temperature( + self, temp, cmnd_topic, cmnd_template, state_topic, attr + ): if temp is not None: if self._topic[state_topic] is None: # optimistic mode @@ -680,7 +690,7 @@ class MqttClimate(MqttEntity, ClimateEntity): or self._current_operation != HVAC_MODE_OFF ): payload = self._command_templates[cmnd_template](temp) - self._publish(cmnd_topic, payload) + await self._publish(cmnd_topic, payload) async def async_set_temperature(self, **kwargs): """Set new target temperatures.""" @@ -688,7 +698,7 @@ class MqttClimate(MqttEntity, ClimateEntity): operation_mode = kwargs.get(ATTR_HVAC_MODE) await self.async_set_hvac_mode(operation_mode) - self._set_temperature( + await self._set_temperature( kwargs.get(ATTR_TEMPERATURE), CONF_TEMP_COMMAND_TOPIC, CONF_TEMP_COMMAND_TEMPLATE, @@ -696,7 +706,7 @@ class MqttClimate(MqttEntity, ClimateEntity): "_target_temp", ) - self._set_temperature( + await self._set_temperature( kwargs.get(ATTR_TARGET_TEMP_LOW), CONF_TEMP_LOW_COMMAND_TOPIC, CONF_TEMP_LOW_COMMAND_TEMPLATE, @@ -704,7 +714,7 @@ class MqttClimate(MqttEntity, ClimateEntity): "_target_temp_low", ) - self._set_temperature( + await self._set_temperature( kwargs.get(ATTR_TARGET_TEMP_HIGH), CONF_TEMP_HIGH_COMMAND_TOPIC, CONF_TEMP_HIGH_COMMAND_TEMPLATE, @@ -721,7 +731,7 @@ class MqttClimate(MqttEntity, ClimateEntity): payload = self._command_templates[CONF_SWING_MODE_COMMAND_TEMPLATE]( swing_mode ) - self._publish(CONF_SWING_MODE_COMMAND_TOPIC, payload) + await self._publish(CONF_SWING_MODE_COMMAND_TOPIC, payload) if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None: self._current_swing_mode = swing_mode @@ -731,7 +741,7 @@ class MqttClimate(MqttEntity, ClimateEntity): """Set new target temperature.""" if self._config[CONF_SEND_IF_OFF] or self._current_operation != HVAC_MODE_OFF: payload = self._command_templates[CONF_FAN_MODE_COMMAND_TEMPLATE](fan_mode) - self._publish(CONF_FAN_MODE_COMMAND_TOPIC, payload) + await self._publish(CONF_FAN_MODE_COMMAND_TOPIC, payload) if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None: self._current_fan_mode = fan_mode @@ -740,12 +750,14 @@ class MqttClimate(MqttEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode) -> None: """Set new operation mode.""" if self._current_operation == HVAC_MODE_OFF and hvac_mode != HVAC_MODE_OFF: - self._publish(CONF_POWER_COMMAND_TOPIC, self._config[CONF_PAYLOAD_ON]) + await self._publish(CONF_POWER_COMMAND_TOPIC, self._config[CONF_PAYLOAD_ON]) elif self._current_operation != HVAC_MODE_OFF and hvac_mode == HVAC_MODE_OFF: - self._publish(CONF_POWER_COMMAND_TOPIC, self._config[CONF_PAYLOAD_OFF]) + await self._publish( + CONF_POWER_COMMAND_TOPIC, self._config[CONF_PAYLOAD_OFF] + ) payload = self._command_templates[CONF_MODE_COMMAND_TEMPLATE](hvac_mode) - self._publish(CONF_MODE_COMMAND_TOPIC, payload) + await self._publish(CONF_MODE_COMMAND_TOPIC, payload) if self._topic[CONF_MODE_STATE_TOPIC] is None: self._current_operation = hvac_mode @@ -770,26 +782,28 @@ class MqttClimate(MqttEntity, ClimateEntity): optimistic_update = False if self._away: - optimistic_update = optimistic_update or self._set_away_mode(False) + optimistic_update = optimistic_update or await self._set_away_mode(False) elif preset_mode == PRESET_AWAY: if self._hold: - self._set_hold_mode(None) - optimistic_update = optimistic_update or self._set_away_mode(True) + await self._set_hold_mode(None) + optimistic_update = optimistic_update or await self._set_away_mode(True) else: hold_mode = preset_mode if preset_mode == PRESET_NONE: hold_mode = None - optimistic_update = optimistic_update or self._set_hold_mode(hold_mode) + optimistic_update = optimistic_update or await self._set_hold_mode( + hold_mode + ) if optimistic_update: self.async_write_ha_state() - def _set_away_mode(self, state): + async def _set_away_mode(self, state): """Set away mode. Returns if we should optimistically write the state. """ - self._publish( + await self._publish( CONF_AWAY_MODE_COMMAND_TOPIC, self._config[CONF_PAYLOAD_ON] if state else self._config[CONF_PAYLOAD_OFF], ) @@ -800,7 +814,7 @@ class MqttClimate(MqttEntity, ClimateEntity): self._away = state return True - def _set_hold_mode(self, hold_mode): + async def _set_hold_mode(self, hold_mode): """Set hold mode. Returns if we should optimistically write the state. @@ -808,7 +822,7 @@ class MqttClimate(MqttEntity, ClimateEntity): payload = self._command_templates[CONF_HOLD_COMMAND_TEMPLATE]( hold_mode or "off" ) - self._publish(CONF_HOLD_COMMAND_TOPIC, payload) + await self._publish(CONF_HOLD_COMMAND_TOPIC, payload) if self._topic[CONF_HOLD_STATE_TOPIC] is not None: return False @@ -816,8 +830,8 @@ class MqttClimate(MqttEntity, ClimateEntity): self._hold = hold_mode return True - def _set_aux_heat(self, state): - self._publish( + async def _set_aux_heat(self, state): + await self._publish( CONF_AUX_COMMAND_TOPIC, self._config[CONF_PAYLOAD_ON] if state else self._config[CONF_PAYLOAD_OFF], ) @@ -828,11 +842,11 @@ class MqttClimate(MqttEntity, ClimateEntity): async def async_turn_aux_heat_on(self): """Turn auxiliary heater on.""" - self._set_aux_heat(True) + await self._set_aux_heat(True) async def async_turn_aux_heat_off(self): """Turn auxiliary heater off.""" - self._set_aux_heat(False) + await self._set_aux_heat(False) @property def supported_features(self): diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 172657ded98..84322ddc1ee 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -5,6 +5,7 @@ import queue import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.hassio import HassioServiceInfo from homeassistant.const import ( CONF_DISCOVERY, CONF_HOST, @@ -14,6 +15,7 @@ from homeassistant.const import ( CONF_PROTOCOL, CONF_USERNAME, ) +from homeassistant.data_entry_flow import FlowResult from .const import ( ATTR_PAYLOAD, @@ -93,11 +95,11 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title="configuration.yaml", data={}) - async def async_step_hassio(self, discovery_info): + async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Receive a Hass.io discovery.""" await self._async_handle_discovery_without_unique_id() - self._hassio_discovery = discovery_info + self._hassio_discovery = discovery_info.config return await self.async_step_hassio_confirm() diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index c626592b0a3..9c788490ea3 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -13,6 +13,7 @@ CONF_AVAILABILITY = "availability" CONF_BROKER = "broker" CONF_BIRTH_MESSAGE = "birth_message" CONF_COMMAND_TOPIC = "command_topic" +CONF_ENCODING = "encoding" CONF_QOS = ATTR_QOS CONF_RETAIN = ATTR_RETAIN CONF_STATE_TOPIC = "state_topic" @@ -24,6 +25,7 @@ DATA_MQTT_CONFIG = "mqtt_config" DEFAULT_PREFIX = "homeassistant" DEFAULT_BIRTH_WILL_TOPIC = DEFAULT_PREFIX + "/status" DEFAULT_DISCOVERY = True +DEFAULT_ENCODING = "utf-8" DEFAULT_QOS = 0 DEFAULT_PAYLOAD_AVAILABLE = "online" DEFAULT_PAYLOAD_NOT_AVAILABLE = "offline" diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 0af9d7d3739..c2800cc8239 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -224,6 +224,7 @@ async def _async_setup_entity( class MqttCover(MqttEntity, CoverEntity): """Representation of a cover that can be controlled using MQTT.""" + _entity_id_format = cover.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_COVER_ATTRIBUTES_BLOCKED def __init__(self, hass, config, config_entry, discovery_data): @@ -528,7 +529,7 @@ class MqttCover(MqttEntity, CoverEntity): This method is a coroutine. """ - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._config.get(CONF_COMMAND_TOPIC), self._config[CONF_PAYLOAD_OPEN], @@ -549,7 +550,7 @@ class MqttCover(MqttEntity, CoverEntity): This method is a coroutine. """ - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._config.get(CONF_COMMAND_TOPIC), self._config[CONF_PAYLOAD_CLOSE], @@ -570,7 +571,7 @@ class MqttCover(MqttEntity, CoverEntity): This method is a coroutine. """ - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._config.get(CONF_COMMAND_TOPIC), self._config[CONF_PAYLOAD_STOP], @@ -580,7 +581,7 @@ class MqttCover(MqttEntity, CoverEntity): async def async_open_cover_tilt(self, **kwargs): """Tilt the cover open.""" - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._config.get(CONF_TILT_COMMAND_TOPIC), self._config[CONF_TILT_OPEN_POSITION], @@ -595,7 +596,7 @@ class MqttCover(MqttEntity, CoverEntity): async def async_close_cover_tilt(self, **kwargs): """Tilt the cover closed.""" - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._config.get(CONF_TILT_COMMAND_TOPIC), self._config[CONF_TILT_CLOSED_POSITION], @@ -626,7 +627,7 @@ class MqttCover(MqttEntity, CoverEntity): } tilt = template.async_render(parse_result=False, variables=variables) - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._config.get(CONF_TILT_COMMAND_TOPIC), tilt, @@ -655,7 +656,7 @@ class MqttCover(MqttEntity, CoverEntity): } position = template.async_render(parse_result=False, variables=variables) - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._config.get(CONF_SET_POSITION_TOPIC), position, diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py index f9bb3f1c91f..e462d76fa31 100644 --- a/homeassistant/components/mqtt/debug_info.py +++ b/homeassistant/components/mqtt/debug_info.py @@ -44,8 +44,7 @@ def log_messages( def add_subscription(hass, message_callback, subscription): """Prepare debug data for subscription.""" - entity_id = getattr(message_callback, "__entity_id", None) - if entity_id: + if entity_id := getattr(message_callback, "__entity_id", None): debug_info = hass.data.setdefault( DATA_MQTT_DEBUG_INFO, {"entities": {}, "triggers": {}} ) diff --git a/homeassistant/components/mqtt/device_tracker/schema_discovery.py b/homeassistant/components/mqtt/device_tracker/schema_discovery.py index f962d9208a4..1ccf03423de 100644 --- a/homeassistant/components/mqtt/device_tracker/schema_discovery.py +++ b/homeassistant/components/mqtt/device_tracker/schema_discovery.py @@ -58,6 +58,8 @@ async def _async_setup_entity( class MqttDeviceTracker(MqttEntity, TrackerEntity): """Representation of a device tracker using MQTT.""" + _entity_id_format = device_tracker.ENTITY_ID_FORMAT + def __init__(self, hass, config, config_entry, discovery_data): """Initialize the tracker.""" self._location_name = None diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index ffc0c1de435..c9b0b816c4e 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -38,6 +38,7 @@ TOPIC_MATCHER = re.compile( SUPPORTED_COMPONENTS = [ "alarm_control_panel", "binary_sensor", + "button", "camera", "climate", "cover", @@ -97,9 +98,8 @@ async def async_start( # noqa: C901 payload = msg.payload topic = msg.topic topic_trimmed = topic.replace(f"{discovery_topic}/", "", 1) - match = TOPIC_MATCHER.match(topic_trimmed) - if not match: + if not (match := TOPIC_MATCHER.match(topic_trimmed)): if topic_trimmed.endswith("config"): _LOGGER.warning( "Received message on illegal discovery topic '%s'", topic @@ -288,14 +288,14 @@ async def async_start( # noqa: C901 if key not in hass.data[INTEGRATION_UNSUBSCRIBE]: return - data = { - "topic": msg.topic, - "payload": msg.payload, - "qos": msg.qos, - "retain": msg.retain, - "subscribed_topic": msg.subscribed_topic, - "timestamp": msg.timestamp, - } + data = mqtt.MqttServiceInfo( + topic=msg.topic, + payload=msg.payload, + qos=msg.qos, + retain=msg.retain, + subscribed_topic=msg.subscribed_topic, + timestamp=msg.timestamp, + ) result = await hass.config_entries.flow.async_init( integration, context={"source": DOMAIN}, data=data ) diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index f950a4d2c60..9d0c954f3ab 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -175,13 +175,13 @@ PLATFORM_SCHEMA = vol.All( # CONF_SPEED_COMMAND_TOPIC, CONF_SPEED_LIST, CONF_SPEED_STATE_TOPIC, CONF_SPEED_VALUE_TEMPLATE and # Speeds SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH SPEED_OFF, # are deprecated, support will be removed with release 2021.9 - cv.deprecated(CONF_PAYLOAD_HIGH_SPEED), - cv.deprecated(CONF_PAYLOAD_LOW_SPEED), - cv.deprecated(CONF_PAYLOAD_MEDIUM_SPEED), - cv.deprecated(CONF_SPEED_COMMAND_TOPIC), - cv.deprecated(CONF_SPEED_LIST), - cv.deprecated(CONF_SPEED_STATE_TOPIC), - cv.deprecated(CONF_SPEED_VALUE_TEMPLATE), + cv.removed(CONF_PAYLOAD_HIGH_SPEED), + cv.removed(CONF_PAYLOAD_LOW_SPEED), + cv.removed(CONF_PAYLOAD_MEDIUM_SPEED), + cv.removed(CONF_SPEED_COMMAND_TOPIC), + cv.removed(CONF_SPEED_LIST), + cv.removed(CONF_SPEED_STATE_TOPIC), + cv.removed(CONF_SPEED_VALUE_TEMPLATE), _PLATFORM_SCHEMA_BASE, valid_speed_range_configuration, valid_preset_mode_configuration, @@ -191,13 +191,13 @@ DISCOVERY_SCHEMA = vol.All( # CONF_SPEED_COMMAND_TOPIC, CONF_SPEED_LIST, CONF_SPEED_STATE_TOPIC, CONF_SPEED_VALUE_TEMPLATE and # Speeds SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH SPEED_OFF, # are deprecated, support will be removed with release 2021.9 - cv.deprecated(CONF_PAYLOAD_HIGH_SPEED), - cv.deprecated(CONF_PAYLOAD_LOW_SPEED), - cv.deprecated(CONF_PAYLOAD_MEDIUM_SPEED), - cv.deprecated(CONF_SPEED_COMMAND_TOPIC), - cv.deprecated(CONF_SPEED_LIST), - cv.deprecated(CONF_SPEED_STATE_TOPIC), - cv.deprecated(CONF_SPEED_VALUE_TEMPLATE), + cv.removed(CONF_PAYLOAD_HIGH_SPEED), + cv.removed(CONF_PAYLOAD_LOW_SPEED), + cv.removed(CONF_PAYLOAD_MEDIUM_SPEED), + cv.removed(CONF_SPEED_COMMAND_TOPIC), + cv.removed(CONF_SPEED_LIST), + cv.removed(CONF_SPEED_STATE_TOPIC), + cv.removed(CONF_SPEED_VALUE_TEMPLATE), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), valid_speed_range_configuration, valid_preset_mode_configuration, @@ -230,6 +230,7 @@ async def _async_setup_entity( class MqttFan(MqttEntity, FanEntity): """A MQTT fan component.""" + _entity_id_format = fan.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_FAN_ATTRIBUTES_BLOCKED def __init__(self, hass, config, config_entry, discovery_data): @@ -520,7 +521,7 @@ class MqttFan(MqttEntity, FanEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[CONF_STATE](self._payload["STATE_ON"]) - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], mqtt_payload, @@ -541,7 +542,7 @@ class MqttFan(MqttEntity, FanEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[CONF_STATE](self._payload["STATE_OFF"]) - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], mqtt_payload, @@ -561,7 +562,7 @@ class MqttFan(MqttEntity, FanEntity): percentage_to_ranged_value(self._speed_range, percentage) ) mqtt_payload = self._command_templates[ATTR_PERCENTAGE](percentage_payload) - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._topic[CONF_PERCENTAGE_COMMAND_TOPIC], mqtt_payload, @@ -584,7 +585,7 @@ class MqttFan(MqttEntity, FanEntity): mqtt_payload = self._command_templates[ATTR_PRESET_MODE](preset_mode) - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._topic[CONF_PRESET_MODE_COMMAND_TOPIC], mqtt_payload, @@ -610,7 +611,7 @@ class MqttFan(MqttEntity, FanEntity): self._payload["OSCILLATE_OFF_PAYLOAD"] ) - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._topic[CONF_OSCILLATION_COMMAND_TOPIC], mqtt_payload, diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index e8bbbc7fd4b..a5346c0bf58 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -163,6 +163,7 @@ async def _async_setup_entity( class MqttHumidifier(MqttEntity, HumidifierEntity): """A MQTT humidifier component.""" + _entity_id_format = humidifier.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_HUMIDIFIER_ATTRIBUTES_BLOCKED def __init__(self, hass, config, config_entry, discovery_data): @@ -385,7 +386,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[CONF_STATE](self._payload["STATE_ON"]) - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], mqtt_payload, @@ -402,7 +403,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[CONF_STATE](self._payload["STATE_OFF"]) - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], mqtt_payload, @@ -419,7 +420,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[ATTR_HUMIDITY](humidity) - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._topic[CONF_TARGET_HUMIDITY_COMMAND_TOPIC], mqtt_payload, @@ -442,7 +443,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): mqtt_payload = self._command_templates[ATTR_MODE](mode) - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._topic[CONF_MODE_COMMAND_TOPIC], mqtt_payload, diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 5dd3f13af25..c0fc65610fd 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -29,6 +29,7 @@ from homeassistant.components.light import ( COLOR_MODE_UNKNOWN, COLOR_MODE_WHITE, COLOR_MODE_XY, + ENTITY_ID_FORMAT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, @@ -239,6 +240,7 @@ async def async_setup_entity_basic( class MqttLight(MqttEntity, LightEntity, RestoreEntity): """Representation of a MQTT light.""" + _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED def __init__(self, hass, config, config_entry, discovery_data): @@ -811,9 +813,9 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): should_update = False on_command_type = self._config[CONF_ON_COMMAND_TYPE] - def publish(topic, payload): + async def publish(topic, payload): """Publish an MQTT message.""" - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._topic[topic], payload, @@ -859,7 +861,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): return True if on_command_type == "first": - publish(CONF_COMMAND_TOPIC, self._payload["on"]) + await publish(CONF_COMMAND_TOPIC, self._payload["on"]) should_update = True # If brightness is being used instead of an on command, make sure @@ -881,13 +883,13 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): # Legacy mode: Convert HS to RGB rgb = scale_rgbx(color_util.color_hsv_to_RGB(*hs_color, 100)) rgb_s = render_rgbx(rgb, CONF_RGB_COMMAND_TEMPLATE, COLOR_MODE_RGB) - publish(CONF_RGB_COMMAND_TOPIC, rgb_s) + await publish(CONF_RGB_COMMAND_TOPIC, rgb_s) should_update |= set_optimistic( ATTR_HS_COLOR, hs_color, condition_attribute=ATTR_RGB_COLOR ) if hs_color and self._topic[CONF_HS_COMMAND_TOPIC] is not None: - publish(CONF_HS_COMMAND_TOPIC, f"{hs_color[0]},{hs_color[1]}") + await publish(CONF_HS_COMMAND_TOPIC, f"{hs_color[0]},{hs_color[1]}") should_update |= set_optimistic(ATTR_HS_COLOR, hs_color, COLOR_MODE_HS) if ( @@ -897,7 +899,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): ): # Legacy mode: Convert HS to XY xy_color = color_util.color_hs_to_xy(*hs_color) - publish(CONF_XY_COMMAND_TOPIC, f"{xy_color[0]},{xy_color[1]}") + await publish(CONF_XY_COMMAND_TOPIC, f"{xy_color[0]},{xy_color[1]}") should_update |= set_optimistic( ATTR_HS_COLOR, hs_color, condition_attribute=ATTR_XY_COLOR ) @@ -909,7 +911,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): ): scaled = scale_rgbx(rgb) rgb_s = render_rgbx(scaled, CONF_RGB_COMMAND_TEMPLATE, COLOR_MODE_RGB) - publish(CONF_RGB_COMMAND_TOPIC, rgb_s) + await publish(CONF_RGB_COMMAND_TOPIC, rgb_s) should_update |= set_optimistic(ATTR_RGB_COLOR, rgb, COLOR_MODE_RGB) if ( @@ -919,7 +921,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): ): scaled = scale_rgbx(rgbw) rgbw_s = render_rgbx(scaled, CONF_RGBW_COMMAND_TEMPLATE, COLOR_MODE_RGBW) - publish(CONF_RGBW_COMMAND_TOPIC, rgbw_s) + await publish(CONF_RGBW_COMMAND_TOPIC, rgbw_s) should_update |= set_optimistic(ATTR_RGBW_COLOR, rgbw, COLOR_MODE_RGBW) if ( @@ -929,7 +931,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): ): scaled = scale_rgbx(rgbww) rgbww_s = render_rgbx(scaled, CONF_RGBWW_COMMAND_TEMPLATE, COLOR_MODE_RGBWW) - publish(CONF_RGBWW_COMMAND_TOPIC, rgbww_s) + await publish(CONF_RGBWW_COMMAND_TOPIC, rgbww_s) should_update |= set_optimistic(ATTR_RGBWW_COLOR, rgbww, COLOR_MODE_RGBWW) if ( @@ -937,7 +939,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): and self._topic[CONF_XY_COMMAND_TOPIC] is not None and not self._legacy_mode ): - publish(CONF_XY_COMMAND_TOPIC, f"{xy_color[0]},{xy_color[1]}") + await publish(CONF_XY_COMMAND_TOPIC, f"{xy_color[0]},{xy_color[1]}") should_update |= set_optimistic(ATTR_XY_COLOR, xy_color, COLOR_MODE_XY) if ( @@ -951,7 +953,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): ) # Make sure the brightness is not rounded down to 0 device_brightness = max(device_brightness, 1) - publish(CONF_BRIGHTNESS_COMMAND_TOPIC, device_brightness) + await publish(CONF_BRIGHTNESS_COMMAND_TOPIC, device_brightness) should_update |= set_optimistic(ATTR_BRIGHTNESS, kwargs[ATTR_BRIGHTNESS]) elif ( ATTR_BRIGHTNESS in kwargs @@ -964,7 +966,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): brightness = kwargs[ATTR_BRIGHTNESS] rgb = scale_rgbx(color_util.color_hsv_to_RGB(*hs_color, 100), brightness) rgb_s = render_rgbx(rgb, CONF_RGB_COMMAND_TEMPLATE, COLOR_MODE_RGB) - publish(CONF_RGB_COMMAND_TOPIC, rgb_s) + await publish(CONF_RGB_COMMAND_TOPIC, rgb_s) should_update |= set_optimistic(ATTR_BRIGHTNESS, kwargs[ATTR_BRIGHTNESS]) elif ( ATTR_BRIGHTNESS in kwargs @@ -975,7 +977,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): rgb_color = self._rgb_color if self._rgb_color is not None else (255,) * 3 rgb = scale_rgbx(rgb_color, kwargs[ATTR_BRIGHTNESS]) rgb_s = render_rgbx(rgb, CONF_RGB_COMMAND_TEMPLATE, COLOR_MODE_RGB) - publish(CONF_RGB_COMMAND_TOPIC, rgb_s) + await publish(CONF_RGB_COMMAND_TOPIC, rgb_s) should_update |= set_optimistic(ATTR_BRIGHTNESS, kwargs[ATTR_BRIGHTNESS]) elif ( ATTR_BRIGHTNESS in kwargs @@ -988,7 +990,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): ) rgbw = scale_rgbx(rgbw_color, kwargs[ATTR_BRIGHTNESS]) rgbw_s = render_rgbx(rgbw, CONF_RGBW_COMMAND_TEMPLATE, COLOR_MODE_RGBW) - publish(CONF_RGBW_COMMAND_TOPIC, rgbw_s) + await publish(CONF_RGBW_COMMAND_TOPIC, rgbw_s) should_update |= set_optimistic(ATTR_BRIGHTNESS, kwargs[ATTR_BRIGHTNESS]) elif ( ATTR_BRIGHTNESS in kwargs @@ -1001,7 +1003,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): ) rgbww = scale_rgbx(rgbww_color, kwargs[ATTR_BRIGHTNESS]) rgbww_s = render_rgbx(rgbww, CONF_RGBWW_COMMAND_TEMPLATE, COLOR_MODE_RGBWW) - publish(CONF_RGBWW_COMMAND_TOPIC, rgbww_s) + await publish(CONF_RGBWW_COMMAND_TOPIC, rgbww_s) should_update |= set_optimistic(ATTR_BRIGHTNESS, kwargs[ATTR_BRIGHTNESS]) if ( ATTR_COLOR_TEMP in kwargs @@ -1012,7 +1014,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if tpl: color_temp = tpl({"value": color_temp}) - publish(CONF_COLOR_TEMP_COMMAND_TOPIC, color_temp) + await publish(CONF_COLOR_TEMP_COMMAND_TOPIC, color_temp) should_update |= set_optimistic( ATTR_COLOR_TEMP, kwargs[ATTR_COLOR_TEMP], COLOR_MODE_COLOR_TEMP ) @@ -1020,14 +1022,14 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if ATTR_EFFECT in kwargs and self._topic[CONF_EFFECT_COMMAND_TOPIC] is not None: effect = kwargs[ATTR_EFFECT] if effect in self._config.get(CONF_EFFECT_LIST): - publish(CONF_EFFECT_COMMAND_TOPIC, effect) + await publish(CONF_EFFECT_COMMAND_TOPIC, effect) should_update |= set_optimistic(ATTR_EFFECT, effect) if ATTR_WHITE in kwargs and self._topic[CONF_WHITE_COMMAND_TOPIC] is not None: percent_white = float(kwargs[ATTR_WHITE]) / 255 white_scale = self._config[CONF_WHITE_SCALE] device_white_value = min(round(percent_white * white_scale), white_scale) - publish(CONF_WHITE_COMMAND_TOPIC, device_white_value) + await publish(CONF_WHITE_COMMAND_TOPIC, device_white_value) should_update |= set_optimistic( ATTR_BRIGHTNESS, kwargs[ATTR_WHITE], @@ -1041,11 +1043,11 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): percent_white = float(kwargs[ATTR_WHITE_VALUE]) / 255 white_scale = self._config[CONF_WHITE_VALUE_SCALE] device_white_value = min(round(percent_white * white_scale), white_scale) - publish(CONF_WHITE_VALUE_COMMAND_TOPIC, device_white_value) + await publish(CONF_WHITE_VALUE_COMMAND_TOPIC, device_white_value) should_update |= set_optimistic(ATTR_WHITE_VALUE, kwargs[ATTR_WHITE_VALUE]) if on_command_type == "last": - publish(CONF_COMMAND_TOPIC, self._payload["on"]) + await publish(CONF_COMMAND_TOPIC, self._payload["on"]) should_update = True if self._optimistic: @@ -1061,7 +1063,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): This method is a coroutine. """ - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], self._payload["off"], diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 9915c2455df..0ba796df4e7 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -24,6 +24,7 @@ from homeassistant.components.light import ( COLOR_MODE_RGBW, COLOR_MODE_RGBWW, COLOR_MODE_XY, + ENTITY_ID_FORMAT, FLASH_LONG, FLASH_SHORT, SUPPORT_BRIGHTNESS, @@ -167,6 +168,7 @@ async def async_setup_entity_json( class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): """Representation of a MQTT JSON light.""" + _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED def __init__(self, hass, config, config_entry, discovery_data): @@ -621,7 +623,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._white_value = kwargs[ATTR_WHITE_VALUE] should_update = True - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], json.dumps(message), @@ -646,7 +648,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._set_flash_and_transition(message, **kwargs) - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], json.dumps(message), diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 6b7396846e1..838daab5860 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -11,6 +11,7 @@ from homeassistant.components.light import ( ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_WHITE_VALUE, + ENTITY_ID_FORMAT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, @@ -97,6 +98,7 @@ async def async_setup_entity_template( class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): """Representation of a MQTT Template light.""" + _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED def __init__(self, hass, config, config_entry, discovery_data): @@ -374,7 +376,7 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): if ATTR_TRANSITION in kwargs: values["transition"] = kwargs[ATTR_TRANSITION] - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._topics[CONF_COMMAND_TOPIC], self._templates[CONF_COMMAND_ON_TEMPLATE].async_render( @@ -399,7 +401,7 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): if ATTR_TRANSITION in kwargs: values["transition"] = kwargs[ATTR_TRANSITION] - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._topics[CONF_COMMAND_TOPIC], self._templates[CONF_COMMAND_OFF_TEMPLATE].async_render( diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index fdcd4294b8c..b43a00166ae 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -4,7 +4,7 @@ import functools import voluptuous as vol from homeassistant.components import lock -from homeassistant.components.lock import LockEntity +from homeassistant.components.lock import SUPPORT_OPEN, LockEntity from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv @@ -19,6 +19,7 @@ from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_hel CONF_PAYLOAD_LOCK = "payload_lock" CONF_PAYLOAD_UNLOCK = "payload_unlock" +CONF_PAYLOAD_OPEN = "payload_open" CONF_STATE_LOCKED = "state_locked" CONF_STATE_UNLOCKED = "state_unlocked" @@ -27,6 +28,7 @@ DEFAULT_NAME = "MQTT Lock" DEFAULT_OPTIMISTIC = False DEFAULT_PAYLOAD_LOCK = "LOCK" DEFAULT_PAYLOAD_UNLOCK = "UNLOCK" +DEFAULT_PAYLOAD_OPEN = "OPEN" DEFAULT_STATE_LOCKED = "LOCKED" DEFAULT_STATE_UNLOCKED = "UNLOCKED" @@ -43,6 +45,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend( vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_PAYLOAD_LOCK, default=DEFAULT_PAYLOAD_LOCK): cv.string, vol.Optional(CONF_PAYLOAD_UNLOCK, default=DEFAULT_PAYLOAD_UNLOCK): cv.string, + vol.Optional(CONF_PAYLOAD_OPEN): cv.string, vol.Optional(CONF_STATE_LOCKED, default=DEFAULT_STATE_LOCKED): cv.string, vol.Optional(CONF_STATE_UNLOCKED, default=DEFAULT_STATE_UNLOCKED): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, @@ -78,6 +81,7 @@ async def _async_setup_entity( class MqttLock(MqttEntity, LockEntity): """Representation of a lock that can be toggled using MQTT.""" + _entity_id_format = lock.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_LOCK_ATTRIBUTES_BLOCKED def __init__(self, hass, config, config_entry, discovery_data): @@ -144,12 +148,17 @@ class MqttLock(MqttEntity, LockEntity): """Return true if we do optimistic updates.""" return self._optimistic + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_OPEN if CONF_PAYLOAD_OPEN in self._config else 0 + async def async_lock(self, **kwargs): """Lock the device. This method is a coroutine. """ - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_LOCK], @@ -166,7 +175,7 @@ class MqttLock(MqttEntity, LockEntity): This method is a coroutine. """ - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_UNLOCK], @@ -177,3 +186,20 @@ class MqttLock(MqttEntity, LockEntity): # Optimistically assume that the lock has changed state. self._state = False self.async_write_ha_state() + + async def async_open(self, **kwargs): + """Open the door latch. + + This method is a coroutine. + """ + await mqtt.async_publish( + self.hass, + self._config[CONF_COMMAND_TOPIC], + self._config[CONF_PAYLOAD_OPEN], + self._config[CONF_QOS], + self._config[CONF_RETAIN], + ) + if self._optimistic: + # Optimistically assume that the lock unlocks when opened. + self._state = False + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 7cfc00da578..713f0e5c030 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -21,6 +21,7 @@ from homeassistant.const import ( CONF_ICON, CONF_NAME, CONF_UNIQUE_ID, + CONF_VALUE_TEMPLATE, ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv @@ -28,7 +29,12 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA, DeviceInfo, Entity +from homeassistant.helpers.entity import ( + ENTITY_CATEGORIES_SCHEMA, + DeviceInfo, + Entity, + async_generate_entity_id, +) from homeassistant.helpers.typing import ConfigType from . import DATA_MQTT, debug_info, publish, subscription @@ -37,6 +43,7 @@ from .const import ( ATTR_DISCOVERY_PAYLOAD, ATTR_DISCOVERY_TOPIC, CONF_AVAILABILITY, + CONF_ENCODING, CONF_QOS, CONF_TOPIC, DEFAULT_PAYLOAD_AVAILABLE, @@ -66,6 +73,7 @@ AVAILABILITY_LATEST = "latest" AVAILABILITY_MODES = [AVAILABILITY_ALL, AVAILABILITY_ANY, AVAILABILITY_LATEST] CONF_AVAILABILITY_MODE = "availability_mode" +CONF_AVAILABILITY_TEMPLATE = "availability_template" CONF_AVAILABILITY_TOPIC = "availability_topic" CONF_ENABLED_BY_DEFAULT = "enabled_by_default" CONF_PAYLOAD_AVAILABLE = "payload_available" @@ -82,6 +90,7 @@ CONF_VIA_DEVICE = "via_device" CONF_DEPRECATED_VIA_HUB = "via_hub" CONF_SUGGESTED_AREA = "suggested_area" CONF_CONFIGURATION_URL = "configuration_url" +CONF_OBJECT_ID = "object_id" MQTT_ATTRIBUTES_BLOCKED = { "assumed_state", @@ -106,6 +115,7 @@ MQTT_ATTRIBUTES_BLOCKED = { MQTT_AVAILABILITY_SINGLE_SCHEMA = vol.Schema( { vol.Exclusive(CONF_AVAILABILITY_TOPIC, "availability"): valid_subscribe_topic, + vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template, vol.Optional( CONF_PAYLOAD_AVAILABLE, default=DEFAULT_PAYLOAD_AVAILABLE ): cv.string, @@ -132,6 +142,7 @@ MQTT_AVAILABILITY_LIST_SCHEMA = vol.Schema( CONF_PAYLOAD_NOT_AVAILABLE, default=DEFAULT_PAYLOAD_NOT_AVAILABLE, ): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, } ], ), @@ -183,6 +194,7 @@ MQTT_ENTITY_COMMON_SCHEMA = MQTT_AVAILABILITY_SCHEMA.extend( vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic, vol.Optional(CONF_JSON_ATTRS_TEMPLATE): cv.template, + vol.Optional(CONF_OBJECT_ID): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -210,6 +222,14 @@ async def async_setup_entry_helper(hass, domain, async_setup, schema): ) +def init_entity_id_from_config(hass, entity, config, entity_id_format): + """Set entity_id from object_id if defined in config.""" + if CONF_OBJECT_ID in config: + entity.entity_id = async_generate_entity_id( + entity_id_format, config[CONF_OBJECT_ID], None, hass + ) + + class MqttAttributes(Entity): """Mixin used for platforms that support JSON attributes.""" @@ -320,6 +340,7 @@ class MqttAvailability(Entity): self._avail_topics[config[CONF_AVAILABILITY_TOPIC]] = { CONF_PAYLOAD_AVAILABLE: config[CONF_PAYLOAD_AVAILABLE], CONF_PAYLOAD_NOT_AVAILABLE: config[CONF_PAYLOAD_NOT_AVAILABLE], + CONF_AVAILABILITY_TEMPLATE: config.get(CONF_AVAILABILITY_TEMPLATE), } if CONF_AVAILABILITY in config: @@ -327,8 +348,22 @@ class MqttAvailability(Entity): self._avail_topics[avail[CONF_TOPIC]] = { CONF_PAYLOAD_AVAILABLE: avail[CONF_PAYLOAD_AVAILABLE], CONF_PAYLOAD_NOT_AVAILABLE: avail[CONF_PAYLOAD_NOT_AVAILABLE], + CONF_AVAILABILITY_TEMPLATE: avail.get(CONF_VALUE_TEMPLATE), } + for ( + topic, # pylint: disable=unused-variable + avail_topic_conf, + ) in self._avail_topics.items(): + tpl = avail_topic_conf[CONF_AVAILABILITY_TEMPLATE] + if tpl is None: + avail_topic_conf[CONF_AVAILABILITY_TEMPLATE] = lambda value: value + else: + tpl.hass = self.hass + avail_topic_conf[ + CONF_AVAILABILITY_TEMPLATE + ] = tpl.async_render_with_possible_json_value + self._avail_config = config async def _availability_subscribe_topics(self): @@ -339,10 +374,11 @@ class MqttAvailability(Entity): def availability_message_received(msg: ReceiveMessage) -> None: """Handle a new received MQTT availability message.""" topic = msg.topic - if msg.payload == self._avail_topics[topic][CONF_PAYLOAD_AVAILABLE]: + payload = self._avail_topics[topic][CONF_AVAILABILITY_TEMPLATE](msg.payload) + if payload == self._avail_topics[topic][CONF_PAYLOAD_AVAILABLE]: self._available[topic] = True self._available_latest = True - elif msg.payload == self._avail_topics[topic][CONF_PAYLOAD_NOT_AVAILABLE]: + elif payload == self._avail_topics[topic][CONF_PAYLOAD_NOT_AVAILABLE]: self._available[topic] = False self._available_latest = False @@ -357,6 +393,7 @@ class MqttAvailability(Entity): "topic": topic, "msg_callback": availability_message_received, "qos": self._avail_config[CONF_QOS], + "encoding": self._avail_config[CONF_ENCODING] or None, } for topic in self._avail_topics } @@ -588,6 +625,8 @@ class MqttEntity( ): """Representation of an MQTT entity.""" + _entity_id_format: str + def __init__(self, hass, config, config_entry, discovery_data): """Init the MQTT Entity.""" self.hass = hass @@ -598,12 +637,21 @@ class MqttEntity( # Load config self._setup_from_config(self._config) + # Initialize entity_id from config + self._init_entity_id() + # Initialize mixin classes MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, config.get(CONF_DEVICE), config_entry) + def _init_entity_id(self): + """Set entity_id from object_id if defined in config.""" + init_entity_id_from_config( + self.hass, self, self._config, self._entity_id_format + ) + async def async_added_to_hass(self): """Subscribe mqtt events.""" await super().async_added_to_hass() diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 902d828d911..29f6b4bad21 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -31,6 +31,8 @@ from .const import CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, from .debug_info import log_messages from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +CONF_COMMAND_TEMPLATE = "command_template" + _LOGGER = logging.getLogger(__name__) CONF_MIN = "min" @@ -61,6 +63,7 @@ def validate_config(config): _PLATFORM_SCHEMA_BASE = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend( { + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): vol.Coerce(float), vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): vol.Coerce(float), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -111,6 +114,7 @@ async def _async_setup_entity( class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): """representation of an MQTT number.""" + _entity_id_format = number.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_NUMBER_ATTRIBUTES_BLOCKED def __init__(self, hass, config, config_entry, discovery_data): @@ -133,9 +137,16 @@ class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): """(Re)Setup the entity.""" self._optimistic = config[CONF_OPTIMISTIC] - value_template = self._config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: - value_template.hass = self.hass + self._templates = { + CONF_COMMAND_TEMPLATE: config.get(CONF_COMMAND_TEMPLATE), + CONF_VALUE_TEMPLATE: config.get(CONF_VALUE_TEMPLATE), + } + for key, tpl in self._templates.items(): + if tpl is None: + self._templates[key] = lambda value: value + else: + tpl.hass = self.hass + self._templates[key] = tpl.async_render_with_possible_json_value async def _subscribe_topics(self): """(Re)Subscribe to topics.""" @@ -144,10 +155,7 @@ class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): @log_messages(self.hass, self.entity_id) def message_received(msg): """Handle new MQTT messages.""" - payload = msg.payload - value_template = self._config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: - payload = value_template.async_render_with_possible_json_value(payload) + payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload) try: if payload == self._config[CONF_PAYLOAD_RESET]: num_value = None @@ -190,10 +198,8 @@ class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): }, ) - if self._optimistic: - last_state = await self.async_get_last_state() - if last_state: - self._current_number = last_state.state + if self._optimistic and (last_state := await self.async_get_last_state()): + self._current_number = last_state.state @property def min_value(self) -> float: @@ -226,15 +232,16 @@ class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): if value.is_integer(): current_number = int(value) + payload = self._templates[CONF_COMMAND_TEMPLATE](current_number) if self._optimistic: self._current_number = current_number self.async_write_ha_state() - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._config[CONF_COMMAND_TOPIC], - current_number, + payload, self._config[CONF_QOS], self._config[CONF_RETAIN], ) diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index 6cf953ccf44..67b757e5e8a 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -15,10 +15,12 @@ from . import PLATFORMS from .. import mqtt from .const import CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, DOMAIN from .mixins import ( + CONF_OBJECT_ID, MQTT_AVAILABILITY_SCHEMA, MqttAvailability, MqttDiscoveryUpdate, async_setup_entry_helper, + init_entity_id_from_config, ) DEFAULT_NAME = "MQTT Scene" @@ -32,6 +34,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( vol.Optional(CONF_PAYLOAD_ON): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_OBJECT_ID): cv.string, } ).extend(MQTT_AVAILABILITY_SCHEMA.schema) @@ -43,22 +46,22 @@ async def async_setup_platform( ): """Set up MQTT scene through configuration.yaml.""" await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - await _async_setup_entity(async_add_entities, config) + await _async_setup_entity(hass, async_add_entities, config) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MQTT scene dynamically through MQTT discovery.""" setup = functools.partial( - _async_setup_entity, async_add_entities, config_entry=config_entry + _async_setup_entity, hass, async_add_entities, config_entry=config_entry ) await async_setup_entry_helper(hass, scene.DOMAIN, setup, DISCOVERY_SCHEMA) async def _async_setup_entity( - async_add_entities, config, config_entry=None, discovery_data=None + hass, async_add_entities, config, config_entry=None, discovery_data=None ): """Set up the MQTT scene.""" - async_add_entities([MqttScene(config, config_entry, discovery_data)]) + async_add_entities([MqttScene(hass, config, config_entry, discovery_data)]) class MqttScene( @@ -68,8 +71,11 @@ class MqttScene( ): """Representation of a scene that can be activated using MQTT.""" - def __init__(self, config, config_entry, discovery_data): + _entity_id_format = scene.DOMAIN + ".{}" + + def __init__(self, hass, config, config_entry, discovery_data): """Initialize the MQTT scene.""" + self.hass = hass self._state = False self._sub_state = None @@ -78,9 +84,18 @@ class MqttScene( # Load config self._setup_from_config(config) + # Initialize entity_id from config + self._init_entity_id() + MqttAvailability.__init__(self, config) MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) + def _init_entity_id(self): + """Set entity_id from object_id if defined in config.""" + init_entity_id_from_config( + self.hass, self, self._config, self._entity_id_format + ) + async def async_added_to_hass(self): """Subscribe to MQTT events.""" await super().async_added_to_hass() @@ -121,7 +136,7 @@ class MqttScene( This method is a coroutine. """ - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_ON], diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index b289bd53f0d..7b374ba8955 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -19,6 +19,8 @@ from .const import CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, from .debug_info import log_messages from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +CONF_COMMAND_TEMPLATE = "command_template" + _LOGGER = logging.getLogger(__name__) CONF_OPTIONS = "options" @@ -33,16 +35,9 @@ MQTT_SELECT_ATTRIBUTES_BLOCKED = frozenset( ) -def validate_config(config): - """Validate that the configuration is valid, throws if it isn't.""" - if len(config[CONF_OPTIONS]) < 2: - raise vol.Invalid(f"'{CONF_OPTIONS}' must include at least 2 options") - - return config - - _PLATFORM_SCHEMA_BASE = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend( { + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Required(CONF_OPTIONS): cv.ensure_list, @@ -50,15 +45,9 @@ _PLATFORM_SCHEMA_BASE = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend( }, ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -PLATFORM_SCHEMA = vol.All( - _PLATFORM_SCHEMA_BASE, - validate_config, -) +PLATFORM_SCHEMA = vol.All(_PLATFORM_SCHEMA_BASE) -DISCOVERY_SCHEMA = vol.All( - _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), - validate_config, -) +DISCOVERY_SCHEMA = vol.All(_PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA)) async def async_setup_platform( @@ -87,6 +76,8 @@ async def _async_setup_entity( class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): """representation of an MQTT select.""" + _entity_id_format = select.ENTITY_ID_FORMAT + _attributes_extra_blocked = MQTT_SELECT_ATTRIBUTES_BLOCKED def __init__(self, hass, config, config_entry, discovery_data): @@ -110,9 +101,16 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): self._optimistic = config[CONF_OPTIMISTIC] self._attr_options = config[CONF_OPTIONS] - value_template = self._config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: - value_template.hass = self.hass + self._templates = { + CONF_COMMAND_TEMPLATE: config.get(CONF_COMMAND_TEMPLATE), + CONF_VALUE_TEMPLATE: config.get(CONF_VALUE_TEMPLATE), + } + for key, tpl in self._templates.items(): + if tpl is None: + self._templates[key] = lambda value: value + else: + tpl.hass = self.hass + self._templates[key] = tpl.async_render_with_possible_json_value async def _subscribe_topics(self): """(Re)Subscribe to topics.""" @@ -121,10 +119,7 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): @log_messages(self.hass, self.entity_id) def message_received(msg): """Handle new MQTT messages.""" - payload = msg.payload - value_template = self._config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: - payload = value_template.async_render_with_possible_json_value(payload) + payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload) if payload.lower() == "none": payload = None @@ -157,21 +152,20 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): }, ) - if self._optimistic: - last_state = await self.async_get_last_state() - if last_state: - self._attr_current_option = last_state.state + if self._optimistic and (last_state := await self.async_get_last_state()): + self._attr_current_option = last_state.state async def async_select_option(self, option: str) -> None: """Update the current value.""" + payload = self._templates[CONF_COMMAND_TEMPLATE](option) if self._optimistic: self._attr_current_option = option self.async_write_ha_state() - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._config[CONF_COMMAND_TOPIC], - option, + payload, self._config[CONF_QOS], self._config[CONF_RETAIN], ) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 3881dfee0e8..72f0b339fe2 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components import sensor from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA, + ENTITY_ID_FORMAT, STATE_CLASSES_SCHEMA, SensorEntity, ) @@ -20,6 +21,8 @@ from homeassistant.const import ( CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, + DEVICE_CLASS_DATE, + DEVICE_CLASS_TIMESTAMP, ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv @@ -30,7 +33,7 @@ from homeassistant.util import dt as dt_util from . import PLATFORMS, subscription from .. import mqtt -from .const import CONF_QOS, CONF_STATE_TOPIC, DOMAIN +from .const import CONF_ENCODING, CONF_QOS, CONF_STATE_TOPIC, DOMAIN from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, @@ -132,6 +135,7 @@ async def _async_setup_entity( class MqttSensor(MqttEntity, SensorEntity): """Representation of a sensor that can be updated using MQTT.""" + _entity_id_format = ENTITY_ID_FORMAT _attr_last_reset = None _attributes_extra_blocked = MQTT_SENSOR_ATTRIBUTES_BLOCKED @@ -194,6 +198,18 @@ class MqttSensor(MqttEntity, SensorEntity): self._state, variables=variables, ) + + if payload is not None and self.device_class in ( + DEVICE_CLASS_DATE, + DEVICE_CLASS_TIMESTAMP, + ): + if (payload := dt_util.parse_datetime(payload)) is None: + _LOGGER.warning( + "Invalid state message '%s' from '%s'", msg.payload, msg.topic + ) + elif self.device_class == DEVICE_CLASS_DATE: + payload = payload.date() + self._state = payload def _update_last_reset(msg): @@ -236,6 +252,7 @@ class MqttSensor(MqttEntity, SensorEntity): "topic": self._config[CONF_STATE_TOPIC], "msg_callback": message_received, "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, } @callback diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 3a593adf1e3..8f9178c15f1 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -1,11 +1,14 @@ """Support for MQTT switches.""" +from __future__ import annotations + import functools import voluptuous as vol from homeassistant.components import switch -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import DEVICE_CLASSES_SCHEMA, SwitchEntity from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_NAME, CONF_OPTIMISTIC, CONF_PAYLOAD_OFF, @@ -48,6 +51,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend( vol.Optional(CONF_STATE_OFF): cv.string, vol.Optional(CONF_STATE_ON): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) @@ -80,6 +84,7 @@ async def _async_setup_entity( class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): """Representation of a switch that can be toggled using MQTT.""" + _entity_id_format = switch.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_SWITCH_ATTRIBUTES_BLOCKED def __init__(self, hass, config, config_entry, discovery_data): @@ -145,10 +150,8 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): }, ) - if self._optimistic: - last_state = await self.async_get_last_state() - if last_state: - self._state = last_state.state == STATE_ON + if self._optimistic and (last_state := await self.async_get_last_state()): + self._state = last_state.state == STATE_ON @property def is_on(self): @@ -160,12 +163,17 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): """Return true if we do optimistic updates.""" return self._optimistic + @property + def device_class(self) -> str | None: + """Return the device class of the sensor.""" + return self._config.get(CONF_DEVICE_CLASS) + async def async_turn_on(self, **kwargs): """Turn the device on. This method is a coroutine. """ - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_ON], @@ -182,7 +190,7 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): This method is a coroutine. """ - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_OFF], diff --git a/homeassistant/components/mqtt/translations/bg.json b/homeassistant/components/mqtt/translations/bg.json index 4790a02329d..f69eb1b4cc9 100644 --- a/homeassistant/components/mqtt/translations/bg.json +++ b/homeassistant/components/mqtt/translations/bg.json @@ -28,10 +28,15 @@ } }, "options": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "broker": { "data": { - "port": "\u041f\u043e\u0440\u0442" + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "port": "\u041f\u043e\u0440\u0442", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" } } } diff --git a/homeassistant/components/mqtt/translations/id.json b/homeassistant/components/mqtt/translations/id.json index 14e047c1694..308c9f31e8c 100644 --- a/homeassistant/components/mqtt/translations/id.json +++ b/homeassistant/components/mqtt/translations/id.json @@ -51,6 +51,8 @@ }, "options": { "error": { + "bad_birth": "Topik birth tidak valid", + "bad_will": "Topik will tidak valid", "cannot_connect": "Gagal terhubung" }, "step": { @@ -61,13 +63,25 @@ "port": "Port", "username": "Nama Pengguna" }, - "description": "Masukkan informasi koneksi broker MQTT Anda." + "description": "Masukkan informasi koneksi broker MQTT Anda.", + "title": "Opsi broker" }, "options": { "data": { - "discovery": "Aktifkan penemuan" + "birth_enable": "Aktifkan pesan 'birth'", + "birth_payload": "Payload pesan birth", + "birth_qos": "QoS pesan birth", + "birth_retain": "Simpan pesan birth", + "birth_topic": "Topik pesan birth", + "discovery": "Aktifkan penemuan", + "will_enable": "Aktifkan pesan 'will'", + "will_payload": "Payload pesan will", + "will_qos": "QoS pesan will", + "will_retain": "Simpan pesan will", + "will_topic": "Topik pesan will" }, - "description": "Pilih opsi MQTT." + "description": "Penemuan - Jika penemuan diaktifkan (disarankan), Home Assistant akan secara otomatis menemukan perangkat dan entitas yang mempublikasikan konfigurasinya di broker MQTT. Jika penemuan dinonaktifkan, semua konfigurasi harus dilakukan secara manual.\nPesan birth - Pesan birth akan dikirim setiap kali Home Assistant terhubung (kembali) ke broker MQTT.\nPesan will akan dikirim setiap kali Home Assistant kehilangan koneksi ke broker, baik dalam kasus bersih (misalnya Home Assistant dimatikan) dan dalam kasus (misalnya Home Assistant mogok atau kehilangan koneksi jaringan) terputus yang tidak bersih.", + "title": "Opsi MQTT" } } } diff --git a/homeassistant/components/mqtt/translations/ja.json b/homeassistant/components/mqtt/translations/ja.json index 0ec3c953a00..a7de3f583b2 100644 --- a/homeassistant/components/mqtt/translations/ja.json +++ b/homeassistant/components/mqtt/translations/ja.json @@ -1,17 +1,85 @@ { "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, "step": { "broker": { "data": { - "broker": "\u30d6\u30ed\u30fc\u30ab\u30fc", + "broker": "Broker", + "discovery": "\u691c\u51fa\u3092\u6709\u52b9\u306b\u3059\u308b", "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", - "port": "\u30dd\u30fc\u30c8" - } + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "MQTT broker\u306e\u63a5\u7d9a\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" }, "hassio_confirm": { "data": { "discovery": "\u691c\u51fa\u3092\u6709\u52b9\u306b\u3059\u308b" - } + }, + "description": "\u30a2\u30c9\u30aa\u30f3 {addon} \u304c\u3001\u63d0\u4f9b\u3059\u308bMQTT broker\u306b\u63a5\u7d9a\u3059\u308b\u3088\u3046\u306bHome Assistant\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u304b\uff1f", + "title": "Home Assistant\u30a2\u30c9\u30aa\u30f3\u3092\u4ecb\u3057\u305fMQTT Broker" + } + } + }, + "device_automation": { + "trigger_subtype": { + "button_1": "1\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "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", + "turn_off": "\u30aa\u30d5\u306b\u3059\u308b", + "turn_on": "\u30aa\u30f3\u306b\u3059\u308b" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" \u30c0\u30d6\u30eb\u30af\u30ea\u30c3\u30af", + "button_long_press": "\"{subtype}\" \u304c\u3001\u7d99\u7d9a\u7684\u306b\u62bc\u3055\u308c\u305f", + "button_quadruple_press": "\"{subtype}\" 4\u56de(quadruple)\u30af\u30ea\u30c3\u30af", + "button_quintuple_press": "\"{subtype}\" 5\u56de(quintuple)\u30af\u30ea\u30c3\u30af", + "button_short_press": "\"{subtype}\" \u304c\u3001\u62bc\u3055\u308c\u307e\u3057\u305f", + "button_triple_press": "\"{subtype}\" 3\u56de\u30af\u30ea\u30c3\u30af" + } + }, + "options": { + "error": { + "bad_birth": "(\u7121\u52b9\u306a)Invalid birth topic.", + "bad_will": "(\u7121\u52b9\u306a)Invalid will topic.", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "broker": { + "data": { + "broker": "Broker", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "MQTT broker\u306e\u63a5\u7d9a\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Broker\u30aa\u30d7\u30b7\u30e7\u30f3" + }, + "options": { + "data": { + "birth_enable": "Birth message Enable(\u6709\u52b9\u5316)", + "birth_payload": "Birth message payload(\u30da\u30a4\u30ed\u30fc\u30c9)", + "birth_qos": "Birth message QoS", + "birth_retain": "Birth message retain(\u4fdd\u6301)", + "birth_topic": "Birth message topic", + "discovery": "\u691c\u51fa\u3092\u6709\u52b9\u306b\u3059\u308b", + "will_enable": "\u30a6\u30a3\u30eb(will)\u30e1\u30c3\u30bb\u30fc\u30b8\u306e\u6709\u52b9\u5316", + "will_payload": "Will message payload(\u30da\u30a4\u30ed\u30fc\u30c9)", + "will_qos": "Will message QoS", + "will_retain": "Will message retain(\u4fdd\u6301)", + "will_topic": "Will message topic" + }, + "description": "\u30c7\u30a3\u30b9\u30ab\u30d0\u30ea\u30fc(Discovery(\u691c\u51fa)) - \u691c\u51fa\u304c\u6709\u52b9\u306b\u306a\u3063\u3066\u3044\u308b\u5834\u5408(\u63a8\u5968)\u3001Home Assistant\u306f\u3001MQTT broker\u306b\u8a2d\u5b9a\u3092\u516c\u958b\u3057\u3066\u3044\u308b\u30c7\u30d0\u30a4\u30b9\u3084\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u3092\u81ea\u52d5\u7684\u306b\u691c\u51fa\u3057\u307e\u3059\u3002\u691c\u51fa\u3092\u7121\u52b9\u306b\u3057\u305f\u5834\u5408\u306f\u3001\u3059\u3079\u3066\u306e\u8a2d\u5b9a\u3092\u624b\u52d5\u3067\u884c\u3046\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\n\u30d0\u30fc\u30b9(Birth(\u8a95\u751f))\u30e1\u30c3\u30bb\u30fc\u30b8 - \u8a95\u751f\u30e1\u30c3\u30bb\u30fc\u30b8\u306f\u3001Home Assistant\u304cMQTT broker\u306b\u3001(\u518d)\u63a5\u7d9a\u3059\u308b\u305f\u3073\u306b\u9001\u4fe1\u3055\u308c\u307e\u3059\u3002\n\u30a6\u30a3\u30eb(Will(\u610f\u601d))\u30e1\u30c3\u30bb\u30fc\u30b8 - \u30a6\u30a3\u30eb\u30e1\u30c3\u30bb\u30fc\u30b8\u306f\u3001Home Assistant\u304c\u30d6\u30ed\u30fc\u30ab\u30fc(broker)\u3078\u306e\u63a5\u7d9a\u3092\u5931\u3046\u305f\u3073\u306b\u9001\u4fe1\u3055\u308c\u307e\u3059\u3002\u3053\u308c\u306f\u3001\u30af\u30ea\u30fc\u30f3\u306a\u63a5\u7d9a(Home Assistant\u306e\u30b7\u30e3\u30c3\u30c8\u30c0\u30a6\u30f3\u306a\u3069)\u306e\u5834\u5408\u3068\u3001\u30af\u30ea\u30fc\u30f3\u3067\u306f\u306a\u3044\u63a5\u7d9a(Home Assistant\u306e\u30af\u30e9\u30c3\u30b7\u30e5\u3084\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u63a5\u7d9a\u3092\u5931\u3063\u305f\u5834\u5408)\u306e\u3069\u3061\u3089\u306e\u5834\u5408\u3067\u3042\u3063\u3066\u3082\u9001\u4fe1\u3055\u308c\u307e\u3059\u3002", + "title": "MQTT\u30aa\u30d7\u30b7\u30e7\u30f3" } } } diff --git a/homeassistant/components/mqtt/translations/tr.json b/homeassistant/components/mqtt/translations/tr.json index 86dce2b6ea4..d00aaf1c06b 100644 --- a/homeassistant/components/mqtt/translations/tr.json +++ b/homeassistant/components/mqtt/translations/tr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, "error": { @@ -9,25 +10,38 @@ "step": { "broker": { "data": { + "broker": "Broker", + "discovery": "Ke\u015ffetmeyi etkinle\u015ftir", "password": "Parola", - "port": "Port" - } + "port": "Port", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "L\u00fctfen MQTT brokerinizin ba\u011flant\u0131 bilgilerini girin." }, "hassio_confirm": { "data": { "discovery": "Ke\u015ffetmeyi etkinle\u015ftir" - } + }, + "description": "{addon} taraf\u0131ndan sa\u011flanan MQTT arac\u0131s\u0131na ba\u011flanacak \u015fekilde yap\u0131land\u0131rmak istiyor musunuz?", + "title": "Home Assistant eklentisi arac\u0131l\u0131\u011f\u0131yla MQTT Broker" } } }, "device_automation": { "trigger_subtype": { + "button_1": "\u0130lk d\u00fc\u011fme", + "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", "turn_off": "Kapat", "turn_on": "A\u00e7" }, "trigger_type": { "button_double_press": "\" {subtype} \" \u00e7ift t\u0131kland\u0131", "button_long_press": "\" {subtype} \" s\u00fcrekli olarak bas\u0131ld\u0131", + "button_long_release": "\" {subtype} \" uzun bas\u0131\u015ftan sonra \u00e7\u0131kt\u0131", "button_quadruple_press": "\" {subtype} \" d\u00f6rt kez t\u0131kland\u0131", "button_quintuple_press": "\" {subtype} \" be\u015fli t\u0131kland\u0131", "button_short_press": "\" {subtype} \" bas\u0131ld\u0131", @@ -37,15 +51,37 @@ }, "options": { "error": { + "bad_birth": "Ge\u00e7ersiz do\u011fum konusu.", + "bad_will": "Ge\u00e7ersiz olacak konu.", "cannot_connect": "Ba\u011flanma hatas\u0131" }, "step": { "broker": { "data": { + "broker": "Broker", "password": "Parola", "port": "Port", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "description": "L\u00fctfen MQTT brokerinizin ba\u011flant\u0131 bilgilerini girin.", + "title": "Broker se\u00e7enekleri" + }, + "options": { + "data": { + "birth_enable": "Do\u011fum mesaj\u0131n\u0131 etkinle\u015ftir", + "birth_payload": "Do\u011fum mesaj\u0131 y\u00fckl\u00fc", + "birth_qos": "Do\u011fum mesaj\u0131 QoS", + "birth_retain": "Do\u011fum mesaj\u0131 saklama", + "birth_topic": "Do\u011fum mesaj\u0131 konusu", + "discovery": "Ke\u015ffetmeyi etkinle\u015ftir", + "will_enable": "Etkinle\u015ftir iletisi", + "will_payload": "\u0130leti y\u00fckl\u00fc", + "will_qos": "QoS mesaj\u0131 g\u00f6nderecek", + "will_retain": "Mesaj korunacak m\u0131", + "will_topic": "Mesaj konusu olacak" + }, + "description": "Ke\u015fif - Ke\u015fif etkinle\u015ftirilirse (\u00f6nerilir), Home Assistant, yap\u0131land\u0131rmalar\u0131n\u0131 MQTT arac\u0131s\u0131nda yay\u0131nlayan cihazlar\u0131 ve varl\u0131klar\u0131 otomatik olarak ke\u015ffeder. Ke\u015fif devre d\u0131\u015f\u0131 b\u0131rak\u0131l\u0131rsa, t\u00fcm yap\u0131land\u0131rma manuel olarak yap\u0131lmal\u0131d\u0131r.\n Do\u011fum mesaj\u0131 - Do\u011fum mesaj\u0131, Home Assistant (yeniden) MQTT arac\u0131s\u0131na her ba\u011fland\u0131\u011f\u0131nda g\u00f6nderilir.\n Will mesaj\u0131 - Will mesaj\u0131, Home Assistant arac\u0131yla olan ba\u011flant\u0131s\u0131n\u0131 her kaybetti\u011finde, hem temizlik durumunda (\u00f6rn. Home Assistant kapan\u0131yor) hem de kirli bir durumda (\u00f6rn. ba\u011flant\u0131y\u0131 kes.", + "title": "MQTT se\u00e7enekleri" } } } diff --git a/homeassistant/components/mqtt/trigger.py b/homeassistant/components/mqtt/trigger.py index f9c035dea85..db366010bb2 100644 --- a/homeassistant/components/mqtt/trigger.py +++ b/homeassistant/components/mqtt/trigger.py @@ -10,13 +10,10 @@ from homeassistant.core import HassJob, callback from homeassistant.helpers import config_validation as cv, template from .. import mqtt -from .const import CONF_QOS, CONF_TOPIC +from .const import CONF_ENCODING, CONF_QOS, CONF_TOPIC, DEFAULT_ENCODING, DEFAULT_QOS # mypy: allow-untyped-defs -CONF_ENCODING = "encoding" -DEFAULT_ENCODING = "utf-8" -DEFAULT_QOS = 0 TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index d827853d603..b19d4afe23e 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -5,6 +5,7 @@ import voluptuous as vol from homeassistant.components.vacuum import ( ATTR_STATUS, + ENTITY_ID_FORMAT, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, @@ -169,6 +170,7 @@ async def async_setup_entity_legacy( class MqttVacuum(MqttEntity, VacuumEntity): """Representation of a MQTT-controlled legacy vacuum.""" + _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED def __init__(self, hass, config, config_entry, discovery_data): @@ -380,7 +382,7 @@ class MqttVacuum(MqttEntity, VacuumEntity): if self.supported_features & SUPPORT_TURN_ON == 0: return - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._command_topic, self._payloads[CONF_PAYLOAD_TURN_ON], @@ -395,7 +397,7 @@ class MqttVacuum(MqttEntity, VacuumEntity): if self.supported_features & SUPPORT_TURN_OFF == 0: return None - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._command_topic, self._payloads[CONF_PAYLOAD_TURN_OFF], @@ -410,7 +412,7 @@ class MqttVacuum(MqttEntity, VacuumEntity): if self.supported_features & SUPPORT_STOP == 0: return None - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._command_topic, self._payloads[CONF_PAYLOAD_STOP], @@ -425,7 +427,7 @@ class MqttVacuum(MqttEntity, VacuumEntity): if self.supported_features & SUPPORT_CLEAN_SPOT == 0: return None - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._command_topic, self._payloads[CONF_PAYLOAD_CLEAN_SPOT], @@ -440,7 +442,7 @@ class MqttVacuum(MqttEntity, VacuumEntity): if self.supported_features & SUPPORT_LOCATE == 0: return None - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._command_topic, self._payloads[CONF_PAYLOAD_LOCATE], @@ -455,7 +457,7 @@ class MqttVacuum(MqttEntity, VacuumEntity): if self.supported_features & SUPPORT_PAUSE == 0: return None - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._command_topic, self._payloads[CONF_PAYLOAD_START_PAUSE], @@ -470,7 +472,7 @@ class MqttVacuum(MqttEntity, VacuumEntity): if self.supported_features & SUPPORT_RETURN_HOME == 0: return None - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._command_topic, self._payloads[CONF_PAYLOAD_RETURN_TO_BASE], @@ -487,7 +489,7 @@ class MqttVacuum(MqttEntity, VacuumEntity): ) or fan_speed not in self._fan_speed_list: return None - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._set_fan_speed_topic, fan_speed, self._qos, self._retain ) self._status = f"Setting fan to {fan_speed}..." @@ -503,7 +505,7 @@ class MqttVacuum(MqttEntity, VacuumEntity): message = json.dumps(message) else: message = command - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._send_command_topic, message, self._qos, self._retain ) self._status = f"Sending command {message}..." diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index 80f566ff5de..0bfb7289f37 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -4,6 +4,7 @@ import json import voluptuous as vol from homeassistant.components.vacuum import ( + ENTITY_ID_FORMAT, STATE_CLEANING, STATE_DOCKED, STATE_ERROR, @@ -149,6 +150,7 @@ async def async_setup_entity_state( class MqttStateVacuum(MqttEntity, StateVacuumEntity): """Representation of a MQTT-controlled state vacuum.""" + _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_VACUUM_ATTRIBUTES_BLOCKED def __init__(self, hass, config, config_entry, discovery_data): @@ -240,7 +242,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): """Start the vacuum.""" if self.supported_features & SUPPORT_START == 0: return None - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._command_topic, self._config[CONF_PAYLOAD_START], @@ -252,7 +254,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): """Pause the vacuum.""" if self.supported_features & SUPPORT_PAUSE == 0: return None - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._command_topic, self._config[CONF_PAYLOAD_PAUSE], @@ -264,7 +266,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): """Stop the vacuum.""" if self.supported_features & SUPPORT_STOP == 0: return None - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._command_topic, self._config[CONF_PAYLOAD_STOP], @@ -278,7 +280,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): fan_speed not in self._fan_speed_list ): return None - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._set_fan_speed_topic, fan_speed, @@ -290,7 +292,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): """Tell the vacuum to return to its dock.""" if self.supported_features & SUPPORT_RETURN_HOME == 0: return None - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._command_topic, self._config[CONF_PAYLOAD_RETURN_TO_BASE], @@ -302,7 +304,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): """Perform a spot clean-up.""" if self.supported_features & SUPPORT_CLEAN_SPOT == 0: return None - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._command_topic, self._config[CONF_PAYLOAD_CLEAN_SPOT], @@ -314,7 +316,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): """Locate the vacuum (usually by playing a song).""" if self.supported_features & SUPPORT_LOCATE == 0: return None - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._command_topic, self._config[CONF_PAYLOAD_LOCATE], @@ -332,7 +334,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): message = json.dumps(message) else: message = command - mqtt.async_publish( + await mqtt.async_publish( self.hass, self._send_command_topic, message, diff --git a/homeassistant/components/mqtt_eventstream/__init__.py b/homeassistant/components/mqtt_eventstream/__init__.py index d31d6d1cd53..6e6596b3425 100644 --- a/homeassistant/components/mqtt_eventstream/__init__.py +++ b/homeassistant/components/mqtt_eventstream/__init__.py @@ -3,6 +3,7 @@ import json import voluptuous as vol +from homeassistant.components import mqtt from homeassistant.components.mqtt import valid_publish_topic, valid_subscribe_topic from homeassistant.const import ( ATTR_SERVICE_DATA, @@ -53,15 +54,13 @@ BLOCKED_EVENTS = [ async def async_setup(hass, config): """Set up the MQTT eventstream component.""" - mqtt = hass.components.mqtt conf = config.get(DOMAIN, {}) pub_topic = conf.get(CONF_PUBLISH_TOPIC) sub_topic = conf.get(CONF_SUBSCRIBE_TOPIC) ignore_event = conf.get(CONF_IGNORE_EVENT) ignore_event.append(EVENT_TIME_CHANGED) - @callback - def _event_publisher(event): + async def _event_publisher(event): """Handle events by publishing them on the MQTT queue.""" if event.origin != EventOrigin.local: return @@ -82,7 +81,7 @@ async def async_setup(hass, config): event_info = {"event_type": event.event_type, "event_data": event.data} msg = json.dumps(event_info, cls=JSONEncoder) - mqtt.async_publish(pub_topic, msg) + await mqtt.async_publish(hass, pub_topic, msg) # Only listen for local events if you are going to publish them. if pub_topic: @@ -117,6 +116,6 @@ async def async_setup(hass, config): # Only subscribe if you specified a topic. if sub_topic: - await mqtt.async_subscribe(sub_topic, _event_receiver) + await mqtt.async_subscribe(hass, sub_topic, _event_receiver) return True diff --git a/homeassistant/components/mqtt_statestream/__init__.py b/homeassistant/components/mqtt_statestream/__init__.py index d7c971b7d35..d0a13a7384b 100644 --- a/homeassistant/components/mqtt_statestream/__init__.py +++ b/homeassistant/components/mqtt_statestream/__init__.py @@ -3,9 +3,9 @@ import json import voluptuous as vol +from homeassistant.components import mqtt from homeassistant.components.mqtt import valid_publish_topic from homeassistant.const import MATCH_ALL -from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, @@ -44,8 +44,7 @@ async def async_setup(hass, config): if not base_topic.endswith("/"): base_topic = f"{base_topic}/" - @callback - def _state_publisher(entity_id, old_state, new_state): + async def _state_publisher(entity_id, old_state, new_state): if new_state is None: return @@ -55,22 +54,30 @@ async def async_setup(hass, config): payload = new_state.state mybase = f"{base_topic}{entity_id.replace('.', '/')}/" - hass.components.mqtt.async_publish(f"{mybase}state", payload, 1, True) + await mqtt.async_publish(hass, f"{mybase}state", payload, 1, True) if publish_timestamps: if new_state.last_updated: - hass.components.mqtt.async_publish( - f"{mybase}last_updated", new_state.last_updated.isoformat(), 1, True + await mqtt.async_publish( + hass, + f"{mybase}last_updated", + new_state.last_updated.isoformat(), + 1, + True, ) if new_state.last_changed: - hass.components.mqtt.async_publish( - f"{mybase}last_changed", new_state.last_changed.isoformat(), 1, True + await mqtt.async_publish( + hass, + f"{mybase}last_changed", + new_state.last_changed.isoformat(), + 1, + True, ) if publish_attributes: for key, val in new_state.attributes.items(): encoded_val = json.dumps(val, cls=JSONEncoder) - hass.components.mqtt.async_publish(mybase + key, encoded_val, 1, True) + await mqtt.async_publish(hass, mybase + key, encoded_val, 1, True) async_track_state_change(hass, MATCH_ALL, _state_publisher) return True diff --git a/homeassistant/components/mullvad/__init__.py b/homeassistant/components/mullvad/__init__.py index eeab5abed2f..fe02983633b 100644 --- a/homeassistant/components/mullvad/__init__.py +++ b/homeassistant/components/mullvad/__init__.py @@ -18,7 +18,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: dict) -> bool: """Set up Mullvad VPN integration.""" async def async_get_mullvad_api_data(): - with async_timeout.timeout(10): + async with async_timeout.timeout(10): api = await hass.async_add_executor_job(MullvadAPI) return api.data diff --git a/homeassistant/components/mullvad/translations/ja.json b/homeassistant/components/mullvad/translations/ja.json new file mode 100644 index 00000000000..fc1791527f9 --- /dev/null +++ b/homeassistant/components/mullvad/translations/ja.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "description": "Mullvad VPN\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mullvad/translations/ru.json b/homeassistant/components/mullvad/translations/ru.json index cf22d6e4f49..3b8af4fe508 100644 --- a/homeassistant/components/mullvad/translations/ru.json +++ b/homeassistant/components/mullvad/translations/ru.json @@ -9,7 +9,7 @@ }, "step": { "user": { - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Mullvad VPN." + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Mullvad VPN." } } } diff --git a/homeassistant/components/mullvad/translations/tr.json b/homeassistant/components/mullvad/translations/tr.json new file mode 100644 index 00000000000..6bbfbca32ce --- /dev/null +++ b/homeassistant/components/mullvad/translations/tr.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "description": "Mullvad VPN entegrasyonunu ayarlad\u0131n\u0131z m\u0131?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mutesync/binary_sensor.py b/homeassistant/components/mutesync/binary_sensor.py index 3186b6fc8f0..df8b0ceb9bb 100644 --- a/homeassistant/components/mutesync/binary_sensor.py +++ b/homeassistant/components/mutesync/binary_sensor.py @@ -1,6 +1,7 @@ """mütesync binary sensor entities.""" from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.helpers import update_coordinator +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from .const import DOMAIN @@ -46,7 +47,7 @@ class MuteStatus(update_coordinator.CoordinatorEntity, BinarySensorEntity): def device_info(self) -> DeviceInfo: """Return the device info of the sensor.""" return DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self.coordinator.data["user-id"])}, manufacturer="mütesync", model="mutesync app", diff --git a/homeassistant/components/mutesync/translations/id.json b/homeassistant/components/mutesync/translations/id.json index 66c930e348b..31da1a72f64 100644 --- a/homeassistant/components/mutesync/translations/id.json +++ b/homeassistant/components/mutesync/translations/id.json @@ -2,6 +2,7 @@ "config": { "error": { "cannot_connect": "Gagal terhubung", + "invalid_auth": "Aktifkan autentikasi di m\u00fctesync: Preferensi > Autentikasi", "unknown": "Kesalahan yang tidak diharapkan" }, "step": { diff --git a/homeassistant/components/mutesync/translations/ja.json b/homeassistant/components/mutesync/translations/ja.json new file mode 100644 index 00000000000..70e09f0ae6d --- /dev/null +++ b/homeassistant/components/mutesync/translations/ja.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u8a2d\u5b9a\u3067\u3001m\u00fctesync\u306e\u8a8d\u8a3c\u3092\u6709\u52b9\u306b\u3059\u308b > \u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mutesync/translations/tr.json b/homeassistant/components/mutesync/translations/tr.json new file mode 100644 index 00000000000..e45abded24b --- /dev/null +++ b/homeassistant/components/mutesync/translations/tr.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Se\u00e7enekler > Kimlik Do\u011frulama'da kimlik do\u011frulamay\u0131 etkinle\u015ftirin", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "host": "Ana bilgisayar" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mychevy/__init__.py b/homeassistant/components/mychevy/__init__.py deleted file mode 100644 index 5ea5b142657..00000000000 --- a/homeassistant/components/mychevy/__init__.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Support for MyChevy.""" -from datetime import timedelta -import logging -import threading -import time - -import mychevy.mychevy as mc -import voluptuous as vol - -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.util import Throttle - -DOMAIN = "mychevy" -UPDATE_TOPIC = DOMAIN -ERROR_TOPIC = f"{DOMAIN}_error" - -MYCHEVY_SUCCESS = "success" -MYCHEVY_ERROR = "error" - -NOTIFICATION_ID = "mychevy_website_notification" -NOTIFICATION_TITLE = "MyChevy website status" - -_LOGGER = logging.getLogger(__name__) - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) -ERROR_SLEEP_TIME = timedelta(minutes=30) - -CONF_COUNTRY = "country" -DEFAULT_COUNTRY = "us" - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_COUNTRY, default=DEFAULT_COUNTRY): vol.All( - cv.string, vol.In(["us", "ca"]) - ), - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -class EVSensorConfig: - """The EV sensor configuration.""" - - def __init__( - self, name, attr, unit_of_measurement=None, icon=None, extra_attrs=None - ): - """Create new sensor configuration.""" - self.name = name - self.attr = attr - self.extra_attrs = extra_attrs or [] - self.unit_of_measurement = unit_of_measurement - self.icon = icon - - -class EVBinarySensorConfig: - """The EV binary sensor configuration.""" - - def __init__(self, name, attr, device_class=None): - """Create new binary sensor configuration.""" - self.name = name - self.attr = attr - self.device_class = device_class - - -def setup(hass, base_config): - """Set up the mychevy component.""" - config = base_config.get(DOMAIN) - - email = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - country = config.get(CONF_COUNTRY) - hass.data[DOMAIN] = MyChevyHub( - mc.MyChevy(email, password, country), hass, base_config - ) - hass.data[DOMAIN].start() - - return True - - -class MyChevyHub(threading.Thread): - """MyChevy Hub. - - Connecting to the mychevy website is done through a selenium - webscraping process. That can only run synchronously. In order to - prevent blocking of other parts of Home Assistant the architecture - launches a polling loop in a thread. - - When new data is received, sensors are updated, and hass is - signaled that there are updates. Sensors are not created until the - first update, which will be 60 - 120 seconds after the platform - starts. - """ - - def __init__(self, client, hass, hass_config): - """Initialize MyChevy Hub.""" - super().__init__() - self._client = client - self.hass = hass - self.hass_config = hass_config - self.cars = [] - self.status = None - self.ready = False - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Update sensors from mychevy website. - - This is a synchronous polling call that takes a very long time - (like 2 to 3 minutes long time) - - """ - self._client.login() - self._client.get_cars() - self.cars = self._client.cars - if self.ready is not True: - discovery.load_platform(self.hass, "sensor", DOMAIN, {}, self.hass_config) - discovery.load_platform( - self.hass, "binary_sensor", DOMAIN, {}, self.hass_config - ) - self.ready = True - self.cars = self._client.update_cars() - - def get_car(self, vid): - """Compatibility to work with one car.""" - if self.cars: - for car in self.cars: - if car.vid == vid: - return car - return None - - def run(self): - """Thread run loop.""" - # We add the status device first outside of the loop - - # And then busy wait on threads - while True: - try: - _LOGGER.info("Starting mychevy loop") - self.update() - self.hass.helpers.dispatcher.dispatcher_send(UPDATE_TOPIC) - time.sleep(MIN_TIME_BETWEEN_UPDATES.total_seconds()) - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - "Error updating mychevy data. " - "This probably means the OnStar link is down again" - ) - self.hass.helpers.dispatcher.dispatcher_send(ERROR_TOPIC) - time.sleep(ERROR_SLEEP_TIME.total_seconds()) diff --git a/homeassistant/components/mychevy/binary_sensor.py b/homeassistant/components/mychevy/binary_sensor.py deleted file mode 100644 index eb28dd26b06..00000000000 --- a/homeassistant/components/mychevy/binary_sensor.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Support for MyChevy binary sensors.""" -from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, - BinarySensorEntity, -) -from homeassistant.core import callback -from homeassistant.util import slugify - -from . import DOMAIN as MYCHEVY_DOMAIN, UPDATE_TOPIC, EVBinarySensorConfig - -SENSORS = [EVBinarySensorConfig("Plugged In", "plugged_in", "plug")] - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the MyChevy sensors.""" - if discovery_info is None: - return - - sensors = [] - hub = hass.data[MYCHEVY_DOMAIN] - for sconfig in SENSORS: - for car in hub.cars: - sensors.append(EVBinarySensor(hub, sconfig, car.vid)) - - async_add_entities(sensors) - - -class EVBinarySensor(BinarySensorEntity): - """Base EVSensor class. - - The only real difference between sensors is which units and what - attribute from the car object they are returning. All logic can be - built with just setting subclass attributes. - """ - - def __init__(self, connection, config, car_vid): - """Initialize sensor with car connection.""" - self._conn = connection - self._name = config.name - self._attr = config.attr - self._type = config.device_class - self._is_on = None - self._car_vid = car_vid - self.entity_id = f"{BINARY_SENSOR_DOMAIN}.{MYCHEVY_DOMAIN}_{slugify(self._car.name)}_{slugify(self._name)}" - - @property - def name(self): - """Return the name.""" - return self._name - - @property - def is_on(self): - """Return if on.""" - return self._is_on - - @property - def _car(self): - """Return the car.""" - return self._conn.get_car(self._car_vid) - - async def async_added_to_hass(self): - """Register callbacks.""" - self.async_on_remove( - self.hass.helpers.dispatcher.async_dispatcher_connect( - UPDATE_TOPIC, self.async_update_callback - ) - ) - - @callback - def async_update_callback(self): - """Update state.""" - if self._car is not None: - self._is_on = getattr(self._car, self._attr, None) - self.async_write_ha_state() - - @property - def should_poll(self): - """Return the polling state.""" - return False diff --git a/homeassistant/components/mychevy/manifest.json b/homeassistant/components/mychevy/manifest.json deleted file mode 100644 index e726d49bb64..00000000000 --- a/homeassistant/components/mychevy/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "mychevy", - "name": "myChevrolet", - "documentation": "https://www.home-assistant.io/integrations/mychevy", - "requirements": ["mychevy==2.1.1"], - "codeowners": [], - "iot_class": "cloud_polling" -} diff --git a/homeassistant/components/mychevy/sensor.py b/homeassistant/components/mychevy/sensor.py deleted file mode 100644 index 1a5613d8864..00000000000 --- a/homeassistant/components/mychevy/sensor.py +++ /dev/null @@ -1,186 +0,0 @@ -"""Support for MyChevy sensors.""" -import logging - -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorEntity -from homeassistant.const import PERCENTAGE -from homeassistant.core import callback -from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.util import slugify - -from . import ( - DOMAIN as MYCHEVY_DOMAIN, - ERROR_TOPIC, - MYCHEVY_ERROR, - MYCHEVY_SUCCESS, - UPDATE_TOPIC, - EVSensorConfig, -) - -_LOGGER = logging.getLogger(__name__) - -BATTERY_SENSOR = "batteryLevel" - -SENSORS = [ - EVSensorConfig("Mileage", "totalMiles", "miles", "mdi:speedometer"), - EVSensorConfig("Electric Range", "electricRange", "miles", "mdi:speedometer"), - EVSensorConfig("Charged By", "estimatedFullChargeBy"), - EVSensorConfig("Charge Mode", "chargeMode"), - EVSensorConfig( - "Battery Level", BATTERY_SENSOR, PERCENTAGE, "mdi:battery", ["charging"] - ), -] - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the MyChevy sensors.""" - if discovery_info is None: - return - - hub = hass.data[MYCHEVY_DOMAIN] - sensors = [MyChevyStatus()] - for sconfig in SENSORS: - for car in hub.cars: - sensors.append(EVSensor(hub, sconfig, car.vid)) - - add_entities(sensors) - - -class MyChevyStatus(SensorEntity): - """A string representing the charge mode.""" - - _name = "MyChevy Status" - _icon = "mdi:car-connected" - - def __init__(self): - """Initialize sensor with car connection.""" - self._state = None - - async def async_added_to_hass(self): - """Register callbacks.""" - self.async_on_remove( - self.hass.helpers.dispatcher.async_dispatcher_connect( - UPDATE_TOPIC, self.success - ) - ) - - self.async_on_remove( - self.hass.helpers.dispatcher.async_dispatcher_connect( - ERROR_TOPIC, self.error - ) - ) - - @callback - def success(self): - """Update state, trigger updates.""" - if self._state != MYCHEVY_SUCCESS: - _LOGGER.debug("Successfully connected to mychevy website") - self._state = MYCHEVY_SUCCESS - self.async_write_ha_state() - - @callback - def error(self): - """Update state, trigger updates.""" - _LOGGER.error( - "Connection to mychevy website failed. " - "This probably means the mychevy to OnStar link is down" - ) - self._state = MYCHEVY_ERROR - self.async_write_ha_state() - - @property - def icon(self): - """Return the icon.""" - return self._icon - - @property - def name(self): - """Return the name.""" - return self._name - - @property - def native_value(self): - """Return the state.""" - return self._state - - @property - def should_poll(self): - """Return the polling state.""" - return False - - -class EVSensor(SensorEntity): - """Base EVSensor class. - - The only real difference between sensors is which units and what - attribute from the car object they are returning. All logic can be - built with just setting subclass attributes. - """ - - def __init__(self, connection, config, car_vid): - """Initialize sensor with car connection.""" - self._conn = connection - self._name = config.name - self._attr = config.attr - self._extra_attrs = config.extra_attrs - self._unit_of_measurement = config.unit_of_measurement - self._icon = config.icon - self._state = None - self._state_attributes = {} - self._car_vid = car_vid - - self.entity_id = f"{SENSOR_DOMAIN}.{MYCHEVY_DOMAIN}_{slugify(self._car.name)}_{slugify(self._name)}" - - async def async_added_to_hass(self): - """Register callbacks.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - UPDATE_TOPIC, self.async_update_callback - ) - - @property - def _car(self): - """Return the car.""" - return self._conn.get_car(self._car_vid) - - @property - def icon(self): - """Return the icon.""" - if self._attr == BATTERY_SENSOR: - charging = self._state_attributes.get("charging", False) - return icon_for_battery_level(self.state, charging) - return self._icon - - @property - def name(self): - """Return the name.""" - return self._name - - @callback - def async_update_callback(self): - """Update state.""" - if self._car is not None: - self._state = getattr(self._car, self._attr, None) - if self._unit_of_measurement == "miles": - self._state = round(self._state) - for attr in self._extra_attrs: - self._state_attributes[attr] = getattr(self._car, attr) - self.async_write_ha_state() - - @property - def native_value(self): - """Return the state.""" - return self._state - - @property - def extra_state_attributes(self): - """Return all the state attributes.""" - return self._state_attributes - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement the state is expressed in.""" - return self._unit_of_measurement - - @property - def should_poll(self): - """Return the polling state.""" - return False diff --git a/homeassistant/components/myq/translations/id.json b/homeassistant/components/myq/translations/id.json index 4972803f37d..1a5368dd611 100644 --- a/homeassistant/components/myq/translations/id.json +++ b/homeassistant/components/myq/translations/id.json @@ -13,7 +13,9 @@ "reauth_confirm": { "data": { "password": "Kata Sandi" - } + }, + "description": "Kata sandi untuk {username} tidak lagi berlaku.", + "title": "Autentikasi ulang Akun MyQ Anda" }, "user": { "data": { diff --git a/homeassistant/components/myq/translations/ja.json b/homeassistant/components/myq/translations/ja.json new file mode 100644 index 00000000000..e57bd9bc245 --- /dev/null +++ b/homeassistant/components/myq/translations/ja.json @@ -0,0 +1,29 @@ +{ + "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": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "{username} \u306e\u30d1\u30b9\u30ef\u30fc\u30c9\u306f\u7121\u52b9\u306b\u306a\u308a\u307e\u3057\u305f\u3002", + "title": "MyQ\u30a2\u30ab\u30a6\u30f3\u30c8\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "MyQ Gateway\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/myq/translations/tr.json b/homeassistant/components/myq/translations/tr.json index 7347d18bc34..7b2a6ec710f 100644 --- a/homeassistant/components/myq/translations/tr.json +++ b/homeassistant/components/myq/translations/tr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "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", @@ -9,6 +10,13 @@ "unknown": "Beklenmeyen hata" }, "step": { + "reauth_confirm": { + "data": { + "password": "Parola" + }, + "description": "{username} i\u00e7in \u015fifre art\u0131k ge\u00e7erli de\u011fil.", + "title": "MyQ Hesab\u0131n\u0131z\u0131 yeniden do\u011frulay\u0131n" + }, "user": { "data": { "password": "Parola", diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index 0e6c5231bc8..35b15bc3595 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -131,6 +131,10 @@ class MySensorsDevice: @property def name(self) -> str: """Return the name of this entity.""" + child = self._child + + if child.description: + return str(child.description) return f"{self.node_name} {self.child_id}" @property diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index e7f97792493..b167c8c58de 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -107,7 +107,7 @@ async def try_connect( connect_task = None try: connect_task = asyncio.create_task(gateway.start()) - with async_timeout.timeout(GATEWAY_READY_TIMEOUT): + async with async_timeout.timeout(GATEWAY_READY_TIMEOUT): await gateway_ready.wait() return True except asyncio.TimeoutError: @@ -185,7 +185,9 @@ async def _get_gateway( def pub_callback(topic: str, payload: str, qos: int, retain: bool) -> None: """Call MQTT publish function.""" - mqtt.async_publish(topic, payload, qos, retain) + hass.async_create_task( + mqtt.async_publish(hass, topic, payload, qos, retain) + ) def sub_callback( topic: str, sub_cb: Callable[[str, ReceivePayloadType, int], None], qos: int @@ -244,15 +246,8 @@ async def finish_setup( hass: HomeAssistant, entry: ConfigEntry, gateway: BaseAsyncGateway ) -> None: """Load any persistent devices and platforms and start gateway.""" - discover_tasks = [] - start_tasks = [] - discover_tasks.append(_discover_persistent_devices(hass, entry, gateway)) - start_tasks.append(_gw_start(hass, entry, gateway)) - if discover_tasks: - # Make sure all devices and platforms are loaded before gateway start. - await asyncio.wait(discover_tasks) - if start_tasks: - await asyncio.wait(start_tasks) + await _discover_persistent_devices(hass, entry, gateway) + await _gw_start(hass, entry, gateway) async def _discover_persistent_devices( @@ -317,7 +312,7 @@ async def _gw_start( # Gatways connected via mqtt doesn't send gateway ready message. return try: - with async_timeout.timeout(GATEWAY_READY_TIMEOUT): + async with async_timeout.timeout(GATEWAY_READY_TIMEOUT): await gateway_ready.wait() except asyncio.TimeoutError: _LOGGER.warning( diff --git a/homeassistant/components/mysensors/manifest.json b/homeassistant/components/mysensors/manifest.json index 3b7695146ba..6e7a4f9cded 100644 --- a/homeassistant/components/mysensors/manifest.json +++ b/homeassistant/components/mysensors/manifest.json @@ -2,7 +2,7 @@ "domain": "mysensors", "name": "MySensors", "documentation": "https://www.home-assistant.io/integrations/mysensors", - "requirements": ["pymysensors==0.21.0"], + "requirements": ["pymysensors==0.22.1"], "after_dependencies": ["mqtt"], "codeowners": ["@MartinHjelmare", "@functionpointer"], "config_flow": true, diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index 8f8c759c364..7c6602b7373 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -7,13 +7,13 @@ import voluptuous as vol from homeassistant.components import mysensors from homeassistant.components.switch import DOMAIN, SwitchEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from ...config_entries import ConfigEntry -from ...helpers.dispatcher import async_dispatcher_connect from .const import ( DOMAIN as MYSENSORS_DOMAIN, MYSENSORS_DISCOVERY, diff --git a/homeassistant/components/mysensors/translations/id.json b/homeassistant/components/mysensors/translations/id.json index e982250b09c..93b10bb8c2c 100644 --- a/homeassistant/components/mysensors/translations/id.json +++ b/homeassistant/components/mysensors/translations/id.json @@ -10,11 +10,13 @@ "invalid_ip": "Alamat IP tidak valid", "invalid_persistence_file": "File persistensi tidak valid", "invalid_port": "Nomor port tidak valid", + "invalid_publish_topic": "Topik publish tidak valid", "invalid_serial": "Port serial tidak valid", "invalid_subscribe_topic": "Topik langganan tidak valid", "invalid_version": "Versi MySensors tidak valid", "not_a_number": "Masukkan angka", "port_out_of_range": "Nilai port minimal 1 dan maksimal 65535", + "same_topic": "Topik subscribe dan publish sama", "unknown": "Kesalahan yang tidak diharapkan" }, "error": { @@ -27,11 +29,14 @@ "invalid_ip": "Alamat IP tidak valid", "invalid_persistence_file": "File persistensi tidak valid", "invalid_port": "Nomor port tidak valid", + "invalid_publish_topic": "Topik publish tidak valid", "invalid_serial": "Port serial tidak valid", "invalid_subscribe_topic": "Topik langganan tidak valid", "invalid_version": "Versi MySensors tidak valid", + "mqtt_required": "Integrasi MQTT belum disiapkan", "not_a_number": "Masukkan angka", "port_out_of_range": "Nilai port minimal 1 dan maksimal 65535", + "same_topic": "Topik subscribe dan publish sama", "unknown": "Kesalahan yang tidak diharapkan" }, "step": { diff --git a/homeassistant/components/mysensors/translations/ja.json b/homeassistant/components/mysensors/translations/ja.json new file mode 100644 index 00000000000..9842d241ad1 --- /dev/null +++ b/homeassistant/components/mysensors/translations/ja.json @@ -0,0 +1,80 @@ +{ + "config": { + "abort": { + "already_configured": "\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", + "duplicate_persistence_file": "\u6c38\u7d9a(Persistence)\u30d5\u30a1\u30a4\u30eb\u304c\u3059\u3067\u306b\u4f7f\u7528\u3055\u308c\u3066\u3044\u307e\u3059", + "duplicate_topic": "\u30c8\u30d4\u30c3\u30af\u306f\u65e2\u306b\u4f7f\u7528\u3055\u308c\u3066\u3044\u307e\u3059", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_device": "\u7121\u52b9\u306a\u30c7\u30d0\u30a4\u30b9", + "invalid_ip": "\u7121\u52b9\u306aIP\u30a2\u30c9\u30ec\u30b9", + "invalid_persistence_file": "\u7121\u52b9\u306a\u6c38\u7d9a(Persistence)\u30d5\u30a1\u30a4\u30eb", + "invalid_port": "\u7121\u52b9\u306a\u30dd\u30fc\u30c8\u756a\u53f7", + "invalid_publish_topic": "\u30d1\u30d6\u30ea\u30c3\u30af \u30c8\u30d4\u30c3\u30af\u304c\u7121\u52b9\u3067\u3059", + "invalid_serial": "\u7121\u52b9\u306a\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8", + "invalid_subscribe_topic": "\u7121\u52b9\u306a\u30b5\u30d6\u30b9\u30af\u30e9\u30a4\u30d6 \u30c8\u30d4\u30c3\u30af", + "invalid_version": "MySensors\u306e\u30d0\u30fc\u30b8\u30e7\u30f3\u304c\u7121\u52b9\u3067\u3059", + "not_a_number": "\u6570\u5b57\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", + "port_out_of_range": "\u30dd\u30fc\u30c8\u756a\u53f7\u306f1\u4ee5\u4e0a65535\u4ee5\u4e0b\u3067\u3042\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059", + "same_topic": "\u30b5\u30d6\u30b9\u30af\u30e9\u30a4\u30d6\u3068\u30d1\u30d6\u30ea\u30c3\u30b7\u30e5\u306e\u30c8\u30d4\u30c3\u30af\u304c\u540c\u3058\u3067\u3059", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "already_configured": "\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", + "duplicate_persistence_file": "\u6c38\u7d9a(Persistence)\u30d5\u30a1\u30a4\u30eb\u304c\u3059\u3067\u306b\u4f7f\u7528\u3055\u308c\u3066\u3044\u307e\u3059", + "duplicate_topic": "\u30c8\u30d4\u30c3\u30af\u306f\u65e2\u306b\u4f7f\u7528\u3055\u308c\u3066\u3044\u307e\u3059", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_device": "\u7121\u52b9\u306a\u30c7\u30d0\u30a4\u30b9", + "invalid_ip": "\u7121\u52b9\u306aIP\u30a2\u30c9\u30ec\u30b9", + "invalid_persistence_file": "\u7121\u52b9\u306a\u6c38\u7d9a(Persistence)\u30d5\u30a1\u30a4\u30eb", + "invalid_port": "\u7121\u52b9\u306a\u30dd\u30fc\u30c8\u756a\u53f7", + "invalid_publish_topic": "\u30d1\u30d6\u30ea\u30c3\u30af \u30c8\u30d4\u30c3\u30af\u304c\u7121\u52b9\u3067\u3059", + "invalid_serial": "\u7121\u52b9\u306a\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8", + "invalid_subscribe_topic": "\u7121\u52b9\u306a\u30b5\u30d6\u30b9\u30af\u30e9\u30a4\u30d6 \u30c8\u30d4\u30c3\u30af", + "invalid_version": "MySensors\u306e\u30d0\u30fc\u30b8\u30e7\u30f3\u304c\u7121\u52b9\u3067\u3059", + "mqtt_required": "MQTT\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093", + "not_a_number": "\u6570\u5b57\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", + "port_out_of_range": "\u30dd\u30fc\u30c8\u756a\u53f7\u306f1\u4ee5\u4e0a65535\u4ee5\u4e0b\u3067\u3042\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059", + "same_topic": "\u30b5\u30d6\u30b9\u30af\u30e9\u30a4\u30d6\u3068\u30d1\u30d6\u30ea\u30c3\u30b7\u30e5\u306e\u30c8\u30d4\u30c3\u30af\u304c\u540c\u3058\u3067\u3059", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "gw_mqtt": { + "data": { + "persistence_file": "\u6c38\u7d9a(persistence)\u30d5\u30a1\u30a4\u30eb(\u7a7a\u306b\u3059\u308b\u3068\u81ea\u52d5\u751f\u6210\u3055\u308c\u307e\u3059)", + "retain": "mqtt retain(\u4fdd\u6301)", + "topic_in_prefix": "\u30a4\u30f3\u30d7\u30c3\u30c8 \u30c8\u30d4\u30c3\u30af\u306e\u30d7\u30ec\u30d5\u30a3\u30c3\u30af\u30b9(topic_in_prefix)", + "topic_out_prefix": "\u30a2\u30a6\u30c8\u30d7\u30c3\u30c8 \u30c8\u30d4\u30c3\u30af\u306e\u30d7\u30ec\u30d5\u30a3\u30c3\u30af\u30b9(topic_out_prefix)", + "version": "MySensors\u306e\u30d0\u30fc\u30b8\u30e7\u30f3" + }, + "description": "MQTT gateway\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + }, + "gw_serial": { + "data": { + "baud_rate": "\u30dc\u30fc\u30ec\u30fc\u30c8", + "device": "\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8", + "persistence_file": "\u6c38\u7d9a(persistence)\u30d5\u30a1\u30a4\u30eb(\u7a7a\u306b\u3059\u308b\u3068\u81ea\u52d5\u751f\u6210\u3055\u308c\u307e\u3059)", + "version": "MySensors\u306e\u30d0\u30fc\u30b8\u30e7\u30f3" + }, + "description": "\u30b7\u30ea\u30a2\u30eb\u30b2\u30fc\u30c8\u30a6\u30a7\u30a4\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + }, + "gw_tcp": { + "data": { + "device": "\u30b2\u30fc\u30c8\u30a6\u30a7\u30a4\u306eIP\u30a2\u30c9\u30ec\u30b9", + "persistence_file": "\u6c38\u7d9a(persistence)\u30d5\u30a1\u30a4\u30eb(\u7a7a\u306b\u3059\u308b\u3068\u81ea\u52d5\u751f\u6210\u3055\u308c\u307e\u3059)", + "tcp_port": "\u30dd\u30fc\u30c8", + "version": "MySensors\u306e\u30d0\u30fc\u30b8\u30e7\u30f3" + }, + "description": "\u30a4\u30fc\u30b5\u30cd\u30c3\u30c8 \u30b2\u30fc\u30c8\u30a6\u30a7\u30a4\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + }, + "user": { + "data": { + "gateway_type": "\u30b2\u30fc\u30c8\u30a6\u30a7\u30a4\u306e\u7a2e\u985e" + }, + "description": "\u30b2\u30fc\u30c8\u30a6\u30a7\u30a4\u3078\u306e\u63a5\u7d9a\u65b9\u6cd5\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044" + } + } + }, + "title": "MySensors" +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/tr.json b/homeassistant/components/mysensors/translations/tr.json new file mode 100644 index 00000000000..4ea99e6cdc7 --- /dev/null +++ b/homeassistant/components/mysensors/translations/tr.json @@ -0,0 +1,80 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "duplicate_persistence_file": "Kal\u0131c\u0131l\u0131k dosyas\u0131 zaten kullan\u0131mda", + "duplicate_topic": "Konu zaten kullan\u0131l\u0131yor", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "invalid_device": "Ge\u00e7ersiz cihaz", + "invalid_ip": "Ge\u00e7ersiz IP adresi", + "invalid_persistence_file": "Ge\u00e7ersiz kal\u0131c\u0131l\u0131k dosyas\u0131", + "invalid_port": "Ge\u00e7ersiz ba\u011flant\u0131 noktas\u0131 numaras\u0131", + "invalid_publish_topic": "Ge\u00e7ersiz yay\u0131nlama konusu", + "invalid_serial": "Ge\u00e7ersiz seri ba\u011flant\u0131 noktas\u0131", + "invalid_subscribe_topic": "Ge\u00e7ersiz abone konusu", + "invalid_version": "Ge\u00e7ersiz MySensors s\u00fcr\u00fcm\u00fc", + "not_a_number": "L\u00fctfen bir numara giriniz", + "port_out_of_range": "Port numaras\u0131 en az 1, en fazla 65535 olmal\u0131d\u0131r", + "same_topic": "Abone olma ve yay\u0131nlama konular\u0131 ayn\u0131", + "unknown": "Beklenmeyen hata" + }, + "error": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "duplicate_persistence_file": "Kal\u0131c\u0131l\u0131k dosyas\u0131 zaten kullan\u0131mda", + "duplicate_topic": "Konu zaten kullan\u0131l\u0131yor", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "invalid_device": "Ge\u00e7ersiz cihaz", + "invalid_ip": "Ge\u00e7ersiz IP adresi", + "invalid_persistence_file": "Ge\u00e7ersiz kal\u0131c\u0131l\u0131k dosyas\u0131", + "invalid_port": "Ge\u00e7ersiz ba\u011flant\u0131 noktas\u0131 numaras\u0131", + "invalid_publish_topic": "Ge\u00e7ersiz yay\u0131nlama konusu", + "invalid_serial": "Ge\u00e7ersiz seri ba\u011flant\u0131 noktas\u0131", + "invalid_subscribe_topic": "Ge\u00e7ersiz abone konusu", + "invalid_version": "Ge\u00e7ersiz MySensors s\u00fcr\u00fcm\u00fc", + "mqtt_required": "MQTT entegrasyonu kurulmam\u0131\u015f", + "not_a_number": "L\u00fctfen bir numara giriniz", + "port_out_of_range": "Port numaras\u0131 en az 1, en fazla 65535 olmal\u0131d\u0131r", + "same_topic": "Abone olma ve yay\u0131nlama konular\u0131 ayn\u0131d\u0131r", + "unknown": "Beklenmeyen hata" + }, + "step": { + "gw_mqtt": { + "data": { + "persistence_file": "kal\u0131c\u0131l\u0131k dosyas\u0131 (otomatik olu\u015fturmak i\u00e7in bo\u015f b\u0131rak\u0131n)", + "retain": "mqtt tutma", + "topic_in_prefix": "girdi konular\u0131 i\u00e7in \u00f6nek (topic_in_prefix)", + "topic_out_prefix": "\u00e7\u0131kt\u0131 konular\u0131 i\u00e7in \u00f6nek (topic_out_prefix)", + "version": "MySensors s\u00fcr\u00fcm\u00fc" + }, + "description": "MQTT a\u011f ge\u00e7idi kurulumu" + }, + "gw_serial": { + "data": { + "baud_rate": "baud h\u0131z\u0131", + "device": "Seri ba\u011flant\u0131 noktas\u0131", + "persistence_file": "kal\u0131c\u0131l\u0131k dosyas\u0131 (otomatik olu\u015fturmak i\u00e7in bo\u015f b\u0131rak\u0131n)", + "version": "MySensors s\u00fcr\u00fcm\u00fcne" + }, + "description": "Seri a\u011f ge\u00e7idi kurulumu" + }, + "gw_tcp": { + "data": { + "device": "A\u011f ge\u00e7idinin IP adresini", + "persistence_file": "kal\u0131c\u0131l\u0131k dosyas\u0131 (otomatik olu\u015fturmak i\u00e7in bo\u015f b\u0131rak\u0131n)", + "tcp_port": "port", + "version": "MySensors s\u00fcr\u00fcm\u00fc" + }, + "description": "Ethernet a\u011f ge\u00e7idi kurulumu" + }, + "user": { + "data": { + "gateway_type": "A\u011f ge\u00e7idi t\u00fcr\u00fc" + }, + "description": "A\u011f ge\u00e7idine ba\u011flant\u0131 y\u00f6ntemini se\u00e7in" + } + } + }, + "title": "Sens\u00f6rlerim" +} \ No newline at end of file diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 4843e96b5a8..094c286b931 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -1,14 +1,16 @@ """The Nettigo Air Monitor component.""" from __future__ import annotations +import asyncio import logging from typing import cast -from aiohttp import ClientSession -from aiohttp.client_exceptions import ClientConnectorError +from aiohttp.client_exceptions import ClientConnectorError, ClientError import async_timeout from nettigo_air_monitor import ( ApiError, + AuthFailed, + ConnectionOptions, InvalidSensorData, NAMSensors, NettigoAirMonitor, @@ -16,13 +18,18 @@ from nettigo_air_monitor import ( from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + DataUpdateCoordinator, + UpdateFailed, +) from .const import ( ATTR_SDS011, @@ -35,16 +42,26 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor"] +PLATFORMS = ["button", "sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nettigo as config entry.""" host: str = entry.data[CONF_HOST] + username: str | None = entry.data.get(CONF_USERNAME) + password: str | None = entry.data.get(CONF_PASSWORD) websession = async_get_clientsession(hass) - coordinator = NAMDataUpdateCoordinator(hass, websession, host, entry.unique_id) + options = ConnectionOptions(host=host, username=username, password=password) + try: + nam = await NettigoAirMonitor.create(websession, options) + except AuthFailed as err: + raise ConfigEntryAuthFailed from err + except (ApiError, ClientError, ClientConnectorError, asyncio.TimeoutError) as err: + raise ConfigEntryNotReady from err + + coordinator = NAMDataUpdateCoordinator(hass, nam, entry.unique_id) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) @@ -81,14 +98,12 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator): def __init__( self, hass: HomeAssistant, - session: ClientSession, - host: str, + nam: NettigoAirMonitor, unique_id: str | None, ) -> None: """Initialize.""" - self.host = host - self.nam = NettigoAirMonitor(session, host) self._unique_id = unique_id + self.nam = nam super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_UPDATE_INTERVAL @@ -100,8 +115,10 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator): # Device firmware uses synchronous code and doesn't respond to http queries # when reading data from sensors. The nettigo-air-quality library tries to # get the data 4 times, so we use a longer than usual timeout here. - with async_timeout.timeout(30): + async with async_timeout.timeout(30): data = await self.nam.async_update() + # We do not need to catch AuthFailed exception here because sensor data is + # always available without authorization. except (ApiError, ClientConnectorError, InvalidSensorData) as error: raise UpdateFailed(error) from error @@ -120,5 +137,5 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator): name=DEFAULT_NAME, sw_version=self.nam.software_version, manufacturer=MANUFACTURER, - configuration_url=f"http://{self.host}/", + configuration_url=f"http://{self.nam.host}/", ) diff --git a/homeassistant/components/nam/button.py b/homeassistant/components/nam/button.py new file mode 100644 index 00000000000..73b5169abcd --- /dev/null +++ b/homeassistant/components/nam/button.py @@ -0,0 +1,62 @@ +"""Support for the Nettigo Air Monitor service.""" +from __future__ import annotations + +import logging + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_CONFIG +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import NAMDataUpdateCoordinator +from .const import DEFAULT_NAME, DOMAIN + +PARALLEL_UPDATES = 1 + +_LOGGER = logging.getLogger(__name__) + +RESTART_BUTTON: ButtonEntityDescription = ButtonEntityDescription( + key="restart", + name=f"{DEFAULT_NAME} Restart", + device_class=ButtonDeviceClass.RESTART, + entity_category=ENTITY_CATEGORY_CONFIG, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add a Nettigo Air Monitor entities from a config_entry.""" + coordinator: NAMDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + buttons: list[NAMButton] = [] + buttons.append(NAMButton(coordinator, RESTART_BUTTON)) + + async_add_entities(buttons, False) + + +class NAMButton(CoordinatorEntity, ButtonEntity): + """Define an Nettigo Air Monitor button.""" + + coordinator: NAMDataUpdateCoordinator + + def __init__( + self, + coordinator: NAMDataUpdateCoordinator, + description: ButtonEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self.entity_description = description + + async def async_press(self) -> None: + """Triggers the restart.""" + await self.coordinator.nam.async_restart() diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index 458895e69c5..82032ac306e 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -3,24 +3,48 @@ from __future__ import annotations import asyncio import logging -from typing import Any, cast +from typing import Any from aiohttp.client_exceptions import ClientConnectorError import async_timeout -from nettigo_air_monitor import ApiError, CannotGetMac, NettigoAirMonitor +from nettigo_air_monitor import ( + ApiError, + AuthFailed, + CannotGetMac, + ConnectionOptions, + NettigoAirMonitor, +) import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ATTR_NAME, CONF_HOST +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant 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 homeassistant.helpers.typing import DiscoveryInfoType from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +AUTH_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + + +async def async_get_mac(hass: HomeAssistant, host: str, data: dict[str, Any]) -> str: + """Get device MAC address.""" + websession = async_get_clientsession(hass) + + options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD)) + nam = await NettigoAirMonitor.create(websession, options) + # Device firmware uses synchronous code and doesn't respond to http queries + # when reading data from sensors. The nettigo-air-monitor library tries to get + # the data 4 times, so we use a longer than usual timeout here. + async with async_timeout.timeout(30): + return await nam.async_get_mac_address() + class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for Nettigo Air Monitor.""" @@ -29,18 +53,22 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize flow.""" - self.host: str | None = None + self.host: str + self.entry: config_entries.ConfigEntry async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initialized by the user.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: self.host = user_input[CONF_HOST] + try: - mac = await self._async_get_mac(cast(str, self.host)) + mac = await async_get_mac(self.hass, self.host, {}) + except AuthFailed: + return await self.async_step_credentials() except (ApiError, ClientConnectorError, asyncio.TimeoutError): errors["base"] = "cannot_connect" except CannotGetMac: @@ -49,36 +77,65 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(format_mac(mac)) self._abort_if_unique_id_configured({CONF_HOST: self.host}) return self.async_create_entry( - title=cast(str, self.host), + title=self.host, data=user_input, ) return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_HOST, default=""): str, - } - ), + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), errors=errors, ) + async def async_step_credentials( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the credentials step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + mac = await async_get_mac(self.hass, self.host, user_input) + except AuthFailed: + errors["base"] = "invalid_auth" + except (ApiError, ClientConnectorError, asyncio.TimeoutError): + errors["base"] = "cannot_connect" + except CannotGetMac: + return self.async_abort(reason="device_unsupported") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) + + return self.async_create_entry( + title=self.host, + data={**user_input, CONF_HOST: self.host}, + ) + + return self.async_show_form( + step_id="credentials", data_schema=AUTH_SCHEMA, errors=errors + ) + async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" - self.host = discovery_info[CONF_HOST] + self.host = discovery_info.host + self.context["title_placeholders"] = {"host": self.host} # Do not probe the device if the host is already configured self._async_abort_entries_match({CONF_HOST: self.host}) try: - mac = await self._async_get_mac(cast(str, self.host)) + mac = await async_get_mac(self.hass, self.host, {}) + except AuthFailed: + return await self.async_step_credentials() except (ApiError, ClientConnectorError, asyncio.TimeoutError): return self.async_abort(reason="cannot_connect") except CannotGetMac: @@ -87,21 +144,17 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(format_mac(mac)) self._abort_if_unique_id_configured({CONF_HOST: self.host}) - self.context["title_placeholders"] = { - ATTR_NAME: discovery_info[ATTR_NAME].split(".")[0] - } - return await self.async_step_confirm_discovery() async def async_step_confirm_discovery( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle discovery confirm.""" - errors: dict = {} + errors: dict[str, str] = {} if user_input is not None: return self.async_create_entry( - title=cast(str, self.host), + title=self.host, data={CONF_HOST: self.host}, ) @@ -109,16 +162,39 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="confirm_discovery", - description_placeholders={CONF_HOST: self.host}, + description_placeholders={"host": self.host}, errors=errors, ) - async def _async_get_mac(self, host: str) -> str: - """Get device MAC address.""" - websession = async_get_clientsession(self.hass) - nam = NettigoAirMonitor(websession, host) - # Device firmware uses synchronous code and doesn't respond to http queries - # when reading data from sensors. The nettigo-air-monitor library tries to get - # the data 4 times, so we use a longer than usual timeout here. - with async_timeout.timeout(30): - return await nam.async_get_mac_address() + async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + if entry := self.hass.config_entries.async_get_entry(self.context["entry_id"]): + self.entry = entry + self.host = data[CONF_HOST] + self.context["title_placeholders"] = {"host": self.host} + 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.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + await async_get_mac(self.hass, self.host, user_input) + except (ApiError, AuthFailed, ClientConnectorError, asyncio.TimeoutError): + return self.async_abort(reason="reauth_unsuccessful") + else: + self.hass.config_entries.async_update_entry( + self.entry, data={**user_input, CONF_HOST: self.host} + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={"host": self.host}, + data_schema=AUTH_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index 114fc4dd48d..1aab1cf4613 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -3,7 +3,7 @@ "name": "Nettigo Air Monitor", "documentation": "https://www.home-assistant.io/integrations/nam", "codeowners": ["@bieniu"], - "requirements": ["nettigo-air-monitor==1.1.1"], + "requirements": ["nettigo-air-monitor==1.2.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index c5c9c9f2e77..88f6008b45f 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -1,7 +1,7 @@ """Support for the Nettigo Air Monitor service.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from typing import cast @@ -75,7 +75,7 @@ class NAMSensor(CoordinatorEntity, SensorEntity): self.entity_description = description @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state.""" return cast( StateType, getattr(self.coordinator.data, self.entity_description.key) @@ -99,11 +99,7 @@ class NAMSensorUptime(NAMSensor): """Define an Nettigo Air Monitor uptime sensor.""" @property - def native_value(self) -> str: + def native_value(self) -> datetime: """Return the state.""" uptime_sec = getattr(self.coordinator.data, self.entity_description.key) - return ( - (utcnow() - timedelta(seconds=uptime_sec)) - .replace(microsecond=0) - .isoformat() - ) + return utcnow() - timedelta(seconds=uptime_sec) diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index e8994a346bf..dab6eefb095 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "{name}", + "flow_title": "{host}", "step": { "user": { "description": "Set up Nettigo Air Monitor integration.", @@ -8,17 +8,34 @@ "host": "[%key:common::config_flow::data::host%]" } }, + "credentials": { + "description": "Please enter the username and password.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reauth_confirm": { + "description": "Please enter the correct username and password for host: {host}", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, "confirm_discovery": { "description": "Do you want to set up Nettigo Air Monitor at {host}?" } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "device_unsupported": "The device is unsupported." + "device_unsupported": "The device is unsupported.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again." } } } diff --git a/homeassistant/components/nam/translations/bg.json b/homeassistant/components/nam/translations/bg.json index c902368616e..50368ce880d 100644 --- a/homeassistant/components/nam/translations/bg.json +++ b/homeassistant/components/nam/translations/bg.json @@ -1,7 +1,37 @@ { "config": { "abort": { - "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." + "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." + }, + "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": "{name}", + "step": { + "credentials": { + "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" + }, + "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e\u0442\u043e \u0438\u043c\u0435 \u0438 \u043f\u0430\u0440\u043e\u043b\u0430\u0442\u0430." + }, + "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" + }, + "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u043d\u043e\u0442\u043e \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435 \u0438 \u043f\u0430\u0440\u043e\u043b\u0430 \u0437\u0430 \u0445\u043e\u0441\u0442\u0430: {host}" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/nam/translations/ca.json b/homeassistant/components/nam/translations/ca.json index bc4ca456f4e..9b2a11e83fe 100644 --- a/homeassistant/components/nam/translations/ca.json +++ b/homeassistant/components/nam/translations/ca.json @@ -2,17 +2,34 @@ "config": { "abort": { "already_configured": "El dispositiu ja est\u00e0 configurat", - "device_unsupported": "El dispositiu no \u00e9s compatible." + "device_unsupported": "El dispositiu no \u00e9s compatible.", + "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", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", "unknown": "Error inesperat" }, - "flow_title": "{name}", + "flow_title": "{host}", "step": { "confirm_discovery": { "description": "Vols configurar Nettigo Air Monitor a {host}?" }, + "credentials": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Introdueix el nom d'usuari i la contrasenya." + }, + "reauth_confirm": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Introdueix el nom d'usuari i contrasenya correctes de l'amfitri\u00f3: {host}" + }, "user": { "data": { "host": "Amfitri\u00f3" diff --git a/homeassistant/components/nam/translations/de.json b/homeassistant/components/nam/translations/de.json index e3c7159a0d6..7c30b441378 100644 --- a/homeassistant/components/nam/translations/de.json +++ b/homeassistant/components/nam/translations/de.json @@ -2,17 +2,34 @@ "config": { "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", - "device_unsupported": "Das Ger\u00e4t wird nicht unterst\u00fctzt." + "device_unsupported": "Das Ger\u00e4t wird nicht unterst\u00fctzt.", + "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", + "invalid_auth": "Ung\u00fcltige Authentifizierung", "unknown": "Unerwarteter Fehler" }, - "flow_title": "{name}", + "flow_title": "{host}", "step": { "confirm_discovery": { "description": "M\u00f6chtest du Nettigo Air Monitor unter {host} einrichten?" }, + "credentials": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Bitte gib den Benutzernamen und das Passwort ein." + }, + "reauth_confirm": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Bitte gib den richtigen Benutzernamen und das richtige Passwort f\u00fcr den Host ein: {host}" + }, "user": { "data": { "host": "Host" diff --git a/homeassistant/components/nam/translations/en.json b/homeassistant/components/nam/translations/en.json index 0ea0c7ae6c1..4378f8d6c51 100644 --- a/homeassistant/components/nam/translations/en.json +++ b/homeassistant/components/nam/translations/en.json @@ -2,17 +2,34 @@ "config": { "abort": { "already_configured": "Device is already configured", - "device_unsupported": "The device is unsupported." + "device_unsupported": "The device is unsupported.", + "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", + "invalid_auth": "Invalid authentication", "unknown": "Unexpected error" }, - "flow_title": "{name}", + "flow_title": "{host}", "step": { "confirm_discovery": { "description": "Do you want to set up Nettigo Air Monitor at {host}?" }, + "credentials": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Please enter the username and password." + }, + "reauth_confirm": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Please enter the correct username and password for host: {host}" + }, "user": { "data": { "host": "Host" diff --git a/homeassistant/components/nam/translations/et.json b/homeassistant/components/nam/translations/et.json index e94cd3a46b6..c8b3040ecbb 100644 --- a/homeassistant/components/nam/translations/et.json +++ b/homeassistant/components/nam/translations/et.json @@ -2,17 +2,34 @@ "config": { "abort": { "already_configured": "Seade on juba h\u00e4\u00e4lestatud", - "device_unsupported": "Seadet ei toetata." + "device_unsupported": "Seadet ei toetata.", + "reauth_successful": "Taastuvastamine \u00f5nnestus", + "reauth_unsuccessful": "Taastuvastamine nurjus, eemalda sidumine ja seadista see uuesti." }, "error": { "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", "unknown": "Ootamatu t\u00f5rge" }, - "flow_title": "{name}", + "flow_title": "{host}", "step": { "confirm_discovery": { "description": "Kas seadistada Nettigo Air Monitori asukohas {host}?" }, + "credentials": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "Sisesta kasutajanimi jasalas\u00f5na." + }, + "reauth_confirm": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "Sisesta hosti jaoks \u00f5ige kasutajanimi ja salas\u00f5na: {host}" + }, "user": { "data": { "host": "host" diff --git a/homeassistant/components/nam/translations/he.json b/homeassistant/components/nam/translations/he.json index 39f3a7e4306..dd6dcc60585 100644 --- a/homeassistant/components/nam/translations/he.json +++ b/homeassistant/components/nam/translations/he.json @@ -1,10 +1,12 @@ { "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", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "flow_title": "{name}", @@ -12,6 +14,18 @@ "confirm_discovery": { "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea Nettigo Air Monitor \u05d1-{host}?" }, + "credentials": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7" diff --git a/homeassistant/components/nam/translations/hu.json b/homeassistant/components/nam/translations/hu.json index 0698b4d3e26..f0cf505163c 100644 --- a/homeassistant/components/nam/translations/hu.json +++ b/homeassistant/components/nam/translations/hu.json @@ -2,10 +2,13 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "device_unsupported": "Az eszk\u00f6z nem t\u00e1mogatott." + "device_unsupported": "Az eszk\u00f6z nem t\u00e1mogatott.", + "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": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "unknown": "V\u00e1ratlan hiba" }, "flow_title": "{name}", @@ -13,6 +16,20 @@ "confirm_discovery": { "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Nettigo Air Monitor-ot a {host} c\u00edmen?" }, + "credentials": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "K\u00e9rem, adja meg a felhaszn\u00e1l\u00f3nevet \u00e9s a jelsz\u00f3t." + }, + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "K\u00e9rem, adja meg a helyes felhaszn\u00e1l\u00f3nevet \u00e9s jelsz\u00f3t ide: {host}" + }, "user": { "data": { "host": "C\u00edm" diff --git a/homeassistant/components/nam/translations/id.json b/homeassistant/components/nam/translations/id.json index e289d14dd37..7b31e71e6d1 100644 --- a/homeassistant/components/nam/translations/id.json +++ b/homeassistant/components/nam/translations/id.json @@ -2,14 +2,34 @@ "config": { "abort": { "already_configured": "Perangkat sudah dikonfigurasi", - "device_unsupported": "Perangkat tidak didukung." + "device_unsupported": "Perangkat tidak didukung.", + "reauth_successful": "Autentikasi ulang berhasil", + "reauth_unsuccessful": "Proses autentikasi ulang tidak berhasil. Hapus integrasi dan siapkan kembali." }, "error": { "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", "unknown": "Kesalahan yang tidak diharapkan" }, - "flow_title": "{name}", + "flow_title": "{host}", "step": { + "confirm_discovery": { + "description": "Ingin menyiapkan Nettigo Air Monitor di {host}?" + }, + "credentials": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + }, + "description": "Masukkan nama pengguna dan kata sandi." + }, + "reauth_confirm": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + }, + "description": "Masukkan nama pengguna dan kata sandi yang benar untuk: {host}." + }, "user": { "data": { "host": "Host" diff --git a/homeassistant/components/nam/translations/ja.json b/homeassistant/components/nam/translations/ja.json new file mode 100644 index 00000000000..2125c9e3e38 --- /dev/null +++ b/homeassistant/components/nam/translations/ja.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "device_unsupported": "\u3053\u306e\u30c7\u30d0\u30a4\u30b9\u306f\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f", + "reauth_unsuccessful": "\u518d\u8a8d\u8a3c\u306b\u5931\u6557\u3057\u305f\u306e\u3067\u3001\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u524a\u9664\u3057\u3066\u518d\u5ea6\u8a2d\u5b9a\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "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" + }, + "flow_title": "{host}", + "step": { + "confirm_discovery": { + "description": "{host} \u306bNettigo Air Monitor\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "credentials": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u30e6\u30fc\u30b6\u30fc\u540d\u3068\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u30db\u30b9\u30c8: {host} \u306e\u6b63\u3057\u3044\u30e6\u30fc\u30b6\u30fc\u540d\u3068\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "Nettigo Air Monitor\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nam/translations/nl.json b/homeassistant/components/nam/translations/nl.json index c6171ead0f4..f900dccc295 100644 --- a/homeassistant/components/nam/translations/nl.json +++ b/homeassistant/components/nam/translations/nl.json @@ -2,17 +2,34 @@ "config": { "abort": { "already_configured": "Apparaat is al geconfigureerd", - "device_unsupported": "Het apparaat wordt niet ondersteund." + "device_unsupported": "Het apparaat wordt niet ondersteund.", + "reauth_successful": "Herauthenticatie was succesvol", + "reauth_unsuccessful": "Herauthenticatie is mislukt, verwijder de integratie en stel het opnieuw in." }, "error": { "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", "unknown": "Onverwachte fout" }, - "flow_title": "{name}", + "flow_title": "{host}", "step": { "confirm_discovery": { "description": "Wilt u Nettigo Air Monitor instellen bij {host} ?" }, + "credentials": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Voer de gebruikersnaam en het wachtwoord in." + }, + "reauth_confirm": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Voer de juiste gebruikersnaam en wachtwoord in voor host: {host}" + }, "user": { "data": { "host": "Host" diff --git a/homeassistant/components/nam/translations/no.json b/homeassistant/components/nam/translations/no.json index 923efe4937b..f66a6b148e3 100644 --- a/homeassistant/components/nam/translations/no.json +++ b/homeassistant/components/nam/translations/no.json @@ -2,17 +2,34 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "device_unsupported": "Enheten st\u00f8ttes ikke." + "device_unsupported": "Enheten st\u00f8ttes ikke.", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_unsuccessful": "Re-autentisering mislyktes. Fjern integrasjonen og konfigurer den p\u00e5 nytt." }, "error": { "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", "unknown": "Uventet feil" }, - "flow_title": "{name}", + "flow_title": "{host}", "step": { "confirm_discovery": { "description": "Vil du konfigurere Nettigo Air Monitor p\u00e5 {host} ?" }, + "credentials": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Vennligst skriv inn brukernavn og passord." + }, + "reauth_confirm": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Vennligst skriv inn riktig brukernavn og passord for verten: {host}" + }, "user": { "data": { "host": "Vert" diff --git a/homeassistant/components/nam/translations/pl.json b/homeassistant/components/nam/translations/pl.json index bdf5014428d..870578b3d4d 100644 --- a/homeassistant/components/nam/translations/pl.json +++ b/homeassistant/components/nam/translations/pl.json @@ -2,17 +2,34 @@ "config": { "abort": { "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", - "device_unsupported": "Urz\u0105dzenie nie jest obs\u0142ugiwane" + "device_unsupported": "Urz\u0105dzenie nie jest obs\u0142ugiwane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", + "reauth_unsuccessful": "B\u0142\u0105d ponownego uwierzytelnienia. Usu\u0144 intergracj\u0119 i skonfiguruj j\u0105 ponownie. " }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", "unknown": "Nieoczekiwany b\u0142\u0105d" }, - "flow_title": "{name}", + "flow_title": "{host}", "step": { "confirm_discovery": { "description": "Czy chcesz skonfigurowa\u0107 Nettigo Air Monitor {host}?" }, + "credentials": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Prosz\u0119 wpisa\u0107 nazw\u0119 u\u017cytkownika i has\u0142o." + }, + "reauth_confirm": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Prosz\u0119 wpisa\u0107 prawid\u0142ow\u0105 nazw\u0119 u\u017cytkownika i has\u0142o dla hosta: {host}." + }, "user": { "data": { "host": "Nazwa hosta lub adres IP" diff --git a/homeassistant/components/nam/translations/ru.json b/homeassistant/components/nam/translations/ru.json index d475081285b..d73f9aa0ee5 100644 --- a/homeassistant/components/nam/translations/ru.json +++ b/homeassistant/components/nam/translations/ru.json @@ -2,22 +2,39 @@ "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.", - "device_unsupported": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f." + "device_unsupported": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\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.", + "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.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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}", + "flow_title": "{host}", "step": { "confirm_discovery": { "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Nettigo Air Monitor ({host})?" }, + "credentials": { + "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 \u0438\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0438 \u043f\u0430\u0440\u043e\u043b\u044c." + }, + "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 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e\u0435 \u0438\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0438 \u043f\u0430\u0440\u043e\u043b\u044c \u0434\u043b\u044f \u0445\u043e\u0441\u0442\u0430: {host}" + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Nettigo Air Monitor." + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Nettigo Air Monitor." } } } diff --git a/homeassistant/components/nam/translations/sl.json b/homeassistant/components/nam/translations/sl.json new file mode 100644 index 00000000000..fca06535159 --- /dev/null +++ b/homeassistant/components/nam/translations/sl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "reauth_unsuccessful": "Ponovna avtentikacija ni bila uspe\u0161na, odstranite integracijo in jo znova nastavite." + }, + "error": { + "invalid_auth": "Neveljavna avtentikacija" + }, + "step": { + "credentials": { + "data": { + "password": "Geslo", + "username": "Uporabni\u0161ko ime" + }, + "description": "Prosimo, vnesite uporabni\u0161ko ime in geslo." + }, + "reauth_confirm": { + "data": { + "password": "Geslo", + "username": "Uporabni\u0161ko ime" + }, + "description": "Vnesite pravilno uporabni\u0161ko ime in geslo za gostitelja: {host}" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nam/translations/tr.json b/homeassistant/components/nam/translations/tr.json new file mode 100644 index 00000000000..e252cff4fcd --- /dev/null +++ b/homeassistant/components/nam/translations/tr.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "device_unsupported": "Cihaz desteklenmiyor.", + "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", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "{host}", + "step": { + "confirm_discovery": { + "description": "{host} Nettigo Air Monitor'\u00fc kurmak istiyor musunuz?" + }, + "credentials": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "L\u00fctfen kullan\u0131c\u0131 ad\u0131 ve \u015fifreyi giriniz." + }, + "reauth_confirm": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "L\u00fctfen sunucu i\u00e7in do\u011fru kullan\u0131c\u0131 ad\u0131n\u0131 ve \u015fifreyi girin: {host}" + }, + "user": { + "data": { + "host": "Ana bilgisayar" + }, + "description": "Nettigo Air Monitor entegrasyonunu kurun." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nam/translations/zh-Hant.json b/homeassistant/components/nam/translations/zh-Hant.json index 5d0b3f179af..f3c343f515c 100644 --- a/homeassistant/components/nam/translations/zh-Hant.json +++ b/homeassistant/components/nam/translations/zh-Hant.json @@ -2,17 +2,34 @@ "config": { "abort": { "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "device_unsupported": "\u88dd\u7f6e\u4e0d\u652f\u63f4\u3002" + "device_unsupported": "\u88dd\u7f6e\u4e0d\u652f\u63f4\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", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, - "flow_title": "{name}", + "flow_title": "{host}", "step": { "confirm_discovery": { "description": "\u662f\u5426\u8981\u8a2d\u5b9a\u4f4d\u5740\u70ba {host} \u7684 Nettigo Air Monitor\uff1f" }, + "credentials": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8acb\u8f38\u5165\u4f7f\u7528\u8005\u540d\u7a31\u548c\u5bc6\u78bc" + }, + "reauth_confirm": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8acb\u8f38\u5165 {host} \u4e3b\u6a5f\u7684\u6b63\u78ba\u4f7f\u7528\u8005\u540d\u7a31\u8207\u5bc6\u78bc" + }, "user": { "data": { "host": "\u4e3b\u6a5f\u7aef" diff --git a/homeassistant/components/nanoleaf/__init__.py b/homeassistant/components/nanoleaf/__init__.py index c706f52035f..3ed82ec2146 100644 --- a/homeassistant/components/nanoleaf/__init__.py +++ b/homeassistant/components/nanoleaf/__init__.py @@ -1,14 +1,30 @@ """The Nanoleaf integration.""" -from aionanoleaf import InvalidToken, Nanoleaf, Unavailable +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +from aionanoleaf import EffectsEvent, InvalidToken, Nanoleaf, StateEvent, Unavailable from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import DOMAIN +PLATFORMS = ["button", "light"] + + +@dataclass +class NanoleafEntryData: + """Class for sharing data within the Nanoleaf integration.""" + + device: Nanoleaf + event_listener: asyncio.Task + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nanoleaf from a config entry.""" @@ -22,9 +38,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except InvalidToken as err: raise ConfigEntryAuthFailed from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = nanoleaf + async def _callback_update_light_state(event: StateEvent | EffectsEvent) -> None: + """Receive state and effect event.""" + async_dispatcher_send(hass, f"{DOMAIN}_update_light_{nanoleaf.serial_no}") - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "light") + event_listener = asyncio.create_task( + nanoleaf.listen_events( + state_callback=_callback_update_light_state, + effects_callback=_callback_update_light_state, + ) ) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = NanoleafEntryData( + nanoleaf, event_listener + ) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + entry_data: NanoleafEntryData = hass.data[DOMAIN].pop(entry.entry_id) + entry_data.event_listener.cancel() return True diff --git a/homeassistant/components/nanoleaf/button.py b/homeassistant/components/nanoleaf/button.py new file mode 100644 index 00000000000..bf7f9fba9f7 --- /dev/null +++ b/homeassistant/components/nanoleaf/button.py @@ -0,0 +1,37 @@ +"""Support for Nanoleaf buttons.""" + +from aionanoleaf import Nanoleaf + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_CONFIG +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import NanoleafEntryData +from .const import DOMAIN +from .entity import NanoleafEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Nanoleaf button.""" + entry_data: NanoleafEntryData = hass.data[DOMAIN][entry.entry_id] + async_add_entities([NanoleafIdentifyButton(entry_data.device)]) + + +class NanoleafIdentifyButton(NanoleafEntity, ButtonEntity): + """Representation of a Nanoleaf identify button.""" + + def __init__(self, nanoleaf: Nanoleaf) -> None: + """Initialize the Nanoleaf button.""" + super().__init__(nanoleaf) + self._attr_unique_id = f"{nanoleaf.serial_no}_identify" + self._attr_name = f"Identify {nanoleaf.name}" + self._attr_icon = "mdi:magnify" + self._attr_entity_category = ENTITY_CATEGORY_CONFIG + + async def async_press(self) -> None: + """Identify the Nanoleaf.""" + await self._nanoleaf.identify() diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index 0f4f8ff75bd..6ae70b32d8e 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -9,10 +9,10 @@ from aionanoleaf import InvalidToken, Nanoleaf, Unauthorized, Unavailable import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import ssdp, zeroconf from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.util.json import load_json, save_json from .const import DOMAIN @@ -88,34 +88,36 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_link() async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle Nanoleaf Zeroconf discovery.""" _LOGGER.debug("Zeroconf discovered: %s", discovery_info) return await self._async_homekit_zeroconf_discovery_handler(discovery_info) - async def async_step_homekit(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_homekit( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle Nanoleaf Homekit discovery.""" _LOGGER.debug("Homekit discovered: %s", discovery_info) return await self._async_homekit_zeroconf_discovery_handler(discovery_info) async def _async_homekit_zeroconf_discovery_handler( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle Nanoleaf Homekit and Zeroconf discovery.""" return await self._async_discovery_handler( - discovery_info["host"], - discovery_info["name"].replace(f".{discovery_info['type']}", ""), - discovery_info["properties"]["id"], + discovery_info.host, + discovery_info.name.replace(f".{discovery_info.type}", ""), + discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID], ) - async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle Nanoleaf SSDP discovery.""" _LOGGER.debug("SSDP discovered: %s", discovery_info) return await self._async_discovery_handler( - discovery_info["_host"], - discovery_info["nl-devicename"], - discovery_info["nl-deviceid"], + discovery_info.ssdp_headers["_host"], + discovery_info.ssdp_headers["nl-devicename"], + discovery_info.ssdp_headers["nl-deviceid"], ) async def _async_discovery_handler( diff --git a/homeassistant/components/nanoleaf/entity.py b/homeassistant/components/nanoleaf/entity.py new file mode 100644 index 00000000000..c6efed91787 --- /dev/null +++ b/homeassistant/components/nanoleaf/entity.py @@ -0,0 +1,23 @@ +"""Base class for Nanoleaf entity.""" + +from aionanoleaf import Nanoleaf + +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN + + +class NanoleafEntity(Entity): + """Representation of a Nanoleaf entity.""" + + def __init__(self, nanoleaf: Nanoleaf) -> None: + """Initialize an Nanoleaf entity.""" + self._nanoleaf = nanoleaf + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, nanoleaf.serial_no)}, + manufacturer=nanoleaf.manufacturer, + model=nanoleaf.model, + name=nanoleaf.name, + sw_version=nanoleaf.firmware_version, + configuration_url=f"http://{nanoleaf.host}", + ) diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index 6203431d609..e8bb994a06b 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -1,6 +1,7 @@ """Support for Nanoleaf Lights.""" from __future__ import annotations +from datetime import timedelta import logging import math from typing import Any @@ -24,9 +25,9 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.color import ( @@ -34,7 +35,9 @@ from homeassistant.util.color import ( color_temperature_mired_to_kelvin as mired_to_kelvin, ) +from . import NanoleafEntryData from .const import DOMAIN +from .entity import NanoleafEntity RESERVED_EFFECTS = ("*Solid*", "*Static*", "*Dynamic*") DEFAULT_NAME = "Nanoleaf" @@ -49,6 +52,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(minutes=5) + async def async_setup_platform( hass: HomeAssistant, @@ -70,29 +75,20 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Nanoleaf light.""" - nanoleaf: Nanoleaf = hass.data[DOMAIN][entry.entry_id] - async_add_entities([NanoleafLight(nanoleaf)]) + entry_data: NanoleafEntryData = hass.data[DOMAIN][entry.entry_id] + async_add_entities([NanoleafLight(entry_data.device)]) -class NanoleafLight(LightEntity): +class NanoleafLight(NanoleafEntity, LightEntity): """Representation of a Nanoleaf Light.""" def __init__(self, nanoleaf: Nanoleaf) -> None: - """Initialize an Nanoleaf light.""" - self._nanoleaf = nanoleaf - self._attr_unique_id = self._nanoleaf.serial_no - self._attr_name = self._nanoleaf.name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._nanoleaf.serial_no)}, - manufacturer=self._nanoleaf.manufacturer, - model=self._nanoleaf.model, - name=self._nanoleaf.name, - sw_version=self._nanoleaf.firmware_version, - ) - self._attr_min_mireds = math.ceil( - 1000000 / self._nanoleaf.color_temperature_max - ) - self._attr_max_mireds = kelvin_to_mired(self._nanoleaf.color_temperature_min) + """Initialize the Nanoleaf light.""" + super().__init__(nanoleaf) + self._attr_unique_id = nanoleaf.serial_no + self._attr_name = nanoleaf.name + self._attr_min_mireds = math.ceil(1000000 / nanoleaf.color_temperature_max) + self._attr_max_mireds = kelvin_to_mired(nanoleaf.color_temperature_min) @property def brightness(self) -> int: @@ -197,3 +193,22 @@ class NanoleafLight(LightEntity): if not self.available: _LOGGER.info("Fetching %s data recovered", self.name) self._attr_available = True + + @callback + def async_handle_update(self) -> None: + """Handle state update.""" + self.async_write_ha_state() + if not self.available: + _LOGGER.info("Connection to %s recovered", self.name) + self._attr_available = True + + async def async_added_to_hass(self) -> None: + """Handle entity being added to Home Assistant.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_update_light_{self._nanoleaf.serial_no}", + self.async_handle_update, + ) + ) diff --git a/homeassistant/components/nanoleaf/manifest.json b/homeassistant/components/nanoleaf/manifest.json index b5e57ac842d..3550b56d352 100644 --- a/homeassistant/components/nanoleaf/manifest.json +++ b/homeassistant/components/nanoleaf/manifest.json @@ -3,7 +3,7 @@ "name": "Nanoleaf", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nanoleaf", - "requirements": ["aionanoleaf==0.0.3"], + "requirements": ["aionanoleaf==0.1.1"], "zeroconf": ["_nanoleafms._tcp.local.", "_nanoleafapi._tcp.local."], "homekit" : { "models": [ @@ -25,5 +25,5 @@ } ], "codeowners": ["@milanmeu"], - "iot_class": "local_polling" + "iot_class": "local_push" } \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/ca.json b/homeassistant/components/nanoleaf/translations/ca.json index 6c966627f94..d040dac3e6b 100644 --- a/homeassistant/components/nanoleaf/translations/ca.json +++ b/homeassistant/components/nanoleaf/translations/ca.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "El dispositiu ja est\u00e0 configurat", "cannot_connect": "Ha fallat la connexi\u00f3", - "invalid_token": "Token d'acc\u00e9s no v\u00e0lid", + "invalid_token": "Token d'acc\u00e9s inv\u00e0lid", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", "unknown": "Error inesperat" }, diff --git a/homeassistant/components/nanoleaf/translations/id.json b/homeassistant/components/nanoleaf/translations/id.json index b0e3328df0b..f17c0de7209 100644 --- a/homeassistant/components/nanoleaf/translations/id.json +++ b/homeassistant/components/nanoleaf/translations/id.json @@ -9,9 +9,15 @@ }, "error": { "cannot_connect": "Gagal terhubung", + "not_allowing_new_tokens": "Nanoleaf tidak mengizinkan token baru, ikuti petunjuk di atas.", "unknown": "Kesalahan yang tidak diharapkan" }, + "flow_title": "{name}", "step": { + "link": { + "description": "Tekan dan tahan tombol daya pada Nanoleaf Anda selama 5 detik hingga LED tombol mulai berkedip, lalu klik **SUBMIT** dalam waktu 30 detik.", + "title": "Tautkan Nanoleaf" + }, "user": { "data": { "host": "Host" diff --git a/homeassistant/components/nanoleaf/translations/ja.json b/homeassistant/components/nanoleaf/translations/ja.json new file mode 100644 index 00000000000..824c4193f87 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\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", + "invalid_token": "\u7121\u52b9\u306a\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "not_allowing_new_tokens": "Nanoleaf\u306f\u65b0\u3057\u3044\u30c8\u30fc\u30af\u30f3\u3092\u8a31\u53ef\u3057\u3066\u3044\u306a\u3044\u306e\u3067\u3001\u4e0a\u8a18\u306e\u624b\u9806\u306b\u5f93\u3063\u3066\u304f\u3060\u3055\u3044\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Nanoleaf\u306e\u96fb\u6e90\u30dc\u30bf\u30f3\u3092\u30dc\u30bf\u30f3\u306eLED\u304c\u70b9\u6ec5\u3057\u59cb\u3081\u308b\u307e\u30675\u79d2\u9593\u62bc\u3057\u7d9a\u3051\u3066\u304b\u3089\u300130\u79d2\u4ee5\u5185\u306b **\u9001\u4fe1(submit)** \u3092\u30af\u30ea\u30c3\u30af\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Nanoleaf\u3092\u30ea\u30f3\u30af\u3059\u308b" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/pl.json b/homeassistant/components/nanoleaf/translations/pl.json index 1c772fa940c..66c7ecd23f1 100644 --- a/homeassistant/components/nanoleaf/translations/pl.json +++ b/homeassistant/components/nanoleaf/translations/pl.json @@ -15,7 +15,7 @@ "flow_title": "{name}", "step": { "link": { - "description": "Naci\u015bnij i przytrzymaj przycisk zasilania na Nanoleaf przez 5 sekund, a\u017c dioda LED przycisku zacznie miga\u0107, a nast\u0119pnie kliknij **WY\u015aLIJ** w ci\u0105gu 30 sekund.", + "description": "Naci\u015bnij i przytrzymaj przycisk zasilania na Nanoleaf przez 5 sekund, a\u017c dioda LED przycisku zacznie miga\u0107, a nast\u0119pnie kliknij **ZATWIERD\u0179** w ci\u0105gu 30 sekund.", "title": "Po\u0142\u0105czenie z Nanoleaf" }, "user": { diff --git a/homeassistant/components/nanoleaf/translations/tr.json b/homeassistant/components/nanoleaf/translations/tr.json new file mode 100644 index 00000000000..55cbde518f2 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/tr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_token": "Ge\u00e7ersiz eri\u015fim anahtar\u0131", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu", + "unknown": "Beklenmeyen hata" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "not_allowing_new_tokens": "Nanoleaf yeni anahtarlara izin vermiyor, yukar\u0131daki talimatlar\u0131 izleyin.", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Nanoleaf'inizdeki g\u00fc\u00e7 d\u00fc\u011fmesini d\u00fc\u011fme LED'leri yan\u0131p s\u00f6nmeye ba\u015flayana kadar 5 saniye bas\u0131l\u0131 tutun, ard\u0131ndan 30 saniye i\u00e7inde **G\u00d6NDER**'e t\u0131klay\u0131n.", + "title": "Ba\u011flant\u0131 Nanoleaf" + }, + "user": { + "data": { + "host": "Ana bilgisayar" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/neato/translations/ja.json b/homeassistant/components/neato/translations/ja.json new file mode 100644 index 00000000000..c5f4605159c --- /dev/null +++ b/homeassistant/components/neato/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", + "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})", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "create_entry": { + "default": "\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f" + }, + "step": { + "pick_implementation": { + "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" + }, + "reauth_confirm": { + "title": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + } + } + }, + "title": "Neato Botvac" +} \ No newline at end of file diff --git a/homeassistant/components/neato/translations/tr.json b/homeassistant/components/neato/translations/tr.json index 18fa4749d88..a461d5b0c2f 100644 --- a/homeassistant/components/neato/translations/tr.json +++ b/homeassistant/components/neato/translations/tr.json @@ -2,12 +2,22 @@ "config": { "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "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})", "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, + "create_entry": { + "default": "Ba\u015far\u0131yla do\u011fruland\u0131" + }, "step": { "pick_implementation": { - "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7in" + "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7" + }, + "reauth_confirm": { + "title": "Kuruluma ba\u015flamak ister misiniz?" } } - } + }, + "title": "Neato Botvac" } \ No newline at end of file diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index fce475e1788..0151fb6a6a5 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -1,16 +1,21 @@ """Support for Nest devices.""" +from __future__ import annotations +from http import HTTPStatus import logging +from aiohttp import web from google_nest_sdm.event import EventMessage from google_nest_sdm.exceptions import ( AuthException, ConfigurationException, GoogleNestException, ) -from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber import voluptuous as vol +from homeassistant.auth.permissions.const import POLICY_READ +from homeassistant.components.http.const import KEY_HASS_USER +from homeassistant.components.http.view import HomeAssistantView from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_BINARY_SENSORS, @@ -21,24 +26,34 @@ from homeassistant.const import ( CONF_STRUCTURE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import ( - aiohttp_client, - config_entry_oauth2_flow, - config_validation as cv, +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, + Unauthorized, ) +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers.entity_registry import async_entries_for_device from homeassistant.helpers.typing import ConfigType from . import api, config_flow -from .const import DATA_SDM, DATA_SUBSCRIBER, DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from .const import ( + CONF_PROJECT_ID, + CONF_SUBSCRIBER_ID, + DATA_NEST_CONFIG, + DATA_SDM, + DATA_SUBSCRIBER, + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + OOB_REDIRECT_URI, +) from .events import EVENT_NAME_MAP, NEST_EVENT from .legacy import async_setup_legacy, async_setup_legacy_entry +from .media_source import get_media_source_devices _LOGGER = logging.getLogger(__name__) -CONF_PROJECT_ID = "project_id" -CONF_SUBSCRIBER_ID = "subscriber_id" -DATA_NEST_CONFIG = "nest_config" DATA_NEST_UNAVAILABLE = "nest_unavailable" NEST_SETUP_NOTIFICATION = "nest_setup" @@ -68,6 +83,56 @@ CONFIG_SCHEMA = vol.Schema( # Platforms for SDM API PLATFORMS = ["sensor", "camera", "climate"] +WEB_AUTH_DOMAIN = DOMAIN +INSTALLED_AUTH_DOMAIN = f"{DOMAIN}.installed" + +# Fetch media for events with an in memory cache. The largest media items +# are mp4 clips at ~90kb each, so this totals a few MB per camera. +# Note: Media for events can only be published within 30 seconds of the event +EVENT_MEDIA_CACHE_SIZE = 64 + + +class WebAuth(config_entry_oauth2_flow.LocalOAuth2Implementation): + """OAuth implementation using OAuth for web applications.""" + + name = "OAuth for Web" + + def __init__( + self, hass: HomeAssistant, client_id: str, client_secret: str, project_id: str + ) -> None: + """Initialize WebAuth.""" + super().__init__( + hass, + WEB_AUTH_DOMAIN, + client_id, + client_secret, + OAUTH2_AUTHORIZE.format(project_id=project_id), + OAUTH2_TOKEN, + ) + + +class InstalledAppAuth(config_entry_oauth2_flow.LocalOAuth2Implementation): + """OAuth implementation using OAuth for installed applications.""" + + name = "OAuth for Apps" + + def __init__( + self, hass: HomeAssistant, client_id: str, client_secret: str, project_id: str + ) -> None: + """Initialize InstalledAppAuth.""" + super().__init__( + hass, + INSTALLED_AUTH_DOMAIN, + client_id, + client_secret, + OAUTH2_AUTHORIZE.format(project_id=project_id), + OAUTH2_TOKEN, + ) + + @property + def redirect_uri(self) -> str: + """Return the redirect uri.""" + return OOB_REDIRECT_URI async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -80,23 +145,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if CONF_PROJECT_ID not in config[DOMAIN]: return await async_setup_legacy(hass, config) - if CONF_SUBSCRIBER_ID not in config[DOMAIN]: - _LOGGER.error("Configuration option 'subscriber_id' required") - return False - # For setup of ConfigEntry below hass.data[DOMAIN][DATA_NEST_CONFIG] = config[DOMAIN] project_id = config[DOMAIN][CONF_PROJECT_ID] config_flow.NestFlowHandler.register_sdm_api(hass) config_flow.NestFlowHandler.async_register_implementation( hass, - config_entry_oauth2_flow.LocalOAuth2Implementation( + InstalledAppAuth( hass, - DOMAIN, config[DOMAIN][CONF_CLIENT_ID], config[DOMAIN][CONF_CLIENT_SECRET], - OAUTH2_AUTHORIZE.format(project_id=project_id), - OAUTH2_TOKEN, + project_id, + ), + ) + config_flow.NestFlowHandler.async_register_implementation( + hass, + WebAuth( + hass, + config[DOMAIN][CONF_CLIENT_ID], + config[DOMAIN][CONF_CLIENT_SECRET], + project_id, ), ) @@ -129,7 +197,7 @@ class SignalUpdateCallback: "device_id": device_entry.id, "type": event_type, "timestamp": event_message.timestamp, - "nest_event_id": image_event.event_id, + "nest_event_id": image_event.event_session_id, } self._hass.bus.async_fire(NEST_EVENT, message) @@ -140,27 +208,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if DATA_SDM not in entry.data: return await async_setup_legacy_entry(hass, entry) - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry - ) - ) + subscriber = await api.new_subscriber(hass, entry) + if not subscriber: + return False + # Keep media for last N events in memory + subscriber.cache_policy.event_cache_size = EVENT_MEDIA_CACHE_SIZE + subscriber.cache_policy.fetch = True - config = hass.data[DOMAIN][DATA_NEST_CONFIG] - - session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - auth = api.AsyncConfigEntryAuth( - aiohttp_client.async_get_clientsession(hass), - session, - config[CONF_CLIENT_ID], - config[CONF_CLIENT_SECRET], - ) - subscriber = GoogleNestSubscriber( - auth, config[CONF_PROJECT_ID], config[CONF_SUBSCRIBER_ID] - ) callback = SignalUpdateCallback(hass) subscriber.set_update_callback(callback.async_handle_event) - try: await subscriber.start_async() except AuthException as err: @@ -191,6 +247,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_setup_platforms(entry, PLATFORMS) + hass.http.register_view(NestEventMediaView(hass)) + return True @@ -208,3 +266,72 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(DATA_NEST_UNAVAILABLE, None) return unload_ok + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle removal of pubsub subscriptions created during config flow.""" + if DATA_SDM not in entry.data or CONF_SUBSCRIBER_ID not in entry.data: + return + + subscriber = await api.new_subscriber(hass, entry) + if not subscriber: + return + _LOGGER.debug("Deleting subscriber '%s'", subscriber.subscriber_id) + try: + await subscriber.delete_subscription() + except GoogleNestException as err: + _LOGGER.warning( + "Unable to delete subscription '%s'; Will be automatically cleaned up by cloud console: %s", + subscriber.subscriber_id, + err, + ) + finally: + subscriber.stop_async() + + +class NestEventMediaView(HomeAssistantView): + """Returns media for related to events for a specific device. + + This is primarily used to render media for events for MediaSource. The media type + depends on the specific device e.g. an image, or a movie clip preview. + """ + + url = "/api/nest/event_media/{device_id}/{event_id}" + name = "api:nest:event_media" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize NestEventMediaView.""" + self.hass = hass + + async def get( + self, request: web.Request, device_id: str, event_id: str + ) -> web.StreamResponse: + """Start a GET request.""" + user = request[KEY_HASS_USER] + entity_registry = await self.hass.helpers.entity_registry.async_get_registry() + for entry in async_entries_for_device(entity_registry, device_id): + if not user.permissions.check_entity(entry.entity_id, POLICY_READ): + raise Unauthorized(entity_id=entry.entity_id) + + devices = await get_media_source_devices(self.hass) + if not (nest_device := devices.get(device_id)): + return self._json_error( + f"No Nest Device found for '{device_id}'", HTTPStatus.NOT_FOUND + ) + try: + event_media = await nest_device.event_media_manager.get_media(event_id) + except GoogleNestException as err: + raise HomeAssistantError("Unable to fetch media for event") from err + if not event_media: + return self._json_error( + f"No event found for event_id '{event_id}'", HTTPStatus.NOT_FOUND + ) + media = event_media.media + return web.Response( + body=media.contents, content_type=media.event_image_type.content_type + ) + + def _json_error(self, message: str, status: HTTPStatus) -> web.StreamResponse: + """Return a json error message with additional logging.""" + _LOGGER.debug(message) + return self.json_message(message, status) diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index 426a651461a..3934b0b3cf1 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -1,17 +1,32 @@ """API for Google Nest Device Access bound to Home Assistant OAuth.""" +from __future__ import annotations + import datetime +import logging from typing import cast from aiohttp import ClientSession from google.oauth2.credentials import Credentials from google_nest_sdm.auth import AbstractAuth +from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow -from .const import API_URL, OAUTH2_TOKEN, SDM_SCOPES +from .const import ( + API_URL, + CONF_PROJECT_ID, + CONF_SUBSCRIBER_ID, + DATA_NEST_CONFIG, + DOMAIN, + OAUTH2_TOKEN, + SDM_SCOPES, +) -# See https://developers.google.com/nest/device-access/registration +_LOGGER = logging.getLogger(__name__) class AsyncConfigEntryAuth(AbstractAuth): @@ -55,3 +70,41 @@ class AsyncConfigEntryAuth(AbstractAuth): ) creds.expiry = datetime.datetime.fromtimestamp(token["expires_at"]) return creds + + +async def new_subscriber( + hass: HomeAssistant, entry: ConfigEntry +) -> GoogleNestSubscriber | None: + """Create a GoogleNestSubscriber.""" + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + config = hass.data[DOMAIN][DATA_NEST_CONFIG] + if not ( + subscriber_id := entry.data.get( + CONF_SUBSCRIBER_ID, config.get(CONF_SUBSCRIBER_ID) + ) + ): + _LOGGER.error("Configuration option 'subscriber_id' required") + return None + return await new_subscriber_with_impl(hass, entry, subscriber_id, implementation) + + +async def new_subscriber_with_impl( + hass: HomeAssistant, + entry: ConfigEntry, + subscriber_id: str, + implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, +) -> GoogleNestSubscriber: + """Create a GoogleNestSubscriber, used during ConfigFlow.""" + config = hass.data[DOMAIN][DATA_NEST_CONFIG] + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + auth = AsyncConfigEntryAuth( + aiohttp_client.async_get_clientsession(hass), + session, + config[CONF_CLIENT_ID], + config[CONF_CLIENT_SECRET], + ) + return GoogleNestSubscriber(auth, config[CONF_PROJECT_ID], subscriber_id) diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index abebc8db3ef..5385eb42b26 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -3,11 +3,10 @@ from __future__ import annotations from collections.abc import Callable import datetime -import io import logging +from pathlib import Path from typing import Any -from PIL import Image, ImageDraw, ImageFilter from google_nest_sdm.camera_traits import ( CameraEventImageTrait, CameraImageTrait, @@ -37,18 +36,11 @@ from .device_info import NestDeviceInfo _LOGGER = logging.getLogger(__name__) +PLACEHOLDER = Path(__file__).parent / "placeholder.png" + # Used to schedule an alarm to refresh the stream before expiration STREAM_EXPIRATION_BUFFER = datetime.timedelta(seconds=30) -# The Google Home app dispays a placeholder image that appears as a faint -# light source (dim, blurred sphere) giving the user an indication the camera -# is available, not just a blank screen. These constants define a blurred -# ellipse at the top left of the thumbnail. -PLACEHOLDER_ELLIPSE_BLUR = 0.1 -PLACEHOLDER_ELLIPSE_XY = [-0.4, 0.3, 0.3, 0.4] -PLACEHOLDER_OVERLAY_COLOR = "#ffffff" -PLACEHOLDER_ELLIPSE_OPACITY = 255 - async def async_setup_sdm_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -73,30 +65,6 @@ async def async_setup_sdm_entry( async_add_entities(entities) -def placeholder_image(width: int | None = None, height: int | None = None) -> Image: - """Return a camera image preview for cameras without live thumbnails.""" - if not width or not height: - return Image.new("RGB", (1, 1)) - # Draw a dark scene with a fake light source - blank = Image.new("RGB", (width, height)) - overlay = Image.new("RGB", blank.size, color=PLACEHOLDER_OVERLAY_COLOR) - ellipse = Image.new("L", blank.size, color=0) - draw = ImageDraw.Draw(ellipse) - draw.ellipse( - ( - width * PLACEHOLDER_ELLIPSE_XY[0], - height * PLACEHOLDER_ELLIPSE_XY[1], - width * PLACEHOLDER_ELLIPSE_XY[2], - height * PLACEHOLDER_ELLIPSE_XY[3], - ), - fill=PLACEHOLDER_ELLIPSE_OPACITY, - ) - mask = ellipse.filter( - ImageFilter.GaussianBlur(radius=width * PLACEHOLDER_ELLIPSE_BLUR) - ) - return Image.composite(overlay, blank, mask) - - class NestCamera(Camera): """Devices that support cameras.""" @@ -111,7 +79,8 @@ class NestCamera(Camera): self._event_id: str | None = None self._event_image_bytes: bytes | None = None self._event_image_cleanup_unsub: Callable[[], None] | None = None - self.is_streaming = CameraLiveStreamTrait.NAME in self._device.traits + self._attr_is_streaming = CameraLiveStreamTrait.NAME in self._device.traits + self._placeholder_image: bytes | None = None @property def should_poll(self) -> bool: @@ -251,10 +220,11 @@ class NestCamera(Camera): return None # Nest Web RTC cams only have image previews for events, and not # for "now" by design to save batter, and need a placeholder. - image = placeholder_image(width=width, height=height) - with io.BytesIO() as content: - image.save(content, format="JPEG", optimize=True) - return content.getvalue() + if not self._placeholder_image: + self._placeholder_image = await self.hass.async_add_executor_job( + PLACEHOLDER.read_bytes + ) + return self._placeholder_image return await async_get_image(self.hass, stream_url, output_format=IMAGE_JPEG) async def _async_active_event_image(self) -> bytes | None: diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 1ec3e421a0d..31192b1a2b2 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -1,15 +1,27 @@ """Config flow to configure Nest. -This configuration flow supports two APIs: - - The new Device Access program and the Smart Device Management API - - The legacy nest API +This configuration flow supports the following: + - SDM API with Installed app flow where user enters an auth code manually + - SDM API with Web OAuth flow with redirect back to Home Assistant + - Legacy Nest API auth flow with where user enters an auth code manually NestFlowHandler is an implementation of AbstractOAuth2FlowHandler with -some overrides to support the old APIs auth flow. That is, for the new -API this class has hardly any special config other than url parameters, -and everything else custom is for the old api. When configured with the -new api via NestFlowHandler.register_sdm_api, the custom methods just -invoke the AbstractOAuth2FlowHandler methods. +some overrides to support installed app and old APIs auth flow, reauth, +and other custom steps inserted in the middle of the flow. + +The notable config flow steps are: +- user: To dispatch between API versions +- auth: Inserted to add a hook for the installed app flow to accept a token +- async_oauth_create_entry: Overridden to handle when OAuth is complete. This + does not actually create the entry, but holds on to the OAuth token data + for later +- pubsub: Configure the pubsub subscription. Note that subscriptions created + by the config flow are deleted when removed. +- finish: Handles creating a new configuration entry or updating the existing + configuration entry for reauth. + +The SDM API config flow supports a hybrid of configuration.yaml (used as defaults) +and config flow. """ from __future__ import annotations @@ -20,20 +32,46 @@ import os from typing import Any import async_timeout +from google_nest_sdm.exceptions import ( + AuthException, + ConfigurationException, + GoogleNestException, +) import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.util import get_random_string from homeassistant.util.json import load_json -from .const import DATA_SDM, DOMAIN, SDM_SCOPES +from . import api +from .const import ( + CONF_CLOUD_PROJECT_ID, + CONF_PROJECT_ID, + CONF_SUBSCRIBER_ID, + DATA_NEST_CONFIG, + DATA_SDM, + DOMAIN, + OOB_REDIRECT_URI, + SDM_SCOPES, +) DATA_FLOW_IMPL = "nest_flow_implementation" +SUBSCRIPTION_FORMAT = "projects/{cloud_project_id}/subscriptions/home-assistant-{rnd}" +SUBSCRIPTION_RAND_LENGTH = 10 +CLOUD_CONSOLE_URL = "https://console.cloud.google.com/home/dashboard" _LOGGER = logging.getLogger(__name__) +def _generate_subscription_id(cloud_project_id: str) -> str: + """Create a new subscription id.""" + rnd = get_random_string(SUBSCRIPTION_RAND_LENGTH) + return SUBSCRIPTION_FORMAT.format(cloud_project_id=cloud_project_id, rnd=rnd) + + @callback def register_flow_implementation( hass: HomeAssistant, @@ -83,8 +121,10 @@ class NestFlowHandler( def __init__(self) -> None: """Initialize NestFlowHandler.""" super().__init__() - # When invoked for reauth, allows updating an existing config entry - self._reauth = False + # Allows updating an existing config entry + self._reauth_data: dict[str, Any] | None = None + # ConfigEntry data for SDM API + self._data: dict[str, Any] = {DATA_SDM: {}} @classmethod def register_sdm_api(cls, hass: HomeAssistant) -> None: @@ -113,9 +153,165 @@ class NestFlowHandler( } async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Complete OAuth setup and finish pubsub or finish.""" + assert self.is_sdm_api(), "Step only supported for SDM API" + self._data.update(data) + if not self._configure_pubsub(): + _LOGGER.debug("Skipping Pub/Sub configuration") + return await self.async_step_finish() + return await self.async_step_pubsub() + + async def async_step_reauth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Perform reauth upon an API authentication error.""" + assert self.is_sdm_api(), "Step only supported for SDM API" + if user_input is None: + _LOGGER.error("Reauth invoked with empty config entry data") + return self.async_abort(reason="missing_configuration") + self._reauth_data = user_input + self._data.update(user_input) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + assert self.is_sdm_api(), "Step only supported for SDM API" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({}), + ) + existing_entries = self._async_current_entries() + if existing_entries: + # Pick an existing auth implementation for Reauth if present. Note + # only one ConfigEntry is allowed so its safe to pick the first. + entry = next(iter(existing_entries)) + if "auth_implementation" in entry.data: + data = {"implementation": entry.data["auth_implementation"]} + return await self.async_step_user(data) + return await self.async_step_user() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + if self.is_sdm_api(): + # Reauth will update an existing entry + if self._async_current_entries() and not self._reauth_data: + return self.async_abort(reason="single_instance_allowed") + return await super().async_step_user(user_input) + return await self.async_step_init(user_input) + + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Create an entry for auth.""" + if self.flow_impl.domain == "nest.installed": + # The default behavior from the parent class is to redirect the + # user with an external step. When using installed app auth, we + # instead prompt the user to sign in and copy/paste and + # authentication code back into this form. + # Note: This is similar to the Legacy API flow below, but it is + # simpler to reuse the OAuth logic in the parent class than to + # reuse SDM code with Legacy API code. + if user_input is not None: + self.external_data = { + "code": user_input["code"], + "state": {"redirect_uri": OOB_REDIRECT_URI}, + } + return await super().async_step_creation(user_input) + + result = await super().async_step_auth() + return self.async_show_form( + step_id="auth", + description_placeholders={"url": result["url"]}, + data_schema=vol.Schema({vol.Required("code"): str}), + ) + return await super().async_step_auth(user_input) + + def _configure_pubsub(self) -> bool: + """Return True if the config flow should configure Pub/Sub.""" + if self._reauth_data is not None and CONF_SUBSCRIBER_ID in self._reauth_data: + # Existing entry needs to be reconfigured + return True + if CONF_SUBSCRIBER_ID in self.hass.data[DOMAIN][DATA_NEST_CONFIG]: + # Hard coded configuration.yaml skips pubsub in config flow + return False + # No existing subscription configured, so create in config flow + return True + + async def async_step_pubsub( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Configure and create Pub/Sub subscriber.""" + # Populate data from the previous config entry during reauth, then + # overwrite with the user entered values. + data = {} + if self._reauth_data: + data.update(self._reauth_data) + if user_input: + data.update(user_input) + cloud_project_id = data.get(CONF_CLOUD_PROJECT_ID, "") + + errors = {} + config = self.hass.data[DOMAIN][DATA_NEST_CONFIG] + if cloud_project_id == config[CONF_PROJECT_ID]: + _LOGGER.error( + "Wrong Project ID. Device Access Project ID used, but expected Cloud Project ID" + ) + errors[CONF_CLOUD_PROJECT_ID] = "wrong_project_id" + + if user_input is not None and not errors: + # Create the subscriber id and/or verify it already exists. Note that + # the existing id is used, and create call below is idempotent + subscriber_id = data.get(CONF_SUBSCRIBER_ID, "") + if not subscriber_id: + subscriber_id = _generate_subscription_id(cloud_project_id) + _LOGGER.debug("Creating subscriber id '%s'", subscriber_id) + # Create a placeholder ConfigEntry to use since with the auth we've already created. + entry = ConfigEntry( + version=1, domain=DOMAIN, title="", data=self._data, source="" + ) + subscriber = await api.new_subscriber_with_impl( + self.hass, entry, subscriber_id, self.flow_impl + ) + try: + await subscriber.create_subscription() + except AuthException as err: + _LOGGER.error("Subscriber authentication error: %s", err) + return self.async_abort(reason="invalid_access_token") + except ConfigurationException as err: + _LOGGER.error("Configuration error creating subscription: %s", err) + errors[CONF_CLOUD_PROJECT_ID] = "bad_project_id" + except GoogleNestException as err: + _LOGGER.error("Error creating subscription: %s", err) + errors[CONF_CLOUD_PROJECT_ID] = "subscriber_error" + + if not errors: + self._data.update( + { + CONF_SUBSCRIBER_ID: subscriber_id, + CONF_CLOUD_PROJECT_ID: cloud_project_id, + } + ) + return await self.async_step_finish() + + return self.async_show_form( + step_id="pubsub", + data_schema=vol.Schema( + { + vol.Required(CONF_CLOUD_PROJECT_ID, default=cloud_project_id): str, + } + ), + description_placeholders={"url": CLOUD_CONSOLE_URL}, + errors=errors, + ) + + async def async_step_finish(self, data: dict[str, Any] | None = None) -> FlowResult: """Create an entry for the SDM flow.""" assert self.is_sdm_api(), "Step only supported for SDM API" - data[DATA_SDM] = {} await self.async_set_unique_id(DOMAIN) # Update existing config entry when in the reauth flow. This # integration only supports one config entry so remove any prior entries @@ -129,43 +325,11 @@ class NestFlowHandler( continue updated = True self.hass.config_entries.async_update_entry( - entry, data=data, unique_id=DOMAIN + entry, data=self._data, unique_id=DOMAIN ) await self.hass.config_entries.async_reload(entry.entry_id) return self.async_abort(reason="reauth_successful") - - return await super().async_oauth_create_entry(data) - - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Perform reauth upon an API authentication error.""" - assert self.is_sdm_api(), "Step only supported for SDM API" - self._reauth = True # Forces update of existing config entry - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Confirm reauth dialog.""" - assert self.is_sdm_api(), "Step only supported for SDM API" - if user_input is None: - return self.async_show_form( - step_id="reauth_confirm", - data_schema=vol.Schema({}), - ) - return await self.async_step_user() - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle a flow initialized by the user.""" - if self.is_sdm_api(): - # Reauth will update an existing entry - if self._async_current_entries() and not self._reauth: - return self.async_abort(reason="single_instance_allowed") - return await super().async_step_user(user_input) - return await self.async_step_init(user_input) + return await super().async_oauth_create_entry(self._data) async def async_step_init( self, user_input: dict[str, Any] | None = None @@ -211,7 +375,7 @@ class NestFlowHandler( if user_input is not None: try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): tokens = await flow["convert_code"](user_input["code"]) return self._entry_from_tokens( f"Nest (via {flow['name']})", flow, tokens @@ -228,7 +392,7 @@ class NestFlowHandler( _LOGGER.exception("Unexpected error resolving code") try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): url = await flow["gen_authorize_url"](self.flow_id) except asyncio.TimeoutError: return self.async_abort(reason="authorize_url_timeout") diff --git a/homeassistant/components/nest/const.py b/homeassistant/components/nest/const.py index 3aba9ef5a7e..a92a48bfd6c 100644 --- a/homeassistant/components/nest/const.py +++ b/homeassistant/components/nest/const.py @@ -3,6 +3,11 @@ DOMAIN = "nest" DATA_SDM = "sdm" DATA_SUBSCRIBER = "subscriber" +DATA_NEST_CONFIG = "nest_config" + +CONF_PROJECT_ID = "project_id" +CONF_SUBSCRIBER_ID = "subscriber_id" +CONF_CLOUD_PROJECT_ID = "cloud_project_id" SIGNAL_NEST_UPDATE = "nest_update" @@ -16,3 +21,4 @@ SDM_SCOPES = [ "https://www.googleapis.com/auth/pubsub", ] API_URL = "https://smartdevicemanagement.googleapis.com/v1" +OOB_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob" diff --git a/homeassistant/components/nest/events.py b/homeassistant/components/nest/events.py index 6802a98cc40..10983768e17 100644 --- a/homeassistant/components/nest/events.py +++ b/homeassistant/components/nest/events.py @@ -57,3 +57,11 @@ EVENT_NAME_MAP = { CameraPersonEvent.NAME: EVENT_CAMERA_PERSON, CameraSoundEvent.NAME: EVENT_CAMERA_SOUND, } + +# Names for event types shown in the media source +MEDIA_SOURCE_EVENT_TITLE_MAP = { + DoorbellChimeEvent.NAME: "Doorbell", + CameraMotionEvent.NAME: "Motion", + CameraPersonEvent.NAME: "Person", + CameraSoundEvent.NAME: "Sound", +} diff --git a/homeassistant/components/nest/legacy/sensor.py b/homeassistant/components/nest/legacy/sensor.py index f2c6670bf8b..7b3ba5ec2fe 100644 --- a/homeassistant/components/nest/legacy/sensor.py +++ b/homeassistant/components/nest/legacy/sensor.py @@ -210,8 +210,7 @@ class NestTempSensor(NestSensorDevice, SensorEntity): else: self._unit = TEMP_FAHRENHEIT - temp = getattr(self.device, self.variable) - if temp is None: + if (temp := getattr(self.device, self.variable)) is None: self._state = None if isinstance(temp, tuple): diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index b7ac58a571a..11a464dbaf1 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -2,9 +2,9 @@ "domain": "nest", "name": "Nest", "config_flow": true, - "dependencies": ["ffmpeg", "http"], + "dependencies": ["ffmpeg", "http", "media_source"], "documentation": "https://www.home-assistant.io/integrations/nest", - "requirements": ["python-nest==4.1.0", "google-nest-sdm==0.3.8"], + "requirements": ["python-nest==4.1.0", "google-nest-sdm==0.4.5"], "codeowners": ["@allenporter"], "quality_scale": "platinum", "dhcp": [ diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py new file mode 100644 index 00000000000..b02b9b1870e --- /dev/null +++ b/homeassistant/components/nest/media_source.py @@ -0,0 +1,260 @@ +"""Nest Media Source implementation. + +The Nest MediaSource implementation provides a directory tree of devices and +events and associated media (e.g. an image or clip). Camera device events +publish an event message, received by the subscriber library. Media for an +event, such as camera image or clip, may be fetched from the cloud during a +short time window after the event happens. + +The actual management of associating events to devices, fetching media for +events, caching, and the overall lifetime of recent events are managed outside +of the Nest MediaSource. + +Users may also record clips to local storage, unrelated to this MediaSource. + +For additional background on Nest Camera events see: +https://developers.google.com/nest/device-access/api/camera#handle_camera_events +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +import logging + +from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait +from google_nest_sdm.device import Device +from google_nest_sdm.event import ImageEventBase + +from homeassistant.components.media_player.const import ( + MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_IMAGE, + MEDIA_CLASS_VIDEO, + MEDIA_TYPE_IMAGE, + MEDIA_TYPE_VIDEO, +) +from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.media_source.error import Unresolvable +from homeassistant.components.media_source.models import ( + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, +) +from homeassistant.components.nest.const import DATA_SUBSCRIBER, DOMAIN +from homeassistant.components.nest.device_info import NestDeviceInfo +from homeassistant.components.nest.events import MEDIA_SOURCE_EVENT_TITLE_MAP +from homeassistant.core import HomeAssistant +from homeassistant.helpers.template import DATE_STR_FORMAT +from homeassistant.util import dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +MEDIA_SOURCE_TITLE = "Nest" +DEVICE_TITLE_FORMAT = "{device_name}: Recent Events" +CLIP_TITLE_FORMAT = "{event_name} @ {event_time}" +EVENT_MEDIA_API_URL_FORMAT = "/api/nest/event_media/{device_id}/{event_id}" + + +async def async_get_media_source(hass: HomeAssistant) -> MediaSource: + """Set up Nest media source.""" + return NestMediaSource(hass) + + +async def get_media_source_devices(hass: HomeAssistant) -> Mapping[str, Device]: + """Return a mapping of device id to eligible Nest event media devices.""" + subscriber = hass.data[DOMAIN][DATA_SUBSCRIBER] + device_manager = await subscriber.async_get_device_manager() + device_registry = await hass.helpers.device_registry.async_get_registry() + devices = {} + for device in device_manager.devices.values(): + if not ( + CameraEventImageTrait.NAME in device.traits + or CameraClipPreviewTrait.NAME in device.traits + ): + continue + if device_entry := device_registry.async_get_device({(DOMAIN, device.name)}): + devices[device_entry.id] = device + return devices + + +@dataclass +class MediaId: + """Media identifier for a node in the Media Browse tree. + + A MediaId can refer to either a device, or a specific event for a device + that is associated with media (e.g. image or video clip). + """ + + device_id: str + event_id: str | None = None + + @property + def identifier(self) -> str: + """Media identifier represented as a string.""" + if self.event_id: + return f"{self.device_id}/{self.event_id}" + return self.device_id + + +def parse_media_id(identifier: str | None = None) -> MediaId | None: + """Parse the identifier path string into a MediaId.""" + if identifier is None or identifier == "": + return None + parts = identifier.split("/") + if len(parts) > 1: + return MediaId(parts[0], parts[1]) + return MediaId(parts[0]) + + +class NestMediaSource(MediaSource): + """Provide Nest Media Sources for Nest Cameras. + + The media source generates a directory tree of devices and media associated + with events for each device (e.g. motion, person, etc). Each node in the + tree has a unique MediaId. + + The lifecycle for event media is handled outside of NestMediaSource, and + instead it just asks the device for all events it knows about. + """ + + name: str = MEDIA_SOURCE_TITLE + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize NestMediaSource.""" + super().__init__(DOMAIN) + self.hass = hass + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media identifier to a url.""" + media_id: MediaId | None = parse_media_id(item.identifier) + if not media_id: + raise Unresolvable("No identifier specified for MediaSourceItem") + if not media_id.event_id: + raise Unresolvable("Identifier missing an event_id: %s" % item.identifier) + devices = await self.devices() + if not (device := devices.get(media_id.device_id)): + raise Unresolvable( + "Unable to find device with identifier: %s" % item.identifier + ) + events = await _get_events(device) + if media_id.event_id not in events: + raise Unresolvable( + "Unable to find event with identifier: %s" % item.identifier + ) + event = events[media_id.event_id] + return PlayMedia( + EVENT_MEDIA_API_URL_FORMAT.format( + device_id=media_id.device_id, event_id=media_id.event_id + ), + event.event_image_type.content_type, + ) + + async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: + """Return media for the specified level of the directory tree. + + The top level is the root that contains devices. Inside each device are + media for events for that device. + """ + media_id: MediaId | None = parse_media_id(item.identifier) + _LOGGER.debug( + "Browsing media for identifier=%s, media_id=%s", item.identifier, media_id + ) + devices = await self.devices() + if media_id is None: + # Browse the root and return child devices + browse_root = _browse_root() + browse_root.children = [] + for device_id, child_device in devices.items(): + browse_root.children.append( + _browse_device(MediaId(device_id), child_device) + ) + return browse_root + + # Browse either a device or events within a device + if not (device := devices.get(media_id.device_id)): + raise BrowseError( + "Unable to find device with identiifer: %s" % item.identifier + ) + if media_id.event_id is None: + # Browse a specific device and return child events + browse_device = _browse_device(media_id, device) + browse_device.children = [] + events = await _get_events(device) + for child_event in events.values(): + event_id = MediaId(media_id.device_id, child_event.event_session_id) + browse_device.children.append( + _browse_event(event_id, device, child_event) + ) + return browse_device + + # Browse a specific event + events = await _get_events(device) + if not (event := events.get(media_id.event_id)): + raise BrowseError( + "Unable to find event with identiifer: %s" % item.identifier + ) + return _browse_event(media_id, device, event) + + async def devices(self) -> Mapping[str, Device]: + """Return all event media related devices.""" + return await get_media_source_devices(self.hass) + + +async def _get_events(device: Device) -> Mapping[str, ImageEventBase]: + """Return relevant events for the specified device.""" + events = await device.event_media_manager.async_events() + return {e.event_session_id: e for e in events} + + +def _browse_root() -> BrowseMediaSource: + """Return devices in the root.""" + return BrowseMediaSource( + domain=DOMAIN, + identifier="", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=MEDIA_TYPE_VIDEO, + children_media_class=MEDIA_CLASS_VIDEO, + title=MEDIA_SOURCE_TITLE, + can_play=False, + can_expand=True, + thumbnail=None, + children=[], + ) + + +def _browse_device(device_id: MediaId, device: Device) -> BrowseMediaSource: + """Return details for the specified device.""" + device_info = NestDeviceInfo(device) + return BrowseMediaSource( + domain=DOMAIN, + identifier=device_id.identifier, + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=MEDIA_TYPE_VIDEO, + children_media_class=MEDIA_CLASS_VIDEO, + title=DEVICE_TITLE_FORMAT.format(device_name=device_info.device_name), + can_play=False, + can_expand=True, + thumbnail=None, + children=[], + ) + + +def _browse_event( + event_id: MediaId, device: Device, event: ImageEventBase +) -> BrowseMediaSource: + """Build a BrowseMediaSource for a specific event.""" + return BrowseMediaSource( + domain=DOMAIN, + identifier=event_id.identifier, + media_class=MEDIA_CLASS_IMAGE, + media_content_type=MEDIA_TYPE_IMAGE, + title=CLIP_TITLE_FORMAT.format( + event_name=MEDIA_SOURCE_EVENT_TITLE_MAP.get(event.event_type, "Event"), + event_time=dt_util.as_local(event.timestamp).strftime(DATE_STR_FORMAT), + ), + can_play=True, + can_expand=False, + thumbnail=None, + children=[], + ) diff --git a/homeassistant/components/nest/placeholder.png b/homeassistant/components/nest/placeholder.png new file mode 100644 index 00000000000..5ccc755abfd Binary files /dev/null and b/homeassistant/components/nest/placeholder.png differ diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 26ec49c0d75..1d3dfda1708 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -4,6 +4,20 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, + "auth": { + "title": "Link Google Account", + "description": "To link your Google account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided Auth Token code below.", + "data": { + "code": "[%key:common::config_flow::data::access_token%]" + } + }, + "pubsub": { + "title": "Configure Google Cloud", + "description": "Visit the [Cloud Console]({url}) to find your Google Cloud Project ID.", + "data": { + "cloud_project_id": "Google Cloud Project ID" + } + }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Nest integration needs to re-authenticate your account" @@ -27,7 +41,10 @@ "timeout": "Timeout validating code", "invalid_pin": "Invalid [%key:common::config_flow::data::pin%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "internal_error": "Internal error validating code" + "internal_error": "Internal error validating code", + "bad_project_id": "Please enter a valid Cloud Project ID (check Cloud Console)", + "wrong_project_id": "Please enter a valid Cloud Project ID (found Device Access Project ID)", + "subscriber_error": "Unknown subscriber error, see logs" }, "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", @@ -35,7 +52,8 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "unknown_authorize_url_generation": "[%key:common::config_flow::abort::unknown_authorize_url_generation%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/nest/translations/af.json b/homeassistant/components/nest/translations/af.json new file mode 100644 index 00000000000..cedc2123597 --- /dev/null +++ b/homeassistant/components/nest/translations/af.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "auth": { + "data": { + "code": "Hozz\u00e1f\u00e9r\u00e9si token" + }, + "title": "Google fi\u00f3k kapcsol\u00e1sa" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/translations/ca.json b/homeassistant/components/nest/translations/ca.json index 35cc6eff946..888b7ed5b44 100644 --- a/homeassistant/components/nest/translations/ca.json +++ b/homeassistant/components/nest/translations/ca.json @@ -2,6 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "Temps d'espera esgotat durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.", + "invalid_access_token": "Token d'acc\u00e9s inv\u00e0lid", "missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3.", "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_successful": "Re-autenticaci\u00f3 realitzada correctament", @@ -12,12 +13,22 @@ "default": "Autenticaci\u00f3 exitosa" }, "error": { + "bad_project_id": "Introdueix un ID de projecte Cloud v\u00e0lid (consulta Cloud Console)", "internal_error": "Error intern al validar el codi", "invalid_pin": "Codi PIN inv\u00e0lid", + "subscriber_error": "Error de subscriptor desconegut, consulta els registres", "timeout": "S'ha acabat el temps d'espera durant la validaci\u00f3 del codi.", - "unknown": "Error inesperat" + "unknown": "Error inesperat", + "wrong_project_id": "Introdueix un ID de projecte Cloud v\u00e0lid (s'ha trobat un ID de projecte d'Acc\u00e9s de Dispositiu)" }, "step": { + "auth": { + "data": { + "code": "Token d'acc\u00e9s" + }, + "description": "Per enlla\u00e7ar un compte de Google, [autoritza el compte]({url}). \n\nDespr\u00e9s de l'autoritzaci\u00f3, copia i enganxa a continuaci\u00f3 el codi 'token' d'autenticaci\u00f3 proporcionat.", + "title": "Vinculaci\u00f3 amb compte de Google" + }, "init": { "data": { "flow_impl": "Prove\u00efdor" @@ -35,6 +46,13 @@ "pick_implementation": { "title": "Selecciona el m\u00e8tode d'autenticaci\u00f3" }, + "pubsub": { + "data": { + "cloud_project_id": "ID de projecte de Google Cloud" + }, + "description": "V\u00e9s a [Cloud Console]({url}) per obtenir l'ID de projecte de Google Cloud.", + "title": "Configuraci\u00f3 de Google Cloud" + }, "reauth_confirm": { "description": "La integraci\u00f3 de Nest ha de tornar a autenticar-se amb el teu compte", "title": "Reautenticaci\u00f3 de la integraci\u00f3" diff --git a/homeassistant/components/nest/translations/de.json b/homeassistant/components/nest/translations/de.json index dde725681d8..47d0505dca5 100644 --- a/homeassistant/components/nest/translations/de.json +++ b/homeassistant/components/nest/translations/de.json @@ -2,6 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", + "invalid_access_token": "Ung\u00fcltiger Zugriffs-Token", "missing_configuration": "Die Komponente ist nicht konfiguriert. Bitte der Dokumentation folgen.", "no_url_available": "Keine URL verf\u00fcgbar. Informationen zu diesem Fehler findest du [im Hilfebereich]({docs_url}).", "reauth_successful": "Die erneute Authentifizierung war erfolgreich", @@ -12,12 +13,22 @@ "default": "Erfolgreich authentifiziert" }, "error": { + "bad_project_id": "Bitte gib eine g\u00fcltige Cloud-Projekt-ID ein (\u00fcberpr\u00fcfe die Cloud-Konsole).", "internal_error": "Ein interner Fehler ist aufgetreten", "invalid_pin": "Ung\u00fcltiger PIN-Code", + "subscriber_error": "Unbekannter Abonnentenfehler, siehe Protokolle", "timeout": "Ein zeit\u00fcberschreitungs Fehler ist aufgetreten", - "unknown": "Unerwarteter Fehler" + "unknown": "Unerwarteter Fehler", + "wrong_project_id": "Bitte gib eine g\u00fcltige Cloud-Projekt-ID ein (gefundene Ger\u00e4tezugriffs-Projekt-ID)" }, "step": { + "auth": { + "data": { + "code": "Zugangstoken" + }, + "description": "Um dein Google-Konto zu verkn\u00fcpfen, w\u00e4hle [Konto autorisieren]({url}).\n\nKopiere nach der Autorisierung den unten angegebenen Authentifizierungstoken-Code.", + "title": "Google-Konto verkn\u00fcpfen" + }, "init": { "data": { "flow_impl": "Anbieter" @@ -35,6 +46,13 @@ "pick_implementation": { "title": "W\u00e4hle die Authentifizierungsmethode" }, + "pubsub": { + "data": { + "cloud_project_id": "Google Cloud-Projekt-ID" + }, + "description": "Rufe die [Cloud Console]( {url} ) auf, um deine Google Cloud-Projekt-ID zu finden.", + "title": "Google Cloud konfigurieren" + }, "reauth_confirm": { "description": "Die Nest-Integration muss das Konto neu authentifizieren", "title": "Integration erneut authentifizieren" diff --git a/homeassistant/components/nest/translations/en.json b/homeassistant/components/nest/translations/en.json index 4487beb0f43..6376807302b 100644 --- a/homeassistant/components/nest/translations/en.json +++ b/homeassistant/components/nest/translations/en.json @@ -2,6 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "Timeout generating authorize URL.", + "invalid_access_token": "Invalid access token", "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})", "reauth_successful": "Re-authentication was successful", @@ -12,12 +13,22 @@ "default": "Successfully authenticated" }, "error": { + "bad_project_id": "Please enter a valid Cloud Project ID (check Cloud Console)", "internal_error": "Internal error validating code", "invalid_pin": "Invalid PIN Code", + "subscriber_error": "Unknown subscriber error, see logs", "timeout": "Timeout validating code", - "unknown": "Unexpected error" + "unknown": "Unexpected error", + "wrong_project_id": "Please enter a valid Cloud Project ID (found Device Access Project ID)" }, "step": { + "auth": { + "data": { + "code": "Access Token" + }, + "description": "To link your Google account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided Auth Token code below.", + "title": "Link Google Account" + }, "init": { "data": { "flow_impl": "Provider" @@ -35,6 +46,13 @@ "pick_implementation": { "title": "Pick Authentication Method" }, + "pubsub": { + "data": { + "cloud_project_id": "Google Cloud Project ID" + }, + "description": "Visit the [Cloud Console]({url}) to find your Google Cloud Project ID.", + "title": "Configure Google Cloud" + }, "reauth_confirm": { "description": "The Nest integration needs to re-authenticate your account", "title": "Reauthenticate Integration" diff --git a/homeassistant/components/nest/translations/es.json b/homeassistant/components/nest/translations/es.json index f9e88c9180f..7ec5f1ab2f6 100644 --- a/homeassistant/components/nest/translations/es.json +++ b/homeassistant/components/nest/translations/es.json @@ -18,6 +18,11 @@ "unknown": "Error desconocido validando el c\u00f3digo" }, "step": { + "auth": { + "data": { + "code": "Token de acceso" + } + }, "init": { "data": { "flow_impl": "Proveedor" diff --git a/homeassistant/components/nest/translations/et.json b/homeassistant/components/nest/translations/et.json index 773835ea29b..898c9e9f3f3 100644 --- a/homeassistant/components/nest/translations/et.json +++ b/homeassistant/components/nest/translations/et.json @@ -2,6 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "Tuvastamise URL-i loomise ajal\u00f5pp.", + "invalid_access_token": "Vigane juurdep\u00e4\u00e4sut\u00f5end", "missing_configuration": "Osis pole seadistatud. Vaata dokumentatsiooni.", "no_url_available": "URL pole saadaval. Selle t\u00f5rke kohta teabe saamiseks vaata [spikrijaotis]({docs_url})", "reauth_successful": "Taastuvastamine \u00f5nnestus", @@ -12,12 +13,22 @@ "default": "Tuvastamine \u00f5nnestus" }, "error": { + "bad_project_id": "Sisesta kehtiv pilveprojekti ID (kontrolli Cloud Console'i)", "internal_error": "Sisemine viga tuvastuskoodi kinnitamisel", "invalid_pin": "Vale PIN kood", + "subscriber_error": "Tundmatu tellija t\u00f5rge, vt logisid", "timeout": "Tuvastuskoodi ajal\u00f5pp", - "unknown": "Tundmatu viga tuvastuskoodi kinnitamisel" + "unknown": "Tundmatu viga tuvastuskoodi kinnitamisel", + "wrong_project_id": "Sisesta kehtiv pilveprojekti ID (leitud seadme juurdep\u00e4\u00e4su projekti ID)" }, "step": { + "auth": { + "data": { + "code": "Juurdep\u00e4\u00e4sut\u00f5end" + }, + "description": "Oma Google'i konto sidumiseks vali [autoriseeri oma konto]({url}).\n\nP\u00e4rast autoriseerimist kopeeri ja aseta allpool esitatud Auth Token'i kood.", + "title": "Google'i konto linkimine" + }, "init": { "data": { "flow_impl": "Pakkuja" @@ -35,6 +46,13 @@ "pick_implementation": { "title": "Vali tuvastusmeetod" }, + "pubsub": { + "data": { + "cloud_project_id": "Google Cloudi projekti ID" + }, + "description": "K\u00fclasta [Cloud Console]({url}), et leida oma Google Cloudi projekti ID.", + "title": "Google Cloudi seadistamine" + }, "reauth_confirm": { "description": "Nesti sidumine peab konto taastuvastama", "title": "Taastuvasta sidumine" diff --git a/homeassistant/components/nest/translations/he.json b/homeassistant/components/nest/translations/he.json index 6efee1d74bd..b1f12277c22 100644 --- a/homeassistant/components/nest/translations/he.json +++ b/homeassistant/components/nest/translations/he.json @@ -18,6 +18,11 @@ "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { + "auth": { + "data": { + "code": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d2\u05d9\u05e9\u05d4" + } + }, "init": { "data": { "flow_impl": "\u05e1\u05e4\u05e7" diff --git a/homeassistant/components/nest/translations/hu.json b/homeassistant/components/nest/translations/hu.json index 95f284b9a81..2bb9d2dbaec 100644 --- a/homeassistant/components/nest/translations/hu.json +++ b/homeassistant/components/nest/translations/hu.json @@ -2,6 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n.", + "invalid_access_token": "\u00c9rv\u00e9nytelen hozz\u00e1f\u00e9r\u00e9si token", "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt.", @@ -12,12 +13,22 @@ "default": "Sikeres hiteles\u00edt\u00e9s" }, "error": { + "bad_project_id": "K\u00e9rem, adjon meg egy \u00e9rv\u00e9nyes Cloud Project ID-t (Cloud Consoleban l\u00e1that\u00f3).", "internal_error": "Bels\u0151 hiba t\u00f6rt\u00e9nt a k\u00f3d valid\u00e1l\u00e1s\u00e1n\u00e1l", "invalid_pin": "\u00c9rv\u00e9nytelen PIN-k\u00f3d", + "subscriber_error": "Ismeretlen el\u0151fizet\u0151i hiba, b\u0151vebben a napl\u00f3kban", "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a k\u00f3d \u00e9rv\u00e9nyes\u00edt\u00e9se sor\u00e1n.", - "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", + "wrong_project_id": "K\u00e9rem, adjon meg egy \u00e9rv\u00e9nyes Cloud Project ID-t (Device Access Project ID tal\u00e1lva)" }, "step": { + "auth": { + "data": { + "code": "Hozz\u00e1f\u00e9r\u00e9si token" + }, + "description": "[Enged\u00e9lyezze]({url}) Google-fi\u00f3kj\u00e1t az \u00f6sszekapcsol\u00e1hoz.\n\nAz enged\u00e9lyez\u00e9s ut\u00e1n m\u00e1solja \u00e1t a kapott token k\u00f3dot.", + "title": "\u00d6sszekapcsol\u00e1s Google-al" + }, "init": { "data": { "flow_impl": "Szolg\u00e1ltat\u00f3" @@ -35,6 +46,13 @@ "pick_implementation": { "title": "V\u00e1lassz hiteles\u00edt\u00e9si m\u00f3dszert" }, + "pubsub": { + "data": { + "cloud_project_id": "Google Cloud Project ID" + }, + "description": "L\u00e1togasson el a [Cloud Console]({url}) oldalra, hogy megtal\u00e1lja a Google Cloud Project ID-t", + "title": "Google Cloud konfigur\u00e1l\u00e1sa" + }, "reauth_confirm": { "description": "A Nest integr\u00e1ci\u00f3nak \u00fajra kell hiteles\u00edtenie a fi\u00f3kodat", "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" diff --git a/homeassistant/components/nest/translations/id.json b/homeassistant/components/nest/translations/id.json index d035433361f..cc7cdc600a8 100644 --- a/homeassistant/components/nest/translations/id.json +++ b/homeassistant/components/nest/translations/id.json @@ -2,6 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "Tenggang waktu pembuatan URL otorisasi habis.", + "invalid_access_token": "Token akses tidak valid", "missing_configuration": "Komponen tidak dikonfigurasi. Ikuti petunjuk dalam dokumentasi.", "no_url_available": "Tidak ada URL yang tersedia. Untuk informasi tentang kesalahan ini, [lihat bagian bantuan]({docs_url})", "reauth_successful": "Autentikasi ulang berhasil", @@ -12,12 +13,22 @@ "default": "Berhasil diautentikasi" }, "error": { + "bad_project_id": "Masukkan Cloud Project ID yang valid (periksa Cloud Console)", "internal_error": "Kesalahan internal saat memvalidasi kode", "invalid_pin": "Invalid Kode PIN", + "subscriber_error": "Kesalahan pelanggan tidak diketahui, lihat log", "timeout": "Tenggang waktu memvalidasi kode telah habis.", - "unknown": "Kesalahan yang tidak diharapkan" + "unknown": "Kesalahan yang tidak diharapkan", + "wrong_project_id": "Masukkan Cloud Project ID yang valid (Device Access Project ID yang ditemukan)" }, "step": { + "auth": { + "data": { + "code": "Token Akses" + }, + "description": "Untuk menautkan akun Google Anda, [otorisasi akun Anda]({url}).\n\nSetelah otorisasi, salin dan tempel Token Auth yang disediakan di bawah ini.", + "title": "Tautkan Akun Google" + }, "init": { "data": { "flow_impl": "Penyedia" @@ -35,6 +46,13 @@ "pick_implementation": { "title": "Pilih Metode Autentikasi" }, + "pubsub": { + "data": { + "cloud_project_id": "ID Proyek Google Cloud" + }, + "description": "Kunjungi [Cloud Console]( {url} ) untuk menemukan ID Proyek Google Cloud Anda.", + "title": "Konfigurasikan Google Cloud" + }, "reauth_confirm": { "description": "Integrasi Nest perlu mengautentikasi ulang akun Anda", "title": "Autentikasi Ulang Integrasi" diff --git a/homeassistant/components/nest/translations/it.json b/homeassistant/components/nest/translations/it.json index bb4c916384d..6227dae21db 100644 --- a/homeassistant/components/nest/translations/it.json +++ b/homeassistant/components/nest/translations/it.json @@ -18,6 +18,13 @@ "unknown": "Errore imprevisto" }, "step": { + "auth": { + "data": { + "code": "Token di accesso" + }, + "description": "Per collegare l'account Google, [authorize your account]({url}).\n\nDopo l'autorizzazione, copia-incolla il codice PIN fornito.", + "title": "Connetti l'account Google" + }, "init": { "data": { "flow_impl": "Provider" diff --git a/homeassistant/components/nest/translations/ja.json b/homeassistant/components/nest/translations/ja.json index bb80db0af5e..752e847336f 100644 --- a/homeassistant/components/nest/translations/ja.json +++ b/homeassistant/components/nest/translations/ja.json @@ -1,21 +1,70 @@ { "config": { + "abort": { + "authorize_url_timeout": "\u8a8d\u8a3cURL\u306e\u751f\u6210\u304c\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002", + "invalid_access_token": "\u7121\u52b9\u306a\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", + "missing_configuration": "\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u306b\u5f93\u3063\u3066\u304f\u3060\u3055\u3044\u3002", + "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})", + "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\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "unknown_authorize_url_generation": "\u8a8d\u8a3cURL\u306e\u751f\u6210\u4e2d\u306b\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002" + }, + "create_entry": { + "default": "\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f" + }, "error": { + "bad_project_id": "\u6709\u52b9\u306aCloud Project ID\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044(\u30af\u30e9\u30a6\u30c9 \u30b3\u30f3\u30bd\u30fc\u30eb\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044)", "internal_error": "\u30b3\u30fc\u30c9\u306e\u691c\u8a3c\u4e2d\u306b\u5185\u90e8\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f", - "timeout": "\u30b3\u30fc\u30c9\u306e\u691c\u8a3c\u3092\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3059" + "invalid_pin": "\u7121\u52b9\u306aPIN\u30b3\u30fc\u30c9", + "subscriber_error": "\u4e0d\u660e\u306a\u30b5\u30d6\u30b9\u30af\u30e9\u30a4\u30d0\u30fc\u30a8\u30e9\u30fc\u3001\u30ed\u30b0\u3092\u53c2\u7167", + "timeout": "\u30b3\u30fc\u30c9\u306e\u691c\u8a3c\u3092\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3059", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc", + "wrong_project_id": "\u6709\u52b9\u306aCloud Project ID\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044(\u30c7\u30d0\u30a4\u30b9\u30a2\u30af\u30bb\u30b9 \u30d7\u30ed\u30b8\u30a7\u30af\u30c8ID\u304c\u898b\u3064\u304b\u308a\u307e\u3057\u305f)" }, "step": { + "auth": { + "data": { + "code": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3" + }, + "description": "Google\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u30ea\u30f3\u30af\u3059\u308b\u306b\u306f\u3001 [authorize your account]({url}) \u3092\u30af\u30ea\u30c3\u30af\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n\n\u8a8d\u8a3c\u5f8c\u3001\u63d0\u4f9b\u3055\u308c\u305f\u8a8d\u8a3c\u30c8\u30fc\u30af\u30f3\u306e\u30b3\u30fc\u30c9\u3092\u4ee5\u4e0b\u306b\u30b3\u30d4\u30fc\u30da\u30fc\u30b9\u30c8\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Google\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u30ea\u30f3\u30af\u3059\u308b" + }, "init": { "data": { "flow_impl": "\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc" }, + "description": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e", "title": "\u8a8d\u8a3c\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc" }, "link": { "data": { "code": "PIN\u30b3\u30fc\u30c9" - } + }, + "description": "Nest\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u30ea\u30f3\u30af\u3059\u308b\u306b\u306f\u3001[Authorize your account]({url})\u3092\u30af\u30ea\u30c3\u30af\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n\n\u8a8d\u8a3c\u5f8c\u3001\u63d0\u4f9b\u3055\u308c\u305fPIN\u30b3\u30fc\u30c9\u3092\u4ee5\u4e0b\u306b\u30b3\u30d4\u30fc\u30da\u30fc\u30b9\u30c8\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Nest\u30a2\u30ab\u30a6\u30f3\u30c8\u3078\u30ea\u30f3\u30af" + }, + "pick_implementation": { + "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" + }, + "pubsub": { + "data": { + "cloud_project_id": "Google Cloud\u30d7\u30ed\u30b8\u30a7\u30af\u30c8ID" + }, + "description": "[Cloud Console]({url})\u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u3001Google Cloud\u30d7\u30ed\u30b8\u30a7\u30af\u30c8ID\u3092\u898b\u3064\u3051\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Google Cloud\u306e\u8a2d\u5b9a" + }, + "reauth_confirm": { + "description": "Nest\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u306f\u3001\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u518d\u8a8d\u8a3c\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" } } + }, + "device_automation": { + "trigger_type": { + "camera_motion": "\u52d5\u4f53\u691c\u77e5", + "camera_person": "\u691c\u51fa\u3055\u308c\u305f\u4eba", + "camera_sound": "\u97f3\u304c\u691c\u51fa\u3055\u308c\u307e\u3057\u305f", + "doorbell_chime": "\u30c9\u30a2\u30d9\u30eb\u304c\u62bc\u3055\u308c\u305f" + } } } \ No newline at end of file diff --git a/homeassistant/components/nest/translations/nl.json b/homeassistant/components/nest/translations/nl.json index a849b76f1c9..0e2984972af 100644 --- a/homeassistant/components/nest/translations/nl.json +++ b/homeassistant/components/nest/translations/nl.json @@ -2,6 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", + "invalid_access_token": "Ongeldig toegangstoken", "missing_configuration": "De component is niet geconfigureerd. Gelieve de documentatie volgen.", "no_url_available": "Geen URL beschikbaar. Voor informatie over deze fout, [check de helpsectie]({docs_url})", "reauth_successful": "Herauthenticatie was succesvol", @@ -12,12 +13,22 @@ "default": "Succesvol geauthenticeerd" }, "error": { + "bad_project_id": "Voer een geldige Cloud Project ID in (controleer Cloud Console)", "internal_error": "Interne foutvalidatiecode", "invalid_pin": "Ongeldige PIN-code", + "subscriber_error": "Onbekende abonneefout, zie logs", "timeout": "Time-out validatie van code", - "unknown": "Onverwachte fout" + "unknown": "Onverwachte fout", + "wrong_project_id": "Voer een geldig Cloud Project ID in (found Device Acces Project ID)" }, "step": { + "auth": { + "data": { + "code": "Toegangstoken" + }, + "description": "Om uw Google account te koppelen, [authoriseer uw account]({url}).\n\nNa autorisatie, copy-paste u de gegeven toegangstoken hieronder.", + "title": "Link Google Account" + }, "init": { "data": { "flow_impl": "Leverancier" @@ -35,6 +46,13 @@ "pick_implementation": { "title": "Kies een authenticatie methode" }, + "pubsub": { + "data": { + "cloud_project_id": "Google Cloud Project ID" + }, + "description": "Bezoek de [Cloud Console]({url}) om uw Google Cloud Project ID te vinden.", + "title": "Google Cloud configureren" + }, "reauth_confirm": { "description": "De Nest-integratie moet uw account opnieuw verifi\u00ebren", "title": "Verifieer de integratie opnieuw" diff --git a/homeassistant/components/nest/translations/no.json b/homeassistant/components/nest/translations/no.json index 914545d5a54..fcc6aeadddf 100644 --- a/homeassistant/components/nest/translations/no.json +++ b/homeassistant/components/nest/translations/no.json @@ -2,6 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "Tidsavbrudd ved oppretting av godkjenningsadresse", + "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", @@ -12,12 +13,22 @@ "default": "Vellykket godkjenning" }, "error": { + "bad_project_id": "Skriv inn en gyldig Cloud Project ID (sjekk Cloud Console)", "internal_error": "Intern feil ved validering av kode", "invalid_pin": "Ugyldig PIN kode", + "subscriber_error": "Ukjent abonnentfeil, se logger", "timeout": "Tidsavbrudd ved validering av kode", - "unknown": "Uventet feil" + "unknown": "Uventet feil", + "wrong_project_id": "Angi en gyldig Cloud Project ID (funnet Device Access Project ID)" }, "step": { + "auth": { + "data": { + "code": "Tilgangstoken" + }, + "description": "For \u00e5 koble til Google-kontoen din, [autoriser kontoen din]( {url} ). \n\n Etter autorisasjon, kopier og lim inn den oppgitte Auth Token-koden nedenfor.", + "title": "Koble til Google-kontoen" + }, "init": { "data": { "flow_impl": "Tilbyder" @@ -35,6 +46,13 @@ "pick_implementation": { "title": "Velg godkjenningsmetode" }, + "pubsub": { + "data": { + "cloud_project_id": "Google Cloud Project ID" + }, + "description": "G\u00e5 til [Cloud Console]( {url} ) for \u00e5 finne Google Cloud Project ID.", + "title": "Konfigurer Google Cloud" + }, "reauth_confirm": { "description": "Nest-integrasjonen m\u00e5 godkjenne kontoen din p\u00e5 nytt", "title": "Godkjenne integrering p\u00e5 nytt" diff --git a/homeassistant/components/nest/translations/pl.json b/homeassistant/components/nest/translations/pl.json index 98506684dbb..23ac1d4f28f 100644 --- a/homeassistant/components/nest/translations/pl.json +++ b/homeassistant/components/nest/translations/pl.json @@ -18,6 +18,13 @@ "unknown": "Nieoczekiwany b\u0142\u0105d" }, "step": { + "auth": { + "data": { + "code": "Token dost\u0119pu" + }, + "description": "Aby po\u0142\u0105czy\u0107 swoje konto Google, [authorize your account]({url}). \n\nPo autoryzacji skopiuj i wklej podany poni\u017cej token uwierzytelniaj\u0105cy.", + "title": "Po\u0142\u0105cz z kontem Google" + }, "init": { "data": { "flow_impl": "Dostawca" diff --git a/homeassistant/components/nest/translations/ru.json b/homeassistant/components/nest/translations/ru.json index 1919f8db89e..f17f48b4560 100644 --- a/homeassistant/components/nest/translations/ru.json +++ b/homeassistant/components/nest/translations/ru.json @@ -2,6 +2,7 @@ "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.", + "invalid_access_token": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430.", "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.", "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.", @@ -12,12 +13,22 @@ "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." }, "error": { + "bad_project_id": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0439 Cloud Project ID (\u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 Cloud Console)", "internal_error": "\u0412\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u044f\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043a\u043e\u0434\u0430.", "invalid_pin": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 PIN-\u043a\u043e\u0434.", + "subscriber_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u043e\u0433\u043e \u043f\u043e\u0434\u043f\u0438\u0441\u0447\u0438\u043a\u0430. \u0411\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0432 \u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0445.", "timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043a\u043e\u0434\u0430.", - "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430.", + "wrong_project_id": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0439 Cloud Project ID (\u043d\u0430\u0439\u0434\u0435\u043d Device Access Project ID)" }, "step": { + "auth": { + "data": { + "code": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430" + }, + "description": "[\u0410\u0432\u0442\u043e\u0440\u0438\u0437\u0443\u0439\u0442\u0435\u0441\u044c]({url}), \u0447\u0442\u043e\u0431\u044b \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c Google. \n\n\u041f\u043e\u0441\u043b\u0435 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0441\u043a\u043e\u043f\u0438\u0440\u0443\u0439\u0442\u0435 \u043f\u0440\u0438\u043b\u0430\u0433\u0430\u0435\u043c\u044b\u0439 \u0442\u043e\u043a\u0435\u043d.", + "title": "\u041f\u0440\u0438\u0432\u044f\u0437\u043a\u0430 \u0430\u043a\u043a\u0430\u0443\u043d\u0442\u0430 Google" + }, "init": { "data": { "flow_impl": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440" @@ -35,6 +46,13 @@ "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" }, + "pubsub": { + "data": { + "cloud_project_id": "Google Cloud Project ID" + }, + "description": "\u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u0432 [Cloud Console]({url}), \u0447\u0442\u043e\u0431\u044b \u0443\u0437\u043d\u0430\u0442\u044c \u0412\u0430\u0448 Google Cloud Project ID.", + "title": "Google Cloud" + }, "reauth_confirm": { "description": "\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Nest", "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" diff --git a/homeassistant/components/nest/translations/sl.json b/homeassistant/components/nest/translations/sl.json index 84f07fdcf42..836ae7761e8 100644 --- a/homeassistant/components/nest/translations/sl.json +++ b/homeassistant/components/nest/translations/sl.json @@ -11,6 +11,12 @@ "unknown": "Neznana napaka pri preverjanju kode" }, "step": { + "auth": { + "data": { + "code": "\u017deton za dostop" + }, + "title": "Pove\u017eite Google Ra\u010dun" + }, "init": { "data": { "flow_impl": "Ponudnik" diff --git a/homeassistant/components/nest/translations/th.json b/homeassistant/components/nest/translations/th.json index 5f14558e2b5..99efbb30cad 100644 --- a/homeassistant/components/nest/translations/th.json +++ b/homeassistant/components/nest/translations/th.json @@ -1,6 +1,9 @@ { "config": { "step": { + "auth": { + "title": "\u0e40\u0e0a\u0e37\u0e48\u0e2d\u0e21\u0e15\u0e48\u0e2d\u0e1a\u0e31\u0e0d\u0e0a\u0e35\u0e02\u0e2d\u0e07 oogle" + }, "link": { "data": { "code": "Pin code" diff --git a/homeassistant/components/nest/translations/tr.json b/homeassistant/components/nest/translations/tr.json index 003c1ccc0c2..f39b0fc935e 100644 --- a/homeassistant/components/nest/translations/tr.json +++ b/homeassistant/components/nest/translations/tr.json @@ -1,12 +1,62 @@ { "config": { "abort": { + "authorize_url_timeout": "Yetkilendirme URL'si olu\u015ftururken zaman a\u015f\u0131m\u0131.", + "invalid_access_token": "Ge\u00e7ersiz eri\u015fim anahtar\u0131", + "missing_configuration": "Bile\u015fen yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin.", + "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_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.", "unknown_authorize_url_generation": "Yetkilendirme url'si olu\u015fturulurken bilinmeyen hata." }, + "create_entry": { + "default": "Ba\u015far\u0131yla do\u011fruland\u0131" + }, "error": { - "unknown": "Beklenmeyen hata" + "bad_project_id": "L\u00fctfen ge\u00e7erli bir Cloud Project Kimli\u011fi girin (Cloud Console'a bak\u0131n)", + "internal_error": "Kod do\u011frularken i\u00e7 hata olu\u015ftu", + "invalid_pin": "Ge\u00e7ersiz PIN Kodu", + "subscriber_error": "Bilinmeyen abone hatas\u0131, g\u00fcnl\u00fcklere bak\u0131n\u0131z", + "timeout": "Zaman a\u015f\u0131m\u0131 do\u011frulama kodu", + "unknown": "Beklenmeyen hata", + "wrong_project_id": "L\u00fctfen ge\u00e7erli bir Bulut Projesi Kimli\u011fi girin (bulunan Ayg\u0131t Eri\u015fimi Proje Kimli\u011fi)" + }, + "step": { + "auth": { + "data": { + "code": "Eri\u015fim Anahtar\u0131" + }, + "description": "Google hesab\u0131n\u0131z\u0131 ba\u011flamak i\u00e7in [hesab\u0131n\u0131z\u0131 yetkilendirin]( {url} ). \n\n Yetkilendirmeden sonra, sa\u011flanan Auth Token kodunu a\u015fa\u011f\u0131ya kopyalay\u0131p yap\u0131\u015ft\u0131r\u0131n.", + "title": "Google Hesab\u0131n\u0131 Ba\u011fla" + }, + "init": { + "data": { + "flow_impl": "Sa\u011flay\u0131c\u0131" + }, + "description": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7", + "title": "Kimlik Do\u011frulama Sa\u011flay\u0131c\u0131s\u0131" + }, + "link": { + "data": { + "code": "PIN Kodu" + }, + "description": "Nest hesab\u0131n\u0131z\u0131 ba\u011flamak i\u00e7in [hesab\u0131n\u0131z\u0131 yetkilendirin]( {url} ). \n\n Yetkilendirmeden sonra, a\u015fa\u011f\u0131da verilen PIN kodunu kopyalay\u0131p yap\u0131\u015ft\u0131r\u0131n.", + "title": "Nest Hesab\u0131n\u0131 Ba\u011fla" + }, + "pick_implementation": { + "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7" + }, + "pubsub": { + "data": { + "cloud_project_id": "Google Bulut Proje Kimli\u011fi" + }, + "description": "Google Cloud Project Kimli\u011finizi bulmak i\u00e7in [Cloud Console]({url}) adresini ziyaret edin.", + "title": "Google Cloud'u yap\u0131land\u0131r\u0131n" + }, + "reauth_confirm": { + "description": "Nest entegrasyonunun hesab\u0131n\u0131z\u0131 yeniden do\u011frulamas\u0131 gerekiyor", + "title": "Entegrasyonu Yeniden Do\u011frula" + } } }, "device_automation": { diff --git a/homeassistant/components/nest/translations/zh-Hant.json b/homeassistant/components/nest/translations/zh-Hant.json index d9b29c7fa7a..afae41f7d7a 100644 --- a/homeassistant/components/nest/translations/zh-Hant.json +++ b/homeassistant/components/nest/translations/zh-Hant.json @@ -2,6 +2,7 @@ "config": { "abort": { "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642\u3002", + "invalid_access_token": "\u5b58\u53d6\u6b0a\u6756\u7121\u6548", "missing_configuration": "\u5143\u4ef6\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002", "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_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", @@ -12,12 +13,22 @@ "default": "\u5df2\u6210\u529f\u8a8d\u8b49" }, "error": { + "bad_project_id": "\u8acb\u8f38\u5165\u6709\u6548 Cloud \u5c08\u6848 ID\uff08\u8acb\u53c3\u95b1 Cloud Console\uff09", "internal_error": "\u8a8d\u8b49\u78bc\u5167\u90e8\u932f\u8aa4", "invalid_pin": "\u7121\u6548\u7684 PIN \u78bc", + "subscriber_error": "\u672a\u77e5\u8a02\u95b1\u932f\u8aa4\uff0c\u8acb\u53c3\u95b1\u65e5\u8a8c", "timeout": "\u8a8d\u8b49\u78bc\u903e\u6642", - "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + "unknown": "\u672a\u9810\u671f\u932f\u8aa4", + "wrong_project_id": "\u8acb\u8f38\u5165\u6709\u6548 Cloud \u5c08\u6848 ID\uff08\u53ef\u65bc Device Access Project ID \u4e2d\u627e\u5230\uff09" }, "step": { + "auth": { + "data": { + "code": "\u5b58\u53d6\u6b0a\u6756" + }, + "description": "\u6b32\u9023\u7d50 Google \u5e33\u865f\u3001\u8acb\u5148 [\u8a8d\u8b49\u5e33\u865f]({url})\u3002\n\n\u65bc\u8a8d\u8b49\u5f8c\u3001\u65bc\u4e0b\u65b9\u8cbc\u4e0a\u8a8d\u8b49\u6b0a\u6756\u4ee3\u78bc\u3002", + "title": "\u9023\u7d50 Google \u5e33\u865f" + }, "init": { "data": { "flow_impl": "\u8a8d\u8b49\u63d0\u4f9b\u8005" @@ -35,6 +46,13 @@ "pick_implementation": { "title": "\u9078\u64c7\u9a57\u8b49\u6a21\u5f0f" }, + "pubsub": { + "data": { + "cloud_project_id": "Google Cloud \u5c08\u6848 ID" + }, + "description": "\u958b\u555f [Cloud Console]({url}) \u9801\u9762\u4ee5\u67e5\u770b Google Cloud \u5c08\u6848 ID\u3002", + "title": "\u8a2d\u5b9a Google Cloud" + }, "reauth_confirm": { "description": "Nest \u6574\u5408\u9700\u8981\u91cd\u65b0\u8a8d\u8b49\u60a8\u7684\u5e33\u865f", "title": "\u91cd\u65b0\u8a8d\u8b49\u6574\u5408" diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 6cb7457f8f6..380571aba70 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -172,13 +172,13 @@ class NetatmoCamera(NetatmoBase, Camera): if data["home_id"] == self._home_id and data["camera_id"] == self._id: if data[WEBHOOK_PUSH_TYPE] in ("NACamera-off", "NACamera-disconnection"): - self.is_streaming = False + self._attr_is_streaming = False self._status = "off" elif data[WEBHOOK_PUSH_TYPE] in ( "NACamera-on", WEBHOOK_NACAMERA_CONNECTION, ): - self.is_streaming = True + self._attr_is_streaming = True self._status = "on" elif data[WEBHOOK_PUSH_TYPE] == WEBHOOK_LIGHT_MODE: self._light_state = data["sub_type"] @@ -273,7 +273,7 @@ class NetatmoCamera(NetatmoBase, Camera): self._sd_status = camera.get("sd_status") self._alim_status = camera.get("alim_status") self._is_local = camera.get("is_local") - self.is_streaming = bool(self._status == "on") + self._attr_is_streaming = bool(self._status == "on") if self._model == "NACamera": # Smart Indoor Camera self.hass.data[DOMAIN][DATA_EVENTS][self._id] = self.process_events( diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index db324cd1722..1ead9d7cbdb 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Any, cast +from typing import Any import pyatmo import voluptuous as vol @@ -22,7 +22,6 @@ from homeassistant.components.climate.const import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_BATTERY_LEVEL, ATTR_SUGGESTED_AREA, ATTR_TEMPERATURE, PRECISION_HALVES, @@ -32,8 +31,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.device_registry import async_get_registry -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -41,7 +42,6 @@ from .const import ( ATTR_HEATING_POWER_REQUEST, ATTR_SCHEDULE_NAME, ATTR_SELECTED_SCHEDULE, - DATA_DEVICE_IDS, DATA_HANDLER, DATA_HOMES, DATA_SCHEDULES, @@ -50,17 +50,17 @@ from .const import ( EVENT_TYPE_SCHEDULE, EVENT_TYPE_SET_POINT, EVENT_TYPE_THERM_MODE, - MANUFACTURER, + NETATMO_CREATE_BATTERY, SERVICE_SET_SCHEDULE, SIGNAL_NAME, TYPE_ENERGY, ) from .data_handler import ( - HOMEDATA_DATA_CLASS_NAME, - HOMESTATUS_DATA_CLASS_NAME, + CLIMATE_STATE_CLASS_NAME, + CLIMATE_TOPOLOGY_CLASS_NAME, NetatmoDataHandler, + NetatmoDevice, ) -from .helper import get_all_home_ids, update_climate_schedules from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -125,44 +125,47 @@ async def async_setup_entry( data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] await data_handler.register_data_class( - HOMEDATA_DATA_CLASS_NAME, HOMEDATA_DATA_CLASS_NAME, None + CLIMATE_TOPOLOGY_CLASS_NAME, CLIMATE_TOPOLOGY_CLASS_NAME, None ) - home_data = data_handler.data.get(HOMEDATA_DATA_CLASS_NAME) + climate_topology = data_handler.data.get(CLIMATE_TOPOLOGY_CLASS_NAME) - if not home_data or home_data.raw_data == {}: + if not climate_topology or climate_topology.raw_data == {}: raise PlatformNotReady entities = [] - for home_id in get_all_home_ids(home_data): - for room_id in home_data.rooms[home_id]: - signal_name = f"{HOMESTATUS_DATA_CLASS_NAME}-{home_id}" + for home_id in climate_topology.home_ids: + signal_name = f"{CLIMATE_STATE_CLASS_NAME}-{home_id}" + + try: await data_handler.register_data_class( - HOMESTATUS_DATA_CLASS_NAME, signal_name, None, home_id=home_id + CLIMATE_STATE_CLASS_NAME, signal_name, None, home_id=home_id ) - home_status = data_handler.data.get(signal_name) - if home_status and room_id in home_status.rooms: - entities.append(NetatmoThermostat(data_handler, home_id, room_id)) + except KeyError: + continue - hass.data[DOMAIN][DATA_SCHEDULES].update( - update_climate_schedules( - home_ids=get_all_home_ids(home_data), - schedules=data_handler.data[HOMEDATA_DATA_CLASS_NAME].schedules, - ) - ) + climate_state = data_handler.data[signal_name] + climate_topology.register_handler(home_id, climate_state.process_topology) - hass.data[DOMAIN][DATA_HOMES] = { - home_id: home_data.get("name") - for home_id, home_data in ( - data_handler.data[HOMEDATA_DATA_CLASS_NAME].homes.items() - ) - } + for room in climate_state.homes[home_id].rooms.values(): + if room.device_type is None or room.device_type.value not in [ + NA_THERM, + NA_VALVE, + ]: + continue + entities.append(NetatmoThermostat(data_handler, room)) + + hass.data[DOMAIN][DATA_SCHEDULES][home_id] = climate_state.homes[ + home_id + ].schedules + + hass.data[DOMAIN][DATA_HOMES][home_id] = climate_state.homes[home_id].name _LOGGER.debug("Adding climate devices %s", entities) async_add_entities(entities, True) platform = entity_platform.async_get_current_platform() - if home_data is not None: + if climate_topology is not None: platform.async_register_entity_service( SERVICE_SET_SCHEDULE, {vol.Required(ATTR_SCHEDULE_NAME): cv.string}, @@ -173,67 +176,62 @@ async def async_setup_entry( class NetatmoThermostat(NetatmoBase, ClimateEntity): """Representation a Netatmo thermostat.""" + _attr_hvac_mode = HVAC_MODE_AUTO + _attr_max_temp = DEFAULT_MAX_TEMP + _attr_preset_modes = SUPPORT_PRESET + _attr_supported_features = SUPPORT_FLAGS + _attr_target_temperature_step = PRECISION_HALVES + _attr_temperature_unit = TEMP_CELSIUS + def __init__( - self, data_handler: NetatmoDataHandler, home_id: str, room_id: str + self, data_handler: NetatmoDataHandler, room: pyatmo.climate.NetatmoRoom ) -> None: """Initialize the sensor.""" ClimateEntity.__init__(self) super().__init__(data_handler) - self._id = room_id - self._home_id = home_id + self._room = room + self._id = self._room.entity_id - self._home_status_class = f"{HOMESTATUS_DATA_CLASS_NAME}-{self._home_id}" + self._climate_state_class = ( + f"{CLIMATE_STATE_CLASS_NAME}-{self._room.home.entity_id}" + ) + self._climate_state: pyatmo.AsyncClimate = data_handler.data[ + self._climate_state_class + ] self._data_classes.extend( [ { - "name": HOMEDATA_DATA_CLASS_NAME, - SIGNAL_NAME: HOMEDATA_DATA_CLASS_NAME, + "name": CLIMATE_TOPOLOGY_CLASS_NAME, + SIGNAL_NAME: CLIMATE_TOPOLOGY_CLASS_NAME, }, { - "name": HOMESTATUS_DATA_CLASS_NAME, - "home_id": self._home_id, - SIGNAL_NAME: self._home_status_class, + "name": CLIMATE_STATE_CLASS_NAME, + "home_id": self._room.home.entity_id, + SIGNAL_NAME: self._climate_state_class, }, ] ) - self._home_status = self.data_handler.data[self._home_status_class] - self._room_status = self._home_status.rooms[room_id] - self._room_data: dict = self._data.rooms[home_id][room_id] - - self._model: str = NA_VALVE - for module in self._room_data.get("module_ids", []): - if self._home_status.thermostats.get(module): - self._model = NA_THERM - break + self._model: str = getattr(room.device_type, "value") self._netatmo_type = TYPE_ENERGY - self._device_name = self._data.rooms[home_id][room_id]["name"] - self._attr_name = f"{MANUFACTURER} {self._device_name}" - self._current_temperature: float | None = None - self._target_temperature: float | None = None - self._preset: str | None = None + self._attr_name = self._room.name self._away: bool | None = None - self._operation_list = [HVAC_MODE_AUTO, HVAC_MODE_HEAT] - self._support_flags = SUPPORT_FLAGS - self._hvac_mode: str = HVAC_MODE_AUTO - self._battery_level = None self._connected: bool | None = None self._away_temperature: float | None = None self._hg_temperature: float | None = None self._boilerstatus: bool | None = None - self._setpoint_duration = None self._selected_schedule = None + self._attr_hvac_modes = [HVAC_MODE_AUTO, HVAC_MODE_HEAT] if self._model == NA_THERM: - self._operation_list.append(HVAC_MODE_OFF) + self._attr_hvac_modes.append(HVAC_MODE_OFF) - self._attr_max_temp = DEFAULT_MAX_TEMP - self._attr_unique_id = f"{self._id}-{self._model}" + self._attr_unique_id = f"{self._room.entity_id}-{self._model}" async def async_added_to_hass(self) -> None: """Entity created.""" @@ -253,125 +251,102 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): ) ) - registry = await async_get_registry(self.hass) - device = registry.async_get_device({(DOMAIN, self._id)}, set()) - assert device - self.hass.data[DOMAIN][DATA_DEVICE_IDS][self._home_id] = device.id + for module in self._room.modules.values(): + if getattr(module.device_type, "value") not in [NA_THERM, NA_VALVE]: + continue + + async_dispatcher_send( + self.hass, + NETATMO_CREATE_BATTERY, + NetatmoDevice( + self.data_handler, + module, + self._id, + self._climate_state_class, + ), + ) @callback def handle_event(self, event: dict) -> None: """Handle webhook events.""" data = event["data"] - if self._home_id != data["home_id"]: + if self._room.home.entity_id != data["home_id"]: return if data["event_type"] == EVENT_TYPE_SCHEDULE and "schedule_id" in data: - self._selected_schedule = self.hass.data[DOMAIN][DATA_SCHEDULES][ - self._home_id - ].get(data["schedule_id"]) - self._attr_extra_state_attributes.update( - {"selected_schedule": self._selected_schedule} + self._selected_schedule = getattr( + self.hass.data[DOMAIN][DATA_SCHEDULES][self._room.home.entity_id].get( + data["schedule_id"] + ), + "name", + None, ) + self._attr_extra_state_attributes[ + ATTR_SELECTED_SCHEDULE + ] = self._selected_schedule self.async_write_ha_state() - self.data_handler.async_force_update(self._home_status_class) + self.data_handler.async_force_update(self._climate_state_class) return home = data["home"] - if self._home_id != home["id"]: + if self._room.home.entity_id != home["id"]: return if data["event_type"] == EVENT_TYPE_THERM_MODE: - self._preset = NETATMO_MAP_PRESET[home[EVENT_TYPE_THERM_MODE]] - self._hvac_mode = HVAC_MAP_NETATMO[self._preset] - if self._preset == PRESET_FROST_GUARD: - self._target_temperature = self._hg_temperature - elif self._preset == PRESET_AWAY: - self._target_temperature = self._away_temperature - elif self._preset == PRESET_SCHEDULE: + self._attr_preset_mode = NETATMO_MAP_PRESET[home[EVENT_TYPE_THERM_MODE]] + self._attr_hvac_mode = HVAC_MAP_NETATMO[self._attr_preset_mode] + if self._attr_preset_mode == PRESET_FROST_GUARD: + self._attr_target_temperature = self._hg_temperature + elif self._attr_preset_mode == PRESET_AWAY: + self._attr_target_temperature = self._away_temperature + elif self._attr_preset_mode == PRESET_SCHEDULE: self.async_update_callback() - self.data_handler.async_force_update(self._home_status_class) + self.data_handler.async_force_update(self._climate_state_class) self.async_write_ha_state() return for room in home.get("rooms", []): - if data["event_type"] == EVENT_TYPE_SET_POINT and self._id == room["id"]: + if ( + data["event_type"] == EVENT_TYPE_SET_POINT + and self._room.entity_id == room["id"] + ): if room["therm_setpoint_mode"] == STATE_NETATMO_OFF: - self._hvac_mode = HVAC_MODE_OFF - self._preset = STATE_NETATMO_OFF - self._target_temperature = 0 + self._attr_hvac_mode = HVAC_MODE_OFF + self._attr_preset_mode = STATE_NETATMO_OFF + self._attr_target_temperature = 0 elif room["therm_setpoint_mode"] == STATE_NETATMO_MAX: - self._hvac_mode = HVAC_MODE_HEAT - self._preset = PRESET_MAP_NETATMO[PRESET_BOOST] - self._target_temperature = DEFAULT_MAX_TEMP + self._attr_hvac_mode = HVAC_MODE_HEAT + self._attr_preset_mode = PRESET_MAP_NETATMO[PRESET_BOOST] + self._attr_target_temperature = DEFAULT_MAX_TEMP elif room["therm_setpoint_mode"] == STATE_NETATMO_MANUAL: - self._hvac_mode = HVAC_MODE_HEAT - self._target_temperature = room["therm_setpoint_temperature"] + self._attr_hvac_mode = HVAC_MODE_HEAT + self._attr_target_temperature = room["therm_setpoint_temperature"] else: - self._target_temperature = room["therm_setpoint_temperature"] - if self._target_temperature == DEFAULT_MAX_TEMP: - self._hvac_mode = HVAC_MODE_HEAT + self._attr_target_temperature = room["therm_setpoint_temperature"] + if self._attr_target_temperature == DEFAULT_MAX_TEMP: + self._attr_hvac_mode = HVAC_MODE_HEAT self.async_write_ha_state() return if ( data["event_type"] == EVENT_TYPE_CANCEL_SET_POINT - and self._id == room["id"] + and self._room.entity_id == room["id"] ): self.async_update_callback() self.async_write_ha_state() return - @property - def _data(self) -> pyatmo.AsyncHomeData: - """Return data for this entity.""" - return cast( - pyatmo.AsyncHomeData, self.data_handler.data[self._data_classes[0]["name"]] - ) - - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return self._support_flags - - @property - def temperature_unit(self) -> str: - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def current_temperature(self) -> float | None: - """Return the current temperature.""" - return self._current_temperature - - @property - def target_temperature(self) -> float | None: - """Return the temperature we try to reach.""" - return self._target_temperature - - @property - def target_temperature_step(self) -> float | None: - """Return the supported step of target temperature.""" - return PRECISION_HALVES - - @property - def hvac_mode(self) -> str: - """Return hvac operation ie. heat, cool mode.""" - return self._hvac_mode - - @property - def hvac_modes(self) -> list[str]: - """Return the list of available hvac operation modes.""" - return self._operation_list - @property def hvac_action(self) -> str | None: """Return the current running hvac operation if supported.""" if self._model == NA_THERM and self._boilerstatus is not None: return CURRENT_HVAC_MAP_NETATMO[self._boilerstatus] # Maybe it is a valve - if self._room_status and self._room_status.get("heating_power_request", 0) > 0: + if ( + heating_req := getattr(self._room, "heating_power_request", 0) + ) is not None and heating_req > 0: return CURRENT_HVAC_HEAT return CURRENT_HVAC_IDLE @@ -392,8 +367,8 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): await self.async_turn_on() if self.target_temperature == 0: - await self._home_status.async_set_room_thermpoint( - self._id, + await self._climate_state.async_set_room_thermpoint( + self._room.entity_id, STATE_NETATMO_HOME, ) @@ -402,15 +377,15 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): and self._model == NA_VALVE and self.hvac_mode == HVAC_MODE_HEAT ): - await self._home_status.async_set_room_thermpoint( - self._id, + await self._climate_state.async_set_room_thermpoint( + self._room.entity_id, STATE_NETATMO_HOME, ) elif ( preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX) and self._model == NA_VALVE ): - await self._home_status.async_set_room_thermpoint( - self._id, + await self._climate_state.async_set_room_thermpoint( + self._room.entity_id, STATE_NETATMO_MANUAL, DEFAULT_MAX_TEMP, ) @@ -418,36 +393,28 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX) and self.hvac_mode == HVAC_MODE_HEAT ): - await self._home_status.async_set_room_thermpoint( - self._id, STATE_NETATMO_HOME + await self._climate_state.async_set_room_thermpoint( + self._room.entity_id, STATE_NETATMO_HOME ) elif preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX): - await self._home_status.async_set_room_thermpoint( - self._id, PRESET_MAP_NETATMO[preset_mode] + await self._climate_state.async_set_room_thermpoint( + self._room.entity_id, PRESET_MAP_NETATMO[preset_mode] ) elif preset_mode in (PRESET_SCHEDULE, PRESET_FROST_GUARD, PRESET_AWAY): - await self._home_status.async_set_thermmode(PRESET_MAP_NETATMO[preset_mode]) + await self._climate_state.async_set_thermmode( + PRESET_MAP_NETATMO[preset_mode] + ) else: _LOGGER.error("Preset mode '%s' not available", preset_mode) self.async_write_ha_state() - @property - def preset_mode(self) -> str | None: - """Return the current preset mode, e.g., home, away, temp.""" - return self._preset - - @property - def preset_modes(self) -> list[str] | None: - """Return a list of available preset modes.""" - return SUPPORT_PRESET - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature for 2 hours.""" if (temp := kwargs.get(ATTR_TEMPERATURE)) is None: return - await self._home_status.async_set_room_thermpoint( - self._id, STATE_NETATMO_MANUAL, min(temp, DEFAULT_MAX_TEMP) + await self._climate_state.async_set_room_thermpoint( + self._room.entity_id, STATE_NETATMO_MANUAL, min(temp, DEFAULT_MAX_TEMP) ) self.async_write_ha_state() @@ -455,20 +422,22 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): async def async_turn_off(self) -> None: """Turn the entity off.""" if self._model == NA_VALVE: - await self._home_status.async_set_room_thermpoint( - self._id, + await self._climate_state.async_set_room_thermpoint( + self._room.entity_id, STATE_NETATMO_MANUAL, DEFAULT_MIN_TEMP, ) elif self.hvac_mode != HVAC_MODE_OFF: - await self._home_status.async_set_room_thermpoint( - self._id, STATE_NETATMO_OFF + await self._climate_state.async_set_room_thermpoint( + self._room.entity_id, STATE_NETATMO_OFF ) self.async_write_ha_state() async def async_turn_on(self) -> None: """Turn the entity on.""" - await self._home_status.async_set_room_thermpoint(self._id, STATE_NETATMO_HOME) + await self._climate_state.async_set_room_thermpoint( + self._room.entity_id, STATE_NETATMO_HOME + ) self.async_write_ha_state() @property @@ -479,135 +448,57 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): @callback def async_update_callback(self) -> None: """Update the entity's state.""" - self._home_status = self.data_handler.data[self._home_status_class] - if self._home_status is None: + if not self._room.reachable: if self.available: self._connected = False return - self._room_status = self._home_status.rooms.get(self._id) - self._room_data = self._data.rooms.get(self._home_id, {}).get(self._id, {}) - - if not self._room_status or not self._room_data: - if self._connected: - _LOGGER.info( - "The thermostat in room %s seems to be out of reach", - self._device_name, - ) - - self._connected = False - return - - roomstatus = {"roomID": self._room_status.get("id", {})} - if self._room_status.get("reachable"): - roomstatus.update(self._build_room_status()) - - self._away_temperature = self._data.get_away_temp(self._home_id) - self._hg_temperature = self._data.get_hg_temp(self._home_id) - self._setpoint_duration = self._data.setpoint_duration[self._home_id] - self._selected_schedule = roomstatus.get("selected_schedule") - - if "current_temperature" not in roomstatus: - return - - self._current_temperature = roomstatus["current_temperature"] - self._target_temperature = roomstatus["target_temperature"] - self._preset = NETATMO_MAP_PRESET[roomstatus["setpoint_mode"]] - self._hvac_mode = HVAC_MAP_NETATMO[self._preset] - self._battery_level = roomstatus.get("battery_state") self._connected = True - self._away = self._hvac_mode == HVAC_MAP_NETATMO[STATE_NETATMO_AWAY] + self._away_temperature = self._room.home.get_away_temp() + self._hg_temperature = self._room.home.get_hg_temp() + self._attr_current_temperature = self._room.therm_measured_temperature + self._attr_target_temperature = self._room.therm_setpoint_temperature + self._attr_preset_mode = NETATMO_MAP_PRESET[ + getattr(self._room, "therm_setpoint_mode", STATE_NETATMO_SCHEDULE) + ] + self._attr_hvac_mode = HVAC_MAP_NETATMO[self._attr_preset_mode] + self._away = self._attr_hvac_mode == HVAC_MAP_NETATMO[STATE_NETATMO_AWAY] - if self._battery_level is not None: - self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = self._battery_level + self._selected_schedule = getattr( + self._room.home.get_selected_schedule(), "name", None + ) + self._attr_extra_state_attributes[ + ATTR_SELECTED_SCHEDULE + ] = self._selected_schedule if self._model == NA_VALVE: self._attr_extra_state_attributes[ ATTR_HEATING_POWER_REQUEST - ] = self._room_status.get("heating_power_request", 0) - - if self._selected_schedule is not None: - self._attr_extra_state_attributes[ - ATTR_SELECTED_SCHEDULE - ] = self._selected_schedule - - def _build_room_status(self) -> dict: - """Construct room status.""" - try: - roomstatus = { - "roomname": self._room_data["name"], - "target_temperature": self._room_status["therm_setpoint_temperature"], - "setpoint_mode": self._room_status["therm_setpoint_mode"], - "current_temperature": self._room_status["therm_measured_temperature"], - "module_type": self._data.get_thermostat_type( - home_id=self._home_id, room_id=self._id - ), - "module_id": None, - "heating_status": None, - "heating_power_request": None, - "selected_schedule": self._data._get_selected_schedule( # pylint: disable=protected-access - home_id=self._home_id - ).get( - "name" - ), - } - - batterylevel = None - for module_id in self._room_data["module_ids"]: - if ( - self._data.modules[self._home_id][module_id]["type"] == NA_THERM - or roomstatus["module_id"] is None - ): - roomstatus["module_id"] = module_id - if roomstatus["module_type"] == NA_THERM: - self._boilerstatus = self._home_status.boiler_status( - roomstatus["module_id"] - ) - roomstatus["heating_status"] = self._boilerstatus - batterylevel = self._home_status.thermostats[ - roomstatus["module_id"] - ].get("battery_state") - elif roomstatus["module_type"] == NA_VALVE: - roomstatus["heating_power_request"] = self._room_status[ - "heating_power_request" - ] - roomstatus["heating_status"] = roomstatus["heating_power_request"] > 0 - if self._boilerstatus is not None: - roomstatus["heating_status"] = ( - self._boilerstatus and roomstatus["heating_status"] - ) - batterylevel = self._home_status.valves[roomstatus["module_id"]].get( - "battery_state" - ) - - if batterylevel: - roomstatus["battery_state"] = batterylevel - - return roomstatus - - except KeyError as err: - _LOGGER.error("Update of room %s failed. Error: %s", self._id, err) - - return {} + ] = self._room.heating_power_request + else: + for module in self._room.modules.values(): + self._boilerstatus = module.boiler_status + break async def _async_service_set_schedule(self, **kwargs: Any) -> None: schedule_name = kwargs.get(ATTR_SCHEDULE_NAME) schedule_id = None - for sid, name in self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id].items(): - if name == schedule_name: + for sid, schedule in self.hass.data[DOMAIN][DATA_SCHEDULES][ + self._room.home.entity_id + ].items(): + if schedule.name == schedule_name: schedule_id = sid + break if not schedule_id: _LOGGER.error("%s is not a valid schedule", kwargs.get(ATTR_SCHEDULE_NAME)) return - await self._data.async_switch_home_schedule( - home_id=self._home_id, schedule_id=schedule_id - ) + await self._climate_state.async_switch_home_schedule(schedule_id=schedule_id) _LOGGER.debug( "Setting %s schedule to %s (%s)", - self._home_id, + self._room.home.entity_id, kwargs.get(ATTR_SCHEDULE_NAME), schedule_id, ) @@ -616,5 +507,5 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): def device_info(self) -> DeviceInfo: """Return the device info for the thermostat.""" device_info: DeviceInfo = super().device_info - device_info[ATTR_SUGGESTED_AREA] = self._room_data["name"] + device_info[ATTR_SUGGESTED_AREA] = self._room.name return device_info diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 14e165b5cb4..a642d59ff1e 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -69,6 +69,7 @@ CAMERA_DATA = "netatmo_camera" HOME_DATA = "netatmo_home_data" DATA_HANDLER = "netatmo_data_handler" SIGNAL_NAME = "signal_name" +NETATMO_CREATE_BATTERY = "netatmo_create_battery" CONF_CLOUDHOOK_URL = "cloudhook_url" CONF_WEATHER_AREAS = "weather_areas" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 8cd0f2047ed..7a97ec3748f 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -32,23 +32,23 @@ _LOGGER = logging.getLogger(__name__) CAMERA_DATA_CLASS_NAME = "AsyncCameraData" WEATHERSTATION_DATA_CLASS_NAME = "AsyncWeatherStationData" HOMECOACH_DATA_CLASS_NAME = "AsyncHomeCoachData" -HOMEDATA_DATA_CLASS_NAME = "AsyncHomeData" -HOMESTATUS_DATA_CLASS_NAME = "AsyncHomeStatus" +CLIMATE_TOPOLOGY_CLASS_NAME = "AsyncClimateTopology" +CLIMATE_STATE_CLASS_NAME = "AsyncClimate" PUBLICDATA_DATA_CLASS_NAME = "AsyncPublicData" DATA_CLASSES = { WEATHERSTATION_DATA_CLASS_NAME: pyatmo.AsyncWeatherStationData, HOMECOACH_DATA_CLASS_NAME: pyatmo.AsyncHomeCoachData, CAMERA_DATA_CLASS_NAME: pyatmo.AsyncCameraData, - HOMEDATA_DATA_CLASS_NAME: pyatmo.AsyncHomeData, - HOMESTATUS_DATA_CLASS_NAME: pyatmo.AsyncHomeStatus, + CLIMATE_TOPOLOGY_CLASS_NAME: pyatmo.AsyncClimateTopology, + CLIMATE_STATE_CLASS_NAME: pyatmo.AsyncClimate, PUBLICDATA_DATA_CLASS_NAME: pyatmo.AsyncPublicData, } BATCH_SIZE = 3 DEFAULT_INTERVALS = { - HOMEDATA_DATA_CLASS_NAME: 900, - HOMESTATUS_DATA_CLASS_NAME: 300, + CLIMATE_TOPOLOGY_CLASS_NAME: 3600, + CLIMATE_STATE_CLASS_NAME: 300, CAMERA_DATA_CLASS_NAME: 900, WEATHERSTATION_DATA_CLASS_NAME: 600, HOMECOACH_DATA_CLASS_NAME: 300, @@ -57,6 +57,16 @@ DEFAULT_INTERVALS = { SCAN_INTERVAL = 60 +@dataclass +class NetatmoDevice: + """Netatmo device class.""" + + data_handler: NetatmoDataHandler + device: pyatmo.climate.NetatmoModule + parent_id: str + state_class_name: str + + @dataclass class NetatmoDataClass: """Class for keeping track of Netatmo data class metadata.""" @@ -184,7 +194,11 @@ class NetatmoDataHandler: self._auth, **kwargs ) - await self.async_fetch_data(data_class_entry) + try: + await self.async_fetch_data(data_class_entry) + except KeyError: + self.data_classes.pop(data_class_entry) + raise self._queue.append(self.data_classes[data_class_entry]) _LOGGER.debug("Data class %s added", data_class_entry) diff --git a/homeassistant/components/netatmo/helper.py b/homeassistant/components/netatmo/helper.py index d824013ed27..ea30e059d3a 100644 --- a/homeassistant/components/netatmo/helper.py +++ b/homeassistant/components/netatmo/helper.py @@ -4,8 +4,6 @@ from __future__ import annotations from dataclasses import dataclass from uuid import UUID, uuid4 -import pyatmo - @dataclass class NetatmoArea: @@ -19,25 +17,3 @@ class NetatmoArea: mode: str show_on_map: bool uuid: UUID = uuid4() - - -def get_all_home_ids(home_data: pyatmo.HomeData | None) -> list[str]: - """Get all the home ids returned by NetAtmo API.""" - if home_data is None: - return [] - return [ - home_data.homes[home_id]["id"] - for home_id in home_data.homes - if "modules" in home_data.homes[home_id] - ] - - -def update_climate_schedules(home_ids: list[str], schedules: dict) -> dict: - """Get updated list of all climate schedules.""" - return { - home_id: { - schedule_id: schedule_data.get("name") - for schedule_id, schedule_data in schedules[home_id].items() - } - for home_id in home_ids - } diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index f162abbaad5..501d5142bcc 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -3,7 +3,7 @@ "name": "Netatmo", "documentation": "https://www.home-assistant.io/integrations/netatmo", "requirements": [ - "pyatmo==6.1.0" + "pyatmo==6.2.0" ], "after_dependencies": [ "cloud", diff --git a/homeassistant/components/netatmo/netatmo_entity_base.py b/homeassistant/components/netatmo/netatmo_entity_base.py index 5a497275eaf..56f25e04906 100644 --- a/homeassistant/components/netatmo/netatmo_entity_base.py +++ b/homeassistant/components/netatmo/netatmo_entity_base.py @@ -66,7 +66,7 @@ class NetatmoBase(Entity): await self.data_handler.unregister_data_class(signal_name, None) registry = await self.hass.helpers.device_registry.async_get_registry() - device = registry.async_get_device({(DOMAIN, self._id)}, set()) + device = registry.async_get_device({(DOMAIN, self._id)}) self.hass.data[DOMAIN][DATA_DEVICE_IDS][self._id] = device.id self.async_update_callback() diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index f5ab43bbd12..98576497f3e 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -from typing import cast import pyatmo @@ -22,8 +21,11 @@ from .const import ( SIGNAL_NAME, TYPE_ENERGY, ) -from .data_handler import HOMEDATA_DATA_CLASS_NAME, NetatmoDataHandler -from .helper import get_all_home_ids, update_climate_schedules +from .data_handler import ( + CLIMATE_STATE_CLASS_NAME, + CLIMATE_TOPOLOGY_CLASS_NAME, + NetatmoDataHandler, +) from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -36,25 +38,42 @@ async def async_setup_entry( data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] await data_handler.register_data_class( - HOMEDATA_DATA_CLASS_NAME, HOMEDATA_DATA_CLASS_NAME, None + CLIMATE_TOPOLOGY_CLASS_NAME, CLIMATE_TOPOLOGY_CLASS_NAME, None ) - home_data = data_handler.data.get(HOMEDATA_DATA_CLASS_NAME) + climate_topology = data_handler.data.get(CLIMATE_TOPOLOGY_CLASS_NAME) - if not home_data or home_data.raw_data == {}: + if not climate_topology or climate_topology.raw_data == {}: raise PlatformNotReady - hass.data[DOMAIN][DATA_SCHEDULES].update( - update_climate_schedules( - home_ids=get_all_home_ids(home_data), - schedules=data_handler.data[HOMEDATA_DATA_CLASS_NAME].schedules, + entities = [] + for home_id in climate_topology.home_ids: + signal_name = f"{CLIMATE_STATE_CLASS_NAME}-{home_id}" + + try: + await data_handler.register_data_class( + CLIMATE_STATE_CLASS_NAME, signal_name, None, home_id=home_id + ) + except KeyError: + continue + + await data_handler.register_data_class( + CLIMATE_STATE_CLASS_NAME, signal_name, None, home_id=home_id ) - ) + climate_state = data_handler.data.get(signal_name) + climate_topology.register_handler(home_id, climate_state.process_topology) + + hass.data[DOMAIN][DATA_SCHEDULES][home_id] = climate_state.homes[ + home_id + ].schedules entities = [ NetatmoScheduleSelect( data_handler, home_id, - list(hass.data[DOMAIN][DATA_SCHEDULES][home_id].values()), + [ + schedule.name + for schedule in hass.data[DOMAIN][DATA_SCHEDULES][home_id].values() + ], ) for home_id in hass.data[DOMAIN][DATA_SCHEDULES] ] @@ -75,16 +94,28 @@ class NetatmoScheduleSelect(NetatmoBase, SelectEntity): self._home_id = home_id + self._climate_state_class = f"{CLIMATE_STATE_CLASS_NAME}-{self._home_id}" + self._climate_state: pyatmo.AsyncClimate = data_handler.data[ + self._climate_state_class + ] + + self._home = self._climate_state.homes[self._home_id] + self._data_classes.extend( [ { - "name": HOMEDATA_DATA_CLASS_NAME, - SIGNAL_NAME: HOMEDATA_DATA_CLASS_NAME, + "name": CLIMATE_TOPOLOGY_CLASS_NAME, + SIGNAL_NAME: CLIMATE_TOPOLOGY_CLASS_NAME, + }, + { + "name": CLIMATE_STATE_CLASS_NAME, + "home_id": self._home_id, + SIGNAL_NAME: self._climate_state_class, }, ] ) - self._device_name = self._data.homes[home_id]["name"] + self._device_name = self._home.name self._attr_name = f"{MANUFACTURER} {self._device_name}" self._model: str = "NATherm1" @@ -92,9 +123,7 @@ class NetatmoScheduleSelect(NetatmoBase, SelectEntity): self._attr_unique_id = f"{self._home_id}-schedule-select" - self._attr_current_option = self._data._get_selected_schedule( - home_id=self._home_id - ).get("name") + self._attr_current_option = getattr(self._home.get_selected_schedule(), "name") self._attr_options = options async def async_added_to_hass(self) -> None: @@ -119,23 +148,20 @@ class NetatmoScheduleSelect(NetatmoBase, SelectEntity): return if data["event_type"] == EVENT_TYPE_SCHEDULE and "schedule_id" in data: - self._attr_current_option = self.hass.data[DOMAIN][DATA_SCHEDULES][ - self._home_id - ].get(data["schedule_id"]) + self._attr_current_option = getattr( + self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id].get( + data["schedule_id"] + ), + "name", + ) self.async_write_ha_state() - @property - def _data(self) -> pyatmo.AsyncHomeData: - """Return data for this entity.""" - return cast( - pyatmo.AsyncHomeData, - self.data_handler.data[self._data_classes[0]["name"]], - ) - async def async_select_option(self, option: str) -> None: """Change the selected option.""" - for sid, name in self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id].items(): - if name != option: + for sid, schedule in self.hass.data[DOMAIN][DATA_SCHEDULES][ + self._home_id + ].items(): + if schedule.name != option: continue _LOGGER.debug( "Setting %s schedule to %s (%s)", @@ -143,25 +169,17 @@ class NetatmoScheduleSelect(NetatmoBase, SelectEntity): option, sid, ) - await self._data.async_switch_home_schedule( - home_id=self._home_id, schedule_id=sid - ) + await self._climate_state.async_switch_home_schedule(schedule_id=sid) break @callback def async_update_callback(self) -> None: """Update the entity's state.""" - self._attr_current_option = ( - self._data._get_selected_schedule( # pylint: disable=protected-access - home_id=self._home_id - ).get("name") - ) - self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id] = { - schedule_id: schedule_data.get("name") - for schedule_id, schedule_data in ( - self._data.schedules[self._home_id].items() - ) - } - self._attr_options = list( - self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id].values() - ) + self._attr_current_option = getattr(self._home.get_selected_schedule(), "name") + self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id] = self._home.schedules + self._attr_options = [ + schedule.name + for schedule in self.hass.data[DOMAIN][DATA_SCHEDULES][ + self._home_id + ].values() + ] diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 5e7d5ae7893..5b3416e3b09 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -8,10 +8,10 @@ from typing import NamedTuple, cast import pyatmo from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -19,12 +19,6 @@ from homeassistant.const import ( ATTR_LONGITUDE, CONCENTRATION_PARTS_PER_MILLION, DEGREE, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CO2, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_TEMPERATURE, ENTITY_CATEGORY_DIAGNOSTIC, LENGTH_MILLIMETERS, PERCENTAGE, @@ -48,6 +42,7 @@ from .const import ( DATA_HANDLER, DOMAIN, MANUFACTURER, + NETATMO_CREATE_BATTERY, SIGNAL_NAME, TYPE_WEATHER, ) @@ -56,6 +51,7 @@ from .data_handler import ( PUBLICDATA_DATA_CLASS_NAME, WEATHERSTATION_DATA_CLASS_NAME, NetatmoDataHandler, + NetatmoDevice, ) from .helper import NetatmoArea from .netatmo_entity_base import NetatmoBase @@ -93,8 +89,8 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( netatmo_name="Temperature", entity_registry_enabled_default=True, native_unit_of_measurement=TEMP_CELSIUS, - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, ), NetatmoSensorEntityDescription( key="temp_trend", @@ -109,8 +105,8 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( netatmo_name="CO2", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, entity_registry_enabled_default=True, - device_class=DEVICE_CLASS_CO2, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CO2, ), NetatmoSensorEntityDescription( key="pressure", @@ -118,8 +114,8 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( netatmo_name="Pressure", entity_registry_enabled_default=True, native_unit_of_measurement=PRESSURE_MBAR, - device_class=DEVICE_CLASS_PRESSURE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PRESSURE, ), NetatmoSensorEntityDescription( key="pressure_trend", @@ -135,7 +131,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=True, native_unit_of_measurement=SOUND_PRESSURE_DB, icon="mdi:volume-high", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), NetatmoSensorEntityDescription( key="humidity", @@ -143,8 +139,8 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( netatmo_name="Humidity", entity_registry_enabled_default=True, native_unit_of_measurement=PERCENTAGE, - device_class=DEVICE_CLASS_HUMIDITY, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.HUMIDITY, ), NetatmoSensorEntityDescription( key="rain", @@ -152,7 +148,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( netatmo_name="Rain", entity_registry_enabled_default=True, native_unit_of_measurement=LENGTH_MILLIMETERS, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, icon="mdi:weather-rainy", ), NetatmoSensorEntityDescription( @@ -161,7 +157,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( netatmo_name="sum_rain_1", entity_registry_enabled_default=False, native_unit_of_measurement=LENGTH_MILLIMETERS, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:weather-rainy", ), NetatmoSensorEntityDescription( @@ -170,7 +166,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( netatmo_name="sum_rain_24", entity_registry_enabled_default=True, native_unit_of_measurement=LENGTH_MILLIMETERS, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:weather-rainy", ), NetatmoSensorEntityDescription( @@ -180,8 +176,8 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=True, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, - device_class=DEVICE_CLASS_BATTERY, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, ), NetatmoSensorEntityDescription( key="windangle", @@ -197,7 +193,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), NetatmoSensorEntityDescription( key="windstrength", @@ -206,7 +202,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=True, native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, icon="mdi:weather-windy", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), NetatmoSensorEntityDescription( key="gustangle", @@ -222,7 +218,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), NetatmoSensorEntityDescription( key="guststrength", @@ -231,7 +227,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, icon="mdi:weather-windy", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), NetatmoSensorEntityDescription( key="reachable", @@ -256,8 +252,8 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, ), NetatmoSensorEntityDescription( key="wifi_status", @@ -274,8 +270,8 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, ), NetatmoSensorEntityDescription( key="health_idx", @@ -460,6 +456,16 @@ async def async_setup_entry( hass, f"signal-{DOMAIN}-public-update-{entry.entry_id}", add_public_entities ) + @callback + def _create_entity(netatmo_device: NetatmoDevice) -> None: + entity = NetatmoClimateBatterySensor(netatmo_device) + _LOGGER.debug("Adding climate battery sensor %s", entity) + async_add_entities([entity]) + + entry.async_on_unload( + async_dispatcher_connect(hass, NETATMO_CREATE_BATTERY, _create_entity) + ) + await add_public_entities(False) if platform_not_ready: @@ -490,9 +496,7 @@ class NetatmoSensor(NetatmoBase, SensorEntity): self._station_id = module_info.get("main_device", self._id) station = self._data.get_station(self._station_id) - device = self._data.get_module(self._id) - - if not device: + if not (device := self._data.get_module(self._id)): # Assume it's a station if module can't be found device = station @@ -569,6 +573,73 @@ class NetatmoSensor(NetatmoBase, SensorEntity): self.async_write_ha_state() +class NetatmoClimateBatterySensor(NetatmoBase, SensorEntity): + """Implementation of a Netatmo sensor.""" + + entity_description: NetatmoSensorEntityDescription + + def __init__( + self, + netatmo_device: NetatmoDevice, + ) -> None: + """Initialize the sensor.""" + super().__init__(netatmo_device.data_handler) + self.entity_description = NetatmoSensorEntityDescription( + key="battery_percent", + name="Battery Percent", + netatmo_name="battery_percent", + entity_registry_enabled_default=True, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + ) + + self._module = netatmo_device.device + self._id = netatmo_device.parent_id + self._attr_name = f"{self._module.name} {self.entity_description.name}" + + self._state_class_name = netatmo_device.state_class_name + self._room_id = self._module.room_id + self._model = getattr(self._module.device_type, "value") + + self._attr_unique_id = ( + f"{self._id}-{self._module.entity_id}-{self.entity_description.key}" + ) + + @callback + def async_update_callback(self) -> None: + """Update the entity's state.""" + if not self._module.reachable: + if self.available: + self._attr_available = False + self._attr_native_value = None + return + + self._attr_available = True + self._attr_native_value = self._process_battery_state() + + def _process_battery_state(self) -> int | None: + """Construct room status.""" + if battery_state := self._module.battery_state: + return process_battery_percentage(battery_state) + + return None + + +def process_battery_percentage(data: str) -> int: + """Process battery data and return percent (int) for display.""" + mapping = { + "max": 100, + "full": 90, + "high": 75, + "medium": 50, + "low": 25, + "very low": 10, + } + return mapping[data] + + def fix_angle(angle: int) -> int: """Fix angle when value is negative.""" if angle < 0: diff --git a/homeassistant/components/netatmo/translations/bg.json b/homeassistant/components/netatmo/translations/bg.json index 83b32fc7c85..4b02d4b6c6e 100644 --- a/homeassistant/components/netatmo/translations/bg.json +++ b/homeassistant/components/netatmo/translations/bg.json @@ -1,11 +1,16 @@ { "config": { "abort": { - "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." + "missing_configuration": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044a\u0442 \u043d\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d. \u041c\u043e\u043b\u044f, \u0441\u043b\u0435\u0434\u0432\u0430\u0439\u0442\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e", + "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": { "pick_implementation": { "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u043c\u0435\u0442\u043e\u0434 \u0437\u0430 \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, + "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/netatmo/translations/he.json b/homeassistant/components/netatmo/translations/he.json index 32d7ecac5f0..1d769516a08 100644 --- a/homeassistant/components/netatmo/translations/he.json +++ b/homeassistant/components/netatmo/translations/he.json @@ -4,6 +4,7 @@ "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})", + "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." }, "create_entry": { @@ -12,6 +13,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/netatmo/translations/id.json b/homeassistant/components/netatmo/translations/id.json index 6812d45816b..a71d2f3412e 100644 --- a/homeassistant/components/netatmo/translations/id.json +++ b/homeassistant/components/netatmo/translations/id.json @@ -4,6 +4,7 @@ "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})", + "reauth_successful": "Autentikasi ulang berhasil", "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." }, "create_entry": { @@ -12,12 +13,17 @@ "step": { "pick_implementation": { "title": "Pilih Metode Autentikasi" + }, + "reauth_confirm": { + "description": "Integrasi Netatmo perlu mengautentikasi ulang akun Anda", + "title": "Autentikasi Ulang Integrasi" } } }, "device_automation": { "trigger_subtype": { "away": "keluar", + "hg": "perlindungan kebekuan", "schedule": "jadwal" }, "trigger_type": { diff --git a/homeassistant/components/netatmo/translations/it.json b/homeassistant/components/netatmo/translations/it.json index 152f7d47597..3f9e7df3ad6 100644 --- a/homeassistant/components/netatmo/translations/it.json +++ b/homeassistant/components/netatmo/translations/it.json @@ -4,6 +4,7 @@ "authorize_url_timeout": "Tempo scaduto nel generare l'URL di autorizzazione.", "missing_configuration": "Il componente non \u00e8 configurato. Si prega di seguire la documentazione.", "no_url_available": "Nessun URL disponibile. Per informazioni su questo errore, [controlla la sezione della guida]({docs_url})", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." }, "create_entry": { @@ -12,6 +13,10 @@ "step": { "pick_implementation": { "title": "Scegli il metodo di autenticazione" + }, + "reauth_confirm": { + "description": "L'integrazione Netatmo deve riautenticare il tuo account", + "title": "Autenticare nuovamente l'integrazione" } } }, diff --git a/homeassistant/components/netatmo/translations/ja.json b/homeassistant/components/netatmo/translations/ja.json new file mode 100644 index 00000000000..ecb21ffe039 --- /dev/null +++ b/homeassistant/components/netatmo/translations/ja.json @@ -0,0 +1,70 @@ +{ + "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})", + "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\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "create_entry": { + "default": "\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f" + }, + "step": { + "pick_implementation": { + "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" + }, + "reauth_confirm": { + "description": "Netatmo\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u306f\u3001\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u518d\u8a8d\u8a3c\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + } + } + }, + "device_automation": { + "trigger_subtype": { + "away": "\u96e2\u5e2d(away)", + "hg": "\u30d5\u30ed\u30b9\u30c8(frost)\u30ac\u30fc\u30c9", + "schedule": "\u30b9\u30b1\u30b8\u30e5\u30fc\u30eb" + }, + "trigger_type": { + "alarm_started": "{entity_name} \u304c\u30a2\u30e9\u30fc\u30e0\u3092\u691c\u51fa\u3057\u307e\u3057\u305f", + "animal": "{entity_name} \u304c\u52d5\u7269\u3092\u691c\u51fa\u3057\u307e\u3057\u305f", + "cancel_set_point": "{entity_name} \u304c\u30b9\u30b1\u30b8\u30e5\u30fc\u30eb\u3092\u518d\u958b\u3057\u307e\u3057\u305f\u3002", + "human": "{entity_name} \u304c\u4eba\u3092\u691c\u51fa\u3057\u307e\u3057\u305f", + "movement": "{entity_name} \u304c\u52d5\u304d\u3092\u691c\u51fa\u3057\u307e\u3057\u305f", + "outdoor": "{entity_name} \u304c\u5c4b\u5916\u30a4\u30d9\u30f3\u30c8\u3092\u691c\u51fa\u3057\u307e\u3057\u305f", + "person": "{entity_name} \u304c\u500b\u4eba\u3092\u691c\u51fa\u3057\u307e\u3057\u305f", + "person_away": "{entity_name} \u304c\u4eba\u304c\u53bb\u3063\u305f\u3053\u3068\u3092\u691c\u51fa\u3057\u307e\u3057\u305f", + "set_point": "\u76ee\u6a19\u6e29\u5ea6 {entity_name} \u3092\u624b\u52d5\u3067\u8a2d\u5b9a", + "therm_mode": "{entity_name} \u306f \"{subtype}\" \u306b\u5207\u308a\u66ff\u308f\u308a\u307e\u3057\u305f\u3002", + "turned_off": "{entity_name} \u30aa\u30d5\u306b\u306a\u308a\u307e\u3057\u305f", + "turned_on": "{entity_name} \u30aa\u30f3\u306b\u306a\u3063\u3066\u3044\u307e\u3059", + "vehicle": "{entity_name} \u304c\u8eca\u4e21\u3092\u691c\u51fa\u3057\u307e\u3057\u305f" + } + }, + "options": { + "step": { + "public_weather": { + "data": { + "area_name": "\u30a8\u30ea\u30a2\u540d", + "lat_ne": "\u7def\u5ea6\u5317\u6771\u306e\u89d2", + "lat_sw": "\u7def\u5ea6\u5357\u897f\u306e\u89d2", + "lon_ne": "\u7d4c\u5ea6\u5317\u6771\u306e\u89d2", + "lon_sw": "\u7d4c\u5ea6 \u5357\u897f\u306e\u89d2", + "mode": "\u8a08\u7b97", + "show_on_map": "\u5730\u56f3\u306b\u8868\u793a" + }, + "description": "\u30a8\u30ea\u30a2\u306e\u30d1\u30d6\u30ea\u30c3\u30af\u6c17\u8c61\u30bb\u30f3\u30b5\u30fc\u3092\u8a2d\u5b9a\u3059\u308b\u3002", + "title": "Netatmo\u30d1\u30d6\u30ea\u30c3\u30af\u6c17\u8c61\u30bb\u30f3\u30b5\u30fc" + }, + "public_weather_areas": { + "data": { + "new_area": "\u30a8\u30ea\u30a2\u540d", + "weather_areas": "\u5929\u6c17\u4e88\u5831\u306e\u30a8\u30ea\u30a2" + }, + "description": "\u30d1\u30d6\u30ea\u30c3\u30af\u6c17\u8c61\u30bb\u30f3\u30b5\u30fc\u3092\u8a2d\u5b9a\u3059\u308b\u3002", + "title": "Netatmo\u30d1\u30d6\u30ea\u30c3\u30af\u6c17\u8c61\u30bb\u30f3\u30b5\u30fc" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netatmo/translations/nl.json b/homeassistant/components/netatmo/translations/nl.json index dc811b63534..1a740f9d5b1 100644 --- a/homeassistant/components/netatmo/translations/nl.json +++ b/homeassistant/components/netatmo/translations/nl.json @@ -4,6 +4,7 @@ "authorize_url_timeout": "Time-out genereren autorisatie-URL.", "missing_configuration": "Het component is niet geconfigureerd. Volg de documentatie.", "no_url_available": "Geen URL beschikbaar. Voor informatie over deze fout, [check de helpsectie]({docs_url})", + "reauth_successful": "Herauthenticatie was succesvol", "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." }, "create_entry": { @@ -12,6 +13,10 @@ "step": { "pick_implementation": { "title": "Kies de verificatiemethode" + }, + "reauth_confirm": { + "description": "De Netatmo-integratie moet uw account opnieuw verifi\u00ebren", + "title": "Verifieer de integratie opnieuw" } } }, diff --git a/homeassistant/components/netatmo/translations/no.json b/homeassistant/components/netatmo/translations/no.json index 9e3e24d5771..dc751d3a4b5 100644 --- a/homeassistant/components/netatmo/translations/no.json +++ b/homeassistant/components/netatmo/translations/no.json @@ -4,6 +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", "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." }, "create_entry": { @@ -12,6 +13,10 @@ "step": { "pick_implementation": { "title": "Velg godkjenningsmetode" + }, + "reauth_confirm": { + "description": "Netatmo-integrasjonen m\u00e5 autentisere kontoen din p\u00e5 nytt", + "title": "Godkjenne integrering p\u00e5 nytt" } } }, diff --git a/homeassistant/components/netatmo/translations/pl.json b/homeassistant/components/netatmo/translations/pl.json index 58dfb34f7fa..4b7ac6490cc 100644 --- a/homeassistant/components/netatmo/translations/pl.json +++ b/homeassistant/components/netatmo/translations/pl.json @@ -4,6 +4,7 @@ "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})", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." }, "create_entry": { @@ -12,6 +13,10 @@ "step": { "pick_implementation": { "title": "Wybierz metod\u0119 uwierzytelniania" + }, + "reauth_confirm": { + "description": "Integracja Netatmo wymaga ponownego uwierzytelnienia Twojego konta", + "title": "Ponownie uwierzytelnij integracj\u0119" } } }, diff --git a/homeassistant/components/netatmo/translations/pt-BR.json b/homeassistant/components/netatmo/translations/pt-BR.json new file mode 100644 index 00000000000..77e55a889c4 --- /dev/null +++ b/homeassistant/components/netatmo/translations/pt-BR.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "title": "Reautenticar integra\u00e7\u00e3o" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netatmo/translations/ru.json b/homeassistant/components/netatmo/translations/ru.json index e53e641b5ee..1bb004b5464 100644 --- a/homeassistant/components/netatmo/translations/ru.json +++ b/homeassistant/components/netatmo/translations/ru.json @@ -54,7 +54,7 @@ "mode": "\u0420\u0430\u0441\u0447\u0435\u0442", "show_on_map": "\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0430 \u043a\u0430\u0440\u0442\u0435" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u043e\u0431\u0449\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0439 \u0434\u0430\u0442\u0447\u0438\u043a \u043f\u043e\u0433\u043e\u0434\u044b \u0434\u043b\u044f \u043e\u0431\u043b\u0430\u0441\u0442\u0438", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043e\u0431\u0449\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e\u0433\u043e \u0434\u0430\u0442\u0447\u0438\u043a\u0430 \u043f\u043e\u0433\u043e\u0434\u044b \u0434\u043b\u044f \u043e\u0431\u043b\u0430\u0441\u0442\u0438.", "title": "\u041e\u0431\u0449\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0439 \u0434\u0430\u0442\u0447\u0438\u043a \u043f\u043e\u0433\u043e\u0434\u044b Netatmo" }, "public_weather_areas": { diff --git a/homeassistant/components/netatmo/translations/tr.json b/homeassistant/components/netatmo/translations/tr.json index 69646be2292..d40de61e36a 100644 --- a/homeassistant/components/netatmo/translations/tr.json +++ b/homeassistant/components/netatmo/translations/tr.json @@ -1,7 +1,23 @@ { "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})", + "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." + }, + "create_entry": { + "default": "Ba\u015far\u0131yla do\u011fruland\u0131" + }, + "step": { + "pick_implementation": { + "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7" + }, + "reauth_confirm": { + "description": "Netatmo entegrasyonunun hesab\u0131n\u0131z\u0131 yeniden do\u011frulamas\u0131 gerekiyor", + "title": "Entegrasyonu Yeniden Do\u011frula" + } } }, "device_automation": { @@ -16,8 +32,14 @@ "cancel_set_point": "{entity_name} zamanlamas\u0131na devam etti", "human": "{entity_name} bir insan alg\u0131lad\u0131", "movement": "{entity_name} hareket alg\u0131lad\u0131", + "outdoor": "{entity_name} bir d\u0131\u015f mekan etkinli\u011fi alg\u0131lad\u0131", + "person": "{entity_name} bir ki\u015fi tespit etti", + "person_away": "{entity_name} bir ki\u015finin ayr\u0131ld\u0131\u011f\u0131n\u0131 tespit etti", + "set_point": "{entity_name} hedef s\u0131cakl\u0131\u011f\u0131 manuel olarak ayarland\u0131", + "therm_mode": "{entity_name} , \" {subtype} \" olarak de\u011fi\u015ftirildi", "turned_off": "{entity_name} kapat\u0131ld\u0131", - "turned_on": "{entity_name} a\u00e7\u0131ld\u0131" + "turned_on": "{entity_name} a\u00e7\u0131ld\u0131", + "vehicle": "{entity_name} bir ara\u00e7 alg\u0131lad\u0131" } }, "options": { diff --git a/homeassistant/components/netatmo/translations/zh-Hant.json b/homeassistant/components/netatmo/translations/zh-Hant.json index c89f91d1d91..f8d181be5d3 100644 --- a/homeassistant/components/netatmo/translations/zh-Hant.json +++ b/homeassistant/components/netatmo/translations/zh-Hant.json @@ -4,6 +4,7 @@ "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})", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" }, "create_entry": { @@ -12,6 +13,10 @@ "step": { "pick_implementation": { "title": "\u9078\u64c7\u9a57\u8b49\u6a21\u5f0f" + }, + "reauth_confirm": { + "description": "Netatmo \u6574\u5408\u9700\u8981\u91cd\u65b0\u8a8d\u8b49\u60a8\u7684\u5e33\u865f", + "title": "\u91cd\u65b0\u8a8d\u8b49\u6574\u5408" } } }, diff --git a/homeassistant/components/netdata/manifest.json b/homeassistant/components/netdata/manifest.json index 9d79f54450c..34fbf45c529 100644 --- a/homeassistant/components/netdata/manifest.json +++ b/homeassistant/components/netdata/manifest.json @@ -2,7 +2,7 @@ "domain": "netdata", "name": "Netdata", "documentation": "https://www.home-assistant.io/integrations/netdata", - "requirements": ["netdata==0.2.0"], + "requirements": ["netdata==1.0.1"], "codeowners": ["@fabaff"], "iot_class": "local_polling" } diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index d1fa87a6e5d..3b1e9a0ed47 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -16,7 +16,6 @@ from homeassistant.const import ( PERCENTAGE, ) from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -61,8 +60,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= port = config.get(CONF_PORT) resources = config.get(CONF_RESOURCES) - session = async_get_clientsession(hass) - netdata = NetdataData(Netdata(host, hass.loop, session, port=port)) + netdata = NetdataData(Netdata(host, port=port)) await netdata.async_update() if netdata.api.metrics is None: diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 301fb780c1b..c3abbd59e27 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -3,14 +3,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.typing import HomeAssistantType from .const import DOMAIN, PLATFORMS from .errors import CannotLoginException from .router import NetgearRouter -async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Netgear component.""" router = NetgearRouter(hass, entry) try: @@ -23,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool entry.async_on_unload(entry.add_update_listener(update_listener)) - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.unique_id)}, @@ -38,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool return True -async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py index 6ce97fdbe60..29a0bc40ed6 100644 --- a/homeassistant/components/netgear/config_flow.py +++ b/homeassistant/components/netgear/config_flow.py @@ -123,11 +123,11 @@ class NetgearFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Import a config entry.""" return await self.async_step_user(user_input) - async def async_step_ssdp(self, discovery_info: dict) -> FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Initialize flow from ssdp.""" updated_data = {} - device_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) + device_url = urlparse(discovery_info.ssdp_location) if device_url.hostname: updated_data[CONF_HOST] = device_url.hostname if device_url.scheme == "https": @@ -137,14 +137,16 @@ class NetgearFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.debug("Netgear ssdp discovery info: %s", discovery_info) - await self.async_set_unique_id(discovery_info[ssdp.ATTR_UPNP_SERIAL]) + await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) self._abort_if_unique_id_configured(updates=updated_data) updated_data[CONF_PORT] = DEFAULT_PORT for model in MODELS_V2: - if discovery_info.get(ssdp.ATTR_UPNP_MODEL_NUMBER, "").startswith( + if discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NUMBER, "").startswith( model - ) or discovery_info.get(ssdp.ATTR_UPNP_MODEL_NAME, "").startswith(model): + ) or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "").startswith( + model + ): updated_data[CONF_PORT] = ORBI_PORT self.placeholders.update(updated_data) diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py index f7c92a271b9..7d7b278f937 100644 --- a/homeassistant/components/netgear/device_tracker.py +++ b/homeassistant/components/netgear/device_tracker.py @@ -19,10 +19,9 @@ from homeassistant.const import ( CONF_SSL, CONF_USERNAME, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import HomeAssistantType from .const import DEVICE_ICONS, DOMAIN from .router import NetgearDeviceEntity, NetgearRouter, async_setup_netgear_entry @@ -65,7 +64,7 @@ async def async_get_scanner(hass, config): async def async_setup_entry( - hass: HomeAssistantType, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up device tracker for Netgear component.""" diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index 8a9a4b3ef85..40e26128d8d 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_SSL, CONF_USERNAME, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.dispatcher import ( @@ -26,7 +26,6 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import dt as dt_util from .const import ( @@ -61,7 +60,7 @@ def get_api( @callback def async_setup_netgear_entry( - hass: HomeAssistantType, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, entity_class_generator: Callable[[NetgearRouter, dict], list], @@ -103,7 +102,7 @@ def async_add_new_entities(router, async_add_entities, tracked, entity_class_gen class NetgearRouter: """Representation of a Netgear router.""" - def __init__(self, hass: HomeAssistantType, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize a Netgear router.""" self.hass = hass self.entry = entry diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 4e9b55d3227..9762a777194 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -6,9 +6,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import HomeAssistantType from .router import NetgearDeviceEntity, NetgearRouter, async_setup_netgear_entry @@ -40,7 +39,7 @@ SENSOR_TYPES = { async def async_setup_entry( - hass: HomeAssistantType, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up device tracker for Netgear component.""" diff --git a/homeassistant/components/netgear/translations/id.json b/homeassistant/components/netgear/translations/id.json index a6a41a5023f..6292d3c18dc 100644 --- a/homeassistant/components/netgear/translations/id.json +++ b/homeassistant/components/netgear/translations/id.json @@ -3,6 +3,9 @@ "abort": { "already_configured": "Perangkat sudah dikonfigurasi" }, + "error": { + "config": "Koneksi atau kesalahan masuk: periksa konfigurasi Anda" + }, "step": { "user": { "data": { @@ -11,7 +14,20 @@ "port": "Port (Opsional)", "ssl": "Menggunakan sertifikat SSL", "username": "Nama Pengguna (Opsional)" - } + }, + "description": "Host default: {host}\nPort default: {port}\nNama pengguna default: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Pertimbangan waktu sebagai di rumah (detik)" + }, + "description": "Tentukan pengaturan opsional", + "title": "Netgear" } } } diff --git a/homeassistant/components/netgear/translations/ja.json b/homeassistant/components/netgear/translations/ja.json new file mode 100644 index 00000000000..a1d795bca9f --- /dev/null +++ b/homeassistant/components/netgear/translations/ja.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "config": "\u63a5\u7d9a\u307e\u305f\u306f\u30ed\u30b0\u30a4\u30f3\u30a8\u30e9\u30fc : \u8a2d\u5b9a\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "ssl": "SSL\u8a3c\u660e\u66f8\u3092\u4f7f\u7528\u3059\u308b", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d(\u30aa\u30d7\u30b7\u30e7\u30f3)" + }, + "description": "\u30c7\u30d5\u30a9\u30eb\u30c8\u306e\u30db\u30b9\u30c8: {host}\n\u30c7\u30d5\u30a9\u30eb\u30c8\u306e\u30dd\u30fc\u30c8: {port}\n\u30c7\u30d5\u30a9\u30eb\u30c8\u306e\u30e6\u30fc\u30b6\u30fc\u540d: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u30db\u30fc\u30e0\u30bf\u30a4\u30e0\u3092\u8003\u616e\u3059\u308b(\u79d2)" + }, + "description": "\u30aa\u30d7\u30b7\u30e7\u30f3\u8a2d\u5b9a\u306e\u6307\u5b9a", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netgear/translations/pl.json b/homeassistant/components/netgear/translations/pl.json index 3cd56289fbd..f5feb67cfe1 100644 --- a/homeassistant/components/netgear/translations/pl.json +++ b/homeassistant/components/netgear/translations/pl.json @@ -3,6 +3,9 @@ "abort": { "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" }, + "error": { + "config": "B\u0142\u0105d po\u0142\u0105czenia lub logowania: sprawd\u017a konfiguracj\u0119" + }, "step": { "user": { "data": { @@ -12,6 +15,7 @@ "ssl": "Certyfikat SSL", "username": "Nazwa u\u017cytkownika (opcjonalnie)" }, + "description": "Domy\u015blne IP lub nazwa hosta: {host}\nDomy\u015blny port: {port}\nDomy\u015blna nazwa u\u017cytkownika: {username}", "title": "Netgear" } } @@ -19,6 +23,9 @@ "options": { "step": { "init": { + "data": { + "consider_home": "Czas przed oznaczeniem \"w domu\" (w sekundach)" + }, "description": "Okre\u015bl opcjonalne ustawienia", "title": "Netgear" } diff --git a/homeassistant/components/netgear/translations/tr.json b/homeassistant/components/netgear/translations/tr.json new file mode 100644 index 00000000000..fbdb76ce1db --- /dev/null +++ b/homeassistant/components/netgear/translations/tr.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "config": "Ba\u011flant\u0131 veya oturum a\u00e7ma hatas\u0131: l\u00fctfen yap\u0131land\u0131rman\u0131z\u0131 kontrol edin" + }, + "step": { + "user": { + "data": { + "host": "Ana bilgisayar (\u0130ste\u011fe ba\u011fl\u0131)", + "password": "Parola", + "port": "Port (\u0130ste\u011fe ba\u011fl\u0131)", + "ssl": "SSL sertifikas\u0131 kullan\u0131r", + "username": "Kullan\u0131c\u0131 Ad\u0131 (\u0130ste\u011fe ba\u011fl\u0131)" + }, + "description": "Varsay\u0131lan ana bilgisayar: {host}\n Varsay\u0131lan ba\u011flant\u0131 noktas\u0131: {port}\n Varsay\u0131lan kullan\u0131c\u0131 ad\u0131: {username}", + "title": "Netgear" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Ev s\u00fcresini g\u00f6z \u00f6n\u00fcnde bulundurun (saniye)" + }, + "description": "\u0130ste\u011fe ba\u011fl\u0131 ayarlar\u0131 belirtin", + "title": "Netgear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 024075ba2c1..7cc864727d7 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -2,40 +2,28 @@ from __future__ import annotations from ipaddress import IPv4Address, IPv6Address, ip_interface -import logging -import voluptuous as vol - -from homeassistant.components import websocket_api -from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from . import util -from .const import ( - ATTR_ADAPTERS, - ATTR_CONFIGURED_ADAPTERS, - DOMAIN, - IPV4_BROADCAST_ADDR, - NETWORK_CONFIG_SCHEMA, -) +from .const import IPV4_BROADCAST_ADDR, PUBLIC_TARGET_IP from .models import Adapter -from .network import Network - -ZEROCONF_DOMAIN = "zeroconf" # cannot import from zeroconf due to circular dep -_LOGGER = logging.getLogger(__name__) +from .network import Network, async_get_network @bind_hass async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]: """Get the network adapter configuration.""" - network: Network = hass.data[DOMAIN] + network: Network = await async_get_network(hass) return network.adapters @bind_hass -async def async_get_source_ip(hass: HomeAssistant, target_ip: str) -> str: +async def async_get_source_ip( + hass: HomeAssistant, target_ip: str = PUBLIC_TARGET_IP +) -> str: """Get the source ip for a target ip.""" adapters = await async_get_adapters(hass) all_ipv4s = [] @@ -98,59 +86,10 @@ async def async_get_ipv4_broadcast_addresses(hass: HomeAssistant) -> set[IPv4Add async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up network for Home Assistant.""" + # Avoid circular issue: http->network->websocket_api->http + from .websocket import ( # pylint: disable=import-outside-toplevel + async_register_websocket_commands, + ) - hass.data[DOMAIN] = network = Network(hass) - await network.async_setup() - if ZEROCONF_DOMAIN in config: - await network.async_migrate_from_zeroconf(config[ZEROCONF_DOMAIN]) - network.async_configure() - - _LOGGER.debug("Adapters: %s", network.adapters) - - websocket_api.async_register_command(hass, websocket_network_adapters) - websocket_api.async_register_command(hass, websocket_network_adapters_configure) - + async_register_websocket_commands(hass) return True - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required("type"): "network"}) -@websocket_api.async_response -async def websocket_network_adapters( - hass: HomeAssistant, - connection: ActiveConnection, - msg: dict, -) -> None: - """Return network preferences.""" - network: Network = hass.data[DOMAIN] - connection.send_result( - msg["id"], - { - ATTR_ADAPTERS: network.adapters, - ATTR_CONFIGURED_ADAPTERS: network.configured_adapters, - }, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required("type"): "network/configure", - vol.Required("config", default={}): NETWORK_CONFIG_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_network_adapters_configure( - hass: HomeAssistant, - connection: ActiveConnection, - msg: dict, -) -> None: - """Update network config.""" - network: Network = hass.data[DOMAIN] - - await network.async_reconfig(msg["config"]) - - connection.send_result( - msg["id"], - {ATTR_CONFIGURED_ADAPTERS: network.configured_adapters}, - ) diff --git a/homeassistant/components/network/const.py b/homeassistant/components/network/const.py index 7e7401251fc..07c12e63a10 100644 --- a/homeassistant/components/network/const.py +++ b/homeassistant/components/network/const.py @@ -11,6 +11,8 @@ DOMAIN: Final = "network" STORAGE_KEY: Final = "core.network" STORAGE_VERSION: Final = 1 +DATA_NETWORK: Final = "network" + ATTR_ADAPTERS: Final = "adapters" ATTR_CONFIGURED_ADAPTERS: Final = "configured_adapters" DEFAULT_CONFIGURED_ADAPTERS: list[str] = [] diff --git a/homeassistant/components/network/network.py b/homeassistant/components/network/network.py index ffe3406e28e..6ec9941da3c 100644 --- a/homeassistant/components/network/network.py +++ b/homeassistant/components/network/network.py @@ -1,23 +1,35 @@ """Network helper class for the network integration.""" from __future__ import annotations +import logging from typing import Any, cast from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.singleton import singleton from .const import ( ATTR_CONFIGURED_ADAPTERS, + DATA_NETWORK, DEFAULT_CONFIGURED_ADAPTERS, STORAGE_KEY, STORAGE_VERSION, ) from .models import Adapter -from .util import ( - adapters_with_exernal_addresses, - async_load_adapters, - enable_adapters, - enable_auto_detected_adapters, -) +from .util import async_load_adapters, enable_adapters, enable_auto_detected_adapters + +_LOGGER = logging.getLogger(__name__) + + +@singleton(DATA_NETWORK) +@callback +async def async_get_network(hass: HomeAssistant) -> Network: + """Get network singleton.""" + network = Network(hass) + await network.async_setup() + network.async_configure() + + _LOGGER.debug("Adapters: %s", network.adapters) + return network class Network: @@ -25,7 +37,9 @@ class Network: def __init__(self, hass: HomeAssistant) -> None: """Initialize the Network class.""" - self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._store = hass.helpers.storage.Store( + STORAGE_VERSION, STORAGE_KEY, atomic_writes=True + ) self._data: dict[str, Any] = {} self.adapters: list[Adapter] = [] @@ -39,21 +53,6 @@ class Network: await self.async_load() self.adapters = await async_load_adapters() - async def async_migrate_from_zeroconf(self, zc_config: dict[str, Any]) -> None: - """Migrate configuration from zeroconf.""" - if self._data or not zc_config: - return - - from homeassistant.components.zeroconf import ( # pylint: disable=import-outside-toplevel - CONF_DEFAULT_INTERFACE, - ) - - if zc_config.get(CONF_DEFAULT_INTERFACE) is False: - self._data[ATTR_CONFIGURED_ADAPTERS] = adapters_with_exernal_addresses( - self.adapters - ) - await self._async_save() - @callback def async_configure(self) -> None: """Configure from storage.""" diff --git a/homeassistant/components/network/util.py b/homeassistant/components/network/util.py index f8b33b3df90..4b920e5d927 100644 --- a/homeassistant/components/network/util.py +++ b/homeassistant/components/network/util.py @@ -57,15 +57,6 @@ def enable_auto_detected_adapters(adapters: list[Adapter]) -> None: ) -def adapters_with_exernal_addresses(adapters: list[Adapter]) -> list[str]: - """Enable all interfaces with an external address.""" - return [ - adapter["name"] - for adapter in adapters - if _adapter_has_external_address(adapter) - ] - - def _adapter_has_external_address(adapter: Adapter) -> bool: """Adapter has a non-loopback and non-link-local address.""" return any( diff --git a/homeassistant/components/network/websocket.py b/homeassistant/components/network/websocket.py new file mode 100644 index 00000000000..c19ed5e8fd7 --- /dev/null +++ b/homeassistant/components/network/websocket.py @@ -0,0 +1,61 @@ +"""The Network Configuration integration websocket commands.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.core import HomeAssistant, callback + +from .const import ATTR_ADAPTERS, ATTR_CONFIGURED_ADAPTERS, NETWORK_CONFIG_SCHEMA +from .network import async_get_network + + +@callback +def async_register_websocket_commands(hass: HomeAssistant) -> None: + """Register network websocket commands.""" + websocket_api.async_register_command(hass, websocket_network_adapters) + websocket_api.async_register_command(hass, websocket_network_adapters_configure) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "network"}) +@websocket_api.async_response +async def websocket_network_adapters( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, +) -> None: + """Return network preferences.""" + network = await async_get_network(hass) + connection.send_result( + msg["id"], + { + ATTR_ADAPTERS: network.adapters, + ATTR_CONFIGURED_ADAPTERS: network.configured_adapters, + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "network/configure", + vol.Required("config", default={}): NETWORK_CONFIG_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_network_adapters_configure( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, +) -> None: + """Update network config.""" + network = await async_get_network(hass) + + await network.async_reconfig(msg["config"]) + + connection.send_result( + msg["id"], + {ATTR_CONFIGURED_ADAPTERS: network.configured_adapters}, + ) diff --git a/homeassistant/components/neurio_energy/sensor.py b/homeassistant/components/neurio_energy/sensor.py index 37113dde8b7..b316281faa1 100644 --- a/homeassistant/components/neurio_energy/sensor.py +++ b/homeassistant/components/neurio_energy/sensor.py @@ -1,4 +1,6 @@ """Support for monitoring a Neurio energy sensor.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -9,9 +11,16 @@ import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) -from homeassistant.const import CONF_API_KEY, ENERGY_KILO_WATT_HOUR, POWER_WATT +from homeassistant.const import ( + CONF_API_KEY, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, +) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle import homeassistant.util.dt as dt_util @@ -139,9 +148,12 @@ class NeurioEnergy(SensorEntity): if sensor_type == ACTIVE_TYPE: self._unit_of_measurement = POWER_WATT + self._attr_device_class = DEVICE_CLASS_POWER self._attr_state_class = STATE_CLASS_MEASUREMENT elif sensor_type == DAILY_TYPE: self._unit_of_measurement = ENERGY_KILO_WATT_HOUR + self._attr_device_class = DEVICE_CLASS_ENERGY + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING @property def name(self): diff --git a/homeassistant/components/nexia/translations/ja.json b/homeassistant/components/nexia/translations/ja.json new file mode 100644 index 00000000000..f06358ef78c --- /dev/null +++ b/homeassistant/components/nexia/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" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "brand": "\u30d6\u30e9\u30f3\u30c9", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "mynexia.com\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nexia/translations/tr.json b/homeassistant/components/nexia/translations/tr.json index 47f3d931c46..8918bcd8f5e 100644 --- a/homeassistant/components/nexia/translations/tr.json +++ b/homeassistant/components/nexia/translations/tr.json @@ -11,6 +11,7 @@ "step": { "user": { "data": { + "brand": "Marka", "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" }, diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index f9df0d60412..3756c1853b7 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -218,6 +218,4 @@ class NextBusDepartureSensor(SensorEntity): ) latest_prediction = maybe_first(predictions) - self._state = utc_from_timestamp( - int(latest_prediction["epochTime"]) / 1000 - ).isoformat() + self._state = utc_from_timestamp(int(latest_prediction["epochTime"]) / 1000) diff --git a/homeassistant/components/nfandroidtv/const.py b/homeassistant/components/nfandroidtv/const.py index 332c1754771..12449a9b046 100644 --- a/homeassistant/components/nfandroidtv/const.py +++ b/homeassistant/components/nfandroidtv/const.py @@ -17,12 +17,20 @@ ATTR_TRANSPARENCY = "transparency" ATTR_COLOR = "color" ATTR_BKGCOLOR = "bkgcolor" ATTR_INTERRUPT = "interrupt" -ATTR_FILE = "file" -# Attributes contained in file -ATTR_FILE_URL = "url" -ATTR_FILE_PATH = "path" -ATTR_FILE_USERNAME = "username" -ATTR_FILE_PASSWORD = "password" -ATTR_FILE_AUTH = "auth" +ATTR_IMAGE = "image" +# Attributes contained in image +ATTR_IMAGE_URL = "url" +ATTR_IMAGE_PATH = "path" +ATTR_IMAGE_USERNAME = "username" +ATTR_IMAGE_PASSWORD = "password" +ATTR_IMAGE_AUTH = "auth" +ATTR_ICON = "icon" +# Attributes contained in icon +ATTR_ICON_URL = "url" +ATTR_ICON_PATH = "path" +ATTR_ICON_USERNAME = "username" +ATTR_ICON_PASSWORD = "password" +ATTR_ICON_AUTH = "auth" # Any other value or absence of 'auth' lead to basic authentication being used -ATTR_FILE_AUTH_DIGEST = "digest" +ATTR_IMAGE_AUTH_DIGEST = "digest" +ATTR_ICON_AUTH_DIGEST = "digest" diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index c769770ae43..479bbc40891 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -16,7 +16,7 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.const import ATTR_ICON, CONF_HOST, CONF_TIMEOUT +from homeassistant.const import CONF_HOST, CONF_TIMEOUT from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -24,14 +24,21 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( ATTR_COLOR, ATTR_DURATION, - ATTR_FILE, - ATTR_FILE_AUTH, - ATTR_FILE_AUTH_DIGEST, - ATTR_FILE_PASSWORD, - ATTR_FILE_PATH, - ATTR_FILE_URL, - ATTR_FILE_USERNAME, ATTR_FONTSIZE, + ATTR_ICON, + ATTR_ICON_AUTH, + ATTR_ICON_AUTH_DIGEST, + ATTR_ICON_PASSWORD, + ATTR_ICON_PATH, + ATTR_ICON_URL, + ATTR_ICON_USERNAME, + ATTR_IMAGE, + ATTR_IMAGE_AUTH, + ATTR_IMAGE_AUTH_DIGEST, + ATTR_IMAGE_PASSWORD, + ATTR_IMAGE_PATH, + ATTR_IMAGE_URL, + ATTR_IMAGE_USERNAME, ATTR_INTERRUPT, ATTR_POSITION, ATTR_TRANSPARENCY, @@ -158,22 +165,23 @@ class NFAndroidTVNotificationService(BaseNotificationService): _LOGGER.warning( "Invalid interrupt-value: %s", str(data.get(ATTR_INTERRUPT)) ) - filedata = data.get(ATTR_FILE) if data else None - if filedata is not None: - if ATTR_ICON in filedata: - icon = self.load_file( - url=filedata[ATTR_ICON], - local_path=filedata.get(ATTR_FILE_PATH), - username=filedata.get(ATTR_FILE_USERNAME), - password=filedata.get(ATTR_FILE_PASSWORD), - auth=filedata.get(ATTR_FILE_AUTH), - ) + imagedata = data.get(ATTR_IMAGE) if data else None + if imagedata is not None: image_file = self.load_file( - url=filedata.get(ATTR_FILE_URL), - local_path=filedata.get(ATTR_FILE_PATH), - username=filedata.get(ATTR_FILE_USERNAME), - password=filedata.get(ATTR_FILE_PASSWORD), - auth=filedata.get(ATTR_FILE_AUTH), + url=imagedata.get(ATTR_IMAGE_URL), + local_path=imagedata.get(ATTR_IMAGE_PATH), + username=imagedata.get(ATTR_IMAGE_USERNAME), + password=imagedata.get(ATTR_IMAGE_PASSWORD), + auth=imagedata.get(ATTR_IMAGE_AUTH), + ) + icondata = data.get(ATTR_ICON) if data else None + if icondata is not None: + icon = self.load_file( + url=icondata.get(ATTR_ICON_URL), + local_path=icondata.get(ATTR_ICON_PATH), + username=icondata.get(ATTR_ICON_USERNAME), + password=icondata.get(ATTR_ICON_PASSWORD), + auth=icondata.get(ATTR_ICON_AUTH), ) self.notify.send( message, @@ -203,7 +211,7 @@ class NFAndroidTVNotificationService(BaseNotificationService): if username is not None and password is not None: # Use digest or basic authentication auth_: HTTPDigestAuth | HTTPBasicAuth - if ATTR_FILE_AUTH_DIGEST == auth: + if auth in (ATTR_IMAGE_AUTH_DIGEST, ATTR_ICON_AUTH_DIGEST): auth_ = HTTPDigestAuth(username, password) else: auth_ = HTTPBasicAuth(username, password) diff --git a/homeassistant/components/nfandroidtv/translations/bg.json b/homeassistant/components/nfandroidtv/translations/bg.json index 78978419e43..484dd2b98e3 100644 --- a/homeassistant/components/nfandroidtv/translations/bg.json +++ b/homeassistant/components/nfandroidtv/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" }, "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", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { @@ -12,7 +12,8 @@ "data": { "host": "\u0425\u043e\u0441\u0442", "name": "\u0418\u043c\u0435" - } + }, + "title": "\u0418\u0437\u0432\u0435\u0441\u0442\u0438\u044f \u0437\u0430 Android TV / Fire TV" } } } diff --git a/homeassistant/components/nfandroidtv/translations/id.json b/homeassistant/components/nfandroidtv/translations/id.json index 087e25a22ae..fd70679d5a4 100644 --- a/homeassistant/components/nfandroidtv/translations/id.json +++ b/homeassistant/components/nfandroidtv/translations/id.json @@ -4,7 +4,18 @@ "already_configured": "Perangkat sudah dikonfigurasi" }, "error": { - "cannot_connect": "Gagal terhubung" + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nama" + }, + "description": "Integrasi ini memerlukan aplikasi Notifikasi untuk Android TV.\n\nUntuk Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nUntuk Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nAnda harus mengatur reservasi DHCP di router Anda (lihat manual pengguna router Anda) atau alamat IP statis pada perangkat. Jika tidak, perangkat akhirnya akan menjadi tidak tersedia.", + "title": "Notifikasi untuk Android TV/Fire TV" + } } } } \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/ja.json b/homeassistant/components/nfandroidtv/translations/ja.json new file mode 100644 index 00000000000..c3cd4fee235 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d" + }, + "description": "\u3053\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306b\u306f\u3001AndroidTV\u30a2\u30d7\u30ea\u306e\u901a\u77e5\u304c\u5fc5\u8981\u3067\u3059\u3002 \n\nAndroid TV\u306e\u5834\u5408: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nFire TV\u306e\u5834\u5408: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\n\u30eb\u30fc\u30bf\u30fc\u306eDHCP\u4e88\u7d04((DHCP reservation)\u30eb\u30fc\u30bf\u30fc\u306e\u30e6\u30fc\u30b6\u30fc\u30de\u30cb\u30e5\u30a2\u30eb\u3092\u53c2\u7167))\u307e\u305f\u306f\u3001\u30c7\u30d0\u30a4\u30b9\u306b\u9759\u7684IP\u30a2\u30c9\u30ec\u30b9\u3092\u8a2d\u5b9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\u305d\u3046\u3067\u306a\u3044\u5834\u5408\u3001\u30c7\u30d0\u30a4\u30b9\u306f\u6700\u7d42\u7684\u306b\u4f7f\u7528\u3067\u304d\u306a\u304f\u306a\u308a\u307e\u3059\u3002", + "title": "Android TV / Fire TV\u306e\u901a\u77e5" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/tr.json b/homeassistant/components/nfandroidtv/translations/tr.json new file mode 100644 index 00000000000..01bc2be90c1 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/tr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "host": "Ana bilgisayar", + "name": "Ad" + }, + "description": "Bu entegrasyon, Android TV i\u00e7in Bildirimler uygulamas\u0131n\u0131 gerektirir. \n\n Android TV i\u00e7in: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\n Fire TV i\u00e7in: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK \n\n Y\u00f6nlendiricinizde DHCP rezervasyonu (y\u00f6nlendiricinizin kullan\u0131m k\u0131lavuzuna bak\u0131n) veya cihazda statik bir IP adresi ayarlamal\u0131s\u0131n\u0131z. Aksi takdirde, cihaz sonunda kullan\u0131lamaz hale gelecektir.", + "title": "Android TV / Fire TV i\u00e7in Bildirimler" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/__init__.py b/homeassistant/components/nightscout/__init__.py index 69d79d1cecb..28906ce6e88 100644 --- a/homeassistant/components/nightscout/__init__.py +++ b/homeassistant/components/nightscout/__init__.py @@ -32,14 +32,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = api - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, server_url)}, manufacturer="Nightscout Foundation", name=status.name, sw_version=status.version, - entry_type="service", + entry_type=dr.DeviceEntryType.SERVICE, ) hass.config_entries.async_setup_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nightscout/translations/bg.json b/homeassistant/components/nightscout/translations/bg.json index 93e70be433e..6685ae35a2f 100644 --- a/homeassistant/components/nightscout/translations/bg.json +++ b/homeassistant/components/nightscout/translations/bg.json @@ -7,7 +7,8 @@ "step": { "user": { "data": { - "api_key": "API \u043a\u043b\u044e\u0447" + "api_key": "API \u043a\u043b\u044e\u0447", + "url": "URL" } } } diff --git a/homeassistant/components/nightscout/translations/ja.json b/homeassistant/components/nightscout/translations/ja.json new file mode 100644 index 00000000000..d0a88be1985 --- /dev/null +++ b/homeassistant/components/nightscout/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" + }, + "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" + }, + "flow_title": "Nightscout", + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "url": "URL" + }, + "description": "- URL: \u3042\u306a\u305f\u306enightscout\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306e\u30a2\u30c9\u30ec\u30b9\u3067\u3059\u3002\u4f8b: https://myhomeassistant.duckdns.org:5423\n- API\u30ad\u30fc(\u30aa\u30d7\u30b7\u30e7\u30f3): \u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u304c\u4fdd\u8b77\u3055\u308c\u3066\u3044\u308b\u5834\u5408\u306b\u306e\u307f\u4f7f\u7528(auth_default_roles != readable)", + "title": "Nightscout\u306e\u30b5\u30fc\u30d0\u30fc\u60c5\u5831\u3092\u5165\u529b\u3057\u307e\u3059\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/pl.json b/homeassistant/components/nightscout/translations/pl.json index a544fc887c0..bc70aab3bf8 100644 --- a/homeassistant/components/nightscout/translations/pl.json +++ b/homeassistant/components/nightscout/translations/pl.json @@ -15,7 +15,7 @@ "api_key": "Klucz API", "url": "URL" }, - "description": "- URL: adres URL Twojej instancji Nightscout. Np: https://m\u00f3jhomeassistant.duckdns.org:5423\n- Klucz API (opcjonalny): u\u017cywaj tylko wtedy, gdy Twoja instancja jest chroniona (auth_default_roles! = readable).", + "description": "- URL: adres URL Twojej instancji Nightscout. Np: https://m\u00f3jhomeassistant.duckdns.org:5423\n- Klucz API (opcjonalnie): u\u017cywaj tylko wtedy, gdy Twoja instancja jest chroniona (auth_default_roles! = readable).", "title": "Wprowad\u017a informacje o serwerze Nightscout." } } diff --git a/homeassistant/components/nightscout/translations/tr.json b/homeassistant/components/nightscout/translations/tr.json index 95f36a4d124..8a27b28aa6e 100644 --- a/homeassistant/components/nightscout/translations/tr.json +++ b/homeassistant/components/nightscout/translations/tr.json @@ -4,7 +4,7 @@ "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, "error": { - "cannot_connect": "Ba\u011flan\u0131lamad\u0131", + "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "unknown": "Beklenmeyen hata" }, @@ -14,7 +14,9 @@ "data": { "api_key": "API Anahtar\u0131", "url": "URL" - } + }, + "description": "- URL: Nightcout \u00f6rne\u011finizin adresi. \u00d6rnek: https://myhomeassistant.duckdns.org:5423\n - API Anahtar\u0131 (iste\u011fe ba\u011fl\u0131): Yaln\u0131zca \u00f6rne\u011finiz korunuyorsa kullan\u0131n (auth_default_roles != okunabilir).", + "title": "Nightscout sunucu bilgilerinizi girin." } } } diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index 44727ebccdd..d450eaf7dad 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -213,12 +213,12 @@ class LeafDataStore: self.car_config = car_config self.force_miles = car_config[CONF_FORCE_MILES] self.data = {} - self.data[DATA_CLIMATE] = False - self.data[DATA_BATTERY] = 0 - self.data[DATA_CHARGING] = False - self.data[DATA_RANGE_AC] = 0 - self.data[DATA_RANGE_AC_OFF] = 0 - self.data[DATA_PLUGGED_IN] = False + self.data[DATA_CLIMATE] = None + self.data[DATA_BATTERY] = None + self.data[DATA_CHARGING] = None + self.data[DATA_RANGE_AC] = None + self.data[DATA_RANGE_AC_OFF] = None + self.data[DATA_PLUGGED_IN] = None self.next_update = None self.last_check = None self.request_in_progress = False diff --git a/homeassistant/components/nissan_leaf/binary_sensor.py b/homeassistant/components/nissan_leaf/binary_sensor.py index 3d2064dad4c..13fe666a3a8 100644 --- a/homeassistant/components/nissan_leaf/binary_sensor.py +++ b/homeassistant/components/nissan_leaf/binary_sensor.py @@ -1,7 +1,11 @@ """Plugged In Status Support for the Nissan Leaf.""" import logging -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_PLUG, + BinarySensorEntity, +) from . import DATA_CHARGING, DATA_LEAF, DATA_PLUGGED_IN, LeafEntity @@ -25,40 +29,40 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class LeafPluggedInSensor(LeafEntity, BinarySensorEntity): """Plugged In Sensor class.""" + _attr_device_class = DEVICE_CLASS_PLUG + @property def name(self): """Sensor name.""" return f"{self.car.leaf.nickname} Plug Status" + @property + def available(self): + """Sensor availability.""" + return self.car.data[DATA_PLUGGED_IN] is not None + @property def is_on(self): """Return true if plugged in.""" return self.car.data[DATA_PLUGGED_IN] - @property - def icon(self): - """Icon handling.""" - if self.car.data[DATA_PLUGGED_IN]: - return "mdi:power-plug" - return "mdi:power-plug-off" - class LeafChargingSensor(LeafEntity, BinarySensorEntity): """Charging Sensor class.""" + _attr_device_class = DEVICE_CLASS_BATTERY_CHARGING + @property def name(self): """Sensor name.""" return f"{self.car.leaf.nickname} Charging Status" + @property + def available(self): + """Sensor availability.""" + return self.car.data[DATA_CHARGING] is not None + @property def is_on(self): """Return true if charging.""" return self.car.data[DATA_CHARGING] - - @property - def icon(self): - """Icon handling.""" - if self.car.data[DATA_CHARGING]: - return "mdi:flash" - return "mdi:flash-off" diff --git a/homeassistant/components/nissan_leaf/sensor.py b/homeassistant/components/nissan_leaf/sensor.py index 4074cd47f50..bed4d264bd4 100644 --- a/homeassistant/components/nissan_leaf/sensor.py +++ b/homeassistant/components/nissan_leaf/sensor.py @@ -52,6 +52,8 @@ class LeafBatterySensor(LeafEntity, SensorEntity): @property def native_value(self): """Battery state percentage.""" + if self.car.data[DATA_BATTERY] is None: + return None return round(self.car.data[DATA_BATTERY]) @property @@ -96,6 +98,9 @@ class LeafRangeSensor(LeafEntity, SensorEntity): else: ret = self.car.data[DATA_RANGE_AC_OFF] + 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) diff --git a/homeassistant/components/nmap_tracker/translations/id.json b/homeassistant/components/nmap_tracker/translations/id.json index 6c06e815565..e1894aff49b 100644 --- a/homeassistant/components/nmap_tracker/translations/id.json +++ b/homeassistant/components/nmap_tracker/translations/id.json @@ -2,15 +2,38 @@ "config": { "abort": { "already_configured": "Lokasi sudah dikonfigurasi" + }, + "error": { + "invalid_hosts": "Host Tidak Valid" + }, + "step": { + "user": { + "data": { + "exclude": "Alamat jaringan (dipisahkan koma) untuk dikecualikan dari pemindaian", + "home_interval": "Jumlah menit minimum antara pemindaian perangkat aktif (menghemat baterai)", + "hosts": "Alamat jaringan (dipisahkan koma) untuk pemindaian", + "scan_options": "Opsi pemindaian mentah yang dapat dikonfigurasi untuk Nmap" + }, + "description": "Konfigurasikan host untuk dipindai oleh Nmap. Alamat jaringan dan pengecualian dapat berupa alamat IP (192.168.1.1), jaringan IP (192.168.0.0/24), atau rentang IP (192.168.1.0-32)." + } } }, "options": { + "error": { + "invalid_hosts": "Host Tidak Valid" + }, "step": { "init": { "data": { + "consider_home": "Waktu tunggu dalam detik untuk pelacak perangkat agar mempertimbangkan perangkat sebagai 'tidak di rumah' jika tidak ditemukan", + "exclude": "Alamat jaringan (dipisahkan koma) untuk dikecualikan dari pemindaian", + "home_interval": "Jumlah menit minimum antara pemindaian perangkat aktif (menghemat baterai)", + "hosts": "Alamat jaringan (dipisahkan koma) untuk pemindaian", "interval_seconds": "Interval pindai", + "scan_options": "Opsi pemindaian mentah yang dapat dikonfigurasi untuk Nmap", "track_new_devices": "Lacak perangkat baru" - } + }, + "description": "Konfigurasikan host untuk dipindai oleh Nmap. Alamat jaringan dan pengecualian dapat berupa alamat IP (192.168.1.1), jaringan IP (192.168.0.0/24), atau rentang IP (192.168.1.0-32)." } } }, diff --git a/homeassistant/components/nmap_tracker/translations/ja.json b/homeassistant/components/nmap_tracker/translations/ja.json new file mode 100644 index 00000000000..fe75eca98c5 --- /dev/null +++ b/homeassistant/components/nmap_tracker/translations/ja.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "invalid_hosts": "\u7121\u52b9\u306a\u30db\u30b9\u30c8" + }, + "step": { + "user": { + "data": { + "exclude": "\u30b9\u30ad\u30e3\u30f3\u5bfe\u8c61\u304b\u3089\u9664\u5916\u3059\u308b\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30a2\u30c9\u30ec\u30b9(\u30b3\u30f3\u30de\u533a\u5207\u308a)", + "home_interval": "\u30a2\u30af\u30c6\u30a3\u30d6\u306a\u30c7\u30d0\u30a4\u30b9\u306e\u30b9\u30ad\u30e3\u30f3\u9593\u9694(\u5206)\u306e\u6700\u5c0f\u6642\u9593(\u30d0\u30c3\u30c6\u30ea\u30fc\u3092\u7bc0\u7d04)", + "hosts": "\u30b9\u30ad\u30e3\u30f3\u3059\u308b\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30a2\u30c9\u30ec\u30b9(\u30b3\u30f3\u30de\u533a\u5207\u308a)", + "scan_options": "Nmap\u306b\u672a\u52a0\u5de5\u3067\u305d\u306e\u307e\u307e\u6e21\u3055\u308c\u308b\u30b9\u30ad\u30e3\u30f3\u8a2d\u5b9a\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + }, + "description": "Nmap\u3067\u30b9\u30ad\u30e3\u30f3\u3055\u308c\u308b\u30db\u30b9\u30c8\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30a2\u30c9\u30ec\u30b9\u304a\u3088\u3073\u9664\u5916\u5bfe\u8c61\u306f\u3001IP\u30a2\u30c9\u30ec\u30b9(192.168.1.1)\u3001IP\u30cd\u30c3\u30c8\u30ef\u30fc\u30af(192.168.0.0/24)\u3001\u307e\u305f\u306f\u3001IP\u7bc4\u56f2(192.168.1.0-32)\u3067\u3059\u3002" + } + } + }, + "options": { + "error": { + "invalid_hosts": "\u7121\u52b9\u306a\u30db\u30b9\u30c8" + }, + "step": { + "init": { + "data": { + "consider_home": "\u898b\u3048\u306a\u304f\u306a\u3063\u305f\u5f8c\u3001\u30c7\u30d0\u30a4\u30b9\u30c8\u30e9\u30c3\u30ab\u30fc\u3092\u30db\u30fc\u30e0\u3067\u306a\u3044\u3082\u306e\u3068\u3057\u3066\u898b\u306a\u3057\u3066\u3001\u30de\u30fc\u30af\u3059\u308b\u307e\u3067\u5f85\u6a5f\u3059\u308b\u307e\u3067\u306e\u79d2\u6570\u3002", + "exclude": "\u30b9\u30ad\u30e3\u30f3\u5bfe\u8c61\u304b\u3089\u9664\u5916\u3059\u308b\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30a2\u30c9\u30ec\u30b9(\u30b3\u30f3\u30de\u533a\u5207\u308a)", + "home_interval": "\u30a2\u30af\u30c6\u30a3\u30d6\u306a\u30c7\u30d0\u30a4\u30b9\u306e\u30b9\u30ad\u30e3\u30f3\u9593\u9694(\u5206)\u306e\u6700\u5c0f\u6642\u9593(\u30d0\u30c3\u30c6\u30ea\u30fc\u3092\u7bc0\u7d04)", + "hosts": "\u30b9\u30ad\u30e3\u30f3\u3059\u308b\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30a2\u30c9\u30ec\u30b9(\u30b3\u30f3\u30de\u533a\u5207\u308a)", + "interval_seconds": "\u30b9\u30ad\u30e3\u30f3\u9593\u9694", + "scan_options": "Nmap\u306b\u672a\u52a0\u5de5\u3067\u305d\u306e\u307e\u307e\u6e21\u3055\u308c\u308b\u30b9\u30ad\u30e3\u30f3\u8a2d\u5b9a\u306e\u30aa\u30d7\u30b7\u30e7\u30f3", + "track_new_devices": "\u65b0\u3057\u3044\u30c7\u30d0\u30a4\u30b9\u306e\u8ffd\u8de1" + }, + "description": "Nmap\u3067\u30b9\u30ad\u30e3\u30f3\u3055\u308c\u308b\u30db\u30b9\u30c8\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30a2\u30c9\u30ec\u30b9\u304a\u3088\u3073\u9664\u5916\u5bfe\u8c61\u306f\u3001IP\u30a2\u30c9\u30ec\u30b9(192.168.1.1)\u3001IP\u30cd\u30c3\u30c8\u30ef\u30fc\u30af(192.168.0.0/24)\u3001\u307e\u305f\u306f\u3001IP\u7bc4\u56f2(192.168.1.0-32)\u3067\u3059\u3002" + } + } + }, + "title": "Nmap\u30c8\u30e9\u30c3\u30ab\u30fc" +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/pl.json b/homeassistant/components/nmap_tracker/translations/pl.json index dc16816609c..98f27dcdd4b 100644 --- a/homeassistant/components/nmap_tracker/translations/pl.json +++ b/homeassistant/components/nmap_tracker/translations/pl.json @@ -25,6 +25,7 @@ "step": { "init": { "data": { + "consider_home": "Czas w sekundach, zanim urz\u0105dzenie otrzyma stan \"poza domem\" od utracenia z nim po\u0142\u0105czenia.", "exclude": "Adresy sieciowe (rozdzielone przecinkami) do wykluczenia ze skanowania", "home_interval": "Minimalna liczba minut mi\u0119dzy skanami aktywnych urz\u0105dze\u0144 (oszcz\u0119dzanie baterii)", "hosts": "Adresy sieciowe (oddzielone przecinkami) do skanowania", diff --git a/homeassistant/components/nmap_tracker/translations/tr.json b/homeassistant/components/nmap_tracker/translations/tr.json new file mode 100644 index 00000000000..2d277e979b3 --- /dev/null +++ b/homeassistant/components/nmap_tracker/translations/tr.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "invalid_hosts": "Ge\u00e7ersiz Ana Bilgisayarlar" + }, + "step": { + "user": { + "data": { + "exclude": "Taraman\u0131n d\u0131\u015f\u0131nda tutulacak a\u011f adresleri (virg\u00fcl ayr\u0131lm\u0131\u015f)", + "home_interval": "Aktif cihazlar\u0131n taranmas\u0131 aras\u0131ndaki minimum dakika say\u0131s\u0131 (pilden tasarruf edin)", + "hosts": "Taranacak a\u011f adresleri (virg\u00fclle ayr\u0131lm\u0131\u015f)", + "scan_options": "Nmap i\u00e7in ham yap\u0131land\u0131r\u0131labilir tarama se\u00e7enekleri" + }, + "description": "Nmap taraf\u0131ndan taranacak ana bilgisayarlar\u0131 yap\u0131land\u0131r\u0131n. A\u011f adresi ve hari\u00e7 tutulanlar, IP Adresleri (192.168.1.1), IP A\u011flar\u0131 (192.168.0.0/24) veya IP Aral\u0131klar\u0131 (192.168.1.0-32) olabilir." + } + } + }, + "options": { + "error": { + "invalid_hosts": "Ge\u00e7ersiz Ana Bilgisayarlar" + }, + "step": { + "init": { + "data": { + "consider_home": "Bir cihaz izleyicisi g\u00f6r\u00fclmedikten sonra evde de\u011fil olarak i\u015faretlenene kadar beklemek i\u00e7in saniyeler kald\u0131.", + "exclude": "Taraman\u0131n d\u0131\u015f\u0131nda tutulacak a\u011f adresleri (virg\u00fcl ayr\u0131lm\u0131\u015f)", + "home_interval": "Aktif cihazlar\u0131n taranmas\u0131 aras\u0131ndaki minimum dakika say\u0131s\u0131 (pilden tasarruf edin)", + "hosts": "Taranacak a\u011f adresleri (virg\u00fclle ayr\u0131lm\u0131\u015f)", + "interval_seconds": "Tarama aral\u0131\u011f\u0131", + "scan_options": "Nmap i\u00e7in ham yap\u0131land\u0131r\u0131labilir tarama se\u00e7enekleri", + "track_new_devices": "Yeni cihazlar\u0131 izle" + }, + "description": "Nmap taraf\u0131ndan taranacak ana bilgisayarlar\u0131 yap\u0131land\u0131r\u0131n. A\u011f adresi ve hari\u00e7 tutulanlar, IP Adresleri (192.168.1.1), IP A\u011flar\u0131 (192.168.0.0/24) veya IP Aral\u0131klar\u0131 (192.168.1.0-32) olabilir." + } + } + }, + "title": "Nmap \u0130zleyici" +} \ No newline at end of file diff --git a/homeassistant/components/no_ip/__init__.py b/homeassistant/components/no_ip/__init__.py index 97015eab38a..1c4bcb40819 100644 --- a/homeassistant/components/no_ip/__init__.py +++ b/homeassistant/components/no_ip/__init__.py @@ -96,7 +96,7 @@ async def _update_no_ip( } try: - with async_timeout.timeout(timeout): + async with async_timeout.timeout(timeout): resp = await session.get(url, params=params, headers=headers) body = await resp.text() diff --git a/homeassistant/components/norway_air/manifest.json b/homeassistant/components/norway_air/manifest.json index 87981f085a6..ade1a149590 100644 --- a/homeassistant/components/norway_air/manifest.json +++ b/homeassistant/components/norway_air/manifest.json @@ -2,7 +2,7 @@ "domain": "norway_air", "name": "Om Luftkvalitet i Norge (Norway Air)", "documentation": "https://www.home-assistant.io/integrations/norway_air", - "requirements": ["pyMetno==0.8.4"], + "requirements": ["pyMetno==0.9.0"], "codeowners": [], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/notify/translations/tr.json b/homeassistant/components/notify/translations/tr.json index 3f10d1f6c19..eb6283999dd 100644 --- a/homeassistant/components/notify/translations/tr.json +++ b/homeassistant/components/notify/translations/tr.json @@ -1,3 +1,3 @@ { - "title": "Bildir" + "title": "Bildirimler" } \ No newline at end of file diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 74fd2b90117..4aa8447eb9a 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -9,7 +9,7 @@ from aionotion import async_get_client from aionotion.errors import InvalidCredentialsError, NotionError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( @@ -24,7 +24,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import DATA_COORDINATOR, DOMAIN, LOGGER +from .const import DOMAIN, LOGGER PLATFORMS = ["binary_sensor", "sensor"] @@ -39,9 +39,6 @@ CONFIG_SCHEMA = cv.deprecated(DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Notion as a config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} - if not entry.unique_id: hass.config_entries.async_update_entry( entry, unique_id=entry.data[CONF_USERNAME] @@ -85,7 +82,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for item in result: if attr == "bridges" and item["id"] not in data["bridges"]: # If a new bridge is discovered, register it: - hass.async_create_task(async_register_new_bridge(hass, item, entry)) + _async_register_new_bridge(hass, item, entry) data[attr][item["id"]] = item return data @@ -99,7 +96,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] = coordinator + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -115,11 +113,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def async_register_new_bridge( +@callback +def _async_register_new_bridge( hass: HomeAssistant, bridge: dict, entry: ConfigEntry ) -> None: """Register a new bridge.""" - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, bridge["hardware_id"])}, @@ -156,7 +155,7 @@ class NotionEntity(CoordinatorEntity): via_device=(DOMAIN, bridge.get("hardware_id")), ) - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._attr_extra_state_attributes = {} self._attr_name = f'{sensor["name"]}: {description.name}' self._attr_unique_id = ( f'{sensor_id}_{coordinator.data["tasks"][task_id]["task_type"]}' @@ -175,7 +174,8 @@ class NotionEntity(CoordinatorEntity): and self._task_id in self.coordinator.data["tasks"] ) - async def _async_update_bridge_id(self) -> None: + @callback + def _async_update_bridge_id(self) -> None: """Update the entity's bridge ID if it has changed. Sensors can move to other bridges based on signal strength, etc. @@ -193,7 +193,7 @@ class NotionEntity(CoordinatorEntity): self._bridge_id = sensor["bridge"]["id"] - device_registry = await dr.async_get_registry(self.hass) + device_registry = dr.async_get(self.hass) this_device = device_registry.async_get_device( {(DOMAIN, sensor["hardware_id"])} ) @@ -218,7 +218,7 @@ class NotionEntity(CoordinatorEntity): def _handle_coordinator_update(self) -> None: """Respond to a DataUpdateCoordinator update.""" if self._task_id in self.coordinator.data["tasks"]: - self.hass.async_create_task(self._async_update_bridge_id()) + self._async_update_bridge_id() self._async_update_from_latest_data() self.async_write_ha_state() diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index ab3d0775436..42e32b1e19c 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -22,7 +22,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import NotionEntity from .const import ( - DATA_COORDINATOR, DOMAIN, LOGGER, SENSOR_BATTERY, @@ -122,7 +121,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Notion sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ diff --git a/homeassistant/components/notion/config_flow.py b/homeassistant/components/notion/config_flow.py index 84fe69eb61a..cdaab389dc7 100644 --- a/homeassistant/components/notion/config_flow.py +++ b/homeassistant/components/notion/config_flow.py @@ -44,23 +44,22 @@ class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): assert self._username assert self._password + errors = {} session = aiohttp_client.async_get_clientsession(self.hass) try: await async_get_client(self._username, self._password, session=session) except InvalidCredentialsError: - return self.async_show_form( - step_id=step_id, - data_schema=schema, - errors={"base": "invalid_auth"}, - description_placeholders={CONF_USERNAME: self._username}, - ) + errors["base"] = "invalid_auth" except NotionError as err: LOGGER.error("Unknown Notion error: %s", err) + errors["base"] = "unknown" + + if errors: return self.async_show_form( step_id=step_id, data_schema=schema, - errors={"base": "unknown"}, + errors=errors, description_placeholders={CONF_USERNAME: self._username}, ) diff --git a/homeassistant/components/notion/const.py b/homeassistant/components/notion/const.py index 5541cfedc70..339d3020734 100644 --- a/homeassistant/components/notion/const.py +++ b/homeassistant/components/notion/const.py @@ -4,8 +4,6 @@ import logging DOMAIN = "notion" LOGGER = logging.getLogger(__package__) -DATA_COORDINATOR = "coordinator" - SENSOR_BATTERY = "low_battery" SENSOR_DOOR = "door" SENSOR_GARAGE_DOOR = "garage_door" diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index efb33944990..2e7260080bb 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import NotionEntity -from .const import DATA_COORDINATOR, DOMAIN, LOGGER, SENSOR_TEMPERATURE +from .const import DOMAIN, LOGGER, SENSOR_TEMPERATURE SENSOR_DESCRIPTIONS = ( SensorEntityDescription( @@ -27,7 +27,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Notion sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ diff --git a/homeassistant/components/notion/translations/id.json b/homeassistant/components/notion/translations/id.json index 35ee7a29544..bf8cb8e1186 100644 --- a/homeassistant/components/notion/translations/id.json +++ b/homeassistant/components/notion/translations/id.json @@ -1,13 +1,22 @@ { "config": { "abort": { - "already_configured": "Akun sudah dikonfigurasi" + "already_configured": "Akun sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "invalid_auth": "Autentikasi tidak valid", - "no_devices": "Tidak ada perangkat yang ditemukan di akun" + "no_devices": "Tidak ada perangkat yang ditemukan di akun", + "unknown": "Kesalahan yang tidak diharapkan" }, "step": { + "reauth_confirm": { + "data": { + "password": "Kata Sandi" + }, + "description": "Masukkan kembali kata sandi untuk {username}.", + "title": "Autentikasi Ulang Integrasi" + }, "user": { "data": { "password": "Kata Sandi", diff --git a/homeassistant/components/notion/translations/ja.json b/homeassistant/components/notion/translations/ja.json new file mode 100644 index 00000000000..fb764116ecf --- /dev/null +++ b/homeassistant/components/notion/translations/ja.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "no_devices": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "{username} \u306e\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u518d\u5ea6\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "\u3042\u306a\u305f\u306e\u60c5\u5831\u3092\u5165\u529b" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/notion/translations/tr.json b/homeassistant/components/notion/translations/tr.json index f89e3fb7533..cd95e502c8d 100644 --- a/homeassistant/components/notion/translations/tr.json +++ b/homeassistant/components/notion/translations/tr.json @@ -1,18 +1,28 @@ { "config": { "abort": { - "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", - "no_devices": "Hesapta cihaz bulunamad\u0131" + "no_devices": "Hesapta cihaz bulunamad\u0131", + "unknown": "Beklenmeyen hata" }, "step": { + "reauth_confirm": { + "data": { + "password": "Parola" + }, + "description": "{username} \u015fifresini tekrar girin.", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, "user": { "data": { "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "Bilgilerinizi doldurun" } } } diff --git a/homeassistant/components/nuheat/translations/ja.json b/homeassistant/components/nuheat/translations/ja.json new file mode 100644 index 00000000000..c8a9b3fc105 --- /dev/null +++ b/homeassistant/components/nuheat/translations/ja.json @@ -0,0 +1,24 @@ +{ + "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "invalid_thermostat": "\u30b5\u30fc\u30e2\u30b9\u30bf\u30c3\u30c8\u306e\u30b7\u30ea\u30a2\u30eb\u756a\u53f7\u304c\u7121\u52b9\u3067\u3059\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "serial_number": "\u30b5\u30fc\u30e2\u30b9\u30bf\u30c3\u30c8\u306e\u30b7\u30ea\u30a2\u30eb\u756a\u53f7\u3002", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "https://MyNuHeat.com \u306b\u30ed\u30b0\u30a4\u30f3\u3057\u3001\u30b5\u30fc\u30e2\u30b9\u30bf\u30c3\u30c8\u3092\u9078\u629e\u3057\u3066\u3001\u30b5\u30fc\u30e2\u30b9\u30bf\u30c3\u30c8\u306e\u30b7\u30ea\u30a2\u30eb\u756a\u53f7\u307e\u305f\u306fID\u3092\u53d6\u5f97\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "title": "NuHeat\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nuheat/translations/tr.json b/homeassistant/components/nuheat/translations/tr.json index 5123f1c7d9a..ee240b2c242 100644 --- a/homeassistant/components/nuheat/translations/tr.json +++ b/homeassistant/components/nuheat/translations/tr.json @@ -15,7 +15,9 @@ "password": "Parola", "serial_number": "Termostat\u0131n seri numaras\u0131.", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "description": "https://MyNuHeat.com'da oturum a\u00e7\u0131p termostat(lar)\u0131n\u0131z\u0131 se\u00e7erek termostat\u0131n\u0131z\u0131n say\u0131sal seri numaras\u0131n\u0131 veya kimli\u011fini alman\u0131z gerekecektir.", + "title": "NuHeat'e ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/nuki/config_flow.py b/homeassistant/components/nuki/config_flow.py index 330f38c96bd..bd2c5a0d750 100644 --- a/homeassistant/components/nuki/config_flow.py +++ b/homeassistant/components/nuki/config_flow.py @@ -7,8 +7,9 @@ from requests.exceptions import RequestException import voluptuous as vol from homeassistant import config_entries, exceptions -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS +from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult from .const import DEFAULT_PORT, DEFAULT_TIMEOUT, DOMAIN @@ -66,15 +67,15 @@ class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a flow initiated by the user.""" return await self.async_step_validate(user_input) - async def async_step_dhcp(self, discovery_info: dict): + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Prepare configuration for a DHCP discovered Nuki bridge.""" - await self.async_set_unique_id(int(discovery_info.get(HOSTNAME)[12:], 16)) + await self.async_set_unique_id(int(discovery_info.hostname[12:], 16)) self._abort_if_unique_id_configured() self.discovery_schema = vol.Schema( { - vol.Required(CONF_HOST, default=discovery_info[IP_ADDRESS]): str, + vol.Required(CONF_HOST, default=discovery_info.ip): str, vol.Required(CONF_PORT, default=DEFAULT_PORT): int, vol.Required(CONF_TOKEN): str, } diff --git a/homeassistant/components/nuki/translations/ja.json b/homeassistant/components/nuki/translations/ja.json new file mode 100644 index 00000000000..6f54d0d8b4b --- /dev/null +++ b/homeassistant/components/nuki/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "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": { + "token": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3" + }, + "description": "Nuki\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u306f\u3001bridge\u3067\u518d\u8a8d\u8a3c\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8", + "token": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nuki/translations/tr.json b/homeassistant/components/nuki/translations/tr.json index ba6a496fa4c..67ff0fb0faf 100644 --- a/homeassistant/components/nuki/translations/tr.json +++ b/homeassistant/components/nuki/translations/tr.json @@ -1,16 +1,26 @@ { "config": { + "abort": { + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "unknown": "Beklenmeyen hata" }, "step": { + "reauth_confirm": { + "data": { + "token": "Eri\u015fim Anahtar\u0131" + }, + "description": "Nuki entegrasyonunun k\u00f6pr\u00fcn\u00fczle yeniden kimlik do\u011frulamas\u0131 yapmas\u0131 gerekiyor.", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "port": "Port", - "token": "Eri\u015fim Belirteci" + "token": "Eri\u015fim Anahtar\u0131" } } } diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 89ad7d8b8c2..af88b5c86b5 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -4,10 +4,11 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any, Literal, final +from typing import Any, final import voluptuous as vol +from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE from homeassistant.core import HomeAssistant, ServiceCall @@ -28,7 +29,6 @@ from .const import ( DEFAULT_MIN_VALUE, DEFAULT_STEP, DOMAIN, - MODE_AUTO, SERVICE_SET_VALUE, ) @@ -41,6 +41,14 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) _LOGGER = logging.getLogger(__name__) +class NumberMode(StrEnum): + """Modes for number entities.""" + + AUTO = "auto" + BOX = "box" + SLIDER = "slider" + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Number entities.""" component = hass.data[DOMAIN] = EntityComponent( @@ -92,7 +100,7 @@ class NumberEntity(Entity): _attr_min_value: float = DEFAULT_MIN_VALUE _attr_state: None = None _attr_step: float - _attr_mode: Literal["auto", "slider", "box"] = MODE_AUTO + _attr_mode: NumberMode = NumberMode.AUTO _attr_value: float @property @@ -128,7 +136,7 @@ class NumberEntity(Entity): return step @property - def mode(self) -> Literal["auto", "slider", "box"]: + def mode(self) -> NumberMode: """Return the mode of the entity.""" return self._attr_mode diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 749463b11e5..50390e7ab81 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -7,10 +7,6 @@ ATTR_MIN = "min" ATTR_MAX = "max" ATTR_STEP = "step" -MODE_AUTO: Final = "auto" -MODE_BOX: Final = "box" -MODE_SLIDER: Final = "slider" - DEFAULT_MIN_VALUE = 0.0 DEFAULT_MAX_VALUE = 100.0 DEFAULT_STEP = 1.0 @@ -18,3 +14,8 @@ DEFAULT_STEP = 1.0 DOMAIN = "number" SERVICE_SET_VALUE = "set_value" + +# MODE_* are deprecated as of 2021.12, use the NumberMode enum instead. +MODE_AUTO: Final = "auto" +MODE_BOX: Final = "box" +MODE_SLIDER: Final = "slider" diff --git a/homeassistant/components/number/translations/ja.json b/homeassistant/components/number/translations/ja.json new file mode 100644 index 00000000000..81c728ea6b8 --- /dev/null +++ b/homeassistant/components/number/translations/ja.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "action_type": { + "set_value": "{entity_name} \u306e\u5024\u3092\u8a2d\u5b9a" + } + }, + "title": "\u6570" +} \ No newline at end of file diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index f0f2777373a..1a040b99f57 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -19,6 +19,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -37,7 +38,8 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Network UPS Tools (NUT) from a config entry.""" - # strip out the stale options CONF_RESOURCES + # strip out the stale options CONF_RESOURCES, + # maintain the entry in data in case of version rollback if CONF_RESOURCES in entry.options: new_data = {**entry.data, CONF_RESOURCES: entry.options[CONF_RESOURCES]} new_options = {k: v for k, v in entry.options.items() if k != CONF_RESOURCES} @@ -79,7 +81,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("NUT Sensors Available: %s", status) undo_listener = entry.add_update_listener(_async_update_listener) - unique_id = _unique_id_from_status(status) if unique_id is None: unique_id = entry.entry_id @@ -92,6 +93,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: UNDO_UPDATE_LISTENER: undo_listener, } + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, unique_id)}, + name=data.name.title(), + manufacturer=data.device_info.get(ATTR_MANUFACTURER), + model=data.device_info.get(ATTR_MODEL), + sw_version=data.device_info.get(ATTR_SW_VERSION), + ) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index ed490eddd37..fb0a2210a69 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -1,37 +1,31 @@ """Config flow for Network UPS Tools (NUT) integration.""" +from __future__ import annotations + import logging import voluptuous as vol from homeassistant import config_entries, core, exceptions +from homeassistant.components import zeroconf from homeassistant.const import ( CONF_ALIAS, CONF_BASE, CONF_HOST, CONF_PASSWORD, CONF_PORT, - CONF_RESOURCES, CONF_SCAN_INTERVAL, CONF_USERNAME, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.data_entry_flow import FlowResult from . import PyNUTData -from .const import ( - DEFAULT_HOST, - DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, - DOMAIN, - KEY_STATUS, - KEY_STATUS_DISPLAY, - SENSOR_TYPES, -) +from .const import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = logging.getLogger(__name__) -def _base_schema(discovery_info): +def _base_schema(discovery_info: zeroconf.ZeroconfServiceInfo | None) -> vol.Schema: """Generate base schema.""" base_schema = {} if not discovery_info: @@ -48,27 +42,6 @@ def _base_schema(discovery_info): return vol.Schema(base_schema) -def _resource_schema_base(available_resources, selected_resources): - """Resource selection schema.""" - - known_available_resources = { - sensor_id: sensor_desc.name - for sensor_id, sensor_desc in SENSOR_TYPES.items() - if sensor_id in available_resources - } - - if KEY_STATUS in known_available_resources: - known_available_resources[KEY_STATUS_DISPLAY] = SENSOR_TYPES[ - KEY_STATUS_DISPLAY - ].name - - return { - vol.Required(CONF_RESOURCES, default=selected_resources): cv.multi_select( - known_available_resources - ) - } - - def _ups_schema(ups_list): """UPS selection schema.""" return vol.Schema({vol.Required(CONF_ALIAS): vol.In(ups_list)}) @@ -112,18 +85,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize the nut config flow.""" self.nut_config = {} - self.available_resources = {} - self.discovery_info = {} + self.discovery_info: zeroconf.ZeroconfServiceInfo | None = None self.ups_list = None self.title = None - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Prepare configuration for a discovered nut device.""" self.discovery_info = discovery_info await self._async_handle_discovery_without_unique_id() self.context["title_placeholders"] = { - CONF_PORT: discovery_info.get(CONF_PORT, DEFAULT_PORT), - CONF_HOST: discovery_info[CONF_HOST], + CONF_PORT: discovery_info.port or DEFAULT_PORT, + CONF_HOST: discovery_info.host, } return await self.async_step_user() @@ -134,8 +108,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if self.discovery_info: user_input.update( { - CONF_HOST: self.discovery_info[CONF_HOST], - CONF_PORT: self.discovery_info.get(CONF_PORT, DEFAULT_PORT), + CONF_HOST: self.discovery_info.host, + CONF_PORT: self.discovery_info.port or DEFAULT_PORT, } ) info, errors = await self._async_validate_or_error(user_input) @@ -148,8 +122,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if self._host_port_alias_already_configured(self.nut_config): return self.async_abort(reason="already_configured") - self.available_resources.update(info["available_resources"]) - return await self.async_step_resources() + title = _format_host_port_alias(self.nut_config) + return self.async_create_entry(title=title, data=self.nut_config) return self.async_show_form( step_id="user", data_schema=_base_schema(self.discovery_info), errors=errors @@ -163,10 +137,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.nut_config.update(user_input) if self._host_port_alias_already_configured(self.nut_config): return self.async_abort(reason="already_configured") - info, errors = await self._async_validate_or_error(self.nut_config) + _, errors = await self._async_validate_or_error(self.nut_config) if not errors: - self.available_resources.update(info["available_resources"]) - return await self.async_step_resources() + title = _format_host_port_alias(self.nut_config) + return self.async_create_entry(title=title, data=self.nut_config) return self.async_show_form( step_id="ups", @@ -174,20 +148,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_resources(self, user_input=None): - """Handle the picking the resources.""" - if user_input is None: - return self.async_show_form( - step_id="resources", - data_schema=vol.Schema( - _resource_schema_base(self.available_resources, []) - ), - ) - - self.nut_config.update(user_input) - title = _format_host_port_alias(self.nut_config) - return self.async_create_entry(title=title, data=self.nut_config) - def _host_port_alias_already_configured(self, user_input): """See if we already have a nut entry matching user input configured.""" existing_host_port_aliases = { diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 59d5cd484af..23ef55f3f09 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -5,19 +5,13 @@ from __future__ import annotations from typing import Final from homeassistant.components.sensor import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_VOLTAGE, - STATE_CLASS_MEASUREMENT, + SensorDeviceClass, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( ELECTRIC_CURRENT_AMPERE, ELECTRIC_POTENTIAL_VOLT, - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, FREQUENCY_HERTZ, PERCENTAGE, POWER_VOLT_AMPERE, @@ -25,6 +19,7 @@ from homeassistant.const import ( TEMP_CELSIUS, TIME_SECONDS, ) +from homeassistant.helpers.entity import EntityCategory DOMAIN = "nut" @@ -65,193 +60,239 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ups.temperature", name="UPS Temperature", native_unit_of_measurement=TEMP_CELSIUS, - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.load": SensorEntityDescription( key="ups.load", name="Load", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), "ups.load.high": SensorEntityDescription( key="ups.load.high", name="Overload Setting", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.id": SensorEntityDescription( key="ups.id", name="System identifier", icon="mdi:information-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.delay.start": SensorEntityDescription( key="ups.delay.start", name="Load Restart Delay", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.delay.reboot": SensorEntityDescription( key="ups.delay.reboot", name="UPS Reboot Delay", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.delay.shutdown": SensorEntityDescription( key="ups.delay.shutdown", name="UPS Shutdown Delay", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.timer.start": SensorEntityDescription( key="ups.timer.start", name="Load Start Timer", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.timer.reboot": SensorEntityDescription( key="ups.timer.reboot", name="Load Reboot Timer", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.timer.shutdown": SensorEntityDescription( key="ups.timer.shutdown", name="Load Shutdown Timer", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.test.interval": SensorEntityDescription( key="ups.test.interval", name="Self-Test Interval", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.test.result": SensorEntityDescription( key="ups.test.result", name="Self-Test Result", icon="mdi:information-outline", - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.test.date": SensorEntityDescription( key="ups.test.date", name="Self-Test Date", icon="mdi:calendar", - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.display.language": SensorEntityDescription( key="ups.display.language", name="Language", icon="mdi:information-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.contacts": SensorEntityDescription( key="ups.contacts", name="External Contacts", icon="mdi:information-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.efficiency": SensorEntityDescription( key="ups.efficiency", name="Efficiency", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.power": SensorEntityDescription( key="ups.power", name="Current Apparent Power", native_unit_of_measurement=POWER_VOLT_AMPERE, icon="mdi:flash", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.power.nominal": SensorEntityDescription( key="ups.power.nominal", name="Nominal Power", native_unit_of_measurement=POWER_VOLT_AMPERE, icon="mdi:flash", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.realpower": SensorEntityDescription( key="ups.realpower", name="Current Real Power", native_unit_of_measurement=POWER_WATT, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.realpower.nominal": SensorEntityDescription( key="ups.realpower.nominal", name="Nominal Real Power", native_unit_of_measurement=POWER_WATT, - device_class=DEVICE_CLASS_POWER, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.beeper.status": SensorEntityDescription( key="ups.beeper.status", name="Beeper Status", icon="mdi:information-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.type": SensorEntityDescription( key="ups.type", name="UPS Type", icon="mdi:information-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.watchdog.status": SensorEntityDescription( key="ups.watchdog.status", name="Watchdog Status", icon="mdi:information-outline", - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.start.auto": SensorEntityDescription( key="ups.start.auto", name="Start on AC", icon="mdi:information-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.start.battery": SensorEntityDescription( key="ups.start.battery", name="Start on Battery", icon="mdi:information-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.start.reboot": SensorEntityDescription( key="ups.start.reboot", name="Reboot on Battery", icon="mdi:information-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ups.shutdown": SensorEntityDescription( key="ups.shutdown", name="Shutdown Ability", icon="mdi:information-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.charge": SensorEntityDescription( key="battery.charge", name="Battery Charge", native_unit_of_measurement=PERCENTAGE, - device_class=DEVICE_CLASS_BATTERY, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, ), "battery.charge.low": SensorEntityDescription( key="battery.charge.low", name="Low Battery Setpoint", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.charge.restart": SensorEntityDescription( key="battery.charge.restart", name="Minimum Battery to Start", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.charge.warning": SensorEntityDescription( key="battery.charge.warning", name="Warning Battery Setpoint", native_unit_of_measurement=PERCENTAGE, icon="mdi:gauge", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.charger.status": SensorEntityDescription( key="battery.charger.status", @@ -262,216 +303,277 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="battery.voltage", name="Battery Voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.voltage.nominal": SensorEntityDescription( key="battery.voltage.nominal", name="Nominal Battery Voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - device_class=DEVICE_CLASS_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.voltage.low": SensorEntityDescription( key="battery.voltage.low", name="Low Battery Voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - device_class=DEVICE_CLASS_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.voltage.high": SensorEntityDescription( key="battery.voltage.high", name="High Battery Voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - device_class=DEVICE_CLASS_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.capacity": SensorEntityDescription( key="battery.capacity", name="Battery Capacity", native_unit_of_measurement="Ah", icon="mdi:flash", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.current": SensorEntityDescription( key="battery.current", name="Battery Current", native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, icon="mdi:flash", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.current.total": SensorEntityDescription( key="battery.current.total", name="Total Battery Current", native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, icon="mdi:flash", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.temperature": SensorEntityDescription( key="battery.temperature", name="Battery Temperature", native_unit_of_measurement=TEMP_CELSIUS, - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.runtime": SensorEntityDescription( key="battery.runtime", name="Battery Runtime", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.runtime.low": SensorEntityDescription( key="battery.runtime.low", name="Low Battery Runtime", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.runtime.restart": SensorEntityDescription( key="battery.runtime.restart", name="Minimum Battery Runtime to Start", native_unit_of_measurement=TIME_SECONDS, icon="mdi:timer-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.alarm.threshold": SensorEntityDescription( key="battery.alarm.threshold", name="Battery Alarm Threshold", icon="mdi:information-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.date": SensorEntityDescription( key="battery.date", name="Battery Date", icon="mdi:calendar", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.mfr.date": SensorEntityDescription( key="battery.mfr.date", name="Battery Manuf. Date", icon="mdi:calendar", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.packs": SensorEntityDescription( key="battery.packs", name="Number of Batteries", icon="mdi:information-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.packs.bad": SensorEntityDescription( key="battery.packs.bad", name="Number of Bad Batteries", icon="mdi:information-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "battery.type": SensorEntityDescription( key="battery.type", name="Battery Chemistry", icon="mdi:information-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "input.sensitivity": SensorEntityDescription( key="input.sensitivity", name="Input Power Sensitivity", icon="mdi:information-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "input.transfer.low": SensorEntityDescription( key="input.transfer.low", name="Low Voltage Transfer", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - device_class=DEVICE_CLASS_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "input.transfer.high": SensorEntityDescription( key="input.transfer.high", name="High Voltage Transfer", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - device_class=DEVICE_CLASS_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "input.transfer.reason": SensorEntityDescription( key="input.transfer.reason", name="Voltage Transfer Reason", icon="mdi:information-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "input.voltage": SensorEntityDescription( key="input.voltage", name="Input Voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, ), "input.voltage.nominal": SensorEntityDescription( key="input.voltage.nominal", name="Nominal Input Voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - device_class=DEVICE_CLASS_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "input.frequency": SensorEntityDescription( key="input.frequency", name="Input Line Frequency", native_unit_of_measurement=FREQUENCY_HERTZ, icon="mdi:flash", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "input.frequency.nominal": SensorEntityDescription( key="input.frequency.nominal", name="Nominal Input Line Frequency", native_unit_of_measurement=FREQUENCY_HERTZ, icon="mdi:flash", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "input.frequency.status": SensorEntityDescription( key="input.frequency.status", name="Input Frequency Status", icon="mdi:information-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "output.current": SensorEntityDescription( key="output.current", name="Output Current", native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, icon="mdi:flash", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "output.current.nominal": SensorEntityDescription( key="output.current.nominal", name="Nominal Output Current", native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, icon="mdi:flash", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "output.voltage": SensorEntityDescription( key="output.voltage", name="Output Voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, ), "output.voltage.nominal": SensorEntityDescription( key="output.voltage.nominal", name="Nominal Output Voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - device_class=DEVICE_CLASS_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "output.frequency": SensorEntityDescription( key="output.frequency", name="Output Frequency", native_unit_of_measurement=FREQUENCY_HERTZ, icon="mdi:flash", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "output.frequency.nominal": SensorEntityDescription( key="output.frequency.nominal", name="Nominal Output Frequency", native_unit_of_measurement=FREQUENCY_HERTZ, icon="mdi:flash", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ambient.humidity": SensorEntityDescription( key="ambient.humidity", name="Ambient Humidity", native_unit_of_measurement=PERCENTAGE, - device_class=DEVICE_CLASS_HUMIDITY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ambient.temperature": SensorEntityDescription( key="ambient.temperature", name="Ambient Temperature", native_unit_of_measurement=TEMP_CELSIUS, - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "watts": SensorEntityDescription( key="watts", name="Watts", native_unit_of_measurement=POWER_WATT, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), } diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 7ec6e28401f..e937e6565df 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -5,7 +5,7 @@ import logging from homeassistant.components.nut import PyNUTData from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.const import CONF_RESOURCES, STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -35,9 +35,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): unique_id = pynut_data[PYNUT_UNIQUE_ID] status = coordinator.data - enabled_resources = [ - resource.lower() for resource in config_entry.data[CONF_RESOURCES] - ] resources = [sensor_id for sensor_id in SENSOR_TYPES if sensor_id in status] # Display status is a special case that falls back to the status value # of the UPS instead. @@ -50,7 +47,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): SENSOR_TYPES[sensor_type], data, unique_id, - sensor_type in enabled_resources, ) for sensor_type in resources ] @@ -67,14 +63,12 @@ class NUTSensor(CoordinatorEntity, SensorEntity): sensor_description: SensorEntityDescription, data: PyNUTData, unique_id: str, - enabled_default: bool, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = sensor_description device_name = data.name.title() - self._attr_entity_registry_enabled_default = enabled_default self._attr_name = f"{device_name} {sensor_description.name}" self._attr_unique_id = f"{unique_id}_{sensor_description.key}" self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 179f974b870..70ecbfb6d2e 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -13,14 +13,7 @@ "ups": { "title": "Choose the UPS to Monitor", "data": { - "alias": "Alias", - "resources": "Resources" - } - }, - "resources": { - "title": "Choose the Resources to Monitor", - "data": { - "resources": "Resources" + "alias": "Alias" } } }, diff --git a/homeassistant/components/nut/translations/ja.json b/homeassistant/components/nut/translations/ja.json new file mode 100644 index 00000000000..4c52cc74ed2 --- /dev/null +++ b/homeassistant/components/nut/translations/ja.json @@ -0,0 +1,50 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "resources": { + "data": { + "resources": "\u30ea\u30bd\u30fc\u30b9" + }, + "title": "\u76e3\u8996\u3059\u308b\u30ea\u30bd\u30fc\u30b9\u3092\u9078\u629e" + }, + "ups": { + "data": { + "alias": "\u30a8\u30a4\u30ea\u30a2\u30b9", + "resources": "\u30ea\u30bd\u30fc\u30b9" + }, + "title": "\u76e3\u8996\u3059\u308bUPS\u3092\u9078\u629e" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "NUT server\u306b\u63a5\u7d9a" + } + } + }, + "options": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "init": { + "data": { + "resources": "\u30ea\u30bd\u30fc\u30b9", + "scan_interval": "\u30b9\u30ad\u30e3\u30f3\u30a4\u30f3\u30bf\u30fc\u30d0\u30eb(\u79d2)" + }, + "description": "\u30bb\u30f3\u30b5\u30fc\u30ea\u30bd\u30fc\u30b9\u3092\u9078\u629e\u3057\u307e\u3059\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nut/translations/tr.json b/homeassistant/components/nut/translations/tr.json index b383d765619..0d8b170c017 100644 --- a/homeassistant/components/nut/translations/tr.json +++ b/homeassistant/components/nut/translations/tr.json @@ -11,28 +11,37 @@ "resources": { "data": { "resources": "Kaynaklar" - } + }, + "title": "\u0130zlenecek Kaynaklar\u0131 Se\u00e7in" }, "ups": { "data": { - "alias": "Takma ad" - } + "alias": "Takma ad", + "resources": "Kaynaklar" + }, + "title": "\u0130zlenecek UPS'i Se\u00e7in" }, "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "password": "Parola", "port": "Port", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "NUT sunucusuna ba\u011flan\u0131n" } } }, "options": { + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, "step": { "init": { "data": { - "resources": "Kaynaklar" + "resources": "Kaynaklar", + "scan_interval": "Tarama Aral\u0131\u011f\u0131 (saniye)" }, "description": "Sens\u00f6r Kaynaklar\u0131'n\u0131 se\u00e7in." } diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index 6f5bdd145c9..02327336035 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import debounce 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.event import async_track_point_in_utc_time from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -172,7 +173,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def device_info(latitude, longitude) -> DeviceInfo: """Return device registry information.""" return DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, base_unique_id(latitude, longitude))}, manufacturer="National Weather Service", name=f"NWS: {latitude}, {longitude}", diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 49387896962..35bbcef838d 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -4,12 +4,12 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, - LENGTH_KILOMETERS, LENGTH_METERS, LENGTH_MILES, PERCENTAGE, PRESSURE_INHG, PRESSURE_PA, + SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR, TEMP_CELSIUS, ) @@ -19,6 +19,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.distance import convert as convert_distance from homeassistant.util.dt import utcnow from homeassistant.util.pressure import convert as convert_pressure +from homeassistant.util.speed import convert as convert_speed from . import base_unique_id, device_info from .const import ( @@ -86,7 +87,9 @@ class NWSSensor(CoordinatorEntity, SensorEntity): # Set alias to unit property -> prevent unnecessary hasattr calls unit_of_measurement = self.native_unit_of_measurement if unit_of_measurement == SPEED_MILES_PER_HOUR: - return round(convert_distance(value, LENGTH_KILOMETERS, LENGTH_MILES)) + return round( + convert_speed(value, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR) + ) if unit_of_measurement == LENGTH_MILES: return round(convert_distance(value, LENGTH_METERS, LENGTH_MILES)) if unit_of_measurement == PRESSURE_INHG: diff --git a/homeassistant/components/nws/translations/ja.json b/homeassistant/components/nws/translations/ja.json new file mode 100644 index 00000000000..39f7b5512dc --- /dev/null +++ b/homeassistant/components/nws/translations/ja.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "station": "METAR station code" + }, + "description": "METAR station code\u304c\u6307\u5b9a\u3055\u308c\u3066\u3044\u306a\u3044\u5834\u5408\u3001\u7def\u5ea6\u3068\u7d4c\u5ea6\u3092\u4f7f\u7528\u3057\u3066\u6700\u3082\u8fd1\u3044\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u304c\u691c\u7d22\u3055\u308c\u307e\u3059\u3002\u4eca\u306e\u3068\u3053\u308d\u3001API\u30ad\u30fc\u306f\u4f55\u3067\u3082\u304b\u307e\u3044\u307e\u305b\u3093\u3002\u6709\u52b9\u306a\u30e1\u30fc\u30eb\u30a2\u30c9\u30ec\u30b9\u3092\u4f7f\u7528\u3059\u308b\u3053\u3068\u3092\u304a\u52e7\u3081\u3057\u307e\u3059\u3002", + "title": "\u30a2\u30e1\u30ea\u30ab\u56fd\u7acb\u6c17\u8c61\u5c40\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nws/translations/tr.json b/homeassistant/components/nws/translations/tr.json index 8f51593aedb..a31bade53f3 100644 --- a/homeassistant/components/nws/translations/tr.json +++ b/homeassistant/components/nws/translations/tr.json @@ -12,9 +12,11 @@ "data": { "api_key": "API Anahtar\u0131", "latitude": "Enlem", - "longitude": "Boylam" + "longitude": "Boylam", + "station": "METAR istasyon kodu" }, - "description": "Bir METAR istasyon kodu belirtilmezse, en yak\u0131n istasyonu bulmak i\u00e7in enlem ve boylam kullan\u0131lacakt\u0131r. \u015eimdilik bir API Anahtar\u0131 herhangi bir \u015fey olabilir. Ge\u00e7erli bir e-posta adresi kullanman\u0131z tavsiye edilir." + "description": "Bir METAR istasyon kodu belirtilmezse, en yak\u0131n istasyonu bulmak i\u00e7in enlem ve boylam kullan\u0131lacakt\u0131r. \u015eimdilik bir API Anahtar\u0131 herhangi bir \u015fey olabilir. Ge\u00e7erli bir e-posta adresi kullanman\u0131z tavsiye edilir.", + "title": "Ulusal Hava Durumu Servisine ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index dc76ebc25e5..3a795095c5d 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -19,6 +19,8 @@ from homeassistant.const import ( PRESSURE_HPA, PRESSURE_INHG, PRESSURE_PA, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) @@ -28,6 +30,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.distance import convert as convert_distance from homeassistant.util.dt import utcnow from homeassistant.util.pressure import convert as convert_pressure +from homeassistant.util.speed import convert as convert_speed from homeassistant.util.temperature import convert as convert_temperature from . import base_unique_id, device_info @@ -196,7 +199,9 @@ class NWSWeather(WeatherEntity): if self.is_metric: wind = wind_km_hr else: - wind = convert_distance(wind_km_hr, LENGTH_KILOMETERS, LENGTH_MILES) + wind = convert_speed( + wind_km_hr, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR + ) return round(wind) @property @@ -271,7 +276,9 @@ class NWSWeather(WeatherEntity): if wind_speed is not None: if self.is_metric: data[ATTR_FORECAST_WIND_SPEED] = round( - convert_distance(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS) + convert_speed( + wind_speed, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR + ) ) else: data[ATTR_FORECAST_WIND_SPEED] = round(wind_speed) diff --git a/homeassistant/components/nx584/binary_sensor.py b/homeassistant/components/nx584/binary_sensor.py index 7a999263332..38787afb080 100644 --- a/homeassistant/components/nx584/binary_sensor.py +++ b/homeassistant/components/nx584/binary_sensor.py @@ -137,8 +137,7 @@ class NX584Watcher(threading.Thread): """Throw away any existing events so we don't replay history.""" self._client.get_events() while True: - events = self._client.get_events() - if events: + if events := self._client.get_events(): self._process_events(events) def run(self): diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index 5bfde7e7c2b..9f94d458f42 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -127,6 +127,6 @@ class NZBGetSensor(NZBGetEntity, SensorEntity): if "UpTimeSec" in sensor_type and value > 0: uptime = utcnow() - timedelta(seconds=value) - return uptime.replace(microsecond=0).isoformat() + return uptime.replace(microsecond=0) return value diff --git a/homeassistant/components/nzbget/translations/ja.json b/homeassistant/components/nzbget/translations/ja.json new file mode 100644 index 00000000000..c6b485976a7 --- /dev/null +++ b/homeassistant/components/nzbget/translations/ja.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "ssl": "SSL\u8a3c\u660e\u66f8\u3092\u4f7f\u7528\u3059\u308b", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + }, + "title": "NZBGet\u306b\u63a5\u7d9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u983b\u5ea6(\u79d2)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nzbget/translations/tr.json b/homeassistant/components/nzbget/translations/tr.json index 63b6c489018..27b101fb59a 100644 --- a/homeassistant/components/nzbget/translations/tr.json +++ b/homeassistant/components/nzbget/translations/tr.json @@ -7,13 +7,19 @@ "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, + "flow_title": "{name}", "step": { "user": { "data": { + "host": "Ana bilgisayar", + "name": "Ad", "password": "Parola", "port": "Port", - "username": "Kullan\u0131c\u0131 Ad\u0131" - } + "ssl": "SSL sertifikas\u0131 kullan\u0131r", + "username": "Kullan\u0131c\u0131 Ad\u0131", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" + }, + "title": "NZBGet'e ba\u011flan\u0131n" } } }, diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py index 1a51738cb77..a5a4a98c3d4 100644 --- a/homeassistant/components/oasa_telematics/sensor.py +++ b/homeassistant/components/oasa_telematics/sensor.py @@ -120,7 +120,7 @@ class OASATelematicsSensor(SensorEntity): self._name_data = self.data.name_data next_arrival_data = self._times[0] if ATTR_NEXT_ARRIVAL in next_arrival_data: - self._state = next_arrival_data[ATTR_NEXT_ARRIVAL].isoformat() + self._state = next_arrival_data[ATTR_NEXT_ARRIVAL] class OASATelematicsData: diff --git a/homeassistant/components/octoprint/binary_sensor.py b/homeassistant/components/octoprint/binary_sensor.py index 3f9e4417c6e..e1db7a95136 100644 --- a/homeassistant/components/octoprint/binary_sensor.py +++ b/homeassistant/components/octoprint/binary_sensor.py @@ -61,8 +61,7 @@ class OctoPrintBinarySensorBase(CoordinatorEntity, BinarySensorEntity): @property def is_on(self): """Return true if binary sensor is on.""" - printer = self.coordinator.data["printer"] - if not printer: + if not (printer := self.coordinator.data["printer"]): return None return bool(self._get_flag_state(printer)) diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 04efde16952..6e6301a0ce5 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -6,6 +6,7 @@ import voluptuous as vol from yarl import URL from homeassistant import config_entries, data_entry_flow, exceptions +from homeassistant.components import ssdp, zeroconf from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -138,31 +139,35 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle import.""" return await self.async_step_user(user_input) - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> data_entry_flow.FlowResult: """Handle discovery flow.""" - uuid = discovery_info["properties"]["uuid"] + uuid = discovery_info.properties["uuid"] await self.async_set_unique_id(uuid) self._abort_if_unique_id_configured() self.context["title_placeholders"] = { - CONF_HOST: discovery_info[CONF_HOST], + CONF_HOST: discovery_info.host, } self.discovery_schema = _schema_with_defaults( - host=discovery_info[CONF_HOST], - port=discovery_info[CONF_PORT], - path=discovery_info["properties"][CONF_PATH], + host=discovery_info.host, + port=discovery_info.port, + path=discovery_info.properties[CONF_PATH], ) return await self.async_step_user() - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp( + self, discovery_info: ssdp.SsdpServiceInfo + ) -> data_entry_flow.FlowResult: """Handle ssdp discovery flow.""" - uuid = discovery_info["UDN"][5:] + uuid = discovery_info.upnp["UDN"][5:] await self.async_set_unique_id(uuid) self._abort_if_unique_id_configured() - url = URL(discovery_info["presentationURL"]) + url = URL(discovery_info.upnp["presentationURL"]) self.context["title_placeholders"] = { CONF_HOST: url.host, } diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index d46dd06b798..68313a16bd3 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -1,7 +1,7 @@ """Support for monitoring OctoPrint sensors.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from pyoctoprintapi import OctoprintJobInfo, OctoprintPrinterInfo @@ -23,6 +23,17 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +JOB_PRINTING_STATES = ["Printing from SD", "Printing"] + + +def _is_printer_printing(printer: OctoprintPrinterInfo) -> bool: + return ( + printer + and printer.state + and printer.state.flags + and printer.state.flags.printing + ) + async def async_setup_entry( hass: HomeAssistant, @@ -130,8 +141,7 @@ class OctoPrintJobPercentageSensor(OctoPrintSensorBase): if not job: return None - state = job.progress.completion - if not state: + if not (state := job.progress.completion): return 0 return round(state, 2) @@ -149,15 +159,19 @@ class OctoPrintEstimatedFinishTimeSensor(OctoPrintSensorBase): super().__init__(coordinator, "Estimated Finish Time", device_id) @property - def native_value(self): + def native_value(self) -> datetime | None: """Return sensor state.""" job: OctoprintJobInfo = self.coordinator.data["job"] - if not job or not job.progress.print_time_left or job.state != "Printing": + if ( + not job + or not job.progress.print_time_left + or not _is_printer_printing(self.coordinator.data["printer"]) + ): return None read_time = self.coordinator.data["last_read_time"] - return (read_time + timedelta(seconds=job.progress.print_time_left)).isoformat() + return read_time + timedelta(seconds=job.progress.print_time_left) class OctoPrintStartTimeSensor(OctoPrintSensorBase): @@ -172,16 +186,20 @@ class OctoPrintStartTimeSensor(OctoPrintSensorBase): super().__init__(coordinator, "Start Time", device_id) @property - def native_value(self): + def native_value(self) -> datetime | None: """Return sensor state.""" job: OctoprintJobInfo = self.coordinator.data["job"] - if not job or not job.progress.print_time or job.state != "Printing": + if ( + not job + or not job.progress.print_time + or not _is_printer_printing(self.coordinator.data["printer"]) + ): return None read_time = self.coordinator.data["last_read_time"] - return (read_time - timedelta(seconds=job.progress.print_time)).isoformat() + return read_time - timedelta(seconds=job.progress.print_time) class OctoPrintTemperatureSensor(OctoPrintSensorBase): @@ -212,12 +230,15 @@ class OctoPrintTemperatureSensor(OctoPrintSensorBase): for temp in printer.temperatures: if temp.name == self._api_tool: - return round( + val = ( temp.actual_temp if self._temp_type == "actual" - else temp.target_temp, - 2, + else temp.target_temp ) + if val is None: + return None + + return round(val, 2) return None diff --git a/homeassistant/components/octoprint/translations/he.json b/homeassistant/components/octoprint/translations/he.json new file mode 100644 index 00000000000..0d36726a3fd --- /dev/null +++ b/homeassistant/components/octoprint/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", + "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" + }, + "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" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/octoprint/translations/id.json b/homeassistant/components/octoprint/translations/id.json new file mode 100644 index 00000000000..647df2d8c51 --- /dev/null +++ b/homeassistant/components/octoprint/translations/id.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "auth_failed": "Gagal mengambil kunci API aplikasi", + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "flow_title": "OctoPrint Printer: {host}", + "progress": { + "get_api_key": "Buka antarmuka OctoPrint dan klik 'Izinkan' pada Permintaan Akses untuk 'Home Assistant'." + }, + "step": { + "user": { + "data": { + "host": "Host", + "path": "Jalur Navigasi", + "port": "Nomor Port", + "ssl": "Gunakan SSL", + "username": "Nama Pengguna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/octoprint/translations/it.json b/homeassistant/components/octoprint/translations/it.json new file mode 100644 index 00000000000..084307b6323 --- /dev/null +++ b/homeassistant/components/octoprint/translations/it.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "auth_failed": "Impossibile recuperare la chiave API dell'applicazione", + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "flow_title": "Stampante OctoPrint: {host}", + "progress": { + "get_api_key": "Apri l'interfaccia utente di OctoPrint e fai clic su \"Consenti\" nella richiesta di accesso per \"Home Assistant\"." + }, + "step": { + "user": { + "data": { + "host": "Host", + "path": "Percorso dell'applicazione", + "port": "Numero porta", + "ssl": "Utilizzare SSL", + "username": "Nome utente" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/octoprint/translations/ja.json b/homeassistant/components/octoprint/translations/ja.json new file mode 100644 index 00000000000..f38635da564 --- /dev/null +++ b/homeassistant/components/octoprint/translations/ja.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "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", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "OctoPrint Printer: {host}", + "progress": { + "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": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "path": "\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u306e\u30d1\u30b9", + "port": "\u30dd\u30fc\u30c8\u756a\u53f7", + "ssl": "SSL\u3092\u4f7f\u7528", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/octoprint/translations/pl.json b/homeassistant/components/octoprint/translations/pl.json index 4040dea0278..eb5ad0a61a1 100644 --- a/homeassistant/components/octoprint/translations/pl.json +++ b/homeassistant/components/octoprint/translations/pl.json @@ -2,6 +2,7 @@ "config": { "abort": { "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", "unknown": "Nieoczekiwany b\u0142\u0105d" }, @@ -9,11 +10,17 @@ "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "unknown": "Nieoczekiwany b\u0142\u0105d" }, + "flow_title": "Drukarka OctoPrint: {host}", + "progress": { + "get_api_key": "Otw\u00f3rz interfejs OctoPrint i kliknij \u201eZezw\u00f3l\u201d przy \u017c\u0105daniu dost\u0119pu do Home Assistanta." + }, "step": { "user": { "data": { "host": "Nazwa hosta lub adres IP", + "path": "\u015acie\u017cka aplikacji", "port": "Port", + "ssl": "U\u017cyj SSL", "username": "Nazwa u\u017cytkownika" } } diff --git a/homeassistant/components/octoprint/translations/sl.json b/homeassistant/components/octoprint/translations/sl.json new file mode 100644 index 00000000000..469e6df6535 --- /dev/null +++ b/homeassistant/components/octoprint/translations/sl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana", + "cannot_connect": "Povezava ni uspela", + "unknown": "Nepri\u010dakovana napaka" + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "port": "\u0160tevilka vrat", + "ssl": "Uporaba SSL", + "username": "Uporabni\u0161ko ime" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/octoprint/translations/tr.json b/homeassistant/components/octoprint/translations/tr.json new file mode 100644 index 00000000000..44fb5399973 --- /dev/null +++ b/homeassistant/components/octoprint/translations/tr.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "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", + "unknown": "Beklenmeyen hata" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "OctoPrint Yaz\u0131c\u0131: {host}", + "progress": { + "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": { + "user": { + "data": { + "host": "Ana bilgisayar", + "path": "Uygulama Yolu", + "port": "Ba\u011flant\u0131 Noktas\u0131 Numaras\u0131", + "ssl": "SSL Kullan", + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/omnilogic/translations/ja.json b/homeassistant/components/omnilogic/translations/ja.json new file mode 100644 index 00000000000..c8f97ac3a40 --- /dev/null +++ b/homeassistant/components/omnilogic/translations/ja.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ph_offset": "pH\u30aa\u30d5\u30bb\u30c3\u30c8(\u6b63\u307e\u305f\u306f\u8ca0)", + "polling_interval": "\u30dd\u30fc\u30ea\u30f3\u30b0\u9593\u9694(\u79d2\u5358\u4f4d)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/omnilogic/translations/tr.json b/homeassistant/components/omnilogic/translations/tr.json index ab93b71de84..73fdc1d4d86 100644 --- a/homeassistant/components/omnilogic/translations/tr.json +++ b/homeassistant/components/omnilogic/translations/tr.json @@ -16,5 +16,15 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "ph_offset": "pH ofseti (pozitif veya negatif)", + "polling_interval": "Kontrol etme aral\u0131\u011f\u0131 (saniye cinsinden)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index e383e4e32c4..6ffee319d33 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -20,14 +20,14 @@ STORAGE_VERSION = 4 class OnboadingStorage(Store): """Store onboarding data.""" - async def _async_migrate_func(self, old_version, old_data): + async def _async_migrate_func(self, old_major_version, old_minor_version, old_data): """Migrate to the new version.""" # From version 1 -> 2, we automatically mark the integration step done - if old_version < 2: + if old_major_version < 2: old_data["done"].append(STEP_INTEGRATION) - if old_version < 3: + if old_major_version < 3: old_data["done"].append(STEP_CORE_CONFIG) - if old_version < 4: + if old_major_version < 4: old_data["done"].append(STEP_ANALYTICS) return old_data @@ -50,9 +50,7 @@ def async_is_user_onboarded(hass): async def async_setup(hass, config): """Set up the onboarding component.""" store = OnboadingStorage(hass, STORAGE_VERSION, STORAGE_KEY, private=True) - data = await store.async_load() - - if data is None: + if (data := await store.async_load()) is None: data = {"done": []} if STEP_USER not in data["done"]: diff --git a/homeassistant/components/onboarding/translations/ja.json b/homeassistant/components/onboarding/translations/ja.json new file mode 100644 index 00000000000..84a3ac79e4e --- /dev/null +++ b/homeassistant/components/onboarding/translations/ja.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "\u5bdd\u5ba4", + "kitchen": "\u53f0\u6240", + "living_room": "\u30ea\u30d3\u30f3\u30b0\u30eb\u30fc\u30e0" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 61a99d345ff..44d239fdb6b 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -129,7 +129,9 @@ class UserOnboardingView(_BaseOnboardingView): provider = _async_get_hass_provider(hass) await provider.async_initialize() - user = await hass.auth.async_create_user(data["name"], [GROUP_ID_ADMIN]) + user = await hass.auth.async_create_user( + data["name"], group_ids=[GROUP_ID_ADMIN] + ) await hass.async_add_executor_job( provider.data.add_auth, data["username"], data["password"] ) diff --git a/homeassistant/components/ondilo_ico/translations/ja.json b/homeassistant/components/ondilo_ico/translations/ja.json new file mode 100644 index 00000000000..86fd09a27c3 --- /dev/null +++ b/homeassistant/components/ondilo_ico/translations/ja.json @@ -0,0 +1,16 @@ +{ + "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" + }, + "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/ondilo_ico/translations/tr.json b/homeassistant/components/ondilo_ico/translations/tr.json index 6a0084d0a96..8474bf04d5b 100644 --- a/homeassistant/components/ondilo_ico/translations/tr.json +++ b/homeassistant/components/ondilo_ico/translations/tr.json @@ -1,8 +1,15 @@ { "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." + }, + "create_entry": { + "default": "Ba\u015far\u0131yla do\u011fruland\u0131" + }, "step": { "pick_implementation": { - "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7in" + "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7" } } } diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index 5981a654820..753d30e5958 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -1,11 +1,9 @@ """The 1-Wire component.""" -import asyncio import logging from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr from .const import DOMAIN, PLATFORMS from .onewirehub import CannotConnect, OneWireHub @@ -25,34 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = onewirehub - async def cleanup_registry(onewirehub: OneWireHub) -> None: - # Get registries - device_registry = dr.async_get(hass) - # Generate list of all device entries - registry_devices = list( - dr.async_entries_for_config_entry(device_registry, entry.entry_id) - ) - # Remove devices that don't belong to any entity - for device in registry_devices: - if not onewirehub.has_device_in_cache(device): - _LOGGER.debug( - "Removing device `%s` because it is no longer available", - device.id, - ) - device_registry.async_remove_device(device.id) - - async def start_platforms(onewirehub: OneWireHub) -> None: - """Start platforms and cleanup devices.""" - # wait until all required platforms are ready - await asyncio.gather( - *( - hass.config_entries.async_forward_entry_setup(entry, platform) - for platform in PLATFORMS - ) - ) - await cleanup_registry(onewirehub) - - hass.async_create_task(start_platforms(onewirehub)) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py index 7bdba0d73d4..91f931e2517 100644 --- a/homeassistant/components/onewire/const.py +++ b/homeassistant/components/onewire/const.py @@ -1,9 +1,7 @@ """Constants for 1-Wire component.""" from __future__ import annotations -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import Platform CONF_MOUNT_DIR = "mount_dir" CONF_NAMES = "names" @@ -20,18 +18,35 @@ DOMAIN = "onewire" DEVICE_KEYS_0_7 = range(8) DEVICE_KEYS_A_B = ("A", "B") +DEVICE_SUPPORT_OWSERVER = { + "05": (), + "10": (), + "12": (), + "1D": (), + "1F": (), + "22": (), + "26": (), + "28": (), + "29": (), + "3A": (), + "3B": (), + "42": (), + "7E": ("EDS0066", "EDS0068"), + "EF": ("HB_MOISTURE_METER", "HobbyBoards_EF"), +} +DEVICE_SUPPORT_SYSBUS = ["10", "22", "28", "3B", "42"] + + MANUFACTURER_MAXIM = "Maxim Integrated" MANUFACTURER_HOBBYBOARDS = "Hobby Boards" MANUFACTURER_EDS = "Embedded Data Systems" -PRESSURE_CBAR = "cbar" - READ_MODE_BOOL = "bool" READ_MODE_FLOAT = "float" READ_MODE_INT = "int" PLATFORMS = [ - BINARY_SENSOR_DOMAIN, - SENSOR_DOMAIN, - SWITCH_DOMAIN, + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index 0d25a546941..ed032eb4fde 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -22,13 +22,14 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity import DeviceInfo from .const import ( CONF_MOUNT_DIR, CONF_TYPE_OWSERVER, CONF_TYPE_SYSBUS, + DEVICE_SUPPORT_OWSERVER, + DEVICE_SUPPORT_SYSBUS, DOMAIN, MANUFACTURER_EDS, MANUFACTURER_HOBBYBOARDS, @@ -53,6 +54,13 @@ DEVICE_MANUFACTURER = { _LOGGER = logging.getLogger(__name__) +def _is_known_owserver_device(device_family: str, device_type: str) -> bool: + """Check if device family/type is known to the library.""" + if device_family in ("7E", "EF"): # EDS or HobbyBoard + return device_type in DEVICE_SUPPORT_OWSERVER[device_family] + return device_family in DEVICE_SUPPORT_OWSERVER + + class OneWireHub: """Hub to communicate with SysBus or OWServer.""" @@ -83,10 +91,13 @@ class OneWireHub: """Initialize a config entry.""" self.type = config_entry.data[CONF_TYPE] if self.type == CONF_TYPE_SYSBUS: - await self.check_mount_dir(config_entry.data[CONF_MOUNT_DIR]) + mount_dir = config_entry.data[CONF_MOUNT_DIR] + _LOGGER.debug("Initializing using SysBus %s", mount_dir) + await self.check_mount_dir(mount_dir) elif self.type == CONF_TYPE_OWSERVER: host = config_entry.data[CONF_HOST] port = config_entry.data[CONF_PORT] + _LOGGER.debug("Initializing using OWServer %s:%s", host, port) await self.connect(host, port) await self.discover_devices() if TYPE_CHECKING: @@ -120,9 +131,23 @@ class OneWireHub: """Discover all sysbus devices.""" devices: list[OWDeviceDescription] = [] assert self.pi1proxy - for interface in self.pi1proxy.find_all_sensors(): + all_sensors = self.pi1proxy.find_all_sensors() + if not all_sensors: + _LOGGER.error( + "No onewire sensor found. Check if dtoverlay=w1-gpio " + "is in your /boot/config.txt. " + "Check the mount_dir parameter if it's defined" + ) + for interface in all_sensors: device_family = interface.mac_address[:2] device_id = f"{device_family}-{interface.mac_address[2:]}" + if device_family not in DEVICE_SUPPORT_SYSBUS: + _LOGGER.warning( + "Ignoring unknown device family (%s) found for device %s", + device_family, + device_id, + ) + continue device_info: DeviceInfo = { ATTR_IDENTIFIERS: {(DOMAIN, device_id)}, ATTR_MANUFACTURER: DEVICE_MANUFACTURER.get( @@ -149,6 +174,14 @@ class OneWireHub: device_family = self.owproxy.read(f"{device_path}family").decode() _LOGGER.debug("read `%sfamily`: %s", device_path, device_family) device_type = self._get_device_type_owserver(device_path) + if not _is_known_owserver_device(device_family, device_type): + _LOGGER.warning( + "Ignoring unknown device family/type (%s/%s) found for device %s", + device_family, + device_type, + device_id, + ) + continue device_info: DeviceInfo = { ATTR_IDENTIFIERS: {(DOMAIN, device_id)}, ATTR_MANUFACTURER: DEVICE_MANUFACTURER.get( @@ -188,16 +221,6 @@ class OneWireHub: assert isinstance(device_type, str) return device_type - def has_device_in_cache(self, device: DeviceEntry) -> bool: - """Check if device was present in the cache.""" - if TYPE_CHECKING: - assert self.devices - for internal_device in self.devices: - for identifier in internal_device.device_info[ATTR_IDENTIFIERS]: - if identifier in device.identifiers: - return True - return False - class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 7efa082d482..cbc616872ba 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -16,24 +16,18 @@ from homeassistant.components.onewire.model import ( OWServerDeviceDescription, ) from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_TYPE, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_VOLTAGE, - ELECTRIC_CURRENT_AMPERE, ELECTRIC_POTENTIAL_VOLT, LIGHT_LUX, PERCENTAGE, + PRESSURE_CBAR, PRESSURE_MBAR, TEMP_CELSIUS, ) @@ -43,12 +37,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( - CONF_MOUNT_DIR, CONF_NAMES, CONF_TYPE_OWSERVER, CONF_TYPE_SYSBUS, DOMAIN, - PRESSURE_CBAR, READ_MODE_FLOAT, READ_MODE_INT, ) @@ -67,11 +59,11 @@ class OneWireSensorEntityDescription(OneWireEntityDescription, SensorEntityDescr SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION = OneWireSensorEntityDescription( key="temperature", - device_class=DEVICE_CLASS_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ) _LOGGER = logging.getLogger(__name__) @@ -82,21 +74,21 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { "12": ( OneWireSensorEntityDescription( key="TAI8570/temperature", - device_class=DEVICE_CLASS_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="TAI8570/pressure", - device_class=DEVICE_CLASS_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, entity_registry_enabled_default=False, name="Pressure", native_unit_of_measurement=PRESSURE_MBAR, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), ), "22": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), @@ -104,93 +96,93 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION, OneWireSensorEntityDescription( key="humidity", - device_class=DEVICE_CLASS_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, name="Humidity", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="HIH3600/humidity", - device_class=DEVICE_CLASS_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, name="Humidity HIH3600", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="HIH4000/humidity", - device_class=DEVICE_CLASS_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, name="Humidity HIH4000", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="HIH5030/humidity", - device_class=DEVICE_CLASS_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, name="Humidity HIH5030", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="HTM1735/humidity", - device_class=DEVICE_CLASS_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, name="Humidity HTM1735", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="B1-R1-A/pressure", - device_class=DEVICE_CLASS_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, entity_registry_enabled_default=False, name="Pressure", native_unit_of_measurement=PRESSURE_MBAR, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="S3-R1-A/illuminance", - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=SensorDeviceClass.ILLUMINANCE, entity_registry_enabled_default=False, name="Illuminance", native_unit_of_measurement=LIGHT_LUX, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="VAD", - device_class=DEVICE_CLASS_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, name="Voltage VAD", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="VDD", - device_class=DEVICE_CLASS_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, name="Voltage VDD", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( - key="IAD", - device_class=DEVICE_CLASS_CURRENT, + key="vis", + device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, - name="Current", - native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + name="vis", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), ), "28": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), @@ -202,22 +194,18 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { name="Counter A", native_unit_of_measurement="count", read_mode=READ_MODE_INT, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, ), OneWireSensorEntityDescription( key="counter.B", name="Counter B", native_unit_of_measurement="count", read_mode=READ_MODE_INT, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, ), ), - "EF": (), # "HobbyBoard": special - "7E": (), # "EDS": special } -DEVICE_SUPPORT_SYSBUS = ["10", "22", "28", "3B", "42"] - # EF sensors are usually hobbyboards specialized sensors. # These can only be read by OWFS. Currently this driver only supports them # via owserver (network protocol) @@ -226,61 +214,61 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { "HobbyBoards_EF": ( OneWireSensorEntityDescription( key="humidity/humidity_corrected", - device_class=DEVICE_CLASS_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, name="Humidity", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="humidity/humidity_raw", - device_class=DEVICE_CLASS_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, name="Humidity Raw", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="humidity/temperature", - device_class=DEVICE_CLASS_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), ), "HB_MOISTURE_METER": ( OneWireSensorEntityDescription( key="moisture/sensor.0", - device_class=DEVICE_CLASS_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, name="Moisture 0", native_unit_of_measurement=PRESSURE_CBAR, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="moisture/sensor.1", - device_class=DEVICE_CLASS_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, name="Moisture 1", native_unit_of_measurement=PRESSURE_CBAR, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="moisture/sensor.2", - device_class=DEVICE_CLASS_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, name="Moisture 2", native_unit_of_measurement=PRESSURE_CBAR, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="moisture/sensor.3", - device_class=DEVICE_CLASS_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, name="Moisture 3", native_unit_of_measurement=PRESSURE_CBAR, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), ), } @@ -291,59 +279,61 @@ EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { "EDS0066": ( OneWireSensorEntityDescription( key="EDS0066/temperature", - device_class=DEVICE_CLASS_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="EDS0066/pressure", - device_class=DEVICE_CLASS_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, name="Pressure", native_unit_of_measurement=PRESSURE_MBAR, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), ), "EDS0068": ( OneWireSensorEntityDescription( key="EDS0068/temperature", - device_class=DEVICE_CLASS_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="EDS0068/pressure", - device_class=DEVICE_CLASS_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, name="Pressure", native_unit_of_measurement=PRESSURE_MBAR, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="EDS0068/light", - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=SensorDeviceClass.ILLUMINANCE, name="Illuminance", native_unit_of_measurement=LIGHT_LUX, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), OneWireSensorEntityDescription( key="EDS0068/humidity", - device_class=DEVICE_CLASS_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, name="Humidity", native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), ), } -def get_sensor_types(device_sub_type: str) -> dict[str, Any]: +def get_sensor_types( + device_sub_type: str, +) -> dict[str, tuple[OneWireSensorEntityDescription, ...]]: """Return the proper info array for the device type.""" if "HobbyBoard" in device_sub_type: return HOBBYBOARD_EF @@ -398,11 +388,6 @@ def get_entities( family = device_type if family not in get_sensor_types(device_sub_type): - _LOGGER.warning( - "Ignoring unknown family (%s) of sensor found for device: %s", - family, - device_id, - ) continue for description in get_sensor_types(device_sub_type)[family]: if description.key.startswith("moisture/"): @@ -414,7 +399,7 @@ def get_entities( ) if is_leaf: description = copy.deepcopy(description) - description.device_class = DEVICE_CLASS_HUMIDITY + description.device_class = SensorDeviceClass.HUMIDITY description.native_unit_of_measurement = PERCENTAGE description.name = f"Wetness {s_id}" device_file = os.path.join( @@ -434,8 +419,6 @@ def get_entities( # We have a raw GPIO ow sensor on a Pi elif conf_type == CONF_TYPE_SYSBUS: - base_dir = config[CONF_MOUNT_DIR] - _LOGGER.debug("Initializing using SysBus %s", base_dir) for device in onewirehub.devices: if TYPE_CHECKING: assert isinstance(device, OWDirectDeviceDescription) @@ -443,14 +426,6 @@ def get_entities( family = p1sensor.mac_address[:2] device_id = f"{family}-{p1sensor.mac_address[2:]}" device_info = device.device_info - if family not in DEVICE_SUPPORT_SYSBUS: - _LOGGER.warning( - "Ignoring unknown family (%s) of sensor found for device: %s", - family, - device_id, - ) - continue - description = SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION device_file = f"/sys/bus/w1/devices/{device_id}/w1_slave" name = f"{device_names.get(device_id, device_id)} {description.name}" @@ -464,12 +439,6 @@ def get_entities( owsensor=p1sensor, ) ) - if not entities: - _LOGGER.error( - "No onewire sensor found. Check if dtoverlay=w1-gpio " - "is in your /boot/config.txt. " - "Check the mount_dir parameter if it's defined" - ) return entities diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 712077c62bd..49f1ece51ca 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -11,6 +11,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -58,6 +59,15 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { for id in DEVICE_KEYS_A_B ] ), + "26": ( + OneWireSwitchEntityDescription( + key="IAD", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + name="IAD", + read_mode=READ_MODE_BOOL, + ), + ), "29": tuple( [ OneWireSwitchEntityDescription( diff --git a/homeassistant/components/onewire/translations/ja.json b/homeassistant/components/onewire/translations/ja.json new file mode 100644 index 00000000000..2aa624fbfb6 --- /dev/null +++ b/homeassistant/components/onewire/translations/ja.json @@ -0,0 +1,26 @@ +{ + "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", + "invalid_path": "\u30c7\u30a3\u30ec\u30af\u30c8\u30ea\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3002" + }, + "step": { + "owserver": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "title": "owserver\u306e\u8a73\u7d30\u8a2d\u5b9a" + }, + "user": { + "data": { + "type": "\u63a5\u7d9a\u30bf\u30a4\u30d7" + }, + "title": "1-Wire\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/onewire/translations/tr.json b/homeassistant/components/onewire/translations/tr.json index f59da2ab7e7..3d29d26fb01 100644 --- a/homeassistant/components/onewire/translations/tr.json +++ b/homeassistant/components/onewire/translations/tr.json @@ -10,14 +10,16 @@ "step": { "owserver": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "port": "Port" - } + }, + "title": "Sunucu ayr\u0131nt\u0131lar\u0131n\u0131 ayarla" }, "user": { "data": { "type": "Ba\u011flant\u0131 t\u00fcr\u00fc" - } + }, + "title": "1-Wire'\u0131 kurun" } } } diff --git a/homeassistant/components/onvif/translations/bg.json b/homeassistant/components/onvif/translations/bg.json index 6ef4c15dd8b..e45e78b79ee 100644 --- a/homeassistant/components/onvif/translations/bg.json +++ b/homeassistant/components/onvif/translations/bg.json @@ -7,7 +7,7 @@ "auth": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", - "username": "Username" + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" } }, "configure": { @@ -22,6 +22,7 @@ "manual_input": { "data": { "host": "\u0425\u043e\u0441\u0442", + "name": "\u0418\u043c\u0435", "port": "\u041f\u043e\u0440\u0442" } } diff --git a/homeassistant/components/onvif/translations/id.json b/homeassistant/components/onvif/translations/id.json index 6fcb49dcd99..77e1353270f 100644 --- a/homeassistant/components/onvif/translations/id.json +++ b/homeassistant/components/onvif/translations/id.json @@ -25,7 +25,8 @@ "password": "Kata Sandi", "port": "Port", "username": "Nama Pengguna" - } + }, + "title": "Konfigurasikan perangkat ONVIF" }, "configure_profile": { "data": { @@ -49,6 +50,9 @@ "title": "Konfigurasikan perangkat ONVIF" }, "user": { + "data": { + "auto": "Cari secara otomatis" + }, "description": "Dengan mengklik kirim, kami akan mencari perangkat ONVIF pada jaringan Anda yang mendukung Profil S.\n\nBeberapa produsen mulai menonaktifkan ONVIF secara default. Pastikan ONVIF diaktifkan dalam konfigurasi kamera Anda.", "title": "Penyiapan perangkat ONVIF" } diff --git a/homeassistant/components/onvif/translations/ja.json b/homeassistant/components/onvif/translations/ja.json new file mode 100644 index 00000000000..db36b9fee5a --- /dev/null +++ b/homeassistant/components/onvif/translations/ja.json @@ -0,0 +1,72 @@ +{ + "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_h264": "\u5229\u7528\u53ef\u80fd\u306aH264\u30b9\u30c8\u30ea\u30fc\u30e0\u304c\u3042\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002\u30c7\u30d0\u30a4\u30b9\u306e\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u8a2d\u5b9a\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "no_mac": "ONVIF\u30c7\u30d0\u30a4\u30b9\u306e\u30e6\u30cb\u30fc\u30af(\u4e00\u610f)ID\u3092\u8a2d\u5b9a\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002", + "onvif_error": "ONVIF\u30c7\u30d0\u30a4\u30b9\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u4e2d\u306b\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001\u30ed\u30b0\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "auth": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "\u8a8d\u8a3c\u306e\u8a2d\u5b9a" + }, + "configure": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "ONVIF\u30c7\u30d0\u30a4\u30b9\u306e\u8a2d\u5b9a" + }, + "configure_profile": { + "data": { + "include": "\u30ab\u30e1\u30e9\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u306e\u4f5c\u6210" + }, + "description": "{resolution} \u89e3\u50cf\u5ea6\u3067 {profile} \u306e\u30ab\u30e1\u30e9 \u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u3092\u4f5c\u6210\u3057\u307e\u3059\u304b\uff1f", + "title": "\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u306e\u8a2d\u5b9a" + }, + "device": { + "data": { + "host": "\u691c\u51fa\u3055\u308c\u305fONVIF\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e" + }, + "title": "ONVIF\u30c7\u30d0\u30a4\u30b9\u306e\u9078\u629e" + }, + "manual_input": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d", + "port": "\u30dd\u30fc\u30c8" + }, + "title": "ONVIF\u30c7\u30d0\u30a4\u30b9\u306e\u8a2d\u5b9a" + }, + "user": { + "data": { + "auto": "\u81ea\u52d5\u7684\u306b\u691c\u7d22" + }, + "description": "\u9001\u4fe1(submit)\u3092\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u3001\u30d7\u30ed\u30d5\u30a1\u30a4\u30ebS\u3092\u30b5\u30dd\u30fc\u30c8\u3059\u308bONVIF\u30c7\u30d0\u30a4\u30b9\u3092\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u3067\u691c\u7d22\u3057\u307e\u3059\u3002\n\n\u4e00\u90e8\u306e\u30e1\u30fc\u30ab\u30fc\u306f\u3001\u30c7\u30d5\u30a9\u30eb\u30c8\u306e\u8a2d\u5b9a\u304b\u3089\u3001ONVIF\u3092\u7121\u52b9\u306b\u3057\u59cb\u3081\u3066\u3044\u307e\u3059\u3002\u30ab\u30e1\u30e9\u306e\u8a2d\u5b9a\u3067ONVIF\u304c\u6709\u52b9\u306b\u306a\u3063\u3066\u3044\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "ONVIF\u30c7\u30d0\u30a4\u30b9\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "onvif_devices": { + "data": { + "extra_arguments": "\u8ffd\u52a0\u306eFFMPEG\u306e\u5f15\u6570", + "rtsp_transport": "RTSP\u30e9\u30f3\u30b9\u30dd\u30fc\u30c8\u30e1\u30ab\u30cb\u30ba\u30e0" + }, + "title": "ONVIF\u30c7\u30d0\u30a4\u30b9\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/onvif/translations/pl.json b/homeassistant/components/onvif/translations/pl.json index 0b72b824abf..f45ad4064b2 100644 --- a/homeassistant/components/onvif/translations/pl.json +++ b/homeassistant/components/onvif/translations/pl.json @@ -53,7 +53,7 @@ "data": { "auto": "Wyszukaj automatycznie" }, - "description": "Klikaj\u0105c przycisk Zatwierd\u017a, Twoja sie\u0107 zostanie przeszukana pod k\u0105tem urz\u0105dze\u0144 ONVIF obs\u0142uguj\u0105cych profil S.\n\nNiekt\u00f3rzy producenci zacz\u0119li domy\u015blnie wy\u0142\u0105cza\u0107 ONVIF. Upewnij si\u0119, \u017ce ONVIF jest w\u0142\u0105czony w konfiguracji kamery.", + "description": "Klikaj\u0105c przycisk \"Zatwierd\u017a\", Twoja sie\u0107 zostanie przeszukana pod k\u0105tem urz\u0105dze\u0144 ONVIF obs\u0142uguj\u0105cych profil S.\n\nNiekt\u00f3rzy producenci zacz\u0119li domy\u015blnie wy\u0142\u0105cza\u0107 ONVIF. Upewnij si\u0119, \u017ce ONVIF jest w\u0142\u0105czony w konfiguracji kamery.", "title": "Konfiguracja urz\u0105dzenia ONVIF" } } diff --git a/homeassistant/components/onvif/translations/tr.json b/homeassistant/components/onvif/translations/tr.json index 683dfbe7b92..559350eb0c3 100644 --- a/homeassistant/components/onvif/translations/tr.json +++ b/homeassistant/components/onvif/translations/tr.json @@ -2,7 +2,10 @@ "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" + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "no_h264": "Kullan\u0131labilir H264 ak\u0131\u015f\u0131 yok. Cihaz\u0131n\u0131zdaki profil yap\u0131land\u0131rmas\u0131n\u0131 kontrol edin.", + "no_mac": "ONVIF cihaz\u0131 i\u00e7in benzersiz kimlik yap\u0131land\u0131r\u0131lamad\u0131.", + "onvif_error": "ONVIF cihaz\u0131 ayarlan\u0131rken hata olu\u015ftu. Daha fazla bilgi i\u00e7in g\u00fcnl\u00fckleri kontrol edin." }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" @@ -12,12 +15,24 @@ "data": { "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "Kimlik do\u011frulamay\u0131 yap\u0131land\u0131r" + }, + "configure": { + "data": { + "host": "Ana bilgisayar", + "name": "Ad", + "password": "Parola", + "port": "Port", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "title": "ONVIF cihaz\u0131n\u0131 yap\u0131land\u0131r\u0131n" }, "configure_profile": { "data": { "include": "Kamera varl\u0131\u011f\u0131 olu\u015ftur" }, + "description": "{profile} i\u00e7in {resolution} \u00e7\u00f6z\u00fcn\u00fcrl\u00fckte kamera varl\u0131\u011f\u0131 olu\u015fturulsun mu?", "title": "Profilleri Yap\u0131land\u0131r" }, "device": { @@ -28,13 +43,16 @@ }, "manual_input": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "name": "Ad", "port": "Port" }, "title": "ONVIF cihaz\u0131n\u0131 yap\u0131land\u0131r\u0131n" }, "user": { + "data": { + "auto": "Otomatik olarak ara" + }, "description": "G\u00f6nder d\u00fc\u011fmesine t\u0131klad\u0131\u011f\u0131n\u0131zda, Profil S'yi destekleyen ONVIF cihazlar\u0131 i\u00e7in a\u011f\u0131n\u0131zda arama yapaca\u011f\u0131z. \n\n Baz\u0131 \u00fcreticiler varsay\u0131lan olarak ONVIF'i devre d\u0131\u015f\u0131 b\u0131rakmaya ba\u015flad\u0131. L\u00fctfen kameran\u0131z\u0131n yap\u0131land\u0131rmas\u0131nda ONVIF'in etkinle\u015ftirildi\u011finden emin olun.", "title": "ONVIF cihaz kurulumu" } diff --git a/homeassistant/components/openalpr_cloud/image_processing.py b/homeassistant/components/openalpr_cloud/image_processing.py index dedf242e0c7..c4734d4c168 100644 --- a/homeassistant/components/openalpr_cloud/image_processing.py +++ b/homeassistant/components/openalpr_cloud/image_processing.py @@ -113,7 +113,7 @@ class OpenAlprCloudEntity(ImageProcessingAlprEntity): body = {"image_bytes": str(b64encode(image), "utf-8")} try: - with async_timeout.timeout(self.timeout): + async with async_timeout.timeout(self.timeout): request = await websession.post( OPENALPR_API_URL, params=params, data=body ) diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index 047acf66bb8..9c1f51c4933 100644 --- a/homeassistant/components/opencv/manifest.json +++ b/homeassistant/components/opencv/manifest.json @@ -2,7 +2,7 @@ "domain": "opencv", "name": "OpenCV", "documentation": "https://www.home-assistant.io/integrations/opencv", - "requirements": ["numpy==1.21.2", "opencv-python-headless==4.5.2.54"], + "requirements": ["numpy==1.21.4", "opencv-python-headless==4.5.2.54"], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/opengarage/manifest.json b/homeassistant/components/opengarage/manifest.json index 929a0a0080d..cd7d0f48eec 100644 --- a/homeassistant/components/opengarage/manifest.json +++ b/homeassistant/components/opengarage/manifest.json @@ -6,7 +6,7 @@ "@danielhiversen" ], "requirements": [ - "open-garage==0.1.6" + "open-garage==0.2.0" ], "iot_class": "local_polling", "config_flow": true diff --git a/homeassistant/components/opengarage/sensor.py b/homeassistant/components/opengarage/sensor.py index ed42b5fef3d..5a409146577 100644 --- a/homeassistant/components/opengarage/sensor.py +++ b/homeassistant/components/opengarage/sensor.py @@ -4,14 +4,12 @@ from __future__ import annotations import logging from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, + SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_TEMPERATURE, ENTITY_CATEGORY_DIAGNOSTIC, LENGTH_CENTIMETERS, PERCENTAGE, @@ -29,27 +27,27 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="dist", native_unit_of_measurement=LENGTH_CENTIMETERS, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="rssi", - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="temp", - device_class=DEVICE_CLASS_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=TEMP_CELSIUS, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="humid", - device_class=DEVICE_CLASS_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), ) diff --git a/homeassistant/components/opengarage/translations/fr.json b/homeassistant/components/opengarage/translations/fr.json new file mode 100644 index 00000000000..909f8bd9eec --- /dev/null +++ b/homeassistant/components/opengarage/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "port": "Port", + "verify_ssl": "V\u00e9rifier le certificat SSL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/id.json b/homeassistant/components/opengarage/translations/id.json new file mode 100644 index 00000000000..d8cd6a0e66e --- /dev/null +++ b/homeassistant/components/opengarage/translations/id.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "device_key": "Kunci perangkat", + "host": "Host", + "port": "Port", + "verify_ssl": "Verifikasi sertifikat SSL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/translations/ja.json b/homeassistant/components/opengarage/translations/ja.json index 83f7d96e874..2c1576048bf 100644 --- a/homeassistant/components/opengarage/translations/ja.json +++ b/homeassistant/components/opengarage/translations/ja.json @@ -1,9 +1,20 @@ { "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, "step": { "user": { "data": { - "device_key": "\u30c7\u30d0\u30a4\u30b9\u30ad\u30fc" + "device_key": "\u30c7\u30d0\u30a4\u30b9\u30ad\u30fc", + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" } } } diff --git a/homeassistant/components/opengarage/translations/pl.json b/homeassistant/components/opengarage/translations/pl.json index d0905f8ed3d..14de1ee3a8e 100644 --- a/homeassistant/components/opengarage/translations/pl.json +++ b/homeassistant/components/opengarage/translations/pl.json @@ -11,6 +11,7 @@ "step": { "user": { "data": { + "device_key": "Klucz urz\u0105dzenia", "host": "Nazwa hosta lub adres IP", "port": "Port", "verify_ssl": "Weryfikacja certyfikatu SSL" diff --git a/homeassistant/components/opengarage/translations/tr.json b/homeassistant/components/opengarage/translations/tr.json index cd800abed1d..2a851ad2046 100644 --- a/homeassistant/components/opengarage/translations/tr.json +++ b/homeassistant/components/opengarage/translations/tr.json @@ -4,14 +4,17 @@ "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, "error": { - "cannot_connect": "Ba\u011flant\u0131 ba\u015far\u0131s\u0131z", - "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" }, "step": { "user": { "data": { "device_key": "Cihaz Anahtar\u0131", - "port": "Port" + "host": "Ana bilgisayar", + "port": "Port", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" } } } diff --git a/homeassistant/components/opentherm_gw/translations/ja.json b/homeassistant/components/opentherm_gw/translations/ja.json new file mode 100644 index 00000000000..fa31eced5ab --- /dev/null +++ b/homeassistant/components/opentherm_gw/translations/ja.json @@ -0,0 +1,32 @@ +{ + "config": { + "error": { + "already_configured": "\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", + "id_exists": "\u30b2\u30fc\u30c8\u30a6\u30a7\u30a4ID\u306f\u3059\u3067\u306b\u5b58\u5728\u3057\u307e\u3059" + }, + "step": { + "init": { + "data": { + "device": "\u30d1\u30b9\u307e\u305f\u306fURL", + "id": "ID", + "name": "\u540d\u524d" + }, + "title": "OpenTherm Gateway" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "floor_temperature": "\u5e8a\u6e29\u5ea6", + "read_precision": "\u7cbe\u5ea6\u3092\u8aad\u307f\u8fbc\u3080", + "set_precision": "\u7cbe\u5ea6\u3092\u8a2d\u5b9a\u3059\u308b", + "temporary_override_mode": "\u4e00\u6642\u7684\u306a\u30bb\u30c3\u30c8\u30dd\u30a4\u30f3\u30c8\u306e\u30aa\u30fc\u30d0\u30fc\u30e9\u30a4\u30c9\u30e2\u30fc\u30c9" + }, + "description": "OpenTherm Gateway\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opentherm_gw/translations/tr.json b/homeassistant/components/opentherm_gw/translations/tr.json index 507b71ede5b..a969927ebe2 100644 --- a/homeassistant/components/opentherm_gw/translations/tr.json +++ b/homeassistant/components/opentherm_gw/translations/tr.json @@ -2,15 +2,31 @@ "config": { "error": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "id_exists": "A\u011f ge\u00e7idi kimli\u011fi zaten var" }, "step": { "init": { "data": { - "device": "Yol veya URL" + "device": "Yol veya URL", + "id": "ID", + "name": "Ad" }, "title": "OpenTherm A\u011f Ge\u00e7idi" } } + }, + "options": { + "step": { + "init": { + "data": { + "floor_temperature": "Zemin S\u0131cakl\u0131\u011f\u0131", + "read_precision": "Hassas Okuma", + "set_precision": "Hassasiyeti Ayarla", + "temporary_override_mode": "Ge\u00e7ici Ayar Noktas\u0131 Ge\u00e7ersiz K\u0131lma Modu" + }, + "description": "OpenTherm A\u011f Ge\u00e7idi i\u00e7in Se\u00e7enekler" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index e38d95a6101..77755fdca21 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -9,7 +9,6 @@ from pyopenuv.errors import OpenUvError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_API_KEY, CONF_BINARY_SENSORS, CONF_ELEVATION, @@ -30,7 +29,6 @@ from homeassistant.helpers.service import verify_domain_control from .const import ( CONF_FROM_WINDOW, CONF_TO_WINDOW, - DATA_CLIENT, DATA_PROTECTION_WINDOW, DATA_UV, DEFAULT_FROM_WINDOW, @@ -51,29 +49,36 @@ PLATFORMS = ["binary_sensor", "sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up OpenUV as config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} - _verify_domain_control = verify_domain_control(hass, DOMAIN) + websession = aiohttp_client.async_get_clientsession(hass) + openuv = OpenUV( + 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, + ), + ) + + # We disable the client's request retry abilities here to avoid a lengthy (and + # blocking) startup: + openuv.client.disable_request_retries() + try: - websession = aiohttp_client.async_get_clientsession(hass) - openuv = OpenUV( - 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, - ), - ) await openuv.async_update() except OpenUvError as err: LOGGER.error("Config entry failed: %s", err) raise ConfigEntryNotReady from err - hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] = openuv + # Once we've successfully authenticated, we re-enable client request retries: + openuv.client.enable_request_retries() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = openuv + hass.config_entries.async_setup_platforms(entry, PLATFORMS) @_verify_domain_control @@ -175,7 +180,7 @@ class OpenUvEntity(Entity): def __init__(self, openuv: OpenUV, description: EntityDescription) -> None: """Initialize.""" - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._attr_extra_state_attributes = {} self._attr_should_poll = False self._attr_unique_id = ( f"{openuv.client.latitude}_{openuv.client.longitude}_{description.key}" diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index 913d844a7c3..503d82d32f2 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -9,13 +9,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import as_local, parse_datetime, utcnow from . import OpenUvEntity -from .const import ( - DATA_CLIENT, - DATA_PROTECTION_WINDOW, - DOMAIN, - LOGGER, - TYPE_PROTECTION_WINDOW, -) +from .const import DATA_PROTECTION_WINDOW, DOMAIN, LOGGER, TYPE_PROTECTION_WINDOW ATTR_PROTECTION_WINDOW_ENDING_TIME = "end_time" ATTR_PROTECTION_WINDOW_ENDING_UV = "end_uv" @@ -33,7 +27,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up an OpenUV sensor based on a config entry.""" - openuv = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + openuv = hass.data[DOMAIN][entry.entry_id] async_add_entities( [OpenUvBinarySensor(openuv, BINARY_SENSOR_DESCRIPTION_PROTECTION_WINDOW)] ) diff --git a/homeassistant/components/openuv/const.py b/homeassistant/components/openuv/const.py index 975511c7297..b03726d5749 100644 --- a/homeassistant/components/openuv/const.py +++ b/homeassistant/components/openuv/const.py @@ -7,7 +7,6 @@ LOGGER = logging.getLogger(__package__) CONF_FROM_WINDOW = "from_window" CONF_TO_WINDOW = "to_window" -DATA_CLIENT = "data_client" DATA_PROTECTION_WINDOW = "protection_window" DATA_UV = "uv" diff --git a/homeassistant/components/openuv/manifest.json b/homeassistant/components/openuv/manifest.json index 207bd307d21..6132cda2710 100644 --- a/homeassistant/components/openuv/manifest.json +++ b/homeassistant/components/openuv/manifest.json @@ -3,7 +3,7 @@ "name": "OpenUV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openuv", - "requirements": ["pyopenuv==2.2.1"], + "requirements": ["pyopenuv==2021.11.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index f1d0ba9e0b1..0660ca740ac 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -14,7 +14,6 @@ from homeassistant.util.dt import as_local, parse_datetime from . import OpenUvEntity from .const import ( - DATA_CLIENT, DATA_UV, DOMAIN, TYPE_CURRENT_OZONE_LEVEL, @@ -122,7 +121,7 @@ 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][DATA_CLIENT] + openuv = hass.data[DOMAIN][entry.entry_id] async_add_entities( [OpenUvSensor(openuv, description) for description in SENSOR_DESCRIPTIONS] ) @@ -157,8 +156,7 @@ class OpenUvSensor(OpenUvEntity, SensorEntity): self._attr_native_value = UV_LEVEL_LOW elif self.entity_description.key == TYPE_MAX_UV_INDEX: self._attr_native_value = data["uv_max"] - uv_max_time = parse_datetime(data["uv_max_time"]) - if uv_max_time: + if uv_max_time := parse_datetime(data["uv_max_time"]): self._attr_extra_state_attributes.update( {ATTR_MAX_UV_TIME: as_local(uv_max_time)} ) diff --git a/homeassistant/components/openuv/translations/bg.json b/homeassistant/components/openuv/translations/bg.json index 9541f00f7f4..1bfee97d1e4 100644 --- a/homeassistant/components/openuv/translations/bg.json +++ b/homeassistant/components/openuv/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, "error": { "invalid_api_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447" }, diff --git a/homeassistant/components/openuv/translations/id.json b/homeassistant/components/openuv/translations/id.json index 5075ec2c965..568e92d1999 100644 --- a/homeassistant/components/openuv/translations/id.json +++ b/homeassistant/components/openuv/translations/id.json @@ -17,5 +17,16 @@ "title": "Isi informasi Anda" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "Indeks UV awal untuk jendela perlindungan", + "to_window": "Indeks UV akhir untuk jendela perlindungan" + }, + "title": "Konfigurasikan OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/ja.json b/homeassistant/components/openuv/translations/ja.json index db717442b5e..feccd2b1afa 100644 --- a/homeassistant/components/openuv/translations/ja.json +++ b/homeassistant/components/openuv/translations/ja.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, "error": { "invalid_api_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc" }, @@ -7,9 +10,22 @@ "user": { "data": { "api_key": "API\u30ad\u30fc", + "elevation": "\u6a19\u9ad8(Elevation)", "latitude": "\u7def\u5ea6", "longitude": "\u7d4c\u5ea6" - } + }, + "title": "\u3042\u306a\u305f\u306e\u60c5\u5831\u3092\u5165\u529b" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "\u30d7\u30ed\u30c6\u30af\u30b7\u30e7\u30f3\u30a6\u30a3\u30f3\u30c9\u30a6\u306eUV\u30a4\u30f3\u30c7\u30c3\u30af\u30b9\u306e\u958b\u59cb", + "to_window": "\u30d7\u30ed\u30c6\u30af\u30b7\u30e7\u30f3\u30a6\u30a3\u30f3\u30c9\u30a6\u306eUV\u30a4\u30f3\u30c7\u30c3\u30af\u30b9\u306e\u7d42\u4e86" + }, + "title": "OpenUV\u306e\u8a2d\u5b9a" } } } diff --git a/homeassistant/components/openuv/translations/tr.json b/homeassistant/components/openuv/translations/tr.json index 241c588f691..d5caa40721a 100644 --- a/homeassistant/components/openuv/translations/tr.json +++ b/homeassistant/components/openuv/translations/tr.json @@ -10,9 +10,22 @@ "user": { "data": { "api_key": "API Anahtar\u0131", + "elevation": "Y\u00fckseklik", "latitude": "Enlem", "longitude": "Boylam" - } + }, + "title": "Bilgilerinizi doldurun" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "Koruma penceresi i\u00e7in ba\u015flang\u0131\u00e7 UV indeksi", + "to_window": "Koruma penceresi i\u00e7in UV indeksini sonland\u0131rma" + }, + "title": "OpenUV'yi yap\u0131land\u0131r\u0131n" } } } diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index 58219ee70b3..8fd7aaae7ad 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -1,5 +1,8 @@ """The openweathermap component.""" +from __future__ import annotations + import logging +from typing import Any from pyowm import OWM from pyowm.utils.config import get_default_config @@ -62,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_migrate_entry(hass, entry): +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" config_entries = hass.config_entries data = entry.data @@ -83,7 +86,7 @@ async def async_migrate_entry(hass, entry): return True -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry): +async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update options.""" await hass.config_entries.async_reload(entry.entry_id) @@ -99,17 +102,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -def _filter_domain_configs(elements, domain): - return list(filter(lambda elem: elem["platform"] == domain, elements)) - - -def _get_config_value(config_entry, key): +def _get_config_value(config_entry: ConfigEntry, key: str) -> Any: if config_entry.options: return config_entry.options[key] return config_entry.data[key] -def _get_owm_config(language): +def _get_owm_config(language: str) -> dict[str, Any]: """Get OpenWeatherMap configuration and add language to it.""" config_dict = get_default_config() config_dict["language"] = language diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 607f223167f..74aa767e44d 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -139,7 +139,7 @@ LANGUAGES = [ WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT = 800 CONDITION_CLASSES = { ATTR_CONDITION_CLOUDY: [803, 804], - ATTR_CONDITION_FOG: [701, 741], + ATTR_CONDITION_FOG: [701, 721, 741], ATTR_CONDITION_HAIL: [906], ATTR_CONDITION_LIGHTNING: [210, 211, 212, 221], ATTR_CONDITION_LIGHTNING_RAINY: [200, 201, 202, 230, 231, 232], @@ -153,7 +153,6 @@ CONDITION_CLASSES = { ATTR_CONDITION_WINDY_VARIANT: [958, 959, 960, 961], ATTR_CONDITION_EXCEPTIONAL: [ 711, - 721, 731, 751, 761, diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 13b282b5ef6..fd18ef32725 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -1,10 +1,22 @@ """Support for the OpenWeatherMap (OWM) service.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from datetime import datetime + +from homeassistant.components.sensor import ( + DEVICE_CLASS_TIMESTAMP, + SensorEntity, + SensorEntityDescription, +) +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.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util from .const import ( ATTR_API_FORECAST, @@ -20,7 +32,11 @@ from .const import ( from .weather_update_coordinator import WeatherUpdateCoordinator -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up OpenWeatherMap sensor entities based on a config entry.""" domain_data = hass.data[DOMAIN][config_entry.entry_id] name = domain_data[ENTRY_NAME] @@ -59,11 +75,11 @@ class AbstractOpenWeatherMapSensor(SensorEntity): def __init__( self, - name, - unique_id, + name: str, + unique_id: str, description: SensorEntityDescription, coordinator: DataUpdateCoordinator, - ): + ) -> None: """Initialize the sensor.""" self.entity_description = description self._coordinator = coordinator @@ -72,29 +88,29 @@ class AbstractOpenWeatherMapSensor(SensorEntity): self._attr_unique_id = unique_id split_unique_id = unique_id.split("-") self._attr_device_info = DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, f"{split_unique_id[0]}-{split_unique_id[1]}")}, manufacturer=MANUFACTURER, name=DEFAULT_NAME, ) @property - def attribution(self): + def attribution(self) -> str: """Return the attribution.""" return ATTRIBUTION @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._coordinator.last_update_success - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Connect to dispatcher listening for entity data notifications.""" self.async_on_remove( self._coordinator.async_add_listener(self.async_write_ha_state) ) - async def async_update(self): + async def async_update(self) -> None: """Get the latest data from OWM and updates the states.""" await self._coordinator.async_request_refresh() @@ -104,17 +120,17 @@ class OpenWeatherMapSensor(AbstractOpenWeatherMapSensor): def __init__( self, - name, - unique_id, + name: str, + unique_id: str, description: SensorEntityDescription, weather_coordinator: WeatherUpdateCoordinator, - ): + ) -> None: """Initialize the sensor.""" super().__init__(name, unique_id, description, weather_coordinator) self._weather_coordinator = weather_coordinator @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the device.""" return self._weather_coordinator.data.get(self.entity_description.key, None) @@ -124,19 +140,24 @@ class OpenWeatherMapForecastSensor(AbstractOpenWeatherMapSensor): def __init__( self, - name, - unique_id, + name: str, + unique_id: str, description: SensorEntityDescription, weather_coordinator: WeatherUpdateCoordinator, - ): + ) -> None: """Initialize the sensor.""" super().__init__(name, unique_id, description, weather_coordinator) self._weather_coordinator = weather_coordinator @property - def native_value(self): + def native_value(self) -> StateType | datetime: """Return the state of the device.""" forecasts = self._weather_coordinator.data.get(ATTR_API_FORECAST) - if forecasts is not None and len(forecasts) > 0: - return forecasts[0].get(self.entity_description.key, None) - return None + if not forecasts: + return None + + value = forecasts[0].get(self.entity_description.key, None) + if value and self.entity_description.device_class == DEVICE_CLASS_TIMESTAMP: + return dt_util.parse_datetime(value) + + return value diff --git a/homeassistant/components/openweathermap/translations/bg.json b/homeassistant/components/openweathermap/translations/bg.json index 463ddf48132..c1816ddae03 100644 --- a/homeassistant/components/openweathermap/translations/bg.json +++ b/homeassistant/components/openweathermap/translations/bg.json @@ -4,7 +4,8 @@ "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\u0445 \u043f\u0440\u0438 \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": { "user": { diff --git a/homeassistant/components/openweathermap/translations/ja.json b/homeassistant/components/openweathermap/translations/ja.json new file mode 100644 index 00000000000..1253ba5af21 --- /dev/null +++ b/homeassistant/components/openweathermap/translations/ja.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_api_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "language": "\u8a00\u8a9e", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "mode": "\u30e2\u30fc\u30c9", + "name": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u540d\u524d" + }, + "description": "OpenWeatherMap\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u3002API\u30ad\u30fc\u3092\u751f\u6210\u3059\u308b\u306b\u306f\u3001https://openweathermap.org/appid \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "OpenWeatherMap" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "language": "\u8a00\u8a9e", + "mode": "\u30e2\u30fc\u30c9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/openweathermap/translations/ru.json b/homeassistant/components/openweathermap/translations/ru.json index f113d8205f7..1a22b38d546 100644 --- a/homeassistant/components/openweathermap/translations/ru.json +++ b/homeassistant/components/openweathermap/translations/ru.json @@ -17,7 +17,7 @@ "mode": "\u0420\u0435\u0436\u0438\u043c", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 OpenWeatherMap. \u0414\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u043a\u043b\u044e\u0447\u0430 API, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043d\u0430 https://openweathermap.org/appid.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 OpenWeatherMap. \u0414\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u043a\u043b\u044e\u0447\u0430 API, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043d\u0430 https://openweathermap.org/appid.", "title": "OpenWeatherMap" } } diff --git a/homeassistant/components/openweathermap/translations/tr.json b/homeassistant/components/openweathermap/translations/tr.json index 0f845a4df73..83109869e39 100644 --- a/homeassistant/components/openweathermap/translations/tr.json +++ b/homeassistant/components/openweathermap/translations/tr.json @@ -11,10 +11,14 @@ "user": { "data": { "api_key": "API Anahtar\u0131", + "language": "Dil", "latitude": "Enlem", "longitude": "Boylam", - "mode": "Mod" - } + "mode": "Mod", + "name": "Cihaz\u0131n ad\u0131" + }, + "description": "OpenWeatherMap entegrasyonunu ayarlay\u0131n. API anahtar\u0131 olu\u015fturmak i\u00e7in https://openweathermap.org/appid adresine gidin.", + "title": "OpenWeatherMap" } } }, @@ -22,6 +26,7 @@ "step": { "init": { "data": { + "language": "Dil", "mode": "Mod" } } diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index f80566be329..aafa1a9c808 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -1,7 +1,13 @@ """Support for the OpenWeatherMap (OWM) service.""" -from homeassistant.components.weather import WeatherEntity +from __future__ import annotations + +from homeassistant.components.weather import Forecast, WeatherEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import PRESSURE_HPA, PRESSURE_INHG, TEMP_CELSIUS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.pressure import convert as pressure_convert from .const import ( @@ -22,7 +28,11 @@ from .const import ( from .weather_update_coordinator import WeatherUpdateCoordinator -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up OpenWeatherMap weather entity based on a config entry.""" domain_data = hass.data[DOMAIN][config_entry.entry_id] name = domain_data[ENTRY_NAME] @@ -39,22 +49,22 @@ class OpenWeatherMapWeather(WeatherEntity): def __init__( self, - name, - unique_id, + name: str, + unique_id: str, weather_coordinator: WeatherUpdateCoordinator, - ): + ) -> None: """Initialize the sensor.""" self._name = name self._unique_id = unique_id self._weather_coordinator = weather_coordinator @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique_id for this entity.""" return self._unique_id @@ -62,39 +72,39 @@ class OpenWeatherMapWeather(WeatherEntity): def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self._unique_id)}, manufacturer=MANUFACTURER, name=DEFAULT_NAME, ) @property - def should_poll(self): + def should_poll(self) -> bool: """Return the polling requirement of the entity.""" return False @property - def attribution(self): + def attribution(self) -> str: """Return the attribution.""" return ATTRIBUTION @property - def condition(self): + def condition(self) -> str | None: """Return the current condition.""" return self._weather_coordinator.data[ATTR_API_CONDITION] @property - def temperature(self): + def temperature(self) -> float | None: """Return the temperature.""" return self._weather_coordinator.data[ATTR_API_TEMPERATURE] @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement.""" return TEMP_CELSIUS @property - def pressure(self): + def pressure(self) -> float | None: """Return the pressure.""" pressure = self._weather_coordinator.data[ATTR_API_PRESSURE] # OpenWeatherMap returns pressure in hPA, so convert to @@ -104,12 +114,12 @@ class OpenWeatherMapWeather(WeatherEntity): return pressure @property - def humidity(self): + def humidity(self) -> float | None: """Return the humidity.""" return self._weather_coordinator.data[ATTR_API_HUMIDITY] @property - def wind_speed(self): + def wind_speed(self) -> float | None: """Return the wind speed.""" wind_speed = self._weather_coordinator.data[ATTR_API_WIND_SPEED] if self.hass.config.units.name == "imperial": @@ -117,26 +127,26 @@ class OpenWeatherMapWeather(WeatherEntity): return round(wind_speed * 3.6, 2) @property - def wind_bearing(self): + def wind_bearing(self) -> float | str | None: """Return the wind bearing.""" return self._weather_coordinator.data[ATTR_API_WIND_BEARING] @property - def forecast(self): + def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" return self._weather_coordinator.data[ATTR_API_FORECAST] @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._weather_coordinator.last_update_success - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Connect to dispatcher listening for entity data notifications.""" self.async_on_remove( self._weather_coordinator.async_add_listener(self.async_write_ha_state) ) - async def async_update(self): + async def async_update(self) -> None: """Get the latest data from OWM and updates the states.""" await self._weather_coordinator.async_request_refresh() diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index 5c2633a7a33..f4814e64d9a 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -73,7 +73,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self): data = {} - with async_timeout.timeout(20): + async with async_timeout.timeout(20): try: weather_response = await self._get_owm_weather() data = self._convert_weather_response(weather_response) diff --git a/homeassistant/components/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py index cb3fba80378..2b69ca0ade7 100644 --- a/homeassistant/components/ovo_energy/__init__.py +++ b/homeassistant/components/ovo_energy/__init__.py @@ -13,6 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( @@ -112,7 +113,7 @@ class OVOEnergyDeviceEntity(OVOEnergyEntity): def device_info(self) -> DeviceInfo: """Return device information about this OVO Energy instance.""" return DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self._client.account_id)}, manufacturer="OVO Energy", name=self._client.username, diff --git a/homeassistant/components/ovo_energy/translations/bg.json b/homeassistant/components/ovo_energy/translations/bg.json index 16c1a3f9b0e..b7636becf45 100644 --- a/homeassistant/components/ovo_energy/translations/bg.json +++ b/homeassistant/components/ovo_energy/translations/bg.json @@ -11,6 +11,12 @@ "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" + }, + "user": { + "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" + } } } } diff --git a/homeassistant/components/ovo_energy/translations/ja.json b/homeassistant/components/ovo_energy/translations/ja.json new file mode 100644 index 00000000000..c6dd8f5b0be --- /dev/null +++ b/homeassistant/components/ovo_energy/translations/ja.json @@ -0,0 +1,27 @@ +{ + "config": { + "error": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "flow_title": "{username}", + "step": { + "reauth": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "OVO Energy\u306e\u8a8d\u8a3c\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u73fe\u5728\u306e\u8a8d\u8a3c\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "OVO Energy\u306e\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u3092\u8a2d\u5b9a\u3057\u3066\u3001\u30a8\u30cd\u30eb\u30ae\u30fc\u4f7f\u7528\u91cf\u306b\u30a2\u30af\u30bb\u30b9\u3059\u308b\u3002", + "title": "OVO Energy\u306e\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u8ffd\u52a0\u3059\u308b" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ovo_energy/translations/ru.json b/homeassistant/components/ovo_energy/translations/ru.json index d0a2ffb798d..f44b0e27110 100644 --- a/homeassistant/components/ovo_energy/translations/ru.json +++ b/homeassistant/components/ovo_energy/translations/ru.json @@ -19,7 +19,7 @@ "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 OVO Energy.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 OVO Energy.", "title": "OVO Energy" } } diff --git a/homeassistant/components/ovo_energy/translations/tr.json b/homeassistant/components/ovo_energy/translations/tr.json index 714daac3253..6ff9f6a25ce 100644 --- a/homeassistant/components/ovo_energy/translations/tr.json +++ b/homeassistant/components/ovo_energy/translations/tr.json @@ -5,11 +5,11 @@ "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" }, - "flow_title": "OVO Enerji: {username}", + "flow_title": "{username}", "step": { "reauth": { "data": { - "password": "\u015eifre" + "password": "Parola" }, "description": "OVO Energy i\u00e7in kimlik do\u011frulama ba\u015far\u0131s\u0131z oldu. L\u00fctfen mevcut kimlik bilgilerinizi girin.", "title": "Yeniden kimlik do\u011frulama" @@ -18,7 +18,9 @@ "data": { "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "description": "Enerji kullan\u0131m\u0131n\u0131za eri\u015fmek i\u00e7in bir OVO Energy \u00f6rne\u011fi ayarlay\u0131n.", + "title": "OVO Enerji Hesab\u0131 Ekle" } } } diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 7ba9346013f..ca6f4f4a343 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -130,9 +130,7 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity): if self._data: return - state = await self.async_get_last_state() - - if state is None: + if (state := await self.async_get_last_state()) is None: return attr = state.attributes diff --git a/homeassistant/components/owntracks/translations/de.json b/homeassistant/components/owntracks/translations/de.json index 891f914f8a9..737b92c642a 100644 --- a/homeassistant/components/owntracks/translations/de.json +++ b/homeassistant/components/owntracks/translations/de.json @@ -4,7 +4,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 -> Verbindung. \u00c4ndere die folgenden Einstellungen:\n - Modus: Privat HTTP\n - Host: {webhook_url}\n - Identifikation:\n - Benutzername: `''`\n - Ger\u00e4te-ID: `''`\n\nUnter iOS \u00f6ffne [die OwnTracks App]({ios_url}), tippe auf das (i)-Symbol oben links -> Einstellungen. \u00c4ndere die folgenden Einstellungen:\n - Modus: HTTP\n - URL: {webhook_url}\n - Authentifizierung einschalten\n - UserID: `''`\n\n{secret}\n\nWeitere Informationen findest du in [der Dokumentation]({docs_url})." + "default": "\n\nUnter Android \u00f6ffne [die OwnTracks App]({android_url}), gehe zu Einstellungen -> Verbindung. \u00c4ndere die folgenden Einstellungen:\n - Modus: Privat HTTP\n - Host: {webhook_url}\n - Identifikation:\n - Benutzername: `''`\n - Ger\u00e4te-ID: `''`\n\nUnter iOS \u00f6ffne [die OwnTracks App]({ios_url}), tippe auf das (i)-Symbol oben links -> Einstellungen. \u00c4ndere die folgenden Einstellungen:\n - Modus: HTTP\n - URL: {webhook_url}\n - Authentifizierung einschalten\n - UserID: `''`\n\n{secret}\n\nWeitere Informationen findest du in [der Dokumentation]({docs_url})." }, "step": { "user": { diff --git a/homeassistant/components/owntracks/translations/fr.json b/homeassistant/components/owntracks/translations/fr.json index 9120cdb8637..35530bd2c86 100644 --- a/homeassistant/components/owntracks/translations/fr.json +++ b/homeassistant/components/owntracks/translations/fr.json @@ -4,7 +4,7 @@ "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "create_entry": { - "default": "\n\n Sous Android, ouvrez [l'application OwnTracks]({android_url}), acc\u00e9dez \u00e0 Pr\u00e9f\u00e9rences - > Connexion. Modifiez les param\u00e8tres suivants: \n - Mode: HTTP priv\u00e9 \n - H\u00f4te: {webhook_url} \n - Identification: \n - Nom d'utilisateur: `''` \n - ID de p\u00e9riph\u00e9rique: `''` \n\n Sur iOS, ouvrez [l'application OwnTracks]({ios_url}), appuyez sur l'ic\u00f4ne (i) en haut \u00e0 gauche - > param\u00e8tres. Modifiez les param\u00e8tres suivants: \n - Mode: HTTP \n - URL: {webhook_url} \n - Activer l'authentification \n - ID utilisateur: `''` \n\n {secret} \n \n Voir [la documentation]({docs_url}) pour plus d'informations." + "default": "\n\nSous Android, ouvrez [l'application OwnTracks]({android_url}), acc\u00e9dez \u00e0 Pr\u00e9f\u00e9rences - > Connexion. Modifiez les param\u00e8tres suivants: \n - Mode: HTTP priv\u00e9 \n - H\u00f4te: {webhook_url} \n - Identification: \n - Nom d'utilisateur: `''` \n - ID de p\u00e9riph\u00e9rique: `''` \n\n Sur iOS, ouvrez [l'application OwnTracks]({ios_url}), appuyez sur l'ic\u00f4ne (i) en haut \u00e0 gauche - > param\u00e8tres. Modifiez les param\u00e8tres suivants: \n - Mode: HTTP \n - URL: {webhook_url} \n - Activer l'authentification \n - ID utilisateur: `''` \n\n {secret} \n \n Voir [la documentation]({docs_url}) pour plus d'informations." }, "step": { "user": { diff --git a/homeassistant/components/owntracks/translations/ja.json b/homeassistant/components/owntracks/translations/ja.json new file mode 100644 index 00000000000..faec1e6977b --- /dev/null +++ b/homeassistant/components/owntracks/translations/ja.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "create_entry": { + "default": "\n\nAndroid\u306e\u5834\u5408\u3001[OwnTracks app]({android_url})\u3092\u958b\u304d\u3001\u74b0\u5883\u8a2d\u5b9a -> \u63a5\u7d9a \u306b\u79fb\u52d5\u3057\u3066\u3001\u6b21\u306e\u8a2d\u5b9a\u3092\u5909\u66f4\u3057\u307e\u3059:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification(\u8b58\u5225\u60c5\u5831):\n - Username: `''`\n - Device ID: `''`\n\nOS\u306e\u5834\u5408\u3001[OwnTracks app]({ios_url})\u3092\u958b\u304d\u3001\u5de6\u4e0a\u306e(i)\u30a2\u30a4\u30b3\u30f3\u3092\u30bf\u30c3\u30d7\u3057\u3066 -> \u8a2d\u5b9a\u3002\u6b21\u306e\u8a2d\u5b9a\u3092\u5909\u66f4\u3057\u307e\u3059:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication(\u8a8d\u8a3c\u3092\u30aa\u30f3\u306b\u3059\u308b)\n - UserID: `''`\n\n{secret}\n\n\u8a73\u7d30\u306f[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8]({docs_url})\u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "user": { + "description": "OwnTracks\u3092\u8a2d\u5b9a\u3057\u3066\u3082\u3088\u308d\u3057\u3044\u3067\u3059\u304b\uff1f", + "title": "OwnTracks\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/translations/nl.json b/homeassistant/components/owntracks/translations/nl.json index ff6cadcbf25..65189e6b0be 100644 --- a/homeassistant/components/owntracks/translations/nl.json +++ b/homeassistant/components/owntracks/translations/nl.json @@ -4,7 +4,7 @@ "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk." }, "create_entry": { - "default": "\n\nOp Android, open [the OwnTracks app]({android_url}), ga naar 'preferences' -> 'connection'. Verander de volgende instellingen:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: `''`\n - Device ID: `''`\n\nOp iOS, open [the OwnTracks app]({ios_url}), tik op het (i) icoon links boven -> 'settings'. Verander de volgende instellingen:\n - Mode: HTTP\n - URL: {webhook_url}\n - zet 'authentication' aan\n - UserID: `''`\n\n{secret}\n\nZie [the documentation]({docs_url}) voor meer informatie." + "default": "\n\nOp Android, open [the OwnTracks app]({android_url}), ga naar 'preferences' -> 'connection'. Verander de volgende instellingen:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification':\n - Username: `''`\n - Device ID: `''`\n\nOp iOS, open [the OwnTracks app]({ios_url}), tik op het (i) icoon links boven -> 'settings'. Verander de volgende instellingen:\n - Mode: HTTP\n - URL: {webhook_url}\n - zet 'authentication' aan\n - UserID: `''`\n\n{secret}\n\nZie [the documentation]({docs_url}) voor meer informatie." }, "step": { "user": { diff --git a/homeassistant/components/owntracks/translations/pl.json b/homeassistant/components/owntracks/translations/pl.json index 98c8779fe1f..09bd29b99f2 100644 --- a/homeassistant/components/owntracks/translations/pl.json +++ b/homeassistant/components/owntracks/translations/pl.json @@ -4,7 +4,7 @@ "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." }, "create_entry": { - "default": "\n\nNa Androidzie, otw\u00f3rz [aplikacj\u0119 OwnTracks]({android_url}), id\u017a do: ustawienia -> po\u0142\u0105czenia. Zmie\u0144 nast\u0119puj\u0105ce ustawienia:\n - Tryb: Private HTTP\n - Host: {webhook_url}\n - Identyfikacja:\n - Nazwa u\u017cytkownika: `''`\n - ID urz\u0105dzenia: `''`\n\nNa iOS, otw\u00f3rz [aplikacj\u0119 OwnTracks]({ios_url}), naci\u015bnij ikon\u0119 (i) w lewym g\u00f3rnym rogu -> ustawienia. Zmie\u0144 nast\u0119puj\u0105ce ustawienia:\n - Tryb: HTTP\n - URL: {webhook_url}\n - W\u0142\u0105cz uwierzytelnianie\n - ID u\u017cytkownika: `''`\n\n{secret}\n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}), by pozna\u0107 szczeg\u00f3\u0142y." + "default": "\n\nNa Androidzie, otw\u00f3rz [aplikacj\u0119 OwnTracks]({android_url}), id\u017a do: ustawienia -> po\u0142\u0105czenia. Zmie\u0144 nast\u0119puj\u0105ce ustawienia:\n - Tryb: Private HTTP\n - Host: {webhook_url}\n - Identyfikacja:\n - Nazwa u\u017cytkow'nika: `''`\n - ID urz\u0105dzenia: `''`\n\nNa iOS, otw\u00f3rz [aplikacj\u0119 OwnTracks]({ios_url}), naci\u015bnij ikon\u0119 (i) w lewym g\u00f3rnym rogu -> ustawienia. Zmie\u0144 nast\u0119puj\u0105ce ustawienia:\n - Tryb: HTTP\n - URL: {webhook_url}\n - W\u0142\u0105cz uwierzytelnianie\n - ID u\u017cytkownika: `''`\n\n{secret}\n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}), by pozna\u0107 szczeg\u00f3\u0142y." }, "step": { "user": { diff --git a/homeassistant/components/owntracks/translations/ru.json b/homeassistant/components/owntracks/translations/ru.json index ed1e084090d..09fdba77266 100644 --- a/homeassistant/components/owntracks/translations/ru.json +++ b/homeassistant/components/owntracks/translations/ru.json @@ -4,7 +4,7 @@ "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": "\u0415\u0441\u043b\u0438 \u0412\u0430\u0448\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u043d\u0430 \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0435 Android, \u043e\u0442\u043a\u0440\u043e\u0439\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 [OwnTracks]({android_url}), \u0437\u0430\u0442\u0435\u043c preferences -> connection. \u0418\u0437\u043c\u0435\u043d\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0442\u0430\u043a, \u043a\u0430\u043a \u0443\u043a\u0430\u0437\u0430\u043d\u043e \u043d\u0438\u0436\u0435:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: `''`\n - Device ID: `''`\n\n\u0415\u0441\u043b\u0438 \u0412\u0430\u0448\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u043d\u0430 iOS, \u043e\u0442\u043a\u0440\u043e\u0439\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 [OwnTracks]({ios_url}), \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u043d\u0430 \u0437\u043d\u0430\u0447\u043e\u043a (i) \u0432 \u043b\u0435\u0432\u043e\u043c \u0432\u0435\u0440\u0445\u043d\u0435\u043c \u0443\u0433\u043b\u0443 -> settings. \u0418\u0437\u043c\u0435\u043d\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0442\u0430\u043a, \u043a\u0430\u043a \u0443\u043a\u0430\u0437\u0430\u043d\u043e \u043d\u0438\u0436\u0435:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: `''`\n\n{secret}\n\n\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 \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438." + "default": "\u0415\u0441\u043b\u0438 \u0412\u0430\u0448\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u043d\u0430 \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0435 Android, \u043e\u0442\u043a\u0440\u043e\u0439\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 [OwnTracks]({android_url}), \u0437\u0430\u0442\u0435\u043c preferences -> connection. \u0418\u0437\u043c\u0435\u043d\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0442\u0430\u043a, \u043a\u0430\u043a \u0443\u043a\u0430\u0437\u0430\u043d\u043e \u043d\u0438\u0436\u0435:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\n\u0415\u0441\u043b\u0438 \u0412\u0430\u0448\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u043d\u0430 iOS, \u043e\u0442\u043a\u0440\u043e\u0439\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 [OwnTracks]({ios_url}), \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u043d\u0430 \u0437\u043d\u0430\u0447\u043e\u043a (i) \u0432 \u043b\u0435\u0432\u043e\u043c \u0432\u0435\u0440\u0445\u043d\u0435\u043c \u0443\u0433\u043b\u0443 -> settings. \u0418\u0437\u043c\u0435\u043d\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0442\u0430\u043a, \u043a\u0430\u043a \u0443\u043a\u0430\u0437\u0430\u043d\u043e \u043d\u0438\u0436\u0435:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\n\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 \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438." }, "step": { "user": { diff --git a/homeassistant/components/owntracks/translations/sl.json b/homeassistant/components/owntracks/translations/sl.json index 9c75e9a9537..f038345498e 100644 --- a/homeassistant/components/owntracks/translations/sl.json +++ b/homeassistant/components/owntracks/translations/sl.json @@ -1,7 +1,7 @@ { "config": { "create_entry": { - "default": "\n\n V Androidu odprite aplikacijo OwnTracks ( {android_url} ) in pojdite na {android_url} nastavitve - > povezave. Spremenite naslednje nastavitve: \n - Na\u010din: zasebni HTTP \n - gostitelj: {webhook_url} \n - Identifikacija: \n - Uporabni\u0161ko ime: ` ` \n - ID naprave: ` ` \n\n V iOS-ju odprite aplikacijo OwnTracks ( {ios_url} ), tapnite ikono (i) v zgornjem levem kotu - > nastavitve. Spremenite naslednje nastavitve: \n - na\u010din: HTTP \n - URL: {webhook_url} \n - Vklopite preverjanje pristnosti \n - UserID: ` ` \n\n {secret} \n \n Za ve\u010d informacij si oglejte [dokumentacijo] ( {docs_url} )." + "default": "\n\nV Androidu odprite aplikacijo OwnTracks ({android_url}) in pojdite na {android_url} nastavitve - > povezave. Spremenite naslednje nastavitve: \n - Na\u010din: zasebni HTTP \n - gostitelj: {webhook_url} \n - Identifikacija: \n - Uporabni\u0161ko ime: `''` \n - ID naprave: `''` \n\n V iOS-ju odprite aplikacijo OwnTracks ({ios_url}), tapnite ikono (i) v zgornjem levem kotu - > nastavitve. Spremenite naslednje nastavitve: \n - na\u010din: HTTP \n - URL: {webhook_url} \n - Vklopite preverjanje pristnosti \n - UserID: `''` \n\n {secret} \n \n Za ve\u010d informacij si oglejte [dokumentacijo] ({docs_url})." }, "step": { "user": { diff --git a/homeassistant/components/owntracks/translations/sv.json b/homeassistant/components/owntracks/translations/sv.json index 8642a32f889..fd2162f153b 100644 --- a/homeassistant/components/owntracks/translations/sv.json +++ b/homeassistant/components/owntracks/translations/sv.json @@ -1,7 +1,7 @@ { "config": { "create_entry": { - "default": "\n\n P\u00e5 Android, \u00f6ppna [OwnTracks-appen]({android_url}), g\u00e5 till inst\u00e4llningar -> anslutning. \u00c4ndra f\u00f6ljande inst\u00e4llningar: \n - L\u00e4ge: Privat HTTP \n - V\u00e4rden: {webhook_url}\n - Identifiering: \n - Anv\u00e4ndarnamn: `''`\n - Enhets-ID: `''` \n\n P\u00e5 IOS, \u00f6ppna [OwnTracks-appen]({ios_url}), tryck p\u00e5 (i) ikonen i \u00f6vre v\u00e4nstra h\u00f6rnet -> inst\u00e4llningarna. \u00c4ndra f\u00f6ljande inst\u00e4llningar: \n - L\u00e4ge: HTTP \n - URL: {webhook_url}\n - Sl\u00e5 p\u00e5 autentisering \n - UserID: `''` \n\n {secret} \n \n Se [dokumentationen]({docs_url}) f\u00f6r mer information." + "default": "\n\n P\u00e5 Android, \u00f6ppna [OwnTracks-appen]({android_url}), g\u00e5 till inst\u00e4llningar -> anslutning. \u00c4ndra f\u00f6ljande inst\u00e4llningar: \n - L\u00e4ge: Privat HTTP \n - V\u00e4rden: {webhook_url}\n - Identifiering: \n - Anv\u00e4ndarnamn: ``\n - Enhets-ID: `` \n\n P\u00e5 IOS, \u00f6ppna [OwnTracks-appen]({ios_url}), tryck p\u00e5 (i) ikonen i \u00f6vre v\u00e4nstra h\u00f6rnet -> inst\u00e4llningarna. \u00c4ndra f\u00f6ljande inst\u00e4llningar: \n - L\u00e4ge: HTTP \n - URL: {webhook_url}\n - Sl\u00e5 p\u00e5 autentisering \n - UserID: `` \n\n {secret} \n \n Se [dokumentationen]({docs_url}) f\u00f6r mer information." }, "step": { "user": { diff --git a/homeassistant/components/owntracks/translations/tr.json b/homeassistant/components/owntracks/translations/tr.json index a152eb19468..944cd176580 100644 --- a/homeassistant/components/owntracks/translations/tr.json +++ b/homeassistant/components/owntracks/translations/tr.json @@ -2,6 +2,15 @@ "config": { "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + }, + "create_entry": { + "default": "\n\n Android'de [OwnTracks uygulamas\u0131n\u0131]( {android_url} ) a\u00e7\u0131n, tercihler - > ba\u011flant\u0131'ya gidin. A\u015fa\u011f\u0131daki ayarlar\u0131 de\u011fi\u015ftirin:\n - Mod: \u00d6zel HTTP\n - Ana makine: {webhook_url}\n - Kimlik:\n - Kullan\u0131c\u0131 ad\u0131: `' ''\n - Cihaz Kimli\u011fi: `' '` \n\n iOS'ta [OwnTracks uygulamas\u0131n\u0131]( {ios_url} ) a\u00e7\u0131n, sol \u00fcstteki (i) simgesine - > ayarlara dokunun. A\u015fa\u011f\u0131daki ayarlar\u0131 de\u011fi\u015ftirin:\n - Mod: HTTP\n - URL: {webhook_url}\n - Kimlik do\u011frulamay\u0131 a\u00e7\n - Kullan\u0131c\u0131 Kimli\u011fi: `' '' \n\n {secret}\n\n Daha fazla bilgi i\u00e7in [belgelere]( {docs_url}" + }, + "step": { + "user": { + "description": "OwnTracks'i kurmak istedi\u011finizden emin misiniz?", + "title": "OwnTracks'i kurun" + } } } } \ No newline at end of file diff --git a/homeassistant/components/owntracks/translations/uk.json b/homeassistant/components/owntracks/translations/uk.json index c355b745d1d..e6a6fc26068 100644 --- a/homeassistant/components/owntracks/translations/uk.json +++ b/homeassistant/components/owntracks/translations/uk.json @@ -4,7 +4,7 @@ "single_instance_allowed": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u0432\u0436\u0435 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e. \u041c\u043e\u0436\u043d\u0430 \u0434\u043e\u0434\u0430\u0442\u0438 \u043b\u0438\u0448\u0435 \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0456\u0433\u0443\u0440\u0430\u0446\u0456\u044e." }, "create_entry": { - "default": "\u042f\u043a\u0449\u043e \u0412\u0430\u0448 \u043f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u043f\u0440\u0430\u0446\u044e\u0454 \u043d\u0430 \u043e\u043f\u0435\u0440\u0430\u0446\u0456\u0439\u043d\u0456\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0456 Android, \u0432\u0456\u0434\u043a\u0440\u0438\u0439\u0442\u0435 \u0434\u043e\u0434\u0430\u0442\u043e\u043a [OwnTracks]({android_url}), \u043f\u043e\u0442\u0456\u043c preferences - > connection. \u0417\u043c\u0456\u043d\u0456\u0442\u044c \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0438 \u0442\u0430\u043a, \u044f\u043a \u0437\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u043e \u043d\u0438\u0436\u0447\u0435:\n- Mode: Private HTTP\n- Host: {webhook_url}\n- Identification:\n- Username: `''`\n- Device ID: `''` \n\n\u042f\u043a\u0449\u043e \u0412\u0430\u0448 \u043f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u043f\u0440\u0430\u0446\u044e\u0454 \u043d\u0430 iOS, \u0432\u0456\u0434\u043a\u0440\u0438\u0439\u0442\u0435 \u0434\u043e\u0434\u0430\u0442\u043e\u043a [OwnTracks]({ios_url}), \u043d\u0430\u0442\u0438\u0441\u043d\u0456\u0442\u044c \u043d\u0430 \u0437\u043d\u0430\u0447\u043e\u043a (i) \u0432 \u043b\u0456\u0432\u043e\u043c\u0443 \u0432\u0435\u0440\u0445\u043d\u044c\u043e\u043c\u0443 \u043a\u0443\u0442\u043a\u0443 - > settings. \u0417\u043c\u0456\u043d\u0456\u0442\u044c \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0438 \u0442\u0430\u043a, \u044f\u043a \u0437\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u043e \u043d\u0438\u0436\u0447\u0435:\n- Mode: HTTP\n- URL: {webhook_url}\n- Turn on authentication\n- UserID: `''`\n\n{secret}\n\n\u041e\u0437\u043d\u0430\u0439\u043e\u043c\u0442\u0435\u0441\u044f \u0437 [\u0456\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0456\u044f\u043c\u0438]({docs_url}) \u0434\u043b\u044f \u043e\u0442\u0440\u0438\u043c\u0430\u043d\u043d\u044f \u0431\u0456\u043b\u044c\u0448 \u0434\u043e\u043a\u043b\u0430\u0434\u043d\u043e\u0457 \u0456\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0456\u0457." + "default": "\u042f\u043a\u0449\u043e \u0412\u0430\u0448 \u043f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u043f\u0440\u0430\u0446\u044e\u0454 \u043d\u0430 \u043e\u043f\u0435\u0440\u0430\u0446\u0456\u0439\u043d\u0456\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0456 Android, \u0432\u0456\u0434\u043a\u0440\u0438\u0439\u0442\u0435 \u0434\u043e\u0434\u0430\u0442\u043e\u043a [OwnTracks]({android_url}), \u043f\u043e\u0442\u0456\u043c preferences - > connection. \u0417\u043c\u0456\u043d\u0456\u0442\u044c \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0438 \u0442\u0430\u043a, \u044f\u043a \u0437\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u043e \u043d\u0438\u0436\u0447\u0435:\n- Mode: Private HTTP\n- Host: {webhook_url}\n- Identification:\n- Username: ``\n- Device ID: `` \n\n\u042f\u043a\u0449\u043e \u0412\u0430\u0448 \u043f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u043f\u0440\u0430\u0446\u044e\u0454 \u043d\u0430 iOS, \u0432\u0456\u0434\u043a\u0440\u0438\u0439\u0442\u0435 \u0434\u043e\u0434\u0430\u0442\u043e\u043a [OwnTracks]({ios_url}), \u043d\u0430\u0442\u0438\u0441\u043d\u0456\u0442\u044c \u043d\u0430 \u0437\u043d\u0430\u0447\u043e\u043a (i) \u0432 \u043b\u0456\u0432\u043e\u043c\u0443 \u0432\u0435\u0440\u0445\u043d\u044c\u043e\u043c\u0443 \u043a\u0443\u0442\u043a\u0443 - > settings. \u0417\u043c\u0456\u043d\u0456\u0442\u044c \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0438 \u0442\u0430\u043a, \u044f\u043a \u0437\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u043e \u043d\u0438\u0436\u0447\u0435:\n- Mode: HTTP\n- URL: {webhook_url}\n- Turn on authentication\n- UserID: ``\n\n{secret}\n\n\u041e\u0437\u043d\u0430\u0439\u043e\u043c\u0442\u0435\u0441\u044f \u0437 [\u0456\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0456\u044f\u043c\u0438]({docs_url}) \u0434\u043b\u044f \u043e\u0442\u0440\u0438\u043c\u0430\u043d\u043d\u044f \u0431\u0456\u043b\u044c\u0448 \u0434\u043e\u043a\u043b\u0430\u0434\u043d\u043e\u0457 \u0456\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0456\u0457." }, "step": { "user": { diff --git a/homeassistant/components/ozw/__init__.py b/homeassistant/components/ozw/__init__.py index 238e7dcd8cd..04c7c3854bc 100644 --- a/homeassistant/components/ozw/__init__.py +++ b/homeassistant/components/ozw/__init__.py @@ -106,7 +106,7 @@ async def async_setup_entry( # noqa: C901 _LOGGER.error("MQTT integration is not set up") return - mqtt.async_publish(hass, topic, json.dumps(payload)) + hass.async_create_task(mqtt.async_publish(hass, topic, json.dumps(payload))) manager_options["send_message"] = send_message diff --git a/homeassistant/components/ozw/config_flow.py b/homeassistant/components/ozw/config_flow.py index c1dbbe2e093..e39a3e2ba89 100644 --- a/homeassistant/components/ozw/config_flow.py +++ b/homeassistant/components/ozw/config_flow.py @@ -4,8 +4,9 @@ import logging import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.hassio import HassioServiceInfo from homeassistant.core import callback -from homeassistant.data_entry_flow import AbortFlow +from homeassistant.data_entry_flow import AbortFlow, FlowResult from .const import CONF_INTEGRATION_CREATED_ADDON, CONF_USE_ADDON, DOMAIN @@ -48,7 +49,7 @@ class DomainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_on_supervisor() - async def async_step_hassio(self, discovery_info): + async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Receive configuration from add-on discovery info. This flow is triggered by the OpenZWave add-on. diff --git a/homeassistant/components/ozw/translations/ja.json b/homeassistant/components/ozw/translations/ja.json new file mode 100644 index 00000000000..d3ef9f7d17f --- /dev/null +++ b/homeassistant/components/ozw/translations/ja.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "addon_info_failed": "OpenZWave\u306e\u30a2\u30c9\u30aa\u30f3\u60c5\u5831\u306e\u53d6\u5f97\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "addon_install_failed": "OpenZWave\u30a2\u30c9\u30aa\u30f3\u306e\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "addon_set_config_failed": "OpenZWave\u306e\u8a2d\u5b9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "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", + "mqtt_required": "MQTT\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "addon_start_failed": "OpenZWave\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" + }, + "progress": { + "install_addon": "OpenZWave\u30a2\u30c9\u30aa\u30f3\u306e\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u304c\u5b8c\u4e86\u3059\u308b\u307e\u3067\u304a\u5f85\u3061\u304f\u3060\u3055\u3044\u3002\u3053\u308c\u306b\u306f\u6570\u5206\u304b\u304b\u308b\u5834\u5408\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "step": { + "hassio_confirm": { + "title": "OpenZWave\u30a2\u30c9\u30aa\u30f3\u3068OpenZWave\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + }, + "install_addon": { + "title": "OpenZWave\u30a2\u30c9\u30aa\u30f3\u306e\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u304c\u958b\u59cb\u3055\u308c\u307e\u3057\u305f" + }, + "on_supervisor": { + "data": { + "use_addon": "OpenZWave Supervisor\u30a2\u30c9\u30aa\u30f3\u3092\u4f7f\u7528\u3059\u308b" + }, + "description": "OpenZWave Supervisor\u30a2\u30c9\u30aa\u30f3\u3092\u4f7f\u7528\u3057\u307e\u3059\u304b\uff1f", + "title": "\u63a5\u7d9a\u65b9\u6cd5\u306e\u9078\u629e" + }, + "start_addon": { + "data": { + "network_key": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30ad\u30fc", + "usb_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "title": "OpenZWave\u30a2\u30c9\u30aa\u30f3\u306e\u8a2d\u5b9a\u3092\u5165\u529b\u3059\u308b" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ozw/translations/tr.json b/homeassistant/components/ozw/translations/tr.json index 99eda8b8311..e9e8643fa94 100644 --- a/homeassistant/components/ozw/translations/tr.json +++ b/homeassistant/components/ozw/translations/tr.json @@ -9,10 +9,16 @@ "mqtt_required": "MQTT entegrasyonu kurulmam\u0131\u015f", "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, + "error": { + "addon_start_failed": "OpenZWave eklentisi ba\u015flat\u0131lamad\u0131. Yap\u0131land\u0131rmay\u0131 kontrol edin." + }, "progress": { "install_addon": "OpenZWave eklenti kurulumu bitene kadar l\u00fctfen bekleyin. Bu birka\u00e7 dakika s\u00fcrebilir." }, "step": { + "hassio_confirm": { + "title": "OpenZWave eklentisi ile OpenZWave entegrasyonunu kurun" + }, "install_addon": { "title": "OpenZWave eklenti kurulumu ba\u015flad\u0131" }, @@ -25,8 +31,10 @@ }, "start_addon": { "data": { - "network_key": "A\u011f Anahtar\u0131" - } + "network_key": "A\u011f Anahtar\u0131", + "usb_path": "USB Cihaz Yolu" + }, + "title": "OpenZWave eklenti yap\u0131land\u0131rmas\u0131n\u0131 girin" } } } diff --git a/homeassistant/components/p1_monitor/const.py b/homeassistant/components/p1_monitor/const.py index 79b53eeed9e..d72927a80f6 100644 --- a/homeassistant/components/p1_monitor/const.py +++ b/homeassistant/components/p1_monitor/const.py @@ -9,8 +9,6 @@ DOMAIN: Final = "p1_monitor" LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(seconds=5) -ENTRY_TYPE_SERVICE: Final = "service" - SERVICE_SMARTMETER: Final = "smartmeter" SERVICE_PHASES: Final = "phases" SERVICE_SETTINGS: Final = "settings" diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py index e3cebf94a68..1b0eedc7554 100644 --- a/homeassistant/components/p1_monitor/sensor.py +++ b/homeassistant/components/p1_monitor/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_HOST, CURRENCY_EURO, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, @@ -25,6 +26,7 @@ from homeassistant.const import ( VOLUME_CUBIC_METERS, ) 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.typing import StateType @@ -33,7 +35,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import P1MonitorDataUpdateCoordinator from .const import ( DOMAIN, - ENTRY_TYPE_SERVICE, SERVICE_PHASES, SERVICE_SETTINGS, SERVICE_SMARTMETER, @@ -264,10 +265,11 @@ class P1MonitorSensorEntity(CoordinatorEntity, SensorEntity): ) self._attr_device_info = DeviceInfo( - entry_type=ENTRY_TYPE_SERVICE, + entry_type=DeviceEntryType.SERVICE, identifiers={ (DOMAIN, f"{coordinator.config_entry.entry_id}_{service_key}") }, + configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}", manufacturer="P1 Monitor", name=service, ) diff --git a/homeassistant/components/p1_monitor/translations/id.json b/homeassistant/components/p1_monitor/translations/id.json index 8c96f3ee6cb..a1241576b2e 100644 --- a/homeassistant/components/p1_monitor/translations/id.json +++ b/homeassistant/components/p1_monitor/translations/id.json @@ -9,7 +9,8 @@ "data": { "host": "Host", "name": "Nama" - } + }, + "description": "Siapkan Monitor P1 untuk diintegrasikan dengan Home Assistant." } } } diff --git a/homeassistant/components/p1_monitor/translations/ja.json b/homeassistant/components/p1_monitor/translations/ja.json new file mode 100644 index 00000000000..b47610f27e3 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/ja.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d" + }, + "description": "P1 Monitor\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3001Home Assistant\u3068\u9023\u643a\u3059\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/ru.json b/homeassistant/components/p1_monitor/translations/ru.json index 661cc1c8968..78277de42c1 100644 --- a/homeassistant/components/p1_monitor/translations/ru.json +++ b/homeassistant/components/p1_monitor/translations/ru.json @@ -10,7 +10,7 @@ "host": "\u0425\u043e\u0441\u0442", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 P1 Monitor." + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 P1 Monitor." } } } diff --git a/homeassistant/components/p1_monitor/translations/tr.json b/homeassistant/components/p1_monitor/translations/tr.json new file mode 100644 index 00000000000..f445e82b585 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/tr.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "host": "Ana bilgisayar", + "name": "Ad" + }, + "description": "Home Assistant ile entegre etmek i\u00e7in P1 Monitor'\u00fc kurun." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/panasonic_viera/translations/ja.json b/homeassistant/components/panasonic_viera/translations/ja.json new file mode 100644 index 00000000000..562e42217f8 --- /dev/null +++ b/homeassistant/components/panasonic_viera/translations/ja.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\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", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_pin_code": "\u5165\u529b\u3057\u305fPIN\u30b3\u30fc\u30c9\u304c\u7121\u52b9\u3067\u3057\u305f" + }, + "step": { + "pairing": { + "data": { + "pin": "PIN\u30b3\u30fc\u30c9" + }, + "description": "\u30c6\u30ec\u30d3\u306b\u8868\u793a\u3055\u308c\u3066\u3044\u308bPIN\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "\u30da\u30a2\u30ea\u30f3\u30b0" + }, + "user": { + "data": { + "host": "IP\u30a2\u30c9\u30ec\u30b9", + "name": "\u540d\u524d" + }, + "description": "Panasonic Viera TV\u306eIP\u30a2\u30c9\u30ec\u30b9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "\u30c6\u30ec\u30d3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/panasonic_viera/translations/tr.json b/homeassistant/components/panasonic_viera/translations/tr.json index d0e573fdcf9..a668bb37b3e 100644 --- a/homeassistant/components/panasonic_viera/translations/tr.json +++ b/homeassistant/components/panasonic_viera/translations/tr.json @@ -6,13 +6,24 @@ "unknown": "Beklenmeyen hata" }, "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_pin_code": "Girdi\u011finiz PIN Kodu ge\u00e7ersiz" }, "step": { + "pairing": { + "data": { + "pin": "PIN Kodu" + }, + "description": "TV'nizde g\u00f6r\u00fcnt\u00fclenen PIN Kodu kodunu girin", + "title": "E\u015fle\u015ftirme" + }, "user": { "data": { - "host": "\u0130p Adresi" - } + "host": "\u0130p Adresi", + "name": "Ad" + }, + "description": "Panasonic Viera TV'nizin IP Adresi 'ni girin", + "title": "TV'nizi kurun" } } } diff --git a/homeassistant/components/pandora/media_player.py b/homeassistant/components/pandora/media_player.py index 33ea72f94ff..902ccebb191 100644 --- a/homeassistant/components/pandora/media_player.py +++ b/homeassistant/components/pandora/media_player.py @@ -274,8 +274,7 @@ class PandoraMediaPlayer(MediaPlayerEntity): def _update_current_station(self, response): """Update current station.""" - station_match = re.search(STATION_PATTERN, response) - if station_match: + if station_match := re.search(STATION_PATTERN, response): self._station = station_match.group(1) _LOGGER.debug("Got station as: %s", self._station) else: @@ -283,8 +282,7 @@ class PandoraMediaPlayer(MediaPlayerEntity): def _update_current_song(self, response): """Update info about current song.""" - song_match = re.search(CURRENT_SONG_PATTERN, response) - if song_match: + if song_match := re.search(CURRENT_SONG_PATTERN, response): ( self._media_title, self._media_artist, @@ -343,8 +341,7 @@ class PandoraMediaPlayer(MediaPlayerEntity): _LOGGER.debug("Getting stations: %s", station_lines) self._stations = [] for line in station_lines.split("\r\n"): - match = re.search(r"\d+\).....(.+)", line) - if match: + if match := re.search(r"\d+\).....(.+)", line): station = match.group(1).strip() _LOGGER.debug("Found station %s", station) self._stations.append(station) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 4b7d6a54b1f..3ec08ae518e 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -148,7 +148,7 @@ UPDATE_FIELDS = { class PersonStore(Store): """Person storage.""" - async def _async_migrate_func(self, old_version, old_data): + async def _async_migrate_func(self, old_major_version, old_minor_version, old_data): """Migrate to the new version. Migrate storage to use format of collection helper. @@ -420,8 +420,7 @@ class Person(RestoreEntity): async def async_added_to_hass(self): """Register device trackers.""" await super().async_added_to_hass() - state = await self.async_get_last_state() - if state: + if state := await self.async_get_last_state(): self._parse_source_state(state) if self.hass.is_running: diff --git a/homeassistant/components/person/translations/ja.json b/homeassistant/components/person/translations/ja.json index 6679d6cca06..5b45acc7cb6 100644 --- a/homeassistant/components/person/translations/ja.json +++ b/homeassistant/components/person/translations/ja.json @@ -2,7 +2,8 @@ "state": { "_": { "home": "\u5728\u5b85", - "not_home": "\u5916\u51fa" + "not_home": "\u96e2\u5e2d(away)" } - } + }, + "title": "\u4eba" } \ No newline at end of file diff --git a/homeassistant/components/philips_js/light.py b/homeassistant/components/philips_js/light.py index 799b3b41631..93b65db90fa 100644 --- a/homeassistant/components/philips_js/light.py +++ b/homeassistant/components/philips_js/light.py @@ -200,8 +200,7 @@ class PhilipsTVLightEntity(CoordinatorEntity, LightEntity): current = self._tv.ambilight_current_configuration if current and self._tv.ambilight_mode != "manual": if current["isExpert"]: - settings = _get_settings(current) - if settings: + if settings := _get_settings(current): return _get_effect( EFFECT_EXPERT, current["styleName"], settings["algorithm"] ) diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index 3bea3ff7337..60bc862406d 100644 --- a/homeassistant/components/philips_js/manifest.json +++ b/homeassistant/components/philips_js/manifest.json @@ -2,7 +2,7 @@ "domain": "philips_js", "name": "Philips TV", "documentation": "https://www.home-assistant.io/integrations/philips_js", - "requirements": ["ha-philipsjs==2.7.5"], + "requirements": ["ha-philipsjs==2.7.6"], "codeowners": ["@elupus"], "config_flow": true, "iot_class": "local_polling" diff --git a/homeassistant/components/philips_js/translations/id.json b/homeassistant/components/philips_js/translations/id.json index b9a1b948a91..8bff8159841 100644 --- a/homeassistant/components/philips_js/translations/id.json +++ b/homeassistant/components/philips_js/translations/id.json @@ -29,5 +29,14 @@ "trigger_type": { "turn_on": "Perangkat diminta untuk dinyalakan" } + }, + "options": { + "step": { + "init": { + "data": { + "allow_notify": "Izinkan penggunaan layanan notifikasi data." + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/ja.json b/homeassistant/components/philips_js/translations/ja.json new file mode 100644 index 00000000000..70c0f2d6acf --- /dev/null +++ b/homeassistant/components/philips_js/translations/ja.json @@ -0,0 +1,42 @@ +{ + "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", + "invalid_pin": "\u7121\u52b9\u306aPIN", + "pairing_failure": "\u30da\u30a2\u30ea\u30f3\u30b0\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f: {error_id}", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "pair": { + "data": { + "pin": "PIN\u30b3\u30fc\u30c9" + }, + "description": "\u30c6\u30ec\u30d3\u306b\u8868\u793a\u3055\u308c\u3066\u3044\u308bPIN\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "\u30da\u30a2" + }, + "user": { + "data": { + "api_version": "API\u30d0\u30fc\u30b8\u30e7\u30f3", + "host": "\u30db\u30b9\u30c8" + } + } + } + }, + "device_automation": { + "trigger_type": { + "turn_on": "\u30c7\u30d0\u30a4\u30b9\u3092\u30aa\u30f3\u306b\u3059\u308b\u3088\u3046\u306b\u8981\u6c42\u3055\u308c\u307e\u3057\u305f" + } + }, + "options": { + "step": { + "init": { + "data": { + "allow_notify": "\u30c7\u30fc\u30bf\u901a\u77e5\u30b5\u30fc\u30d3\u30b9\u306e\u4f7f\u7528\u3092\u8a31\u53ef\u3057\u307e\u3059\u3002" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/tr.json b/homeassistant/components/philips_js/translations/tr.json new file mode 100644 index 00000000000..884c74b0ffa --- /dev/null +++ b/homeassistant/components/philips_js/translations/tr.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_pin": "Ge\u00e7ersiz PIN", + "pairing_failure": "E\u015fle\u015ftirilemiyor: {error_id}", + "unknown": "Beklenmeyen hata" + }, + "step": { + "pair": { + "data": { + "pin": "PIN Kodu" + }, + "description": "TV'nizde g\u00f6r\u00fcnt\u00fclenen PIN'i girin", + "title": "E\u015fle\u015ftir" + }, + "user": { + "data": { + "api_version": "API S\u00fcr\u00fcm\u00fc", + "host": "Ana bilgisayar" + } + } + } + }, + "device_automation": { + "trigger_type": { + "turn_on": "Cihaz\u0131n a\u00e7\u0131lmas\u0131 isteniyor" + } + }, + "options": { + "step": { + "init": { + "data": { + "allow_notify": "Veri bildirim hizmetinin kullan\u0131m\u0131na izin ver." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index 5e960a1f70d..930ded4aa42 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -102,13 +102,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session = async_get_clientsession(hass, verify_tls) api = Hole( host, - hass.loop, session, location=location, tls=use_tls, api_token=api_key, ) await api.get_data() + await api.get_versions() + except HoleError as ex: _LOGGER.warning("Failed to connect: %s", ex) raise ConfigEntryNotReady from ex @@ -117,6 +118,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Fetch data from API endpoint.""" try: await api.get_data() + await api.get_versions() except HoleError as err: raise UpdateFailed(f"Failed to communicate with API: {err}") from err @@ -150,11 +152,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def _async_platforms(entry: ConfigEntry) -> list[str]: """Return platforms to be loaded / unloaded.""" - platforms = ["sensor"] + platforms = ["binary_sensor", "sensor"] if not entry.data[CONF_STATISTICS_ONLY]: platforms.append("switch") - else: - platforms.append("binary_sensor") return platforms diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index 5758c0e4145..e887f2ea12f 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -1,12 +1,27 @@ """Support for getting status from a Pi-hole system.""" +from __future__ import annotations + +from typing import Any + +from hole import Hole + from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PiHoleEntity -from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN as PIHOLE_DOMAIN +from .const import ( + BINARY_SENSOR_TYPES, + BINARY_SENSOR_TYPES_STATISTICS_ONLY, + CONF_STATISTICS_ONLY, + DATA_KEY_API, + DATA_KEY_COORDINATOR, + DOMAIN as PIHOLE_DOMAIN, + PiHoleBinarySensorEntityDescription, +) async def async_setup_entry( @@ -15,33 +30,63 @@ async def async_setup_entry( """Set up the Pi-hole binary sensor.""" name = entry.data[CONF_NAME] hole_data = hass.data[PIHOLE_DOMAIN][entry.entry_id] + binary_sensors = [ PiHoleBinarySensor( hole_data[DATA_KEY_API], hole_data[DATA_KEY_COORDINATOR], name, entry.entry_id, + description, ) + for description in BINARY_SENSOR_TYPES ] + + if entry.data[CONF_STATISTICS_ONLY]: + binary_sensors += [ + PiHoleBinarySensor( + hole_data[DATA_KEY_API], + hole_data[DATA_KEY_COORDINATOR], + name, + entry.entry_id, + description, + ) + for description in BINARY_SENSOR_TYPES_STATISTICS_ONLY + ] + async_add_entities(binary_sensors, True) class PiHoleBinarySensor(PiHoleEntity, BinarySensorEntity): """Representation of a Pi-hole binary sensor.""" - _attr_icon = "mdi:pi-hole" + entity_description: PiHoleBinarySensorEntityDescription - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name + def __init__( + self, + api: Hole, + coordinator: DataUpdateCoordinator, + name: str, + server_unique_id: str, + description: PiHoleBinarySensorEntityDescription, + ) -> None: + """Initialize a Pi-hole sensor.""" + super().__init__(api, coordinator, name, server_unique_id) + self.entity_description = description - @property - def unique_id(self) -> str: - """Return the unique id of the sensor.""" - return f"{self._server_unique_id}/Status" + if description.key == "status": + self._attr_name = f"{name}" + else: + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{self._server_unique_id}/{description.name}" @property def is_on(self) -> bool: """Return if the service is on.""" - return self.api.data.get("status") == "enabled" # type: ignore[no-any-return] + + return self.entity_description.state_value(self.api) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the state attributes of the Pi-hole.""" + return self.entity_description.extra_value(self.api) diff --git a/homeassistant/components/pi_hole/config_flow.py b/homeassistant/components/pi_hole/config_flow.py index cccd80472e3..5acaffd13b1 100644 --- a/homeassistant/components/pi_hole/config_flow.py +++ b/homeassistant/components/pi_hole/config_flow.py @@ -170,5 +170,5 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self, host: str, location: str, tls: bool, verify_tls: bool ) -> None: session = async_get_clientsession(self.hass, verify_tls) - pi_hole = Hole(host, self.hass.loop, session, location=location, tls=tls) + pi_hole = Hole(host, session, location=location, tls=tls) await pi_hole.get_data() diff --git a/homeassistant/components/pi_hole/const.py b/homeassistant/components/pi_hole/const.py index 37167cb873a..d13c83c7b28 100644 --- a/homeassistant/components/pi_hole/const.py +++ b/homeassistant/components/pi_hole/const.py @@ -1,9 +1,17 @@ """Constants for the pi_hole integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta +from typing import Any +from hole import Hole + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_UPDATE, + BinarySensorEntityDescription, +) from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import PERCENTAGE @@ -22,6 +30,7 @@ DEFAULT_STATISTICS_ONLY = True SERVICE_DISABLE = "disable" SERVICE_DISABLE_ATTR_DURATION = "duration" +ATTR_BLOCKED_DOMAINS = "domains_blocked" MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) DATA_KEY_API = "api" @@ -91,3 +100,62 @@ SENSOR_TYPES: tuple[PiHoleSensorEntityDescription, ...] = ( icon="mdi:domain", ), ) + + +@dataclass +class RequiredPiHoleBinaryDescription: + """Represent the required attributes of the PiHole binary description.""" + + state_value: Callable[[Hole], bool] + + +@dataclass +class PiHoleBinarySensorEntityDescription( + BinarySensorEntityDescription, RequiredPiHoleBinaryDescription +): + """Describes PiHole binary sensor entity.""" + + extra_value: Callable[[Hole], dict[str, Any] | None] = lambda api: None + + +BINARY_SENSOR_TYPES: tuple[PiHoleBinarySensorEntityDescription, ...] = ( + PiHoleBinarySensorEntityDescription( + key="core_update_available", + name="Core Update Available", + device_class=DEVICE_CLASS_UPDATE, + extra_value=lambda api: { + "current_version": api.versions["core_current"], + "latest_version": api.versions["core_latest"], + }, + state_value=lambda api: bool(api.versions["core_update"]), + ), + PiHoleBinarySensorEntityDescription( + key="web_update_available", + name="Web Update Available", + device_class=DEVICE_CLASS_UPDATE, + extra_value=lambda api: { + "current_version": api.versions["web_current"], + "latest_version": api.versions["web_latest"], + }, + state_value=lambda api: bool(api.versions["web_update"]), + ), + PiHoleBinarySensorEntityDescription( + key="ftl_update_available", + name="FTL Update Available", + device_class=DEVICE_CLASS_UPDATE, + extra_value=lambda api: { + "current_version": api.versions["FTL_current"], + "latest_version": api.versions["FTL_latest"], + }, + state_value=lambda api: bool(api.versions["FTL_update"]), + ), +) + +BINARY_SENSOR_TYPES_STATISTICS_ONLY: tuple[PiHoleBinarySensorEntityDescription, ...] = ( + PiHoleBinarySensorEntityDescription( + key="status", + name="Status", + icon="mdi:pi-hole", + state_value=lambda api: bool(api.data.get("status") == "enabled"), + ), +) diff --git a/homeassistant/components/pi_hole/manifest.json b/homeassistant/components/pi_hole/manifest.json index a96cae8b22b..28ceb8e6c45 100644 --- a/homeassistant/components/pi_hole/manifest.json +++ b/homeassistant/components/pi_hole/manifest.json @@ -2,7 +2,7 @@ "domain": "pi_hole", "name": "Pi-hole", "documentation": "https://www.home-assistant.io/integrations/pi_hole", - "requirements": ["hole==0.5.1"], + "requirements": ["hole==0.7.0"], "codeowners": ["@fabaff", "@johnluetke", "@shenxn"], "config_flow": true, "iot_class": "local_polling" diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 656bd8a652b..0e231868647 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -14,6 +14,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PiHoleEntity from .const import ( + ATTR_BLOCKED_DOMAINS, DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN as PIHOLE_DOMAIN, @@ -68,3 +69,8 @@ class PiHoleSensor(PiHoleEntity, SensorEntity): return round(self.api.data[self.entity_description.key], 2) except TypeError: return self.api.data[self.entity_description.key] + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the Pi-hole.""" + return {ATTR_BLOCKED_DOMAINS: self.api.data["domains_being_blocked"]} diff --git a/homeassistant/components/pi_hole/translations/bg.json b/homeassistant/components/pi_hole/translations/bg.json index 4983c9a14b2..0a8f88b6b0d 100644 --- a/homeassistant/components/pi_hole/translations/bg.json +++ b/homeassistant/components/pi_hole/translations/bg.json @@ -1,8 +1,23 @@ { "config": { + "abort": { + "already_configured": "\u0423\u0441\u043b\u0443\u0433\u0430\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { + "api_key": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447" + } + }, "user": { "data": { + "api_key": "API \u043a\u043b\u044e\u0447", + "host": "\u0425\u043e\u0441\u0442", + "location": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435", + "name": "\u0418\u043c\u0435", "port": "\u041f\u043e\u0440\u0442" } } diff --git a/homeassistant/components/pi_hole/translations/ja.json b/homeassistant/components/pi_hole/translations/ja.json new file mode 100644 index 00000000000..313790dfcfc --- /dev/null +++ b/homeassistant/components/pi_hole/translations/ja.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\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": { + "api_key": { + "data": { + "api_key": "API\u30ad\u30fc" + } + }, + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "host": "\u30db\u30b9\u30c8", + "location": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3", + "name": "\u540d\u524d", + "port": "\u30dd\u30fc\u30c8", + "ssl": "SSL\u8a3c\u660e\u66f8\u3092\u4f7f\u7528\u3059\u308b", + "statistics_only": "\u7d71\u8a08\u306e\u307f", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/pi_hole/translations/tr.json b/homeassistant/components/pi_hole/translations/tr.json index e2fff8d904b..8484e2310f1 100644 --- a/homeassistant/components/pi_hole/translations/tr.json +++ b/homeassistant/components/pi_hole/translations/tr.json @@ -15,11 +15,13 @@ "user": { "data": { "api_key": "API Anahtar\u0131", - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "location": "Konum", - "name": "\u0130sim", + "name": "Ad", "port": "Port", - "statistics_only": "Yaln\u0131zca \u0130statistikler" + "ssl": "SSL sertifikas\u0131 kullan\u0131r", + "statistics_only": "Yaln\u0131zca \u0130statistikler", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" } } } diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index d3de3d1dfb3..95612e7b272 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -6,6 +6,7 @@ from typing import Any, cast from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( @@ -79,7 +80,7 @@ class PicnicSensor(SensorEntity, CoordinatorEntity): def device_info(self) -> DeviceInfo: """Return device info.""" return DeviceInfo( - entry_type="service", + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, cast(str, self._service_unique_id))}, manufacturer="Picnic", model=self._service_unique_id, diff --git a/homeassistant/components/picnic/translations/ja.json b/homeassistant/components/picnic/translations/ja.json new file mode 100644 index 00000000000..9233c2f6aea --- /dev/null +++ b/homeassistant/components/picnic/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" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "country_code": "\u56fd\u5225\u30b3\u30fc\u30c9", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + }, + "title": "\u30d4\u30af\u30cb\u30c3\u30af" +} \ No newline at end of file diff --git a/homeassistant/components/picnic/translations/tr.json b/homeassistant/components/picnic/translations/tr.json new file mode 100644 index 00000000000..242b4ae4e6a --- /dev/null +++ b/homeassistant/components/picnic/translations/tr.json @@ -0,0 +1,22 @@ +{ + "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": { + "country_code": "\u00dclke kodu", + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + } + } + }, + "title": "Picnic" +} \ No newline at end of file diff --git a/homeassistant/components/pilight/base_class.py b/homeassistant/components/pilight/base_class.py index 95eb987875b..97ebaef0080 100644 --- a/homeassistant/components/pilight/base_class.py +++ b/homeassistant/components/pilight/base_class.py @@ -86,8 +86,7 @@ class PilightBaseDevice(RestoreEntity): async def async_added_to_hass(self): """Call when entity about to be added to hass.""" await super().async_added_to_hass() - state = await self.async_get_last_state() - if state: + if state := await self.async_get_last_state(): self._is_on = state.state == STATE_ON self._brightness = state.attributes.get("brightness") diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index 0c82c9ff8c4..ea07c3123e9 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -38,7 +38,7 @@ DEFAULT_PING_COUNT = 5 SCAN_INTERVAL = timedelta(minutes=5) -PARALLEL_UPDATES = 0 +PARALLEL_UPDATES = 50 PING_MATCHER = re.compile( r"(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)" diff --git a/homeassistant/components/plaato/translations/ja.json b/homeassistant/components/plaato/translations/ja.json new file mode 100644 index 00000000000..8b3f030b72f --- /dev/null +++ b/homeassistant/components/plaato/translations/ja.json @@ -0,0 +1,54 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "webhook_not_internet_accessible": "Webhook\u30e1\u30c3\u30bb\u30fc\u30b8\u3092\u53d7\u4fe1\u3059\u308b\u306b\u306f\u3001Home Assistant\u306e\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306b\u3001\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u304b\u3089\u30a2\u30af\u30bb\u30b9\u3067\u304d\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "create_entry": { + "default": "Plaato {device_type} \u540d\u524d **{device_name}** \u304c\u6b63\u5e38\u306b\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3055\u308c\u307e\u3057\u305f\u3002" + }, + "error": { + "invalid_webhook_device": "Webhook\u3078\u306e\u30c7\u30fc\u30bf\u9001\u4fe1\u3092\u30b5\u30dd\u30fc\u30c8\u3057\u3066\u3044\u306a\u3044\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002Airlock\u3067\u306e\u307f\u5229\u7528\u53ef\u80fd\u3067\u3059", + "no_api_method": "\u8a8d\u8a3c\u30c8\u30fc\u30af\u30f3\u3092\u8ffd\u52a0\u3059\u308b\u304b\u3001webhook\u3092\u9078\u629e\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059", + "no_auth_token": "\u8a8d\u8a3c\u30c8\u30fc\u30af\u30f3\u3092\u8ffd\u52a0\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059" + }, + "step": { + "api_method": { + "data": { + "token": "\u3053\u3053\u306b\u8a8d\u8a3c\u30c8\u30fc\u30af\u30f3\u3092\u8cbc\u308a\u4ed8\u3051\u307e\u3059", + "use_webhook": "webhook\u3092\u4f7f\u7528" + }, + "description": "API\u306b\u554f\u3044\u5408\u308f\u305b\u308b\u306b\u306f\u3001`auth_token` \u304c\u5fc5\u8981\u3067\u3059\u3002\u3053\u308c\u306f\u3001[\u6b21\u306e\u624b\u9806\u3067\u53d6\u5f97\u3067\u304d\u307e\u3059](https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token)\n\n\u9078\u629e\u3057\u305f\u30c7\u30d0\u30a4\u30b9: **{device_type}** \n\n\u7d44\u307f\u8fbc\u307f\u306eWebhook\u30e1\u30bd\u30c3\u30c9(Airlock\u306e\u307f)\u3092\u4f7f\u7528\u3059\u308b\u5834\u5408\u306f\u3001\u30c1\u30a7\u30c3\u30af\u30dc\u30c3\u30af\u30b9\u3092\u30aa\u30f3\u306b\u3057\u3066\u3001\u8a8d\u8a3c\u30c8\u30fc\u30af\u30f3(Auth Token)\u3092\u7a7a\u767d\u306b\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "API\u65b9\u5f0f\u3092\u9078\u629e" + }, + "user": { + "data": { + "device_name": "\u30c7\u30d0\u30a4\u30b9\u306b\u540d\u524d\u3092\u4ed8\u3051\u308b", + "device_type": "Plaato\u30c7\u30d0\u30a4\u30b9\u306e\u30bf\u30a4\u30d7" + }, + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f", + "title": "Plaato Wdebhookvices\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + }, + "webhook": { + "description": "Home Assistant\u306b\u30a4\u30d9\u30f3\u30c8\u3092\u9001\u4fe1\u3059\u308b\u306b\u306f\u3001Plaato Airlock\u3067webhook\u6a5f\u80fd\u3092\u8a2d\u5b9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\n\n\u6b21\u306e\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044:\n\n- URL: `{webhook_url}`\n- Method(\u65b9\u5f0f): POST\n\n\u8a73\u7d30\u306f[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8]({docs_url}) \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u4f7f\u7528\u3059\u308bWebhook" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "update_interval": "\u66f4\u65b0\u9593\u9694(\u5206)" + }, + "description": "\u66f4\u65b0\u9593\u9694\u306e\u8a2d\u5b9a(\u5206)", + "title": "Plaato\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + }, + "webhook": { + "description": "Webhook\u60c5\u5831:\n\n- URL: `{webhook_url}`\n- Method(\u65b9\u5f0f): POST\n\n", + "title": "Plaato Airlock\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plaato/translations/tr.json b/homeassistant/components/plaato/translations/tr.json index 1f21b08ec81..579617127ac 100644 --- a/homeassistant/components/plaato/translations/tr.json +++ b/homeassistant/components/plaato/translations/tr.json @@ -5,23 +5,33 @@ "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr.", "webhook_not_internet_accessible": "Webhook mesajlar\u0131n\u0131 alabilmek i\u00e7in Home Assistant \u00f6rne\u011finize internetten eri\u015filebilmelidir." }, + "create_entry": { + "default": "Plaato {device_type} ad\u0131 ** ile {device_name} ** ba\u015far\u0131yla kurulum oldu!" + }, "error": { + "invalid_webhook_device": "Webhook veri g\u00f6ndermeyi desteklemeyen bir cihaz se\u00e7tiniz. Yaln\u0131zca Airlock i\u00e7in kullan\u0131labilir", + "no_api_method": "Bir kimlik do\u011frulama anahtar\u0131 eklemeniz veya webhook se\u00e7meniz gerekiyor", "no_auth_token": "Bir kimlik do\u011frulama jetonu eklemeniz gerekiyor" }, "step": { "api_method": { "data": { + "token": "Yetkilendirme Anahtar\u0131'\u0131n\u0131 buraya yap\u0131\u015ft\u0131r\u0131n", "use_webhook": "Webhook kullan" }, + "description": "API'yi sorgulayabilmek i\u00e7in, [bu](https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token) talimatlar\u0131 izleyerek elde edilebilecek bir \"auth_token\" gereklidir. \n\n Se\u00e7ilen cihaz: ** {device_type} ** \n\n Yerle\u015fik webhook y\u00f6ntemini kullanmay\u0131 tercih ediyorsan\u0131z (yaln\u0131zca Airlock) l\u00fctfen a\u015fa\u011f\u0131daki kutuyu i\u015faretleyin ve Yetkilendirme Anahtar\u0131n\u0131 bo\u015f b\u0131rak\u0131n", "title": "API y\u00f6ntemini se\u00e7in" }, "user": { "data": { "device_name": "Cihaz\u0131n\u0131z\u0131 adland\u0131r\u0131n", "device_type": "Plaato cihaz\u0131n\u0131n t\u00fcr\u00fc" - } + }, + "description": "Kuruluma ba\u015flamak ister misiniz?", + "title": "Plaato cihazlar\u0131n\u0131 kurun" }, "webhook": { + "description": "Olaylar\u0131 Home Assistant'a g\u00f6ndermek i\u00e7in Plaato Airlock'ta webhook \u00f6zelli\u011fini kurman\u0131z gerekir. \n\n A\u015fa\u011f\u0131daki bilgileri doldurun: \n\n - URL: ` {webhook_url} `\n - Y\u00f6ntem: POST \n\n Daha fazla ayr\u0131nt\u0131 i\u00e7in [belgelere]( {docs_url}", "title": "Webhook kullanmak i\u00e7in" } } @@ -36,6 +46,7 @@ "title": "Plaato i\u00e7in se\u00e7enekler" }, "webhook": { + "description": "Webhook bilgisi: \n\n - URL: ` {webhook_url} `\n - Y\u00f6ntem: POST \n\n", "title": "Plaato Airlock i\u00e7in se\u00e7enekler" } } diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py index 5e734c7ba62..fe5b4b2483b 100644 --- a/homeassistant/components/plant/__init__.py +++ b/homeassistant/components/plant/__init__.py @@ -238,8 +238,7 @@ class Plant(Entity): result = [] for sensor_name in self._sensormap.values(): params = self.READINGS[sensor_name] - value = getattr(self, f"_{sensor_name}") - if value is not None: + if (value := getattr(self, f"_{sensor_name}")) is not None: if value == STATE_UNAVAILABLE: result.append(f"{sensor_name} unavailable") else: diff --git a/homeassistant/components/plant/translations/ja.json b/homeassistant/components/plant/translations/ja.json index 01708fffd87..d7e8fa0a238 100644 --- a/homeassistant/components/plant/translations/ja.json +++ b/homeassistant/components/plant/translations/ja.json @@ -1,7 +1,9 @@ { "state": { "_": { - "ok": "OK" + "ok": "OK", + "problem": "\u554f\u984c" } - } + }, + "title": "\u30d7\u30e9\u30f3\u30c8\u30e2\u30cb\u30bf\u30fc" } \ No newline at end of file diff --git a/homeassistant/components/plant/translations/tr.json b/homeassistant/components/plant/translations/tr.json index 0fe07001b3b..47ae1695630 100644 --- a/homeassistant/components/plant/translations/tr.json +++ b/homeassistant/components/plant/translations/tr.json @@ -2,8 +2,8 @@ "state": { "_": { "ok": "Tamam", - "problem": "Problem" + "problem": "Sorun" } }, - "title": "Bitki" + "title": "Tesis Monit\u00f6r\u00fc" } \ No newline at end of file diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index f210ebe8363..6a48a427519 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -23,6 +23,7 @@ from homeassistant.components.media_player.const import ( from homeassistant.const import STATE_IDLE, STATE_PAUSED, STATE_PLAYING from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -481,9 +482,7 @@ class PlexMediaPlayer(MediaPlayerEntity): if isinstance(src, int): src = {"plex_key": src} - playqueue_id = src.pop("playqueue_id", None) - - if playqueue_id: + if playqueue_id := src.pop("playqueue_id", None): try: playqueue = self.plex_server.get_playqueue(playqueue_id) except plexapi.exceptions.NotFound as err: @@ -518,8 +517,7 @@ class PlexMediaPlayer(MediaPlayerEntity): "media_summary", "username", ): - value = getattr(self, attr, None) - if value: + if value := getattr(self, attr, None): attributes[attr] = value return attributes @@ -536,7 +534,7 @@ class PlexMediaPlayer(MediaPlayerEntity): name="Plex Client Service", manufacturer="Plex", model="Plex Clients", - entry_type="service", + entry_type=DeviceEntryType.SERVICE, ) return DeviceInfo( diff --git a/homeassistant/components/plex/services.py b/homeassistant/components/plex/services.py index 32af3c429dc..e07d94f5a1f 100644 --- a/homeassistant/components/plex/services.py +++ b/homeassistant/components/plex/services.py @@ -107,8 +107,7 @@ def lookup_plex_media(hass, content_type, content_id): plex_server_name = content.pop("plex_server", None) plex_server = get_plex_server(hass, plex_server_name) - playqueue_id = content.pop("playqueue_id", None) - if playqueue_id: + if playqueue_id := content.pop("playqueue_id", None): try: playqueue = plex_server.get_playqueue(playqueue_id) except NotFound as err: diff --git a/homeassistant/components/plex/translations/ja.json b/homeassistant/components/plex/translations/ja.json new file mode 100644 index 00000000000..4b48bdfe695 --- /dev/null +++ b/homeassistant/components/plex/translations/ja.json @@ -0,0 +1,62 @@ +{ + "config": { + "abort": { + "all_configured": "\u30ea\u30f3\u30af\u3055\u308c\u305f\u3059\u3079\u3066\u306e\u30b5\u30fc\u30d0\u30fc\u306f\u3059\u3067\u306b\u69cb\u6210\u3055\u308c\u3066\u3044\u307e\u3059", + "already_configured": "\u3053\u306ePlex server\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", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f", + "token_request_timeout": "\u30c8\u30fc\u30af\u30f3\u306e\u53d6\u5f97\u306b\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "faulty_credentials": "\u8a8d\u8a3c\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3001\u30c8\u30fc\u30af\u30f3\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044", + "host_or_token": "\u5c11\u306a\u304f\u3068\u30821\u3064\u306e\u30db\u30b9\u30c8\u307e\u305f\u306f\u30c8\u30fc\u30af\u30f3\u3092\u63d0\u4f9b\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059", + "no_servers": "Plex\u30a2\u30ab\u30a6\u30f3\u30c8\u306b\u30ea\u30f3\u30af\u3055\u308c\u3066\u3044\u308b\u30b5\u30fc\u30d0\u30fc\u306f\u3042\u308a\u307e\u305b\u3093", + "not_found": "Plex server\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "ssl_error": "SSL\u8a3c\u660e\u66f8\u306e\u554f\u984c" + }, + "flow_title": "{name} ({host})", + "step": { + "manual_setup": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8", + "ssl": "SSL\u8a3c\u660e\u66f8\u3092\u4f7f\u7528\u3059\u308b", + "token": "\u30c8\u30fc\u30af\u30f3(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + }, + "title": "\u624b\u52d5\u306b\u3088\u308bPlex\u306e\u8a2d\u5b9a" + }, + "select_server": { + "data": { + "server": "\u30b5\u30fc\u30d0\u30fc" + }, + "description": "\u8907\u6570\u306e\u30b5\u30fc\u30d0\u30fc\u304c\u5229\u7528\u53ef\u80fd\u3067\u3059\u3002\u6b21\u306e\u3044\u305a\u308c\u304b\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044:", + "title": "Plex\u30b5\u30fc\u30d0\u30fc\u3092\u9078\u629e" + }, + "user": { + "description": "[plex.tv](https://plex.tv) \u306b\u9032\u307f\u3001Plex server\u3092\u30ea\u30f3\u30af\u3057\u307e\u3059\u3002", + "title": "Plex Media Server" + }, + "user_advanced": { + "data": { + "setup_method": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u65b9\u6cd5" + }, + "title": "Plex Media Server" + } + } + }, + "options": { + "step": { + "plex_mp_settings": { + "data": { + "ignore_new_shared_users": "\u65b0\u3057\u3044\u7ba1\u7406\u5bfe\u8c61/\u5171\u6709\u30e6\u30fc\u30b6\u30fc\u3092\u7121\u8996\u3059\u308b", + "ignore_plex_web_clients": "Plex Web clients\u3092\u7121\u8996\u3059\u308b", + "monitored_users": "\u76e3\u8996\u5bfe\u8c61\u306e\u30e6\u30fc\u30b6\u30fc", + "use_episode_art": "\u30a8\u30d4\u30bd\u30fc\u30c9\u30a2\u30fc\u30c8\u3092\u4f7f\u7528" + }, + "description": "Plex Media Player\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plex/translations/tr.json b/homeassistant/components/plex/translations/tr.json index 93f8cc85eae..c052006687f 100644 --- a/homeassistant/components/plex/translations/tr.json +++ b/homeassistant/components/plex/translations/tr.json @@ -1,19 +1,41 @@ { "config": { "abort": { + "all_configured": "T\u00fcm ba\u011flant\u0131l\u0131 sunucular zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "already_configured": "Bu Plex sunucusu zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu", + "token_request_timeout": "Anahtar alma zaman a\u015f\u0131m\u0131na u\u011frad\u0131", "unknown": "Beklenmeyen hata" }, + "error": { + "faulty_credentials": "Yetkilendirme ba\u015far\u0131s\u0131z oldu, Token'\u0131 do\u011frulay\u0131n", + "host_or_token": "Ana Bilgisayar veya Anahtardan az birini sa\u011flamal\u0131d\u0131r", + "no_servers": "Plex hesab\u0131na ba\u011fl\u0131 sunucu yok", + "not_found": "Plex sunucusu bulunamad\u0131", + "ssl_error": "SSL sertifikas\u0131 sorunu" + }, + "flow_title": "{name} ({host})", "step": { "manual_setup": { "data": { "host": "Ana Bilgisayar", - "port": "Port" - } + "port": "Port", + "ssl": "SSL sertifikas\u0131 kullan\u0131r", + "token": "Anahtar (opsiyonel)", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" + }, + "title": "Manuel Plex Yap\u0131land\u0131rmas\u0131" + }, + "select_server": { + "data": { + "server": "Sunucu" + }, + "description": "Birden fazla sunucu mevcut, birini se\u00e7in:", + "title": "Plex sunucusunu se\u00e7in" }, "user": { + "description": "Bir Plex sunucusunu ba\u011flamak i\u00e7in [plex.tv](https://plex.tv) ile devam edin.", "title": "Plex Medya Sunucusu" }, "user_advanced": { @@ -23,5 +45,18 @@ "title": "Plex Medya Sunucusu" } } + }, + "options": { + "step": { + "plex_mp_settings": { + "data": { + "ignore_new_shared_users": "Yeni y\u00f6netilen/payla\u015f\u0131lan kullan\u0131c\u0131lar\u0131 yoksay", + "ignore_plex_web_clients": "Plex Web istemcilerini yoksay", + "monitored_users": "\u0130zlenen kullan\u0131c\u0131lar", + "use_episode_art": "B\u00f6l\u00fcm resmini kullan" + }, + "description": "Plex Medya Oynat\u0131c\u0131lar i\u00e7in Se\u00e7enekler" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 023ffa3de70..5faf5f00dde 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -112,9 +112,7 @@ class PwBinarySensor(SmileBinarySensor, BinarySensorEntity): @callback def _async_process_data(self): """Update the entity.""" - data = self._api.get_device_data(self._dev_id) - - if not data: + if not (data := self._api.get_device_data(self._dev_id)): _LOGGER.error("Received no data for device %s", self._binary_sensor) self.async_write_ha_state() return diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index 1dbf4324590..a120daf0083 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Plugwise integration.""" +from __future__ import annotations + import logging from plugwise.exceptions import InvalidAuthentication, PlugwiseException @@ -6,6 +8,7 @@ from plugwise.smile import Smile import voluptuous as vol from homeassistant import config_entries, core, exceptions +from homeassistant.components import zeroconf from homeassistant.const import ( CONF_BASE, CONF_HOST, @@ -16,8 +19,8 @@ from homeassistant.const import ( 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.typing import DiscoveryInfoType from .const import ( API, @@ -98,30 +101,32 @@ class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize the Plugwise config flow.""" - self.discovery_info = {} + self.discovery_info: zeroconf.ZeroconfServiceInfo | None = None + self._username: str = DEFAULT_USERNAME - async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Prepare configuration for a discovered Plugwise Smile.""" self.discovery_info = discovery_info - self.discovery_info[CONF_USERNAME] = DEFAULT_USERNAME - _properties = self.discovery_info.get("properties") + _properties = discovery_info.properties # unique_id is needed here, to be able to determine whether the discovered device is known, or not. - unique_id = self.discovery_info.get("hostname").split(".")[0] + unique_id = discovery_info.hostname.split(".")[0] await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured({CONF_HOST: self.discovery_info[CONF_HOST]}) + self._abort_if_unique_id_configured({CONF_HOST: discovery_info.host}) if DEFAULT_USERNAME not in unique_id: - self.discovery_info[CONF_USERNAME] = STRETCH_USERNAME + self._username = STRETCH_USERNAME _product = _properties.get("product", None) _version = _properties.get("version", "n/a") _name = f"{ZEROCONF_MAP.get(_product, _product)} v{_version}" self.context["title_placeholders"] = { - CONF_HOST: self.discovery_info[CONF_HOST], + CONF_HOST: discovery_info.host, CONF_NAME: _name, - CONF_PORT: self.discovery_info[CONF_PORT], - CONF_USERNAME: self.discovery_info[CONF_USERNAME], + CONF_PORT: discovery_info.port, + CONF_USERNAME: self._username, } return await self.async_step_user_gateway() @@ -136,9 +141,9 @@ class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): user_input.pop(FLOW_TYPE, None) if self.discovery_info: - user_input[CONF_HOST] = self.discovery_info[CONF_HOST] - user_input[CONF_PORT] = self.discovery_info[CONF_PORT] - user_input[CONF_USERNAME] = self.discovery_info[CONF_USERNAME] + user_input[CONF_HOST] = self.discovery_info.host + user_input[CONF_PORT] = self.discovery_info.port + user_input[CONF_USERNAME] = self._username try: api = await validate_gw_input(self.hass, user_input) diff --git a/homeassistant/components/plugwise/gateway.py b/homeassistant/components/plugwise/gateway.py index 05d8925aeb0..8cf007ca82c 100644 --- a/homeassistant/components/plugwise/gateway.py +++ b/homeassistant/components/plugwise/gateway.py @@ -123,7 +123,7 @@ async def async_setup_entry_gw(hass: HomeAssistant, entry: ConfigEntry) -> bool: UNDO_UPDATE_LISTENER: undo_listener, } - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, api.gateway_id)}, diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 6b33eccc753..59e7858f947 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -361,9 +361,7 @@ class PwThermostatSensor(SmileSensor): @callback def _async_process_data(self): """Update the entity.""" - data = self._api.get_device_data(self._dev_id) - - if not data: + if not (data := self._api.get_device_data(self._dev_id)): _LOGGER.error("Received no data for device %s", self._entity_name) self.async_write_ha_state() return @@ -388,9 +386,7 @@ class PwAuxDeviceSensor(SmileSensor): @callback def _async_process_data(self): """Update the entity.""" - data = self._api.get_device_data(self._dev_id) - - if not data: + if not (data := self._api.get_device_data(self._dev_id)): _LOGGER.error("Received no data for device %s", self._entity_name) self.async_write_ha_state() return @@ -434,9 +430,7 @@ class PwPowerSensor(SmileSensor): @callback def _async_process_data(self): """Update the entity.""" - data = self._api.get_device_data(self._dev_id) - - if not data: + if not (data := self._api.get_device_data(self._dev_id)): _LOGGER.error("Received no data for device %s", self._entity_name) self.async_write_ha_state() return diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index ce3be04681a..033fbcd7693 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -103,9 +103,7 @@ class GwSwitch(SmileGateway, SwitchEntity): @callback def _async_process_data(self): """Update the data from the Plugs.""" - data = self._api.get_device_data(self._dev_id) - - if not data: + if not (data := self._api.get_device_data(self._dev_id)): _LOGGER.error("Received no data for device %s", self._name) self.async_write_ha_state() return diff --git a/homeassistant/components/plugwise/translations/bg.json b/homeassistant/components/plugwise/translations/bg.json index cf043c65495..0c1cf067319 100644 --- a/homeassistant/components/plugwise/translations/bg.json +++ b/homeassistant/components/plugwise/translations/bg.json @@ -1,6 +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" + }, + "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": "{name}", "step": { + "user": { + "description": "\u041f\u0440\u043e\u0434\u0443\u043a\u0442:" + }, "user_gateway": { "data": { "host": "IP \u0430\u0434\u0440\u0435\u0441", diff --git a/homeassistant/components/plugwise/translations/ja.json b/homeassistant/components/plugwise/translations/ja.json new file mode 100644 index 00000000000..cc3810edcd3 --- /dev/null +++ b/homeassistant/components/plugwise/translations/ja.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "flow_type": "\u63a5\u7d9a\u30bf\u30a4\u30d7" + }, + "description": "\u30d7\u30ed\u30c0\u30af\u30c8:", + "title": "Plugwise type" + }, + "user_gateway": { + "data": { + "host": "IP\u30a2\u30c9\u30ec\u30b9", + "password": "Smile ID", + "port": "\u30dd\u30fc\u30c8", + "username": "Smile \u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Smile\u306b\u63a5\u7d9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u30b9\u30ad\u30e3\u30f3\u30a4\u30f3\u30bf\u30fc\u30d0\u30eb(\u79d2)" + }, + "description": "Plugwise\u30aa\u30d7\u30b7\u30e7\u30f3\u306e\u8abf\u6574" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/tr.json b/homeassistant/components/plugwise/translations/tr.json index 60d6b1f92be..4c752efbd4a 100644 --- a/homeassistant/components/plugwise/translations/tr.json +++ b/homeassistant/components/plugwise/translations/tr.json @@ -8,8 +8,15 @@ "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "unknown": "Beklenmeyen hata" }, - "flow_title": "Smile: {name}", + "flow_title": "{name}", "step": { + "user": { + "data": { + "flow_type": "Ba\u011flant\u0131 t\u00fcr\u00fc" + }, + "description": "\u00dcr\u00fcn:", + "title": "Plugwise tipi" + }, "user_gateway": { "data": { "host": "\u0130p Adresi", @@ -17,7 +24,18 @@ "port": "Port", "username": "Smile Kullan\u0131c\u0131 Ad\u0131" }, - "description": "L\u00fctfen girin" + "description": "L\u00fctfen girin", + "title": "Smile'a Ba\u011flan\u0131n" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Tarama Aral\u0131\u011f\u0131 (saniye)" + }, + "description": "Plugwise Se\u00e7eneklerini Ayarlay\u0131n" } } } diff --git a/homeassistant/components/plum_lightpad/translations/bg.json b/homeassistant/components/plum_lightpad/translations/bg.json new file mode 100644 index 00000000000..597f4d36165 --- /dev/null +++ b/homeassistant/components/plum_lightpad/translations/bg.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "Email" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plum_lightpad/translations/ja.json b/homeassistant/components/plum_lightpad/translations/ja.json new file mode 100644 index 00000000000..ff548917c18 --- /dev/null +++ b/homeassistant/components/plum_lightpad/translations/ja.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\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": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "E\u30e1\u30fc\u30eb" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 6865664af7c..5cbab59eabf 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -185,7 +185,7 @@ class MinutPointClient: async def _sync(self): """Update local list of devices.""" - if not await self._client.update() and self._is_available: + if not await self._client.update(): self._is_available = False _LOGGER.warning("Device is unavailable") async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) @@ -315,7 +315,7 @@ class MinutPointEntity(Entity): connections={ (device_registry.CONNECTION_NETWORK_MAC, device["device_mac"]) }, - identifiers=device["device_id"], + identifiers={(DOMAIN, device["device_id"])}, manufacturer="Minut", model=f"Point v{device['hardware_version']}", name=device["description"], diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index fbcbcd02a2b..8520e53654f 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -93,7 +93,7 @@ class PointFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "follow_link" try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): url = await self._get_authorization_url() except asyncio.TimeoutError: return self.async_abort(reason="authorize_url_timeout") diff --git a/homeassistant/components/point/translations/ca.json b/homeassistant/components/point/translations/ca.json index 39269e3740d..3bd746f9388 100644 --- a/homeassistant/components/point/translations/ca.json +++ b/homeassistant/components/point/translations/ca.json @@ -12,7 +12,7 @@ }, "error": { "follow_link": "V\u00e9s a l'enlla\u00e7 i autentica't abans de pr\u00e9mer Envia", - "no_token": "Token d'acc\u00e9s no v\u00e0lid" + "no_token": "Token d'acc\u00e9s inv\u00e0lid" }, "step": { "auth": { diff --git a/homeassistant/components/point/translations/ja.json b/homeassistant/components/point/translations/ja.json new file mode 100644 index 00000000000..6d573895877 --- /dev/null +++ b/homeassistant/components/point/translations/ja.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_setup": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "authorize_url_timeout": "\u8a8d\u8a3cURL\u306e\u751f\u6210\u304c\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002", + "external_setup": "\u5225\u306e\u30d5\u30ed\u30fc\u304b\u3089\u30dd\u30a4\u30f3\u30c8\u304c\u6b63\u5e38\u306b\u69cb\u6210\u3055\u308c\u307e\u3057\u305f\u3002", + "no_flows": "\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", + "unknown_authorize_url_generation": "\u8a8d\u8a3cURL\u306e\u751f\u6210\u4e2d\u306b\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002" + }, + "create_entry": { + "default": "\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f" + }, + "error": { + "follow_link": "\u9001\u4fe1(submit) \u3092\u30af\u30ea\u30c3\u30af\u3059\u308b\u524d\u306b\u3001\u4e8b\u524d\u306b\u30ea\u30f3\u30af\u3092\u305f\u3069\u3063\u3066\u8a8d\u8a3c\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "no_token": "\u7121\u52b9\u306a\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3" + }, + "step": { + "auth": { + "description": "\u4ee5\u4e0b\u306e\u30ea\u30f3\u30af\u3092\u30af\u30ea\u30c3\u30af\u3057\u3066\u3001Minut\u30a2\u30ab\u30a6\u30f3\u30c8\u3078\u306e\u30a2\u30af\u30bb\u30b9\u3092 **\u627f\u8a8d(Accept)** \u3057\u3066\u304b\u3089\u3001\u623b\u3063\u3066\u304d\u3066\u4ee5\u4e0b\u306e **\u9001\u4fe1(submit)** \u3092\u62bc\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n\n[\u30ea\u30f3\u30af]({authorization_url})", + "title": "\u8a8d\u8a3c\u30dd\u30a4\u30f3\u30c8" + }, + "user": { + "data": { + "flow_impl": "\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc" + }, + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f", + "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/point/translations/tr.json b/homeassistant/components/point/translations/tr.json index 5a4849fad07..d3258722848 100644 --- a/homeassistant/components/point/translations/tr.json +++ b/homeassistant/components/point/translations/tr.json @@ -2,10 +2,30 @@ "config": { "abort": { "already_setup": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr.", + "authorize_url_timeout": "Yetkilendirme URL'si olu\u015ftururken zaman a\u015f\u0131m\u0131.", + "external_setup": "Nokta, ba\u015fka bir ak\u0131\u015ftan ba\u015far\u0131yla yap\u0131land\u0131r\u0131ld\u0131.", + "no_flows": "Bile\u015fen yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin.", "unknown_authorize_url_generation": "Yetkilendirme url'si olu\u015fturulurken bilinmeyen hata." }, + "create_entry": { + "default": "Ba\u015far\u0131yla do\u011fruland\u0131" + }, "error": { + "follow_link": "L\u00fctfen ba\u011flant\u0131y\u0131 takip edin ve G\u00f6nder'e basmadan \u00f6nce kimlik do\u011frulamas\u0131 yap\u0131n", "no_token": "Eri\u015fim Belirteci" + }, + "step": { + "auth": { + "description": "L\u00fctfen a\u015fa\u011f\u0131daki ba\u011flant\u0131y\u0131 takip edin ve Minut hesab\u0131n\u0131za eri\u015fimi **Kabul** edin, ard\u0131ndan geri d\u00f6n\u00fcn ve a\u015fa\u011f\u0131daki **G\u00f6nder**'e bas\u0131n. \n\n [Ba\u011flant\u0131]( {authorization_url} )", + "title": "Kimlik Do\u011frulama Noktas\u0131" + }, + "user": { + "data": { + "flow_impl": "Sa\u011flay\u0131c\u0131" + }, + "description": "Kuruluma ba\u015flamak ister misiniz?", + "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7" + } } } } \ No newline at end of file diff --git a/homeassistant/components/poolsense/__init__.py b/homeassistant/components/poolsense/__init__.py index fb7927de970..7ca7751b6f0 100644 --- a/homeassistant/components/poolsense/__init__.py +++ b/homeassistant/components/poolsense/__init__.py @@ -90,7 +90,7 @@ class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self): """Update data via library.""" data = {} - with async_timeout.timeout(10): + async with async_timeout.timeout(10): try: data = await self.poolsense.get_poolsense_data() except (PoolSenseError) as error: diff --git a/homeassistant/components/poolsense/translations/bg.json b/homeassistant/components/poolsense/translations/bg.json new file mode 100644 index 00000000000..a89cca15270 --- /dev/null +++ b/homeassistant/components/poolsense/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" + }, + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + }, + "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\u0432\u0430\u043d\u0435\u0442\u043e?", + "title": "PoolSense" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/poolsense/translations/ja.json b/homeassistant/components/poolsense/translations/ja.json new file mode 100644 index 00000000000..28d7e1d3d18 --- /dev/null +++ b/homeassistant/components/poolsense/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": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "user": { + "data": { + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f", + "title": "PoolSense" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/poolsense/translations/tr.json b/homeassistant/components/poolsense/translations/tr.json index 1e2e9d0c5b8..e1b4f150e92 100644 --- a/homeassistant/components/poolsense/translations/tr.json +++ b/homeassistant/components/poolsense/translations/tr.json @@ -11,7 +11,9 @@ "data": { "email": "E-posta", "password": "Parola" - } + }, + "description": "Kuruluma ba\u015flamak ister misiniz?", + "title": "PoolSense" } } } diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index 420212a86ba..cb9929a890e 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -10,8 +10,9 @@ from tesla_powerwall import ( import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.components.dhcp import IP_ADDRESS +from homeassistant.components import dhcp from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN @@ -57,11 +58,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Initialize the powerwall flow.""" self.ip_address = None - async def async_step_dhcp(self, discovery_info): + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" - self.ip_address = discovery_info[IP_ADDRESS] + self.ip_address = discovery_info.ip self._async_abort_entries_match({CONF_IP_ADDRESS: self.ip_address}) - self.ip_address = discovery_info[IP_ADDRESS] self.context["title_placeholders"] = {CONF_IP_ADDRESS: self.ip_address} return await self.async_step_user() diff --git a/homeassistant/components/powerwall/translations/ja.json b/homeassistant/components/powerwall/translations/ja.json new file mode 100644 index 00000000000..27c9cfded6a --- /dev/null +++ b/homeassistant/components/powerwall/translations/ja.json @@ -0,0 +1,24 @@ +{ + "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" + }, + "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", + "wrong_version": "PowerWall\u306b\u3001\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u306a\u3044\u30d0\u30fc\u30b8\u30e7\u30f3\u306e\u30bd\u30d5\u30c8\u30a6\u30a7\u30a2\u304c\u4f7f\u7528\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u3053\u306e\u554f\u984c\u3092\u89e3\u6c7a\u3059\u308b\u305f\u3081\u306b\u3001\u30a2\u30c3\u30d7\u30b0\u30ec\u30fc\u30c9\u3092\u691c\u8a0e\u3059\u308b\u304b\u3001\u3053\u306e\u554f\u984c\u3092\u5831\u544a\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "flow_title": "{ip_address}", + "step": { + "user": { + "data": { + "ip_address": "IP\u30a2\u30c9\u30ec\u30b9", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "title": "Powerwall\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/powerwall/translations/tr.json b/homeassistant/components/powerwall/translations/tr.json index dd09a83a78c..a243e22b566 100644 --- a/homeassistant/components/powerwall/translations/tr.json +++ b/homeassistant/components/powerwall/translations/tr.json @@ -1,18 +1,24 @@ { "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", - "unknown": "Beklenmeyen hata" + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata", + "wrong_version": "G\u00fc\u00e7 duvar\u0131n\u0131z desteklenmeyen bir yaz\u0131l\u0131m s\u00fcr\u00fcm\u00fc kullan\u0131yor. \u00c7\u00f6z\u00fclebilmesi i\u00e7in l\u00fctfen bu sorunu y\u00fckseltmeyi veya bildirmeyi d\u00fc\u015f\u00fcn\u00fcn." }, - "flow_title": "Tesla Powerwall ( {ip_address} )", + "flow_title": "{ip_address}", "step": { "user": { "data": { - "ip_address": "\u0130p Adresi" - } + "ip_address": "\u0130p Adresi", + "password": "Parola" + }, + "description": "Parola genellikle Backup Gateway i\u00e7in seri numaras\u0131n\u0131n son 5 karakteridir ve Tesla uygulamas\u0131nda veya Backup Gateway 2 i\u00e7in kap\u0131n\u0131n i\u00e7inde bulunan parolan\u0131n son 5 karakterinde bulunabilir.", + "title": "Powerwall'a ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/profiler/manifest.json b/homeassistant/components/profiler/manifest.json index b448e3d1793..6e6dde8df1b 100644 --- a/homeassistant/components/profiler/manifest.json +++ b/homeassistant/components/profiler/manifest.json @@ -2,7 +2,7 @@ "domain": "profiler", "name": "Profiler", "documentation": "https://www.home-assistant.io/integrations/profiler", - "requirements": ["pyprof2calltree==1.4.5", "guppy3==3.1.0", "objgraph==3.4.1"], + "requirements": ["pyprof2calltree==1.4.5", "guppy3==3.1.2", "objgraph==3.4.1"], "codeowners": ["@bdraco"], "quality_scale": "internal", "config_flow": true diff --git a/homeassistant/components/profiler/translations/ja.json b/homeassistant/components/profiler/translations/ja.json new file mode 100644 index 00000000000..c9c3cc04633 --- /dev/null +++ b/homeassistant/components/profiler/translations/ja.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "user": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/profiler/translations/tr.json b/homeassistant/components/profiler/translations/tr.json index a152eb19468..48ce4808c04 100644 --- a/homeassistant/components/profiler/translations/tr.json +++ b/homeassistant/components/profiler/translations/tr.json @@ -2,6 +2,11 @@ "config": { "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + }, + "step": { + "user": { + "description": "Kuruluma ba\u015flamak ister misiniz?" + } } } } \ No newline at end of file diff --git a/homeassistant/components/progettihwsw/translations/ja.json b/homeassistant/components/progettihwsw/translations/ja.json new file mode 100644 index 00000000000..39038a24b1a --- /dev/null +++ b/homeassistant/components/progettihwsw/translations/ja.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "relay_modes": { + "data": { + "relay_1": "\u30ea\u30ec\u30fc1", + "relay_10": "\u30ea\u30ec\u30fc10", + "relay_11": "\u30ea\u30ec\u30fc11", + "relay_12": "\u30ea\u30ec\u30fc12", + "relay_13": "\u30ea\u30ec\u30fc13", + "relay_14": "\u30ea\u30ec\u30fc14", + "relay_15": "\u30ea\u30ec\u30fc15", + "relay_16": "\u30ea\u30ec\u30fc16", + "relay_2": "\u30ea\u30ec\u30fc2", + "relay_3": "\u30ea\u30ec\u30fc3", + "relay_4": "\u30ea\u30ec\u30fc4", + "relay_5": "\u30ea\u30ec\u30fc5", + "relay_6": "\u30ea\u30ec\u30fc6", + "relay_7": "\u30ea\u30ec\u30fc7", + "relay_8": "\u30ea\u30ec\u30fc8", + "relay_9": "\u30ea\u30ec\u30fc9" + }, + "title": "\u30ea\u30ec\u30fc\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "title": "\u30dc\u30fc\u30c9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/progettihwsw/translations/tr.json b/homeassistant/components/progettihwsw/translations/tr.json index 1d3d77584dd..6c78ef295df 100644 --- a/homeassistant/components/progettihwsw/translations/tr.json +++ b/homeassistant/components/progettihwsw/translations/tr.json @@ -31,7 +31,7 @@ }, "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "port": "Port" }, "title": "Panoyu kur" diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 7c531a292b1..2eb4193377d 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -276,6 +276,15 @@ class PrometheusMetrics: value = self.state_as_number(state) metric.labels(**self._labels(state)).set(value) + def _handle_input_number(self, state): + metric = self._metric( + "input_number_state", + self.prometheus_cli.Gauge, + "State of the input number", + ) + value = self.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + def _handle_device_tracker(self, state): metric = self._metric( "device_tracker_state", @@ -409,9 +418,11 @@ class PrometheusMetrics: break if metric is not None: - _metric = self._metric( - metric, self.prometheus_cli.Gauge, f"Sensor data measured in {unit}" - ) + documentation = "State of the sensor" + if unit: + documentation = f"Sensor data measured in {unit}" + + _metric = self._metric(metric, self.prometheus_cli.Gauge, documentation) try: value = self.state_as_number(state) @@ -449,8 +460,12 @@ class PrometheusMetrics: def _sensor_fallback_metric(state, unit): """Get metric from fallback logic for compatibility.""" if unit in (None, ""): - _LOGGER.debug("Unsupported sensor: %s", state.entity_id) - return None + try: + state_helper.state_as_number(state) + except ValueError: + _LOGGER.debug("Unsupported sensor: %s", state.entity_id) + return None + return "sensor_state" return f"sensor_unit_{unit}" @staticmethod diff --git a/homeassistant/components/prosegur/translations/id.json b/homeassistant/components/prosegur/translations/id.json index 9616471c03a..29fecb900da 100644 --- a/homeassistant/components/prosegur/translations/id.json +++ b/homeassistant/components/prosegur/translations/id.json @@ -12,6 +12,7 @@ "step": { "reauth_confirm": { "data": { + "description": "Autentikasi ulang dengan akun Prosegur.", "password": "Kata Sandi", "username": "Nama Pengguna" } @@ -19,7 +20,8 @@ "user": { "data": { "country": "Negara", - "password": "Kata Sandi" + "password": "Kata Sandi", + "username": "Nama Pengguna" } } } diff --git a/homeassistant/components/prosegur/translations/ja.json b/homeassistant/components/prosegur/translations/ja.json new file mode 100644 index 00000000000..7a10da31188 --- /dev/null +++ b/homeassistant/components/prosegur/translations/ja.json @@ -0,0 +1,29 @@ +{ + "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" + }, + "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": { + "description": "Prosegur\u30a2\u30ab\u30a6\u30f3\u30c8\u3067\u518d\u8a8d\u8a3c\u3057\u307e\u3059\u3002", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + }, + "user": { + "data": { + "country": "\u56fd", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/tr.json b/homeassistant/components/prosegur/translations/tr.json new file mode 100644 index 00000000000..9b14241980a --- /dev/null +++ b/homeassistant/components/prosegur/translations/tr.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "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", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Prosegur hesab\u0131yla yeniden kimlik do\u011frulamas\u0131 yap\u0131n.", + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + }, + "user": { + "data": { + "country": "\u00dclke", + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prowl/notify.py b/homeassistant/components/prowl/notify.py index 91dd8eca5ca..837bad930f4 100644 --- a/homeassistant/components/prowl/notify.py +++ b/homeassistant/components/prowl/notify.py @@ -56,7 +56,7 @@ class ProwlNotificationService(BaseNotificationService): session = async_get_clientsession(self._hass) try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): response = await session.post(url, data=payload) result = await response.text() diff --git a/homeassistant/components/proximity/translations/ja.json b/homeassistant/components/proximity/translations/ja.json new file mode 100644 index 00000000000..6b085416cc5 --- /dev/null +++ b/homeassistant/components/proximity/translations/ja.json @@ -0,0 +1,3 @@ +{ + "title": "\u8fd1\u63a5" +} \ No newline at end of file diff --git a/homeassistant/components/ps4/translations/ja.json b/homeassistant/components/ps4/translations/ja.json new file mode 100644 index 00000000000..041ba2e121e --- /dev/null +++ b/homeassistant/components/ps4/translations/ja.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "credential_error": "\u8cc7\u683c\u60c5\u5831\u306e\u53d6\u5f97\u4e2d\u306b\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002", + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "port_987_bind_error": "\u30dd\u30fc\u30c8 987\u306b\u30d0\u30a4\u30f3\u30c9\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002\u8a73\u7d30\u306f\u3001[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8](https://www.home-assistant.io/components/ps4/)\u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "port_997_bind_error": "\u30dd\u30fc\u30c8 997\u306b\u30d0\u30a4\u30f3\u30c9\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002\u8a73\u7d30\u306f\u3001[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8](https://www.home-assistant.io/components/ps4/)\u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "credential_timeout": "\u8cc7\u683c\u60c5\u5831\u30b5\u30fc\u30d3\u30b9\u304c\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002\u9001\u4fe1(submit)\u3092\u62bc\u3057\u3066\u518d\u8d77\u52d5\u3057\u307e\u3059\u3002", + "login_failed": "PlayStation 4\u3068\u306e\u30da\u30a2\u30ea\u30f3\u30b0\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002PIN\u30b3\u30fc\u30c9\u304c\u6b63\u3057\u3044\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "no_ipaddress": "\u8a2d\u5b9a\u3057\u305f\u3044PlayStation4\u306eIP\u30a2\u30c9\u30ec\u30b9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "step": { + "creds": { + "description": "\u8a8d\u8a3c\u60c5\u5831\u304c\u5fc5\u8981\u3067\u3059\u3002'\u9001\u4fe1(submit)' \u3092\u62bc\u3057\u3066\u3001PS4\u306e2nd Screen App\u3067\u30c7\u30d0\u30a4\u30b9\u3092\u66f4\u65b0\u3057\u3001'Home-Assistant' \u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e\u3057\u3066\u7d9a\u884c\u3057\u307e\u3059\u3002", + "title": "Play Station 4" + }, + "link": { + "data": { + "code": "PIN\u30b3\u30fc\u30c9", + "ip_address": "IP\u30a2\u30c9\u30ec\u30b9", + "name": "\u540d\u524d", + "region": "\u30ea\u30fc\u30b8\u30e7\u30f3" + }, + "description": "PlayStation4\u306e\u60c5\u5831\u3092\u5165\u529b\u3057\u307e\u3059\u3002 PIN\u30b3\u30fc\u30c9(PIN CodePIN Code)\u306e\u5834\u5408\u306f\u3001PlayStation4\u672c\u4f53\u306e '\u8a2d\u5b9a' \u306b\u79fb\u52d5\u3057\u307e\u3059\u3002\u6b21\u306b\u3001'\u30e2\u30d0\u30a4\u30eb\u30a2\u30d7\u30ea\u63a5\u7d9a\u8a2d\u5b9a' \u306b\u79fb\u52d5\u3057\u3066\u3001'\u30c7\u30d0\u30a4\u30b9\u306e\u8ffd\u52a0' \u3092\u9078\u629e\u3057\u307e\u3059\u3002\u8868\u793a\u3055\u308c\u305f PIN\u30b3\u30fc\u30c9(PIN CodePIN Code) \u3092\u5165\u529b\u3057\u307e\u3059\u3002\u8a73\u7d30\u306f\u3001[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8]\uff08(https://www.home-assistant.io/components/ps4/) \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Play Station 4" + }, + "mode": { + "data": { + "ip_address": "IP\u30a2\u30c9\u30ec\u30b9(\u81ea\u52d5\u691c\u51fa\u3092\u4f7f\u7528\u3059\u308b\u5834\u5408\u306f\u7a7a\u306e\u307e\u307e\u306b\u3057\u307e\u3059)", + "mode": "\u30b3\u30f3\u30d5\u30a3\u30b0\u30e2\u30fc\u30c9" + }, + "description": "\u30b3\u30f3\u30d5\u30a3\u30ae\u30e5\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u30e2\u30fc\u30c9\u3092\u9078\u629e\u3057\u307e\u3059\u3002\u81ea\u52d5\u691c\u51fa\u3092\u9078\u629e\u3059\u308b\u3068\u3001IP\u30a2\u30c9\u30ec\u30b9\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u306f\u3001\u7a7a\u767d\u306e\u307e\u307e\u3067\u3082\u30c7\u30d0\u30a4\u30b9\u304c\u81ea\u52d5\u7684\u306b\u691c\u51fa\u3055\u308c\u307e\u3059\u3002", + "title": "Play Station 4" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ps4/translations/tr.json b/homeassistant/components/ps4/translations/tr.json index 4e3e0b53445..895491bfe4b 100644 --- a/homeassistant/components/ps4/translations/tr.json +++ b/homeassistant/components/ps4/translations/tr.json @@ -1,21 +1,40 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "credential_error": "Kimlik bilgileri al\u0131n\u0131rken hata olu\u015ftu.", + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", + "port_987_bind_error": "987 numaral\u0131 ba\u011flant\u0131 noktas\u0131na ba\u011flanamad\u0131. Ek bilgi i\u00e7in [belgelere](https://www.home-assistant.io/components/ps4/) bak\u0131n.", + "port_997_bind_error": "997 numaral\u0131 ba\u011flant\u0131 noktas\u0131na ba\u011flanamad\u0131. Ek bilgi i\u00e7in [belgelere](https://www.home-assistant.io/components/ps4/) bak\u0131n." }, "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "credential_timeout": "Kimlik bilgisi hizmeti zaman a\u015f\u0131m\u0131na u\u011frad\u0131. Yeniden ba\u015flatmak i\u00e7in g\u00f6nder'e bas\u0131n.", + "login_failed": "PlayStation 4 ile e\u015fle\u015ftirilemedi. PIN Kodu nin do\u011fru oldu\u011funu do\u011frulay\u0131n.", + "no_ipaddress": "Yap\u0131land\u0131rmak istedi\u011finiz PlayStation 4'\u00fcn IP Adresi kodunu girin." }, "step": { + "creds": { + "description": "Kimlik bilgileri gerekli. \"G\u00f6nder\"e bas\u0131n ve ard\u0131ndan PS4 2. Ekran Uygulamas\u0131nda cihazlar\u0131 yenileyin ve devam etmek i\u00e7in \"Home-Asistan\" cihaz\u0131n\u0131 se\u00e7in.", + "title": "PlayStation 4" + }, "link": { "data": { - "ip_address": "\u0130p Adresi" - } + "code": "PIN Kodu", + "ip_address": "\u0130p Adresi", + "name": "Ad", + "region": "B\u00f6lge" + }, + "description": "PlayStation 4 bilgilerinizi girin. PIN Kodu i\u00e7in PlayStation 4 konsolunuzda 'Ayarlar'a gidin. Ard\u0131ndan \"Mobil Uygulama Ba\u011flant\u0131 Ayarlar\u0131\"na gidin ve \"Cihaz Ekle\"yi se\u00e7in. PIN Kodu kodunu girin. Ek bilgi i\u00e7in [belgelere](https://www.home-assistant.io/components/ps4/) bak\u0131n.", + "title": "PlayStation 4" }, "mode": { "data": { - "ip_address": "\u0130p Adresi (Otomatik Bulma kullan\u0131l\u0131yorsa bo\u015f b\u0131rak\u0131n)." - } + "ip_address": "\u0130p Adresi (Otomatik Bulma kullan\u0131l\u0131yorsa bo\u015f b\u0131rak\u0131n).", + "mode": "Yap\u0131land\u0131rma Modu" + }, + "description": "Yap\u0131land\u0131rma i\u00e7in modu se\u00e7in. IP Adresi alan\u0131, Otomatik Ke\u015fif se\u00e7ildi\u011finde cihazlar otomatik olarak ke\u015ffedilece\u011finden bo\u015f b\u0131rak\u0131labilir.", + "title": "PlayStation 4" } } } diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index 8f4d1d04dcf..b216eabbd85 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -10,12 +10,7 @@ import aiohttp import async_timeout import voluptuous as vol -from homeassistant.components.camera import ( - PLATFORM_SCHEMA, - STATE_IDLE, - STATE_RECORDING, - Camera, -) +from homeassistant.components.camera import PLATFORM_SCHEMA, STATE_IDLE, Camera from homeassistant.components.camera.const import DOMAIN from homeassistant.const import CONF_NAME, CONF_TIMEOUT, CONF_WEBHOOK_ID from homeassistant.core import callback @@ -72,7 +67,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def handle_webhook(hass, webhook_id, request): """Handle incoming webhook POST with image files.""" try: - with async_timeout.timeout(5): + async with async_timeout.timeout(5): data = dict(await request.post()) except (asyncio.TimeoutError, aiohttp.web.HTTPException) as error: _LOGGER.error("Could not get information from POST <%s>", error) @@ -99,7 +94,6 @@ class PushCamera(Camera): self._last_trip = None self._filename = None self._expired_listener = None - self._state = STATE_IDLE self._timeout = timeout self.queue = deque([], buffer_size) self._current_image = None @@ -125,15 +119,10 @@ class PushCamera(Camera): """HTTP field containing the image file.""" return self._image_field - @property - def state(self): - """Return current state of the camera.""" - return self._state - async def update_image(self, image, filename): """Update the camera image.""" - if self._state == STATE_IDLE: - self._state = STATE_RECORDING + if self.state == STATE_IDLE: + self._attr_is_recording = True self._last_trip = dt_util.utcnow() self.queue.clear() @@ -143,7 +132,7 @@ class PushCamera(Camera): @callback def reset_state(now): """Set state to idle after no new images for a period of time.""" - self._state = STATE_IDLE + self._attr_is_recording = False self._expired_listener = None _LOGGER.debug("Reset state") self.async_write_ha_state() @@ -162,7 +151,7 @@ class PushCamera(Camera): ) -> bytes | None: """Return a still image response.""" if self.queue: - if self._state == STATE_IDLE: + if self.state == STATE_IDLE: self.queue.rotate(1) self._current_image = self.queue[0] diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py index a64517d2f48..6f851f8000e 100644 --- a/homeassistant/components/pushbullet/notify.py +++ b/homeassistant/components/pushbullet/notify.py @@ -41,7 +41,7 @@ def get_service(hass, config, discovery_info=None): class PushBulletNotificationService(BaseNotificationService): """Implement the notification service for Pushbullet.""" - def __init__(self, pb): + def __init__(self, pb): # pylint: disable=invalid-name """Initialize the service.""" self.pushbullet = pb self.pbtargets = {} diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index f7c946a91d9..e58296e56fe 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -95,7 +95,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class PushBulletNotificationSensor(SensorEntity): """Representation of a Pushbullet Sensor.""" - def __init__(self, pb, description: SensorEntityDescription): + def __init__( + self, + pb, # pylint: disable=invalid-name + description: SensorEntityDescription, + ): """Initialize the Pushbullet sensor.""" self.entity_description = description self.pushbullet = pb @@ -118,10 +122,10 @@ class PushBulletNotificationSensor(SensorEntity): class PushBulletNotificationProvider: """Provider for an account, leading to one or more sensors.""" - def __init__(self, pb): + def __init__(self, pushbullet): """Start to retrieve pushes from the given Pushbullet instance.""" - self.pushbullet = pb + self.pushbullet = pushbullet self._data = None self.listener = None self.thread = threading.Thread(target=self.retrieve_pushes) diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index 8126e00d8e5..1d8b3400d8b 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -27,7 +27,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -_ENDPOINT = "http://pvoutput.org/service/r2/getstatus.jsp" +_ENDPOINT = "https://pvoutput.org/service/r2/getstatus.jsp" ATTR_ENERGY_GENERATION = "energy_generation" ATTR_POWER_GENERATION = "power_generation" diff --git a/homeassistant/components/pvpc_hourly_pricing/manifest.json b/homeassistant/components/pvpc_hourly_pricing/manifest.json index a30ae1e2732..86696784638 100644 --- a/homeassistant/components/pvpc_hourly_pricing/manifest.json +++ b/homeassistant/components/pvpc_hourly_pricing/manifest.json @@ -3,7 +3,7 @@ "name": "Spain electricity hourly pricing (PVPC)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/pvpc_hourly_pricing", - "requirements": ["aiopvpc==2.2.1"], + "requirements": ["aiopvpc==2.2.4"], "codeowners": ["@azogue"], "quality_scale": "platinum", "iot_class": "cloud_polling" diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py index 9cc5603e35b..1000d23b5bf 100644 --- a/homeassistant/components/pvpc_hourly_pricing/sensor.py +++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py @@ -66,8 +66,7 @@ class ElecPriceSensor(RestoreEntity, SensorEntity): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() - state = await self.async_get_last_state() - if state: + if state := await self.async_get_last_state(): self._pvpc_data.state = state.state # Update 'state' value in hour changes diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/id.json b/homeassistant/components/pvpc_hourly_pricing/translations/id.json index 9a8a18a7543..5705a2a4fcb 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/id.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/id.json @@ -7,6 +7,21 @@ "user": { "data": { "name": "Nama Sensor", + "power": "Daya terkontrak (kW)", + "power_p3": "Daya terkontrak untuk periode lembah P3 (kW)", + "tariff": "Tarif yang berlaku menurut zona geografis" + }, + "description": "Sensor ini menggunakan API resmi untuk mendapatkan [harga listrik per jam (PVPC)](https://www.esios.ree.es/es/pvpc) di Spanyol.\nUntuk penjelasan yang lebih tepat, kunjungi [dokumen integrasi](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", + "title": "Penyiapan sensor" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "power": "Daya terkontrak (kW)", + "power_p3": "Daya terkontrak untuk periode lembah P3 (kW)", "tariff": "Tarif yang berlaku menurut zona geografis" }, "description": "Sensor ini menggunakan API resmi untuk mendapatkan [harga listrik per jam (PVPC)](https://www.esios.ree.es/es/pvpc) di Spanyol.\nUntuk penjelasan yang lebih tepat, kunjungi [dokumen integrasi](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/ja.json b/homeassistant/components/pvpc_hourly_pricing/translations/ja.json new file mode 100644 index 00000000000..1021f47a38a --- /dev/null +++ b/homeassistant/components/pvpc_hourly_pricing/translations/ja.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "step": { + "user": { + "data": { + "name": "\u30bb\u30f3\u30b5\u30fc\u540d", + "power": "\u5951\u7d04\u96fb\u529b (kW)", + "power_p3": "\u8c37\u9593(valley period) P3 (kW)\u306e\u5951\u7d04\u96fb\u529b", + "tariff": "\u5730\u57df\u5225\u9069\u7528\u95a2\u7a0e" + }, + "description": "\u3053\u306e\u30bb\u30f3\u30b5\u30fc\u306f\u3001\u516c\u5f0fAPI\u3092\u4f7f\u7528\u3057\u3066\u3001\u30b9\u30da\u30a4\u30f3\u3067\u306e[\u96fb\u6c17\u306e\u6642\u9593\u4fa1\u683c((hourly pricing of electricity)PVPC)](https://www.esios.ree.es/es/pvpc) \u3092\u53d6\u5f97\u3057\u307e\u3059\u3002\n\u3088\u308a\u6b63\u78ba\u306a\u8aac\u660e\u306b\u3064\u3044\u3066\u306f\u3001[\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3 \u30c9\u30ad\u30e5\u30e1\u30f3\u30c8](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/) \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30bb\u30f3\u30b5\u30fc\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "power": "\u5951\u7d04\u96fb\u529b (kW)", + "power_p3": "\u8c37\u9593(valley period) P3 (kW)\u306e\u5951\u7d04\u96fb\u529b", + "tariff": "\u5730\u57df\u5225\u9069\u7528\u95a2\u7a0e" + }, + "description": "\u3053\u306e\u30bb\u30f3\u30b5\u30fc\u306f\u3001\u516c\u5f0fAPI\u3092\u4f7f\u7528\u3057\u3066\u3001\u30b9\u30da\u30a4\u30f3\u3067\u306e[\u96fb\u6c17\u306e\u6642\u9593\u4fa1\u683c((hourly pricing of electricity)PVPC)](https://www.esios.ree.es/es/pvpc) \u3092\u53d6\u5f97\u3057\u307e\u3059\u3002\n\u3088\u308a\u6b63\u78ba\u306a\u8aac\u660e\u306b\u3064\u3044\u3066\u306f\u3001[\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3 \u30c9\u30ad\u30e5\u30e1\u30f3\u30c8](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/) \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30bb\u30f3\u30b5\u30fc\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/tr.json b/homeassistant/components/pvpc_hourly_pricing/translations/tr.json index 394f876401b..5d27e2bb033 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/tr.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/tr.json @@ -6,8 +6,26 @@ "step": { "user": { "data": { - "name": "Sens\u00f6r Ad\u0131" - } + "name": "Sens\u00f6r Ad\u0131", + "power": "S\u00f6zle\u015fmeli g\u00fc\u00e7 (kW)", + "power_p3": "Vadi d\u00f6nemi i\u00e7in taahh\u00fct edilen g\u00fc\u00e7 P3 (kW)", + "tariff": "Co\u011frafi b\u00f6lgeye g\u00f6re ge\u00e7erli tarife" + }, + "description": "Bu sens\u00f6r, \u0130spanya'da [saatlik elektrik fiyatland\u0131rmas\u0131 (PVPC)](https://www.esios.ree.es/es/pvpc) almak i\u00e7in resmi API'yi kullan\u0131r.\n Daha kesin a\u00e7\u0131klama i\u00e7in [entegrasyon belgelerini](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/) ziyaret edin.", + "title": "Sens\u00f6r kurulumu" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "power": "S\u00f6zle\u015fmeli g\u00fc\u00e7 (kW)", + "power_p3": "Vadi d\u00f6nemi i\u00e7in taahh\u00fct edilen g\u00fc\u00e7 P3 (kW)", + "tariff": "Co\u011frafi b\u00f6lgeye g\u00f6re ge\u00e7erli tarife" + }, + "description": "Bu sens\u00f6r, \u0130spanya'da [saatlik elektrik fiyatland\u0131rmas\u0131 (PVPC)](https://www.esios.ree.es/es/pvpc) almak i\u00e7in resmi API'yi kullan\u0131r.\n Daha kesin a\u00e7\u0131klama i\u00e7in [entegrasyon belgelerini](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/) ziyaret edin.", + "title": "Sens\u00f6r kurulumu" } } } diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json index 0b5af0ae432..8db94bb9817 100644 --- a/homeassistant/components/python_script/manifest.json +++ b/homeassistant/components/python_script/manifest.json @@ -2,7 +2,7 @@ "domain": "python_script", "name": "Python Scripts", "documentation": "https://www.home-assistant.io/integrations/python_script", - "requirements": ["restrictedpython==5.1"], + "requirements": ["restrictedpython==5.2"], "codeowners": [], "quality_scale": "internal" } diff --git a/homeassistant/components/rachio/config_flow.py b/homeassistant/components/rachio/config_flow.py index aa4efd971a6..9e93dba065e 100644 --- a/homeassistant/components/rachio/config_flow.py +++ b/homeassistant/components/rachio/config_flow.py @@ -7,8 +7,10 @@ from requests.exceptions import ConnectTimeout import voluptuous as vol from homeassistant import config_entries, core, exceptions +from homeassistant.components import zeroconf from homeassistant.const import CONF_API_KEY from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import ( CONF_MANUAL_RUN_MINS, @@ -78,13 +80,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_homekit(self, discovery_info): + async def async_step_homekit( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle HomeKit discovery.""" self._async_abort_entries_match() - properties = { - key.lower(): value for (key, value) in discovery_info["properties"].items() - } - await self.async_set_unique_id(properties["id"]) + await self.async_set_unique_id( + discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] + ) return await self.async_step_user() @staticmethod diff --git a/homeassistant/components/rachio/translations/bg.json b/homeassistant/components/rachio/translations/bg.json new file mode 100644 index 00000000000..fdbdc5b1cdf --- /dev/null +++ b/homeassistant/components/rachio/translations/bg.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rachio/translations/ja.json b/homeassistant/components/rachio/translations/ja.json new file mode 100644 index 00000000000..37b8fa99800 --- /dev/null +++ b/homeassistant/components/rachio/translations/ja.json @@ -0,0 +1,30 @@ +{ + "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc" + }, + "description": "https://app.rach.io/ \u304b\u3089\u306eAPI Key\u304c\u5fc5\u8981\u3067\u3059\u3002Settings(\u8a2d\u5b9a)\u3092\u958b\u304d\u3001'GET API KEY'\u3092\u30af\u30ea\u30c3\u30af\u3057\u307e\u3059\u3002", + "title": "Rachio device\u306b\u63a5\u7d9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "manual_run_mins": "\u30be\u30fc\u30f3 \u30b9\u30a4\u30c3\u30c1\u3092\u30a2\u30af\u30c6\u30a3\u30d6\u5316\u3059\u308b\u3068\u304d\u306e\u5b9f\u884c\u6642\u9593(\u5206)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rachio/translations/tr.json b/homeassistant/components/rachio/translations/tr.json index 8bbc4eb1e49..a6c9de15318 100644 --- a/homeassistant/components/rachio/translations/tr.json +++ b/homeassistant/components/rachio/translations/tr.json @@ -12,6 +12,17 @@ "user": { "data": { "api_key": "API Anahtar\u0131" + }, + "description": "https://app.rach.io/ adresinden API Anahtar\u0131na ihtiyac\u0131n\u0131z olacak. Ayarlar'a gidin, ard\u0131ndan 'API ANAHTARI AL' se\u00e7ene\u011fini t\u0131klay\u0131n.", + "title": "Rachio cihaz\u0131n\u0131za ba\u011flan\u0131n" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "manual_run_mins": "Bir b\u00f6lge anahtar\u0131 etkinle\u015ftirilirken \u00e7al\u0131\u015ft\u0131r\u0131lacak dakika cinsinden s\u00fcre" } } } diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py index 91447392ea8..52f40e81d40 100644 --- a/homeassistant/components/rainforest_eagle/data.py +++ b/homeassistant/components/rainforest_eagle/data.py @@ -50,7 +50,7 @@ async def async_get_type(hass, cloud_id, install_code, host): ) try: - with async_timeout.timeout(30): + async with async_timeout.timeout(30): meters = await hub.get_device_list() except aioeagle.BadAuth as err: raise InvalidAuth from err diff --git a/homeassistant/components/rainforest_eagle/translations/id.json b/homeassistant/components/rainforest_eagle/translations/id.json index 80db8f3182d..06e9c5644b6 100644 --- a/homeassistant/components/rainforest_eagle/translations/id.json +++ b/homeassistant/components/rainforest_eagle/translations/id.json @@ -11,7 +11,9 @@ "step": { "user": { "data": { - "host": "Host" + "cloud_id": "ID cloud", + "host": "Host", + "install_code": "Kode instalasi" } } } diff --git a/homeassistant/components/rainforest_eagle/translations/ja.json b/homeassistant/components/rainforest_eagle/translations/ja.json new file mode 100644 index 00000000000..d215480e052 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "cloud_id": "\u30af\u30e9\u30a6\u30c9ID", + "host": "\u30db\u30b9\u30c8", + "install_code": "\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u30b3\u30fc\u30c9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/tr.json b/homeassistant/components/rainforest_eagle/translations/tr.json new file mode 100644 index 00000000000..39886e8669d --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/tr.json @@ -0,0 +1,21 @@ +{ + "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": { + "cloud_id": "Bulut kimli\u011fi", + "host": "Ana bilgisayar", + "install_code": "Y\u00fckleme kodu" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index b72fe0fb25d..9f7f014f13d 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -11,9 +11,8 @@ from regenmaschine.controller import Controller from regenmaschine.errors import RainMachineError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_DEVICE_ID, CONF_IP_ADDRESS, CONF_PASSWORD, @@ -22,8 +21,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_validation as cv -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import ( + aiohttp_client, + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -166,9 +169,6 @@ async def async_update_programs_and_zones( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up RainMachine as config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} - websession = aiohttp_client.async_get_clientsession(hass) client = Client(session=websession) @@ -184,9 +184,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # regenmaschine can load multiple controllers at once, but we only grab the one # we loaded above: - controller = hass.data[DOMAIN][entry.entry_id][ - DATA_CONTROLLER - ] = get_client_controller(client) + controller = get_client_controller(client) entry_updates: dict[str, Any] = {} if not entry.unique_id or is_ip_address(entry.unique_id): @@ -244,7 +242,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: controller_init_tasks.append(coordinator.async_refresh()) await asyncio.gather(*controller_init_tasks) - hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] = coordinators + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + DATA_CONTROLLER: controller, + DATA_COORDINATOR: coordinators, + } hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -310,9 +313,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) - if len(hass.config_entries.async_entries(DOMAIN)) == 1: - # If this is the last instance of RainMachine, deregister any services defined - # during integration setup: + loaded_entries = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + if len(loaded_entries) == 1: + # If this is the last loaded instance of RainMachine, deregister any services + # defined during integration setup: for service_name in ( SERVICE_NAME_PAUSE_WATERING, SERVICE_NAME_PUSH_WEATHER_DATA, @@ -324,6 +332,38 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate an old config entry.""" + version = entry.version + + LOGGER.debug("Migrating from version %s", version) + + # 1 -> 2: Update unique IDs to be consistent across platform (including removing + # the silly removal of colons in the MAC address that was added originally): + if version == 1: + version = entry.version = 2 + + ent_reg = er.async_get(hass) + for entity_entry in [ + e for e in ent_reg.entities.values() if e.config_entry_id == entry.entry_id + ]: + unique_id_pieces = entity_entry.unique_id.split("_") + old_mac = unique_id_pieces[0] + new_mac = ":".join(old_mac[i : i + 2] for i in range(0, len(old_mac), 2)) + unique_id_pieces[0] = new_mac + + if entity_entry.entity_id.startswith("switch"): + unique_id_pieces[1] = unique_id_pieces[1][11:].lower() + + ent_reg.async_update_entity( + entity_entry.entity_id, new_unique_id="_".join(unique_id_pieces) + ) + + LOGGER.info("Migration to version %s successful", version) + + return True + + async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle an options update.""" await hass.config_entries.async_reload(entry.entry_id) @@ -354,12 +394,9 @@ class RainMachineEntity(CoordinatorEntity): ), sw_version=controller.software_version, ) - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._attr_extra_state_attributes = {} self._attr_name = f"{controller.name} {description.name}" - # The colons are removed from the device MAC simply because that value - # (unnecessarily) makes up the existing unique ID formula and we want to avoid - # a breaking change: - self._attr_unique_id = f"{controller.mac.replace(':', '')}_{description.key}" + self._attr_unique_id = f"{controller.mac}_{description.key}" self._controller = controller self.entity_description = description diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index c392ad1f8ce..39884681967 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -9,12 +9,12 @@ from regenmaschine.errors import RainMachineError import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv -from homeassistant.helpers.typing import DiscoveryInfoType from .const import CONF_ZONE_RUN_TIME, DEFAULT_PORT, DEFAULT_ZONE_RUN, DOMAIN @@ -42,7 +42,7 @@ async def async_get_controller( class RainMachineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a RainMachine config flow.""" - VERSION = 1 + VERSION = 2 discovered_ip_address: str | None = None @@ -54,15 +54,23 @@ class RainMachineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Define the config flow to handle options.""" return RainMachineOptionsFlowHandler(config_entry) - async def async_step_homekit(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_homekit( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle a flow initialized by homekit discovery.""" - return await self.async_step_zeroconf(discovery_info) + return await self.async_step_homekit_zeroconf(discovery_info) async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle discovery via zeroconf.""" - ip_address = discovery_info["host"] + return await self.async_step_homekit_zeroconf(discovery_info) + + async def async_step_homekit_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle discovery via zeroconf.""" + ip_address = discovery_info.host self._async_abort_entries_match({CONF_IP_ADDRESS: ip_address}) # Handle IP change diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index 345da380316..5a178718c9b 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -1,6 +1,7 @@ """This component provides support for RainMachine programs and zones.""" from __future__ import annotations +import asyncio from collections.abc import Coroutine from dataclasses import dataclass from datetime import datetime @@ -12,8 +13,9 @@ import voluptuous as vol from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ID +from homeassistant.const import ATTR_ID, ENTITY_CATEGORY_CONFIG from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -51,8 +53,6 @@ ATTR_TIME_REMAINING = "time_remaining" ATTR_VEGETATION_TYPE = "vegetation_type" ATTR_ZONES = "zones" -DEFAULT_ICON = "mdi:water" - DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] RUN_STATUS_MAP = {0: "Not Running", 1: "Running", 2: "Queued"} @@ -130,10 +130,6 @@ async def async_setup_entry( platform = entity_platform.async_get_current_platform() for service_name, schema, method in ( - ("disable_program", {}, "async_disable_program"), - ("disable_zone", {}, "async_disable_zone"), - ("enable_program", {}, "async_enable_program"), - ("enable_zone", {}, "async_enable_zone"), ("start_program", {}, "async_start_program"), ( "start_zone", @@ -149,44 +145,55 @@ async def async_setup_entry( ): platform.async_register_entity_service(service_name, schema, method) - controller = hass.data[DOMAIN][entry.entry_id][DATA_CONTROLLER] - programs_coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR][ - DATA_PROGRAMS - ] - zones_coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR][DATA_ZONES] + data = hass.data[DOMAIN][entry.entry_id] + controller = data[DATA_CONTROLLER] + program_coordinator = data[DATA_COORDINATOR][DATA_PROGRAMS] + zone_coordinator = data[DATA_COORDINATOR][DATA_ZONES] - entities: list[RainMachineProgram | RainMachineZone] = [ - RainMachineProgram( - entry, - programs_coordinator, - controller, - RainMachineSwitchDescription( - key=f"RainMachineProgram_{uid}", name=program["name"], uid=uid - ), - ) - for uid, program in programs_coordinator.data.items() - ] - entities.extend( - [ - RainMachineZone( - entry, - zones_coordinator, - controller, - RainMachineSwitchDescription( - key=f"RainMachineZone_{uid}", name=zone["name"], uid=uid - ), + entities: list[RainMachineActivitySwitch | RainMachineEnabledSwitch] = [] + + for kind, coordinator, switch_class, switch_enabled_class in ( + ("program", program_coordinator, RainMachineProgram, RainMachineProgramEnabled), + ("zone", zone_coordinator, RainMachineZone, RainMachineZoneEnabled), + ): + for uid, data in coordinator.data.items(): + # Add a switch to start/stop the program or zone: + entities.append( + switch_class( + entry, + coordinator, + controller, + RainMachineSwitchDescription( + key=f"{kind}_{uid}", + name=data["name"], + icon="mdi:water", + uid=uid, + ), + ) + ) + + # Add a switch to enabled/disable the program or zone: + entities.append( + switch_enabled_class( + entry, + coordinator, + controller, + RainMachineSwitchDescription( + key=f"{kind}_{uid}_enabled", + name=f"{data['name']} Enabled", + entity_category=ENTITY_CATEGORY_CONFIG, + icon="mdi:cog", + uid=uid, + ), + ) ) - for uid, zone in zones_coordinator.data.items() - ] - ) async_add_entities(entities) -class RainMachineSwitch(RainMachineEntity, SwitchEntity): - """A class to represent a generic RainMachine switch.""" +class RainMachineBaseSwitch(RainMachineEntity, SwitchEntity): + """Define a base RainMachine switch.""" - _attr_icon = DEFAULT_ICON entity_description: RainMachineSwitchDescription def __init__( @@ -196,37 +203,30 @@ class RainMachineSwitch(RainMachineEntity, SwitchEntity): controller: Controller, description: RainMachineSwitchDescription, ) -> None: - """Initialize a generic RainMachine switch.""" + """Initialize.""" super().__init__(entry, coordinator, controller, description) self._attr_is_on = False - self._data = coordinator.data[self.entity_description.uid] self._entry = entry - self._is_active = True - @property - def available(self) -> bool: - """Return True if entity is available.""" - return super().available and self._is_active - - async def _async_run_switch_coroutine(self, api_coro: Coroutine) -> None: - """Run a coroutine to toggle the switch.""" + async def _async_run_api_coroutine(self, api_coro: Coroutine) -> None: + """Await an API coroutine, handle any errors, and update as appropriate.""" try: resp = await api_coro except RequestError as err: LOGGER.error( - 'Error while toggling %s "%s": %s', - self.entity_description.key, - self.unique_id, + 'Error while executing %s on "%s": %s', + api_coro.__name__, + self.name, err, ) return if resp["statusCode"] != 0: LOGGER.error( - 'Error while toggling %s "%s": %s', - self.entity_description.key, - self.unique_id, + 'Error while executing %s on "%s": %s', + api_coro.__name__, + self.name, resp["message"], ) return @@ -237,94 +237,104 @@ class RainMachineSwitch(RainMachineEntity, SwitchEntity): async_update_programs_and_zones(self.hass, self._entry) ) - async def async_disable_program(self) -> None: - """Disable a program.""" - raise NotImplementedError("Service not implemented for this entity") - - async def async_disable_zone(self) -> None: - """Disable a zone.""" - raise NotImplementedError("Service not implemented for this entity") - - async def async_enable_program(self) -> None: - """Enable a program.""" - raise NotImplementedError("Service not implemented for this entity") - - async def async_enable_zone(self) -> None: - """Enable a zone.""" - raise NotImplementedError("Service not implemented for this entity") - async def async_start_program(self) -> None: - """Start a program.""" + """Execute the start_program entity service.""" raise NotImplementedError("Service not implemented for this entity") async def async_start_zone(self, *, zone_run_time: int) -> None: - """Start a zone.""" + """Execute the start_zone entity service.""" raise NotImplementedError("Service not implemented for this entity") async def async_stop_program(self) -> None: - """Stop a program.""" + """Execute the stop_program entity service.""" raise NotImplementedError("Service not implemented for this entity") async def async_stop_zone(self) -> None: - """Stop a zone.""" + """Execute the stop_zone entity service.""" raise NotImplementedError("Service not implemented for this entity") + +class RainMachineActivitySwitch(RainMachineBaseSwitch): + """Define a RainMachine switch to start/stop an activity (program or zone).""" + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off. + + The only way this could occur is if someone rapidly turns a disabled activity + off right after turning it on. + """ + if not self.coordinator.data[self.entity_description.uid]["active"]: + raise HomeAssistantError( + f"Cannot turn off an inactive program/zone: {self.name}" + ) + + await self.async_turn_off_when_active(**kwargs) + + async def async_turn_off_when_active(self, **kwargs: Any) -> None: + """Turn the switch off when its associated activity is active.""" + raise NotImplementedError + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + if not self.coordinator.data[self.entity_description.uid]["active"]: + self._attr_is_on = False + self.async_write_ha_state() + raise HomeAssistantError( + f"Cannot turn on an inactive program/zone: {self.name}" + ) + + await self.async_turn_on_when_active(**kwargs) + + async def async_turn_on_when_active(self, **kwargs: Any) -> None: + """Turn the switch on when its associated activity is active.""" + raise NotImplementedError + + +class RainMachineEnabledSwitch(RainMachineBaseSwitch): + """Define a RainMachine switch to enable/disable an activity (program or zone).""" + @callback def update_from_latest_data(self) -> None: - """Update the state.""" - self._data = self.coordinator.data[self.entity_description.uid] - self._is_active = self._data["active"] + """Update the entity when new data is received.""" + self._attr_is_on = self.coordinator.data[self.entity_description.uid]["active"] -class RainMachineProgram(RainMachineSwitch): - """A RainMachine program.""" - - @property - def zones(self) -> list: - """Return a list of active zones associated with this program.""" - return [z for z in self._data["wateringTimes"] if z["active"]] - - async def async_disable_program(self) -> None: - """Disable a program.""" - await self._controller.programs.disable(self.entity_description.uid) - await async_update_programs_and_zones(self.hass, self._entry) - - async def async_enable_program(self) -> None: - """Enable a program.""" - await self._controller.programs.enable(self.entity_description.uid) - await async_update_programs_and_zones(self.hass, self._entry) +class RainMachineProgram(RainMachineActivitySwitch): + """Define a RainMachine program.""" async def async_start_program(self) -> None: - """Start a program.""" + """Start the program.""" await self.async_turn_on() async def async_stop_program(self) -> None: - """Stop a program.""" + """Stop the program.""" await self.async_turn_off() - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the program off.""" - await self._async_run_switch_coroutine( + async def async_turn_off_when_active(self, **kwargs: Any) -> None: + """Turn the switch off when its associated activity is active.""" + await self._async_run_api_coroutine( self._controller.programs.stop(self.entity_description.uid) ) - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the program on.""" - await self._async_run_switch_coroutine( + async def async_turn_on_when_active(self, **kwargs: Any) -> None: + """Turn the switch on when its associated activity is active.""" + await self._async_run_api_coroutine( self._controller.programs.start(self.entity_description.uid) ) @callback def update_from_latest_data(self) -> None: - """Update the state.""" - super().update_from_latest_data() + """Update the entity when new data is received.""" + data = self.coordinator.data[self.entity_description.uid] - self._attr_is_on = bool(self._data["status"]) + self._attr_is_on = bool(data["status"]) - next_run: str | None = None - if self._data.get("nextRun") is not None: + next_run: str | None + if data.get("nextRun") is None: + next_run = None + else: next_run = datetime.strptime( - f"{self._data['nextRun']} {self._data['startTime']}", + f"{data['nextRun']} {data['startTime']}", "%Y-%m-%d %H:%M", ).isoformat() @@ -332,76 +342,107 @@ class RainMachineProgram(RainMachineSwitch): { ATTR_ID: self.entity_description.uid, ATTR_NEXT_RUN: next_run, - ATTR_SOAK: self.coordinator.data[self.entity_description.uid].get( - "soak" - ), - ATTR_STATUS: RUN_STATUS_MAP[ - self.coordinator.data[self.entity_description.uid]["status"] - ], - ATTR_ZONES: ", ".join(z["name"] for z in self.zones), + ATTR_SOAK: data.get("soak"), + ATTR_STATUS: RUN_STATUS_MAP[data["status"]], + ATTR_ZONES: [z for z in data["wateringTimes"] if z["active"]], } ) -class RainMachineZone(RainMachineSwitch): - """A RainMachine zone.""" +class RainMachineProgramEnabled(RainMachineEnabledSwitch): + """Define a switch to enable/disable a RainMachine program.""" - async def async_disable_zone(self) -> None: - """Disable a zone.""" - await self._controller.zones.disable(self.entity_description.uid) - await async_update_programs_and_zones(self.hass, self._entry) + async def async_turn_off(self, **kwargs: Any) -> None: + """Disable the program.""" + tasks = [ + self._async_run_api_coroutine( + self._controller.programs.stop(self.entity_description.uid) + ), + self._async_run_api_coroutine( + self._controller.programs.disable(self.entity_description.uid) + ), + ] - async def async_enable_zone(self) -> None: - """Enable a zone.""" - await self._controller.zones.enable(self.entity_description.uid) - await async_update_programs_and_zones(self.hass, self._entry) + await asyncio.gather(*tasks) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Enable the program.""" + await self._async_run_api_coroutine( + self._controller.programs.enable(self.entity_description.uid) + ) + + +class RainMachineZone(RainMachineActivitySwitch): + """Define a RainMachine zone.""" async def async_start_zone(self, *, zone_run_time: int) -> None: """Start a particular zone for a certain amount of time.""" - await self._controller.zones.start(self.entity_description.uid, zone_run_time) - await async_update_programs_and_zones(self.hass, self._entry) + await self.async_turn_off(duration=zone_run_time) async def async_stop_zone(self) -> None: """Stop a zone.""" await self.async_turn_off() - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the zone off.""" - await self._async_run_switch_coroutine( + async def async_turn_off_when_active(self, **kwargs: Any) -> None: + """Turn the switch off when its associated activity is active.""" + await self._async_run_api_coroutine( self._controller.zones.stop(self.entity_description.uid) ) - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the zone on.""" - await self._async_run_switch_coroutine( + async def async_turn_on_when_active(self, **kwargs: Any) -> None: + """Turn the switch on when its associated activity is active.""" + await self._async_run_api_coroutine( self._controller.zones.start( self.entity_description.uid, - self._entry.options[CONF_ZONE_RUN_TIME], + kwargs.get("duration", self._entry.options[CONF_ZONE_RUN_TIME]), ) ) @callback def update_from_latest_data(self) -> None: - """Update the state.""" - super().update_from_latest_data() + """Update the entity when new data is received.""" + data = self.coordinator.data[self.entity_description.uid] - self._attr_is_on = bool(self._data["state"]) + self._attr_is_on = bool(data["state"]) self._attr_extra_state_attributes.update( { - ATTR_STATUS: RUN_STATUS_MAP[self._data["state"]], - ATTR_AREA: self._data.get("waterSense").get("area"), - ATTR_CURRENT_CYCLE: self._data.get("cycle"), - ATTR_FIELD_CAPACITY: self._data.get("waterSense").get("fieldCapacity"), - ATTR_ID: self._data["uid"], - ATTR_NO_CYCLES: self._data.get("noOfCycles"), - ATTR_PRECIP_RATE: self._data.get("waterSense").get("precipitationRate"), - ATTR_RESTRICTIONS: self._data.get("restriction"), - ATTR_SLOPE: SLOPE_TYPE_MAP.get(self._data.get("slope")), - ATTR_SOIL_TYPE: SOIL_TYPE_MAP.get(self._data.get("soil")), - ATTR_SPRINKLER_TYPE: SPRINKLER_TYPE_MAP.get(self._data.get("group_id")), - ATTR_SUN_EXPOSURE: SUN_EXPOSURE_MAP.get(self._data.get("sun")), - ATTR_TIME_REMAINING: self._data.get("remaining"), - ATTR_VEGETATION_TYPE: VEGETATION_MAP.get(self._data.get("type")), + ATTR_AREA: data.get("waterSense").get("area"), + ATTR_CURRENT_CYCLE: data.get("cycle"), + ATTR_FIELD_CAPACITY: data.get("waterSense").get("fieldCapacity"), + ATTR_ID: data["uid"], + ATTR_NO_CYCLES: data.get("noOfCycles"), + ATTR_PRECIP_RATE: data.get("waterSense").get("precipitationRate"), + ATTR_RESTRICTIONS: data.get("restriction"), + ATTR_SLOPE: SLOPE_TYPE_MAP.get(data.get("slope")), + ATTR_SOIL_TYPE: SOIL_TYPE_MAP.get(data.get("soil")), + ATTR_SPRINKLER_TYPE: SPRINKLER_TYPE_MAP.get(data.get("group_id")), + ATTR_STATUS: RUN_STATUS_MAP[data["state"]], + ATTR_SUN_EXPOSURE: SUN_EXPOSURE_MAP.get(data.get("sun")), + ATTR_TIME_REMAINING: data.get("remaining"), + ATTR_VEGETATION_TYPE: VEGETATION_MAP.get(data.get("type")), } ) + + +class RainMachineZoneEnabled(RainMachineEnabledSwitch): + """Define a switch to enable/disable a RainMachine zone.""" + + async def async_turn_off(self, **kwargs: Any) -> None: + """Disable the zone.""" + tasks = [ + self._async_run_api_coroutine( + self._controller.zones.stop(self.entity_description.uid) + ), + self._async_run_api_coroutine( + self._controller.zones.disable(self.entity_description.uid) + ), + ] + + await asyncio.gather(*tasks) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Enable the zone.""" + await self._async_run_api_coroutine( + self._controller.zones.enable(self.entity_description.uid) + ) diff --git a/homeassistant/components/rainmachine/translations/bg.json b/homeassistant/components/rainmachine/translations/bg.json index 0ced0b2f334..b54660f8e9f 100644 --- a/homeassistant/components/rainmachine/translations/bg.json +++ b/homeassistant/components/rainmachine/translations/bg.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{ip}", "step": { "user": { "data": { diff --git a/homeassistant/components/rainmachine/translations/ja.json b/homeassistant/components/rainmachine/translations/ja.json new file mode 100644 index 00000000000..a7750784766 --- /dev/null +++ b/homeassistant/components/rainmachine/translations/ja.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "flow_title": "{ip}", + "step": { + "user": { + "data": { + "ip_address": "\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8" + }, + "title": "\u3042\u306a\u305f\u306e\u60c5\u5831\u3092\u5165\u529b" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "zone_run_time": "\u30c7\u30d5\u30a9\u30eb\u30c8\u30be\u30fc\u30f3\u306e\u5b9f\u884c\u6642\u9593(\u79d2\u5358\u4f4d)" + }, + "title": "RainMachine\u306e\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/translations/tr.json b/homeassistant/components/rainmachine/translations/tr.json index 80cfc05e568..4a45215a737 100644 --- a/homeassistant/components/rainmachine/translations/tr.json +++ b/homeassistant/components/rainmachine/translations/tr.json @@ -6,13 +6,15 @@ "error": { "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" }, + "flow_title": "{ip}", "step": { "user": { "data": { "ip_address": "Ana makine ad\u0131 veya IP adresi", "password": "Parola", "port": "Port" - } + }, + "title": "Bilgilerinizi doldurun" } } }, diff --git a/homeassistant/components/rdw/__init__.py b/homeassistant/components/rdw/__init__.py new file mode 100644 index 00000000000..bde83fffad3 --- /dev/null +++ b/homeassistant/components/rdw/__init__.py @@ -0,0 +1,43 @@ +"""Support for RDW.""" +from __future__ import annotations + +from vehicle import RDW, Vehicle + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +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 + +from .const import CONF_LICENSE_PLATE, DOMAIN, LOGGER, SCAN_INTERVAL + +PLATFORMS = (BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up RDW from a config entry.""" + session = async_get_clientsession(hass) + rdw = RDW(session=session, license_plate=entry.data[CONF_LICENSE_PLATE]) + + coordinator: DataUpdateCoordinator[Vehicle] = DataUpdateCoordinator( + hass, + LOGGER, + name=f"{DOMAIN}_APK", + update_interval=SCAN_INTERVAL, + update_method=rdw.vehicle, + ) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload RDW config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + return unload_ok diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py new file mode 100644 index 00000000000..80f4a425212 --- /dev/null +++ b/homeassistant/components/rdw/binary_sensor.py @@ -0,0 +1,102 @@ +"""Support for RDW binary sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from vehicle import Vehicle + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_PROBLEM, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +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 ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + + +@dataclass +class RDWBinarySensorEntityDescriptionMixin: + """Mixin for required keys.""" + + is_on_fn: Callable[[Vehicle], bool | None] + + +@dataclass +class RDWBinarySensorEntityDescription( + BinarySensorEntityDescription, RDWBinarySensorEntityDescriptionMixin +): + """Describes RDW binary sensor entity.""" + + +BINARY_SENSORS: tuple[RDWBinarySensorEntityDescription, ...] = ( + RDWBinarySensorEntityDescription( + key="liability_insured", + name="Liability Insured", + icon="mdi:shield-car", + is_on_fn=lambda vehicle: vehicle.liability_insured, + ), + RDWBinarySensorEntityDescription( + key="pending_recall", + name="Pending Recall", + device_class=DEVICE_CLASS_PROBLEM, + is_on_fn=lambda vehicle: vehicle.pending_recall, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up RDW binary sensors based on a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + RDWBinarySensorEntity( + coordinator=coordinator, + description=description, + ) + for description in BINARY_SENSORS + if description.is_on_fn(coordinator.data) is not None + ) + + +class RDWBinarySensorEntity(CoordinatorEntity, BinarySensorEntity): + """Defines an RDW binary sensor.""" + + entity_description: RDWBinarySensorEntityDescription + + def __init__( + self, + *, + coordinator: DataUpdateCoordinator, + description: RDWBinarySensorEntityDescription, + ) -> None: + """Initialize RDW binary sensor.""" + super().__init__(coordinator=coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.license_plate}_{description.key}" + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, coordinator.data.license_plate)}, + manufacturer=coordinator.data.brand, + name=f"{coordinator.data.brand}: {coordinator.data.license_plate}", + model=coordinator.data.model, + configuration_url=f"https://ovi.rdw.nl/default.aspx?kenteken={coordinator.data.license_plate}", + ) + + @property + def is_on(self) -> bool: + """Return the state of the sensor.""" + return bool(self.entity_description.is_on_fn(self.coordinator.data)) diff --git a/homeassistant/components/rdw/config_flow.py b/homeassistant/components/rdw/config_flow.py new file mode 100644 index 00000000000..a9fedc88dac --- /dev/null +++ b/homeassistant/components/rdw/config_flow.py @@ -0,0 +1,56 @@ +"""Config flow to configure the RDW integration.""" +from __future__ import annotations + +from typing import Any + +from vehicle import RDW, RDWError, RDWUnknownLicensePlateError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_LICENSE_PLATE, DOMAIN + + +class RDWFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for RDW.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + session = async_get_clientsession(self.hass) + rdw = RDW(session=session) + try: + vehicle = await rdw.vehicle( + license_plate=user_input[CONF_LICENSE_PLATE] + ) + except RDWUnknownLicensePlateError: + errors["base"] = "unknown_license_plate" + except RDWError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(vehicle.license_plate) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_LICENSE_PLATE], + data={ + CONF_LICENSE_PLATE: vehicle.license_plate, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_LICENSE_PLATE): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/rdw/const.py b/homeassistant/components/rdw/const.py new file mode 100644 index 00000000000..e39e4048791 --- /dev/null +++ b/homeassistant/components/rdw/const.py @@ -0,0 +1,13 @@ +"""Constants for the RDW integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "rdw" + +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(hours=1) + +CONF_LICENSE_PLATE: Final = "license_plate" diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json new file mode 100644 index 00000000000..c4f80f812ca --- /dev/null +++ b/homeassistant/components/rdw/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "rdw", + "name": "RDW", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/rdw", + "requirements": ["vehicle==0.2.2"], + "codeowners": ["@frenck"], + "quality_scale": "platinum", + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py new file mode 100644 index 00000000000..f2c8d93a8a2 --- /dev/null +++ b/homeassistant/components/rdw/sensor.py @@ -0,0 +1,104 @@ +"""Support for RDW sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import date + +from vehicle import Vehicle + +from homeassistant.components.sensor import ( + DEVICE_CLASS_DATE, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +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 ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import CONF_LICENSE_PLATE, DOMAIN + + +@dataclass +class RDWSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Vehicle], date | str | float | None] + + +@dataclass +class RDWSensorEntityDescription( + SensorEntityDescription, RDWSensorEntityDescriptionMixin +): + """Describes RDW sensor entity.""" + + +SENSORS: tuple[RDWSensorEntityDescription, ...] = ( + RDWSensorEntityDescription( + key="apk_expiration", + name="APK Expiration", + device_class=DEVICE_CLASS_DATE, + value_fn=lambda vehicle: vehicle.apk_expiration, + ), + RDWSensorEntityDescription( + key="ascription_date", + name="Ascription Date", + device_class=DEVICE_CLASS_DATE, + value_fn=lambda vehicle: vehicle.ascription_date, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up RDW sensors based on a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + RDWSensorEntity( + coordinator=coordinator, + license_plate=entry.data[CONF_LICENSE_PLATE], + description=description, + ) + for description in SENSORS + ) + + +class RDWSensorEntity(CoordinatorEntity, SensorEntity): + """Defines an RDW sensor.""" + + entity_description: RDWSensorEntityDescription + + def __init__( + self, + *, + coordinator: DataUpdateCoordinator, + license_plate: str, + description: RDWSensorEntityDescription, + ) -> None: + """Initialize RDW sensor.""" + super().__init__(coordinator=coordinator) + self.entity_description = description + self._attr_unique_id = f"{license_plate}_{description.key}" + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, f"{license_plate}")}, + manufacturer=coordinator.data.brand, + name=f"{coordinator.data.brand}: {coordinator.data.license_plate}", + model=coordinator.data.model, + configuration_url=f"https://ovi.rdw.nl/default.aspx?kenteken={coordinator.data.license_plate}", + ) + + @property + def native_value(self) -> date | str | float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/rdw/strings.json b/homeassistant/components/rdw/strings.json new file mode 100644 index 00000000000..48bcd8c0c5d --- /dev/null +++ b/homeassistant/components/rdw/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "user": { + "data": { + "license_plate": "License plate" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown_license_plate": "Unknown license plate" + } + } +} diff --git a/homeassistant/components/rdw/translations/bg.json b/homeassistant/components/rdw/translations/bg.json new file mode 100644 index 00000000000..e9a9c468402 --- /dev/null +++ b/homeassistant/components/rdw/translations/bg.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "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/rdw/translations/ca.json b/homeassistant/components/rdw/translations/ca.json new file mode 100644 index 00000000000..3871995e288 --- /dev/null +++ b/homeassistant/components/rdw/translations/ca.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown_license_plate": "Matr\u00edcula desconeguda" + }, + "step": { + "user": { + "data": { + "license_plate": "Matr\u00edcula" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rdw/translations/de.json b/homeassistant/components/rdw/translations/de.json new file mode 100644 index 00000000000..b7b25359183 --- /dev/null +++ b/homeassistant/components/rdw/translations/de.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown_license_plate": "Unbekanntes Nummernschild" + }, + "step": { + "user": { + "data": { + "license_plate": "Nummernschild" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rdw/translations/en.json b/homeassistant/components/rdw/translations/en.json new file mode 100644 index 00000000000..9d2827ed4de --- /dev/null +++ b/homeassistant/components/rdw/translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "unknown_license_plate": "Unknown license plate" + }, + "step": { + "user": { + "data": { + "license_plate": "License plate" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rdw/translations/es.json b/homeassistant/components/rdw/translations/es.json new file mode 100644 index 00000000000..e3f8891f3b1 --- /dev/null +++ b/homeassistant/components/rdw/translations/es.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "cannot_connect": "No se pudo conectar" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rdw/translations/et.json b/homeassistant/components/rdw/translations/et.json new file mode 100644 index 00000000000..48911f2bcb7 --- /dev/null +++ b/homeassistant/components/rdw/translations/et.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "unknown_license_plate": "Tundmatu numbrim\u00e4rk" + }, + "step": { + "user": { + "data": { + "license_plate": "Numbrim\u00e4rk" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rdw/translations/fr.json b/homeassistant/components/rdw/translations/fr.json new file mode 100644 index 00000000000..4da885d870f --- /dev/null +++ b/homeassistant/components/rdw/translations/fr.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00c9chec de connexion" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rdw/translations/he.json b/homeassistant/components/rdw/translations/he.json new file mode 100644 index 00000000000..5acc1e1e9f3 --- /dev/null +++ b/homeassistant/components/rdw/translations/he.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown_license_plate": "\u05dc\u05d5\u05d7\u05d9\u05ea \u05e8\u05d9\u05e9\u05d5\u05d9 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4" + }, + "step": { + "user": { + "data": { + "license_plate": "\u05dc\u05d5\u05d7\u05d9\u05ea \u05e8\u05d9\u05e9\u05d5\u05d9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rdw/translations/hu.json b/homeassistant/components/rdw/translations/hu.json new file mode 100644 index 00000000000..52d3e6dac59 --- /dev/null +++ b/homeassistant/components/rdw/translations/hu.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "unknown_license_plate": "Ismeretlen rendsz\u00e1m" + }, + "step": { + "user": { + "data": { + "license_plate": "Rendsz\u00e1m" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rdw/translations/id.json b/homeassistant/components/rdw/translations/id.json new file mode 100644 index 00000000000..2fbe6f9509f --- /dev/null +++ b/homeassistant/components/rdw/translations/id.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Gagal terhubung", + "unknown_license_plate": "Plat nomor tidak dikenal" + }, + "step": { + "user": { + "data": { + "license_plate": "Plat nomor" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rdw/translations/it.json b/homeassistant/components/rdw/translations/it.json new file mode 100644 index 00000000000..36ed596282f --- /dev/null +++ b/homeassistant/components/rdw/translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown_license_plate": "Targa sconosciuta" + }, + "step": { + "user": { + "data": { + "license_plate": "Targa" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rdw/translations/ja.json b/homeassistant/components/rdw/translations/ja.json new file mode 100644 index 00000000000..3813eaf04e0 --- /dev/null +++ b/homeassistant/components/rdw/translations/ja.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown_license_plate": "\u4e0d\u660e\u306a\u30e9\u30a4\u30bb\u30f3\u30b9\u30d7\u30ec\u30fc\u30c8" + }, + "step": { + "user": { + "data": { + "license_plate": "\u30e9\u30a4\u30bb\u30f3\u30b9\u30d7\u30ec\u30fc\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rdw/translations/nl.json b/homeassistant/components/rdw/translations/nl.json new file mode 100644 index 00000000000..4adb309d0e2 --- /dev/null +++ b/homeassistant/components/rdw/translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown_license_plate": "Onbekend kentekenplaat" + }, + "step": { + "user": { + "data": { + "license_plate": "Kentekenplaat" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rdw/translations/no.json b/homeassistant/components/rdw/translations/no.json new file mode 100644 index 00000000000..f6d742f1bac --- /dev/null +++ b/homeassistant/components/rdw/translations/no.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Tilkobling mislyktes", + "unknown_license_plate": "Ukjent nummerskilt" + }, + "step": { + "user": { + "data": { + "license_plate": "Bilskilt" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rdw/translations/pl.json b/homeassistant/components/rdw/translations/pl.json new file mode 100644 index 00000000000..43baead5444 --- /dev/null +++ b/homeassistant/components/rdw/translations/pl.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown_license_plate": "Nieznana tablica rejestracyjna" + }, + "step": { + "user": { + "data": { + "license_plate": "Tablica rejestracyjna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rdw/translations/ru.json b/homeassistant/components/rdw/translations/ru.json new file mode 100644 index 00000000000..a885bf3067e --- /dev/null +++ b/homeassistant/components/rdw/translations/ru.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "unknown_license_plate": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u044b\u0439 \u043d\u043e\u043c\u0435\u0440\u043d\u043e\u0439 \u0437\u043d\u0430\u043a." + }, + "step": { + "user": { + "data": { + "license_plate": "\u041d\u043e\u043c\u0435\u0440\u043d\u043e\u0439 \u0437\u043d\u0430\u043a" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rdw/translations/sl.json b/homeassistant/components/rdw/translations/sl.json new file mode 100644 index 00000000000..78b07d7baff --- /dev/null +++ b/homeassistant/components/rdw/translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Povezava ni uspela", + "unknown_license_plate": "Neznana registrska tablica" + }, + "step": { + "user": { + "data": { + "license_plate": "Registrska tablica" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rdw/translations/tr.json b/homeassistant/components/rdw/translations/tr.json new file mode 100644 index 00000000000..c47dadfafa4 --- /dev/null +++ b/homeassistant/components/rdw/translations/tr.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown_license_plate": "Bilinmeyen plaka" + }, + "step": { + "user": { + "data": { + "license_plate": "Plaka" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rdw/translations/zh-Hant.json b/homeassistant/components/rdw/translations/zh-Hant.json new file mode 100644 index 00000000000..fdf54dd247a --- /dev/null +++ b/homeassistant/components/rdw/translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown_license_plate": "\u672a\u77e5\u8eca\u724c" + }, + "step": { + "user": { + "data": { + "license_plate": "\u8eca\u724c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/recollect_waste/__init__.py b/homeassistant/components/recollect_waste/__init__.py index c32b4119c67..25900345205 100644 --- a/homeassistant/components/recollect_waste/__init__.py +++ b/homeassistant/components/recollect_waste/__init__.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DATA_COORDINATOR, DOMAIN, LOGGER +from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DOMAIN, LOGGER DEFAULT_NAME = "recollect_waste" DEFAULT_UPDATE_INTERVAL = timedelta(days=1) @@ -21,9 +21,6 @@ PLATFORMS = ["sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up RainMachine as config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} - session = aiohttp_client.async_get_clientsession(hass) client = Client( entry.data[CONF_PLACE_ID], entry.data[CONF_SERVICE_ID], session=session @@ -49,7 +46,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] = coordinator + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/recollect_waste/const.py b/homeassistant/components/recollect_waste/const.py index 4a6c9dbda6c..5589507d4ac 100644 --- a/homeassistant/components/recollect_waste/const.py +++ b/homeassistant/components/recollect_waste/const.py @@ -7,5 +7,3 @@ LOGGER = logging.getLogger(__package__) CONF_PLACE_ID = "place_id" CONF_SERVICE_ID = "service_id" - -DATA_COORDINATOR = "coordinator" diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 619a12a42f7..b7291df86ba 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -5,7 +5,7 @@ from aiorecollect.client import PickupType from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION, CONF_FRIENDLY_NAME, DEVICE_CLASS_DATE +from homeassistant.const import CONF_FRIENDLY_NAME, DEVICE_CLASS_DATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -14,14 +14,13 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DATA_COORDINATOR, DOMAIN +from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DOMAIN ATTR_PICKUP_TYPES = "pickup_types" ATTR_AREA_NAME = "area_name" ATTR_NEXT_PICKUP_TYPES = "next_pickup_types" ATTR_NEXT_PICKUP_DATE = "next_pickup_date" -DEFAULT_ATTRIBUTION = "Pickup data provided by ReCollect Waste" DEFAULT_NAME = "Waste Pickup" PLATFORM_SCHEMA = cv.deprecated(DOMAIN) @@ -44,7 +43,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up ReCollect Waste sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities([ReCollectWasteSensor(coordinator, entry)]) @@ -57,7 +56,7 @@ class ReCollectWasteSensor(CoordinatorEntity, SensorEntity): """Initialize the sensor.""" super().__init__(coordinator) - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._attr_extra_state_attributes = {} self._attr_name = DEFAULT_NAME self._attr_unique_id = ( f"{entry.data[CONF_PLACE_ID]}{entry.data[CONF_SERVICE_ID]}" @@ -98,4 +97,4 @@ class ReCollectWasteSensor(CoordinatorEntity, SensorEntity): ATTR_NEXT_PICKUP_DATE: next_pickup_event.date.isoformat(), } ) - self._attr_native_value = pickup_event.date.isoformat() + self._attr_native_value = pickup_event.date diff --git a/homeassistant/components/recollect_waste/translations/ja.json b/homeassistant/components/recollect_waste/translations/ja.json new file mode 100644 index 00000000000..c346781c1a6 --- /dev/null +++ b/homeassistant/components/recollect_waste/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "invalid_place_or_service_id": "\u7121\u52b9\u306a\u30d7\u30ec\u30a4\u30b9ID\u3001\u307e\u305f\u306f\u30b5\u30fc\u30d3\u30b9ID" + }, + "step": { + "user": { + "data": { + "place_id": "\u30d7\u30ec\u30a4\u30b9ID", + "service_id": "\u30b5\u30fc\u30d3\u30b9ID" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "friendly_name": "\u30d4\u30c3\u30af\u30a2\u30c3\u30d7\u30bf\u30a4\u30d7\u306b\u306f\u308f\u304b\u308a\u3084\u3059\u3044\u540d\u524d\u3092\u4f7f\u7528\u3057\u3066\u304f\u3060\u3055\u3044(\u53ef\u80fd\u306a\u5834\u5408)" + }, + "title": "Recollect Waste\u306e\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/recollect_waste/translations/tr.json b/homeassistant/components/recollect_waste/translations/tr.json index 5307276a71d..084edd95657 100644 --- a/homeassistant/components/recollect_waste/translations/tr.json +++ b/homeassistant/components/recollect_waste/translations/tr.json @@ -2,6 +2,27 @@ "config": { "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "invalid_place_or_service_id": "Ge\u00e7ersiz Yer veya Hizmet Kimli\u011fi" + }, + "step": { + "user": { + "data": { + "place_id": "Yer kimli\u011fi", + "service_id": "Hizmet Kimli\u011fi" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "friendly_name": "Al\u0131m t\u00fcrleri i\u00e7in kolay adlar kullan\u0131n (m\u00fcmk\u00fcn oldu\u011funda)" + }, + "title": "At\u0131k Geri Toplama Yap\u0131land\u0131r" + } } } } \ No newline at end of file diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 9d19e0d876e..8a907a8d9fa 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Iterable import concurrent.futures +from dataclasses import dataclass from datetime import datetime, timedelta import logging import queue @@ -41,6 +42,7 @@ from homeassistant.helpers.entityfilter import ( from homeassistant.helpers.event import ( async_track_time_change, async_track_time_interval, + async_track_utc_time_change, ) from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, @@ -51,7 +53,13 @@ from homeassistant.loader import bind_hass import homeassistant.util.dt as dt_util from . import history, migration, purge, statistics, websocket_api -from .const import CONF_DB_INTEGRITY_CHECK, DATA_INSTANCE, DOMAIN, SQLITE_URL_PREFIX +from .const import ( + CONF_DB_INTEGRITY_CHECK, + DATA_INSTANCE, + DOMAIN, + MAX_QUEUE_BACKLOG, + SQLITE_URL_PREFIX, +) from .models import ( Base, Events, @@ -69,6 +77,7 @@ from .util import ( session_scope, setup_connection_for_dialect, validate_or_move_away_sqlite_database, + write_lock_db, ) _LOGGER = logging.getLogger(__name__) @@ -82,8 +91,6 @@ ATTR_KEEP_DAYS = "keep_days" ATTR_REPACK = "repack" ATTR_APPLY_FILTER = "apply_filter" -MAX_QUEUE_BACKLOG = 30000 - SERVICE_PURGE_SCHEMA = vol.Schema( { vol.Optional(ATTR_KEEP_DAYS): cv.positive_int, @@ -118,6 +125,9 @@ KEEPALIVE_TIME = 30 # States and Events objects EXPIRE_AFTER_COMMITS = 120 +DB_LOCK_TIMEOUT = 30 +DB_LOCK_QUEUE_CHECK_TIMEOUT = 1 + CONF_AUTO_PURGE = "auto_purge" CONF_DB_URL = "db_url" CONF_DB_MAX_RETRIES = "db_max_retries" @@ -171,18 +181,6 @@ CONFIG_SCHEMA = vol.Schema( ) -@bind_hass -async def async_migration_in_progress(hass: HomeAssistant) -> bool: - """Determine is a migration is in progress. - - This is a thin wrapper that allows us to change - out the implementation later. - """ - if DATA_INSTANCE not in hass.data: - return False - return hass.data[DATA_INSTANCE].migration_in_progress - - @bind_hass def is_entity_recorded(hass: HomeAssistant, entity_id: str) -> bool: """Check if an entity is being recorded. @@ -377,6 +375,15 @@ class WaitTask: """An object to insert into the recorder queue to tell it set the _queue_watch event.""" +@dataclass +class DatabaseLockTask: + """An object to insert into the recorder queue to prevent writes to the database.""" + + database_locked: asyncio.Event + database_unlock: threading.Event + queue_overflow: bool + + class Recorder(threading.Thread): """A threaded recorder class.""" @@ -426,6 +433,7 @@ class Recorder(threading.Thread): self.migration_in_progress = False self._queue_watcher = None self._db_supports_row_number = True + self._database_lock_task: DatabaseLockTask | None = None self.enabled = True @@ -622,7 +630,7 @@ class Recorder(threading.Thread): ) # Compile short term statistics every 5 minutes - async_track_time_change( + async_track_utc_time_change( self.hass, self.async_periodic_statistics, minute=range(0, 60, 5), second=10 ) @@ -694,6 +702,8 @@ class Recorder(threading.Thread): def _process_one_event_or_recover(self, event): """Process an event, reconnect, or recover a malformed database.""" try: + if self._process_one_task(event): + return self._process_one_event(event) return except exc.DatabaseError as err: @@ -795,34 +805,63 @@ class Recorder(threading.Thread): # Schedule a new statistics task if this one didn't finish self.queue.put(ExternalStatisticsTask(metadata, stats)) - def _process_one_event(self, event): + def _lock_database(self, task: DatabaseLockTask): + @callback + def _async_set_database_locked(task: DatabaseLockTask): + task.database_locked.set() + + with write_lock_db(self): + # Notify that lock is being held, wait until database can be used again. + self.hass.add_job(_async_set_database_locked, task) + while not task.database_unlock.wait(timeout=DB_LOCK_QUEUE_CHECK_TIMEOUT): + if self.queue.qsize() > MAX_QUEUE_BACKLOG * 0.9: + _LOGGER.warning( + "Database queue backlog reached more than 90% of maximum queue " + "length while waiting for backup to finish; recorder will now " + "resume writing to database. The backup can not be trusted and " + "must be restarted" + ) + task.queue_overflow = True + break + _LOGGER.info( + "Database queue backlog reached %d entries during backup", + self.queue.qsize(), + ) + + def _process_one_task(self, event) -> bool: """Process one event.""" if isinstance(event, PurgeTask): self._run_purge(event.purge_before, event.repack, event.apply_filter) - return + return True if isinstance(event, PurgeEntitiesTask): self._run_purge_entities(event.entity_filter) - return + return True if isinstance(event, PerodicCleanupTask): perodic_db_cleanups(self) - return + return True if isinstance(event, StatisticsTask): self._run_statistics(event.start) - return + return True if isinstance(event, ClearStatisticsTask): statistics.clear_statistics(self, event.statistic_ids) - return + return True if isinstance(event, UpdateStatisticsMetadataTask): statistics.update_statistics_metadata( self, event.statistic_id, event.unit_of_measurement ) - return + return True if isinstance(event, ExternalStatisticsTask): self._run_external_statistics(event.metadata, event.statistics) - return + return True if isinstance(event, WaitTask): self._queue_watch.set() - return + return True + if isinstance(event, DatabaseLockTask): + self._lock_database(event) + return True + return False + + def _process_one_event(self, event): if event.event_type == EVENT_TIME_CHANGED: self._keepalive_count += 1 if self._keepalive_count >= KEEPALIVE_TIME: @@ -989,6 +1028,42 @@ class Recorder(threading.Thread): self.queue.put(WaitTask()) self._queue_watch.wait() + async def lock_database(self) -> bool: + """Lock database so it can be backed up safely.""" + if self._database_lock_task: + _LOGGER.warning("Database already locked") + return False + + database_locked = asyncio.Event() + task = DatabaseLockTask(database_locked, threading.Event(), False) + self.queue.put(task) + try: + await asyncio.wait_for(database_locked.wait(), timeout=DB_LOCK_TIMEOUT) + except asyncio.TimeoutError as err: + task.database_unlock.set() + raise TimeoutError( + f"Could not lock database within {DB_LOCK_TIMEOUT} seconds." + ) from err + self._database_lock_task = task + return True + + @callback + def unlock_database(self) -> bool: + """Unlock database. + + Returns true if database lock has been held throughout the process. + """ + if not self._database_lock_task: + _LOGGER.warning("Database currently not locked") + return False + + self._database_lock_task.database_unlock.set() + success = not self._database_lock_task.queue_overflow + + self._database_lock_task = None + + return success + def _setup_connection(self): """Ensure database is ready to fly.""" kwargs = {} @@ -1088,3 +1163,8 @@ class Recorder(threading.Thread): self.hass.add_job(self._async_stop_queue_watcher_and_event_listener) self._end_session() self._close_connection() + + @property + def recording(self): + """Return if the recorder is recording.""" + return self._event_listener is not None diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index eab3c30e99e..a04218264ee 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -6,6 +6,8 @@ DOMAIN = "recorder" CONF_DB_INTEGRITY_CHECK = "db_integrity_check" +MAX_QUEUE_BACKLOG = 30000 + # The maximum number of rows (events) we purge in one delete statement # sqlite3 has a limit of 999 until version 3.32.0 diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 9ea08cc1f64..a6a746f019e 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.23"], + "requirements": ["sqlalchemy==1.4.27"], "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 fe3e1aeb84d..2e6e5a7bd12 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -220,7 +220,7 @@ def _add_columns(connection, table_name, columns_def): ) ) except (InternalError, OperationalError) as err: - raise_if_exception_missing_str(err, ["duplicate"]) + raise_if_exception_missing_str(err, ["already exists", "duplicate"]) _LOGGER.warning( "Column %s already exists on %s, continuing", column_def.split(" ")[1], diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index edac688bdd1..02c00722e72 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -478,7 +478,7 @@ def get_metadata_with_session( hass: HomeAssistant, session: scoped_session, *, - statistic_ids: Iterable[str] | None = None, + statistic_ids: list[str] | tuple[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, ) -> dict[str, tuple[int, StatisticMetaData]]: @@ -533,7 +533,7 @@ def get_metadata_with_session( def get_metadata( hass: HomeAssistant, *, - statistic_ids: Iterable[str] | None = None, + statistic_ids: list[str] | tuple[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, ) -> dict[str, tuple[int, StatisticMetaData]]: @@ -620,8 +620,7 @@ def list_statistic_ids( platform_statistic_ids = platform.list_statistic_ids(hass, statistic_type) for statistic_id, info in platform_statistic_ids.items(): - unit = info["unit_of_measurement"] - if unit is not None: + if (unit := info["unit_of_measurement"]) is not None: # Display unit according to user settings unit = _configured_unit(unit, units) platform_statistic_ids[statistic_id]["unit_of_measurement"] = unit @@ -698,8 +697,8 @@ def _reduce_statistics( "mean": mean(mean_values) if mean_values else None, "min": min(min_values) if min_values else None, "max": max(max_values) if max_values else None, - "last_reset": prev_stat["last_reset"], - "state": prev_stat["state"], + "last_reset": prev_stat.get("last_reset"), + "state": prev_stat.get("state"), "sum": prev_stat["sum"], } ) @@ -717,50 +716,54 @@ def _reduce_statistics( return result +def same_day(time1: datetime, time2: datetime) -> bool: + """Return True if time1 and time2 are in the same date.""" + date1 = dt_util.as_local(time1).date() + date2 = dt_util.as_local(time2).date() + return date1 == date2 + + +def day_start_end(time: datetime) -> tuple[datetime, datetime]: + """Return the start and end of the period (day) time is within.""" + start = dt_util.as_utc( + dt_util.as_local(time).replace(hour=0, minute=0, second=0, microsecond=0) + ) + end = start + timedelta(days=1) + return (start, end) + + def _reduce_statistics_per_day( stats: dict[str, list[dict[str, Any]]] ) -> dict[str, list[dict[str, Any]]]: """Reduce hourly statistics to daily statistics.""" - def same_period(time1: datetime, time2: datetime) -> bool: - """Return True if time1 and time2 are in the same date.""" - date1 = dt_util.as_local(time1).date() - date2 = dt_util.as_local(time2).date() - return date1 == date2 + return _reduce_statistics(stats, same_day, day_start_end, timedelta(days=1)) - def period_start_end(time: datetime) -> tuple[datetime, datetime]: - """Return the start and end of the period (day) time is within.""" - start = dt_util.as_utc( - dt_util.as_local(time).replace(hour=0, minute=0, second=0, microsecond=0) - ) - end = start + timedelta(days=1) - return (start, end) - return _reduce_statistics(stats, same_period, period_start_end, timedelta(days=1)) +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() + date2 = dt_util.as_local(time2).date() + return (date1.year, date1.month) == (date2.year, date2.month) + + +def month_start_end(time: datetime) -> tuple[datetime, datetime]: + """Return the start and end of the period (month) time is within.""" + start_local = dt_util.as_local(time).replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + start = dt_util.as_utc(start_local) + end_local = (start_local + timedelta(days=31)).replace(day=1) + end = dt_util.as_utc(end_local) + return (start, end) def _reduce_statistics_per_month( - stats: dict[str, list[dict[str, Any]]] + stats: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: """Reduce hourly statistics to monthly statistics.""" - def same_period(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() - date2 = dt_util.as_local(time2).date() - return (date1.year, date1.month) == (date2.year, date2.month) - - def period_start_end(time: datetime) -> tuple[datetime, datetime]: - """Return the start and end of the period (month) time is within.""" - start = dt_util.as_utc( - dt_util.as_local(time).replace( - day=1, hour=0, minute=0, second=0, microsecond=0 - ) - ) - end = (start + timedelta(days=31)).replace(day=1) - return (start, end) - - return _reduce_statistics(stats, same_period, period_start_end, timedelta(days=31)) + return _reduce_statistics(stats, same_month, month_start_end, timedelta(days=31)) def statistics_during_period( @@ -769,6 +772,7 @@ def statistics_during_period( end_time: datetime | None = None, statistic_ids: list[str] | None = None, period: Literal["5minute", "day", "hour", "month"] = "hour", + start_time_as_datetime: bool = False, ) -> dict[str, list[dict[str, Any]]]: """Return statistics during UTC period start_time - end_time for the statistic_ids. @@ -809,7 +813,15 @@ def statistics_during_period( # Return statistics combined with metadata if period not in ("day", "month"): return _sorted_statistics_to_dict( - hass, session, stats, statistic_ids, metadata, True, table, start_time + hass, + session, + stats, + statistic_ids, + metadata, + True, + table, + start_time, + start_time_as_datetime, ) result = _sorted_statistics_to_dict( @@ -822,8 +834,12 @@ def statistics_during_period( return _reduce_statistics_per_month(result) -def get_last_statistics( - hass: HomeAssistant, number_of_stats: int, statistic_id: str, convert_units: bool +def _get_last_statistics( + hass: HomeAssistant, + number_of_stats: int, + statistic_id: str, + convert_units: bool, + table: type[Statistics | StatisticsShortTerm], ) -> dict[str, list[dict]]: """Return the last number_of_stats statistics for a given statistic_id.""" statistic_ids = [statistic_id] @@ -833,16 +849,19 @@ def get_last_statistics( if not metadata: return {} - baked_query = hass.data[STATISTICS_SHORT_TERM_BAKERY]( - lambda session: session.query(*QUERY_STATISTICS_SHORT_TERM) - ) + if table == StatisticsShortTerm: + bakery = STATISTICS_SHORT_TERM_BAKERY + base_query = QUERY_STATISTICS_SHORT_TERM + else: + bakery = STATISTICS_BAKERY + base_query = QUERY_STATISTICS + + baked_query = hass.data[bakery](lambda session: session.query(*base_query)) baked_query += lambda q: q.filter_by(metadata_id=bindparam("metadata_id")) metadata_id = metadata[statistic_id][0] - baked_query += lambda q: q.order_by( - StatisticsShortTerm.metadata_id, StatisticsShortTerm.start.desc() - ) + baked_query += lambda q: q.order_by(table.metadata_id, table.start.desc()) baked_query += lambda q: q.limit(bindparam("number_of_stats")) @@ -862,11 +881,29 @@ def get_last_statistics( statistic_ids, metadata, convert_units, - StatisticsShortTerm, + table, None, ) +def get_last_statistics( + hass: HomeAssistant, number_of_stats: int, statistic_id: str, convert_units: bool +) -> dict[str, list[dict]]: + """Return the last number_of_stats statistics for a statistic_id.""" + return _get_last_statistics( + hass, number_of_stats, statistic_id, convert_units, Statistics + ) + + +def get_last_short_term_statistics( + hass: HomeAssistant, number_of_stats: int, statistic_id: str, convert_units: bool +) -> dict[str, list[dict]]: + """Return the last number_of_stats short term statistics for a statistic_id.""" + return _get_last_statistics( + hass, number_of_stats, statistic_id, convert_units, StatisticsShortTerm + ) + + def _statistics_at_time( session: scoped_session, metadata_ids: set[int], diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 6c53347a536..3900641db63 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -455,3 +455,33 @@ def perodic_db_cleanups(instance: Recorder): _LOGGER.debug("WAL checkpoint") with instance.engine.connect() as connection: connection.execute(text("PRAGMA wal_checkpoint(TRUNCATE);")) + + +@contextmanager +def write_lock_db(instance: Recorder): + """Lock database for writes.""" + + if instance.engine.dialect.name == "sqlite": + with instance.engine.connect() as connection: + # Execute sqlite to create a wal checkpoint + # This is optional but makes sure the backup is going to be minimal + connection.execute(text("PRAGMA wal_checkpoint(TRUNCATE)")) + # Create write lock + _LOGGER.debug("Lock database") + connection.execute(text("BEGIN IMMEDIATE;")) + try: + yield + finally: + _LOGGER.debug("Unlock database") + connection.execute(text("END;")) + + +def async_migration_in_progress(hass: HomeAssistant) -> bool: + """Determine is a migration is in progress. + + This is a thin wrapper that allows us to change + out the implementation later. + """ + if DATA_INSTANCE not in hass.data: + return False + return hass.data[DATA_INSTANCE].migration_in_progress diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index ba77692fe8e..aec7905615f 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -1,13 +1,22 @@ """The Energy websocket API.""" from __future__ import annotations +import logging +from typing import TYPE_CHECKING + import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback -from .const import DATA_INSTANCE +from .const import DATA_INSTANCE, MAX_QUEUE_BACKLOG from .statistics import validate_statistics +from .util import async_migration_in_progress + +if TYPE_CHECKING: + from . import Recorder + +_LOGGER: logging.Logger = logging.getLogger(__package__) @callback @@ -16,6 +25,9 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_validate_statistics) websocket_api.async_register_command(hass, ws_clear_statistics) websocket_api.async_register_command(hass, ws_update_statistics_metadata) + websocket_api.async_register_command(hass, ws_info) + websocket_api.async_register_command(hass, ws_backup_start) + websocket_api.async_register_command(hass, ws_backup_end) @websocket_api.websocket_command( @@ -72,3 +84,65 @@ def ws_update_statistics_metadata( msg["statistic_id"], msg["unit_of_measurement"] ) connection.send_result(msg["id"]) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "recorder/info", + } +) +@callback +def ws_info( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Return status of the recorder.""" + instance: Recorder = hass.data[DATA_INSTANCE] + + backlog = instance.queue.qsize() if instance and instance.queue else None + migration_in_progress = async_migration_in_progress(hass) + recording = instance.recording if instance else False + thread_alive = instance.is_alive() if instance else False + + recorder_info = { + "backlog": backlog, + "max_backlog": MAX_QUEUE_BACKLOG, + "migration_in_progress": migration_in_progress, + "recording": recording, + "thread_running": thread_alive, + } + connection.send_result(msg["id"], recorder_info) + + +@websocket_api.ws_require_user(only_supervisor=True) +@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 +) -> None: + """Backup start notification.""" + + _LOGGER.info("Backup start notification, locking database for writes") + instance: Recorder = hass.data[DATA_INSTANCE] + try: + await instance.lock_database() + except TimeoutError as err: + connection.send_error(msg["id"], "timeout_error", str(err)) + return + connection.send_result(msg["id"]) + + +@websocket_api.ws_require_user(only_supervisor=True) +@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 +) -> None: + """Backup end notification.""" + + instance: Recorder = hass.data[DATA_INSTANCE] + _LOGGER.info("Backup end notification, releasing write lock") + if not instance.unlock_database(): + connection.send_error( + msg["id"], "database_unlock_failed", "Failed to unlock database." + ) + connection.send_result(msg["id"]) diff --git a/homeassistant/components/remote/device_condition.py b/homeassistant/components/remote/device_condition.py index 02e6ea6bd23..33f680e6829 100644 --- a/homeassistant/components/remote/device_condition.py +++ b/homeassistant/components/remote/device_condition.py @@ -20,12 +20,10 @@ CONDITION_SCHEMA = toggle_entity.CONDITION_SCHEMA.extend( @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> ConditionCheckerType: """Evaluate state based on configuration.""" - if config_validation: - config = CONDITION_SCHEMA(config) - return toggle_entity.async_condition_from_config(config) + return toggle_entity.async_condition_from_config(hass, config) async def async_get_conditions( diff --git a/homeassistant/components/remote/translations/ca.json b/homeassistant/components/remote/translations/ca.json index b8a317c60d0..7e001059f14 100644 --- a/homeassistant/components/remote/translations/ca.json +++ b/homeassistant/components/remote/translations/ca.json @@ -16,8 +16,8 @@ }, "state": { "_": { - "off": "off", - "on": "on" + "off": "OFF", + "on": "ON" } }, "title": "Comandament" diff --git a/homeassistant/components/remote/translations/ja.json b/homeassistant/components/remote/translations/ja.json index 15dd3796187..4669a228b1a 100644 --- a/homeassistant/components/remote/translations/ja.json +++ b/homeassistant/components/remote/translations/ja.json @@ -1,8 +1,24 @@ { + "device_automation": { + "action_type": { + "toggle": "\u30c8\u30b0\u30eb {entity_name}", + "turn_off": "\u30aa\u30d5\u306b\u3059\u308b {entity_name}", + "turn_on": "\u30aa\u30f3\u306b\u3059\u308b {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u306f\u30aa\u30d5\u3067\u3059", + "is_on": "{entity_name} \u304c\u30aa\u30f3\u3067\u3059" + }, + "trigger_type": { + "turned_off": "{entity_name} \u30aa\u30d5\u306b\u306a\u308a\u307e\u3057\u305f", + "turned_on": "{entity_name} \u30aa\u30f3\u306b\u306a\u3063\u3066\u3044\u307e\u3059" + } + }, "state": { "_": { "off": "\u30aa\u30d5", "on": "\u30aa\u30f3" } - } + }, + "title": "\u30ea\u30e2\u30fc\u30c8" } \ No newline at end of file diff --git a/homeassistant/components/remote/translations/tr.json b/homeassistant/components/remote/translations/tr.json index 5359c99a78a..59c6f9a6a6e 100644 --- a/homeassistant/components/remote/translations/tr.json +++ b/homeassistant/components/remote/translations/tr.json @@ -1,9 +1,14 @@ { "device_automation": { "action_type": { + "toggle": "{entity_name} de\u011fi\u015ftir", "turn_off": "{entity_name} kapat", "turn_on": "{entity_name} a\u00e7\u0131n" }, + "condition_type": { + "is_off": "{entity_name} kapal\u0131", + "is_on": "{entity_name} a\u00e7\u0131k" + }, "trigger_type": { "turned_off": "{entity_name} kapat\u0131ld\u0131", "turned_on": "{entity_name} a\u00e7\u0131ld\u0131" diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index 2799289fc1d..c2ebdb5cb0f 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -7,8 +7,7 @@ from renault_api.kamereon.enums import ChargeState, PlugState from renault_api.kamereon.models import KamereonVehicleBatteryStatusData from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_BATTERY_CHARGING, - DEVICE_CLASS_PLUG, + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -18,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN -from .renault_entities import RenaultDataEntity, RenaultEntityDescription +from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub @@ -33,7 +32,7 @@ class RenaultBinarySensorRequiredKeysMixin: @dataclass class RenaultBinarySensorEntityDescription( BinarySensorEntityDescription, - RenaultEntityDescription, + RenaultDataEntityDescription, RenaultBinarySensorRequiredKeysMixin, ): """Class describing Renault binary sensor entities.""" @@ -75,7 +74,7 @@ BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = ( RenaultBinarySensorEntityDescription( key="plugged_in", coordinator="battery", - device_class=DEVICE_CLASS_PLUG, + device_class=BinarySensorDeviceClass.PLUG, name="Plugged In", on_key="plugStatus", on_value=PlugState.PLUGGED.value, @@ -83,7 +82,7 @@ BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = ( RenaultBinarySensorEntityDescription( key="charging", coordinator="battery", - device_class=DEVICE_CLASS_BATTERY_CHARGING, + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, name="Charging", on_key="chargingStatus", on_value=ChargeState.CHARGE_IN_PROGRESS.value, diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py new file mode 100644 index 00000000000..e62bdf083ae --- /dev/null +++ b/homeassistant/components/renault/button.py @@ -0,0 +1,83 @@ +"""Support for Renault button entities.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .renault_entities import RenaultEntity +from .renault_hub import RenaultHub + + +@dataclass +class RenaultButtonRequiredKeysMixin: + """Mixin for required keys.""" + + async_press: Callable[[RenaultButtonEntity], Awaitable] + + +@dataclass +class RenaultButtonEntityDescription( + ButtonEntityDescription, RenaultButtonRequiredKeysMixin +): + """Class describing Renault button entities.""" + + requires_electricity: bool = False + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renault entities from config entry.""" + proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] + entities: list[RenaultButtonEntity] = [ + RenaultButtonEntity(vehicle, description) + for vehicle in proxy.vehicles.values() + for description in BUTTON_TYPES + if not description.requires_electricity or vehicle.details.uses_electricity() + ] + async_add_entities(entities) + + +class RenaultButtonEntity(RenaultEntity, ButtonEntity): + """Mixin for button specific attributes.""" + + entity_description: RenaultButtonEntityDescription + + async def async_press(self) -> None: + """Process the button press.""" + await self.entity_description.async_press(self) + + +async def _start_charge(entity: RenaultButtonEntity) -> None: + """Start charge on the vehicle.""" + await entity.vehicle.vehicle.set_charge_start() + + +async def _start_air_conditioner(entity: RenaultButtonEntity) -> None: + """Start air conditioner on the vehicle.""" + await entity.vehicle.vehicle.set_ac_start(21, None) + + +BUTTON_TYPES: tuple[RenaultButtonEntityDescription, ...] = ( + RenaultButtonEntityDescription( + async_press=_start_air_conditioner, + key="start_air_conditioner", + icon="mdi:air-conditioner", + name="Start Air Conditioner", + ), + RenaultButtonEntityDescription( + async_press=_start_charge, + key="start_charge", + icon="mdi:ev-station", + name="Start Charge", + requires_electricity=True, + ), +) diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 4c1376288f0..89bf322c2bf 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -1,8 +1,5 @@ """Constants for the Renault component.""" -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN -from homeassistant.components.select import DOMAIN as SELECT_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import Platform DOMAIN = "renault" @@ -12,10 +9,11 @@ CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" DEFAULT_SCAN_INTERVAL = 300 # 5 minutes PLATFORMS = [ - BINARY_SENSOR_DOMAIN, - DEVICE_TRACKER_DOMAIN, - SELECT_DOMAIN, - SENSOR_DOMAIN, + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.DEVICE_TRACKER, + Platform.SELECT, + Platform.SENSOR, ] DEVICE_CLASS_PLUG_STATE = "renault__plug_state" diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py index 466a1f9e4a6..3e9a2608f80 100644 --- a/homeassistant/components/renault/device_tracker.py +++ b/homeassistant/components/renault/device_tracker.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .renault_entities import RenaultDataEntity, RenaultEntityDescription +from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub @@ -51,8 +51,8 @@ class RenaultDeviceTracker( return SOURCE_TYPE_GPS -DEVICE_TRACKER_TYPES: tuple[RenaultEntityDescription, ...] = ( - RenaultEntityDescription( +DEVICE_TRACKER_TYPES: tuple[RenaultDataEntityDescription, ...] = ( + RenaultDataEntityDescription( key="location", coordinator="location", icon="mdi:car", diff --git a/homeassistant/components/renault/renault_entities.py b/homeassistant/components/renault/renault_entities.py index b963edbc81f..14ebcf2c2e4 100644 --- a/homeassistant/components/renault/renault_entities.py +++ b/homeassistant/components/renault/renault_entities.py @@ -14,40 +14,33 @@ from .renault_vehicle import RenaultVehicleProxy @dataclass -class RenaultRequiredKeysMixin: +class RenaultDataRequiredKeysMixin: """Mixin for required keys.""" coordinator: str @dataclass -class RenaultEntityDescription(EntityDescription, RenaultRequiredKeysMixin): - """Class describing Renault entities.""" +class RenaultDataEntityDescription(EntityDescription, RenaultDataRequiredKeysMixin): + """Class describing Renault data entities.""" -class RenaultDataEntity(CoordinatorEntity[Optional[T]], Entity): +class RenaultEntity(Entity): """Implementation of a Renault entity with a data coordinator.""" - entity_description: RenaultEntityDescription + entity_description: EntityDescription def __init__( self, vehicle: RenaultVehicleProxy, - description: RenaultEntityDescription, + description: EntityDescription, ) -> None: """Initialise entity.""" - super().__init__(vehicle.coordinators[description.coordinator]) self.vehicle = vehicle self.entity_description = description self._attr_device_info = self.vehicle.device_info self._attr_unique_id = f"{self.vehicle.details.vin}_{description.key}".lower() - def _get_data_attr(self, key: str) -> StateType: - """Return the attribute value from the coordinator data.""" - if self.coordinator.data is None: - return None - return cast(StateType, getattr(self.coordinator.data, key)) - @property def name(self) -> str: """Return the name of the entity. @@ -55,3 +48,22 @@ class RenaultDataEntity(CoordinatorEntity[Optional[T]], Entity): Overridden to include the device name. """ return f"{self.vehicle.device_info[ATTR_NAME]} {self.entity_description.name}" + + +class RenaultDataEntity(CoordinatorEntity[Optional[T]], RenaultEntity): + """Implementation of a Renault entity with a data coordinator.""" + + def __init__( + self, + vehicle: RenaultVehicleProxy, + description: RenaultDataEntityDescription, + ) -> None: + """Initialise entity.""" + super().__init__(vehicle.coordinators[description.coordinator]) + RenaultEntity.__init__(self, vehicle, description) + + def _get_data_attr(self, key: str) -> StateType: + """Return the attribute value from the coordinator data.""" + if self.coordinator.data is None: + return None + return cast(StateType, getattr(self.coordinator.data, key)) diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py index a8f4a15dc21..e7ec97b3927 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DEVICE_CLASS_CHARGE_MODE, DOMAIN -from .renault_entities import RenaultDataEntity, RenaultEntityDescription +from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub @@ -29,7 +29,9 @@ class RenaultSelectRequiredKeysMixin: @dataclass class RenaultSelectEntityDescription( - SelectEntityDescription, RenaultEntityDescription, RenaultSelectRequiredKeysMixin + SelectEntityDescription, + RenaultDataEntityDescription, + RenaultSelectRequiredKeysMixin, ): """Class describing Renault select entities.""" diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index e8e26e06d6c..d98b9864875 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from typing import TYPE_CHECKING, cast from renault_api.kamereon.enums import ChargeState, PlugState @@ -14,19 +15,13 @@ from renault_api.kamereon.models import ( ) from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, ELECTRIC_CURRENT_AMPERE, ENERGY_KILO_WATT_HOUR, LENGTH_KILOMETERS, @@ -43,7 +38,7 @@ from homeassistant.util.dt import as_utc, parse_datetime from .const import DEVICE_CLASS_CHARGE_STATE, DEVICE_CLASS_PLUG_STATE, DOMAIN from .renault_coordinator import T -from .renault_entities import RenaultDataEntity, RenaultEntityDescription +from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub from .renault_vehicle import RenaultVehicleProxy @@ -58,14 +53,16 @@ class RenaultSensorRequiredKeysMixin: @dataclass class RenaultSensorEntityDescription( - SensorEntityDescription, RenaultEntityDescription, RenaultSensorRequiredKeysMixin + SensorEntityDescription, + RenaultDataEntityDescription, + RenaultSensorRequiredKeysMixin, ): """Class describing Renault sensor entities.""" icon_lambda: Callable[[RenaultSensor[T]], str] | None = None condition_lambda: Callable[[RenaultVehicleProxy], bool] | None = None requires_fuel: bool = False - value_lambda: Callable[[RenaultSensor[T]], StateType] | None = None + value_lambda: Callable[[RenaultSensor[T]], StateType | datetime] | None = None async def async_setup_entry( @@ -104,7 +101,7 @@ class RenaultSensor(RenaultDataEntity[T], SensorEntity): return self.entity_description.icon_lambda(self) @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state of this entity.""" if self.data is None: return None @@ -151,12 +148,12 @@ def _get_rounded_value(entity: RenaultSensor[T]) -> float: return round(cast(float, entity.data)) -def _get_utc_value(entity: RenaultSensor[T]) -> str: +def _get_utc_value(entity: RenaultSensor[T]) -> datetime: """Return the UTC value of this entity.""" original_dt = parse_datetime(cast(str, entity.data)) if TYPE_CHECKING: assert original_dt is not None - return as_utc(original_dt).isoformat() + return as_utc(original_dt) SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( @@ -164,11 +161,11 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( key="battery_level", coordinator="battery", data_key="batteryLevel", - device_class=DEVICE_CLASS_BATTERY, + device_class=SensorDeviceClass.BATTERY, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], name="Battery Level", native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), RenaultSensorEntityDescription( key="charge_state", @@ -188,29 +185,29 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( icon="mdi:timer", name="Charging Remaining Time", native_unit_of_measurement=TIME_MINUTES, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), RenaultSensorEntityDescription( key="charging_power", condition_lambda=lambda a: not a.details.reports_charging_power_in_watts(), coordinator="battery", data_key="chargingInstantaneousPower", - device_class=DEVICE_CLASS_CURRENT, + device_class=SensorDeviceClass.CURRENT, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], name="Charging Power", native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), RenaultSensorEntityDescription( key="charging_power", condition_lambda=lambda a: a.details.reports_charging_power_in_watts(), coordinator="battery", data_key="chargingInstantaneousPower", - device_class=DEVICE_CLASS_POWER, + device_class=SensorDeviceClass.POWER, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], name="Charging Power", native_unit_of_measurement=POWER_KILO_WATT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, value_lambda=_get_charging_power, ), RenaultSensorEntityDescription( @@ -231,32 +228,32 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( icon="mdi:ev-station", name="Battery Autonomy", native_unit_of_measurement=LENGTH_KILOMETERS, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), RenaultSensorEntityDescription( key="battery_available_energy", coordinator="battery", data_key="batteryAvailableEnergy", entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], - device_class=DEVICE_CLASS_ENERGY, + device_class=SensorDeviceClass.ENERGY, name="Battery Available Energy", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), RenaultSensorEntityDescription( key="battery_temperature", coordinator="battery", data_key="batteryTemperature", - device_class=DEVICE_CLASS_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], name="Battery Temperature", native_unit_of_measurement=TEMP_CELSIUS, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), RenaultSensorEntityDescription( key="battery_last_activity", coordinator="battery", - device_class=DEVICE_CLASS_TIMESTAMP, + device_class=SensorDeviceClass.TIMESTAMP, data_key="timestamp", entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], entity_registry_enabled_default=False, @@ -271,7 +268,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( icon="mdi:sign-direction", name="Mileage", native_unit_of_measurement=LENGTH_KILOMETERS, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, value_lambda=_get_rounded_value, ), RenaultSensorEntityDescription( @@ -282,7 +279,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( icon="mdi:gas-station", name="Fuel Autonomy", native_unit_of_measurement=LENGTH_KILOMETERS, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, requires_fuel=True, value_lambda=_get_rounded_value, ), @@ -294,24 +291,24 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( icon="mdi:fuel", name="Fuel Quantity", native_unit_of_measurement=VOLUME_LITERS, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, requires_fuel=True, value_lambda=_get_rounded_value, ), RenaultSensorEntityDescription( key="outside_temperature", coordinator="hvac_status", - device_class=DEVICE_CLASS_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, data_key="externalTemperature", entity_class=RenaultSensor[KamereonVehicleHvacStatusData], name="Outside Temperature", native_unit_of_measurement=TEMP_CELSIUS, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), RenaultSensorEntityDescription( key="location_last_activity", coordinator="location", - device_class=DEVICE_CLASS_TIMESTAMP, + device_class=SensorDeviceClass.TIMESTAMP, data_key="lastUpdateTime", entity_class=RenaultSensor[KamereonVehicleLocationData], entity_registry_enabled_default=False, diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index 972befcec6d..de69daefef6 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -112,6 +112,14 @@ def setup_services(hass: HomeAssistant) -> None: async def charge_start(service_call: ServiceCall) -> None: """Start charge.""" + # The Renault start charge service has been replaced by a + # dedicated button entity and marked as deprecated + LOGGER.warning( + "The 'renault.charge_start' service is deprecated and " + "replaced by a dedicated start charge button entity; please " + "use that entity to start the charge instead" + ) + proxy = get_vehicle_proxy(service_call.data) LOGGER.debug("Charge start attempt") diff --git a/homeassistant/components/renault/translations/fr.json b/homeassistant/components/renault/translations/fr.json index d0ea9d0284c..8cfc294cf4a 100644 --- a/homeassistant/components/renault/translations/fr.json +++ b/homeassistant/components/renault/translations/fr.json @@ -15,6 +15,9 @@ "title": "S\u00e9lectionner l'identifiant du compte Kamereon" }, "reauth_confirm": { + "data": { + "password": "Mot de passe" + }, "title": "R\u00e9-authentifier l'int\u00e9gration" }, "user": { diff --git a/homeassistant/components/renault/translations/id.json b/homeassistant/components/renault/translations/id.json index e1b1f3fc893..eacdca285d2 100644 --- a/homeassistant/components/renault/translations/id.json +++ b/homeassistant/components/renault/translations/id.json @@ -2,12 +2,19 @@ "config": { "abort": { "already_configured": "Akun sudah dikonfigurasi", + "kamereon_no_account": "Tidak dapat menemukan akun Kamereon", "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "invalid_credentials": "Autentikasi tidak valid" }, "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Id akun Kamereon" + }, + "title": "Pilih id akun Kamereon" + }, "reauth_confirm": { "data": { "password": "Kata Sandi" @@ -17,9 +24,11 @@ }, "user": { "data": { + "locale": "Pelokalan", "password": "Kata Sandi", "username": "Email" - } + }, + "title": "Setel kredensial Renault" } } } diff --git a/homeassistant/components/renault/translations/ja.json b/homeassistant/components/renault/translations/ja.json new file mode 100644 index 00000000000..743dfa36fb5 --- /dev/null +++ b/homeassistant/components/renault/translations/ja.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "kamereon_no_account": "Kamereon\u306e\u30a2\u30ab\u30a6\u30f3\u30c8\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "invalid_credentials": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon\u30a2\u30ab\u30a6\u30f3\u30c8ID" + }, + "title": "Kamereon\u306e\u30a2\u30ab\u30a6\u30f3\u30c8ID\u3092\u9078\u629e" + }, + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "{username} \u306e\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u66f4\u65b0\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "locale": "\u30ed\u30b1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "E\u30e1\u30fc\u30eb" + }, + "title": "\u30eb\u30ce\u30fc\u306e\u8a8d\u8a3c\u60c5\u5831\u3092\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/tr.json b/homeassistant/components/renault/translations/tr.json index 866fc513d4a..e2da3f97c16 100644 --- a/homeassistant/components/renault/translations/tr.json +++ b/homeassistant/components/renault/translations/tr.json @@ -1,10 +1,34 @@ { "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "kamereon_no_account": "Kamereon hesab\u0131 bulunamad\u0131", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "invalid_credentials": "Ge\u00e7ersiz kimlik do\u011frulama" + }, "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon hesap kimli\u011fi" + }, + "title": "Kamereon hesap kimli\u011fini se\u00e7in" + }, "reauth_confirm": { "data": { - "password": "\u015eifre" - } + "password": "Parola" + }, + "description": "{username} i\u00e7in \u015fifrenizi g\u00fcncelleyin", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, + "user": { + "data": { + "locale": "Yerel ayar", + "password": "Parola", + "username": "E-posta" + }, + "title": "Renault kimlik bilgilerini ayarla" } } } diff --git a/homeassistant/components/repetier/__init__.py b/homeassistant/components/repetier/__init__.py index a71038ffcb8..4c5534d1a28 100644 --- a/homeassistant/components/repetier/__init__.py +++ b/homeassistant/components/repetier/__init__.py @@ -262,8 +262,7 @@ class PrinterAPI: printer = self.printers[printer_id] methods = API_PRINTER_METHODS[sensor_type] for prop, offline in methods.offline.items(): - state = getattr(printer, prop) - if state == offline: + if getattr(printer, prop) == offline: # if state matches offline, sensor is offline return None diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py index b21ff092c67..25c70cc2960 100644 --- a/homeassistant/components/repetier/sensor.py +++ b/homeassistant/components/repetier/sensor.py @@ -106,8 +106,7 @@ class RepetierSensor(SensorEntity): def update(self): """Update the sensor.""" - data = self._get_data() - if data is None: + if (data := self._get_data()) is None: return state = data.pop("state") _LOGGER.debug("Printer %s State %s", self.name, state) @@ -127,8 +126,7 @@ class RepetierTempSensor(RepetierSensor): def update(self): """Update the sensor.""" - data = self._get_data() - if data is None: + if (data := self._get_data()) is None: return state = data.pop("state") temp_set = data["temp_set"] @@ -155,15 +153,14 @@ class RepetierJobEndSensor(RepetierSensor): def update(self): """Update the sensor.""" - data = self._get_data() - if data is None: + if (data := self._get_data()) is None: return job_name = data["job_name"] start = data["start"] print_time = data["print_time"] from_start = data["from_start"] time_end = start + round(print_time, 0) - self._state = datetime.utcfromtimestamp(time_end).isoformat() + self._state = datetime.utcfromtimestamp(time_end) remaining = print_time - from_start remaining_secs = int(round(remaining, 0)) _LOGGER.debug( @@ -180,13 +177,12 @@ class RepetierJobStartSensor(RepetierSensor): def update(self): """Update the sensor.""" - data = self._get_data() - if data is None: + if (data := self._get_data()) is None: return job_name = data["job_name"] start = data["start"] from_start = data["from_start"] - self._state = datetime.utcfromtimestamp(start).isoformat() + self._state = datetime.utcfromtimestamp(start) elapsed_secs = int(round(from_start, 0)) _LOGGER.debug( "Job %s elapsed %s", diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 8186db1c3c2..b55d9c6d844 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -25,7 +25,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import discovery +from homeassistant.helpers import discovery, template from homeassistant.helpers.entity_component import ( DEFAULT_SCAN_INTERVAL, EntityComponent, @@ -51,8 +51,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def reload_service_handler(service): """Remove all user-defined groups and load new ones from config.""" - conf = await component.async_prepare_reload() - if conf is None: + if (conf := await component.async_prepare_reload()) is None: return await async_reload_integration_platforms(hass, DOMAIN, PLATFORMS) _async_setup_shared_data(hass) @@ -161,6 +160,9 @@ def create_rest_data_from_config(hass, config): resource_template.hass = hass resource = resource_template.async_render(parse_result=False) + template.attach(hass, headers) + template.attach(hass, params) + if username and password: if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: auth = httpx.DigestAuth(username, password) diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 8b03bcfb128..7c8fd61e688 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -3,6 +3,7 @@ import logging import httpx +from homeassistant.helpers import template from homeassistant.helpers.httpx_client import get_async_client DEFAULT_TIMEOUT = 10 @@ -51,16 +52,20 @@ class RestData: self._hass, verify_ssl=self._verify_ssl ) + rendered_headers = template.render_complex(self._headers, parse_result=False) + rendered_params = template.render_complex(self._params) + _LOGGER.debug("Updating from %s", self._resource) try: response = await self._async_client.request( self._method, self._resource, - headers=self._headers, - params=self._params, + headers=rendered_headers, + params=rendered_params, auth=self._auth, data=self._request_data, timeout=self._timeout, + follow_redirects=True, ) self.data = response.text self.headers = response.headers diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index a4b87051c4b..c5b6949bd39 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -54,8 +54,8 @@ RESOURCE_SCHEMA = { vol.Optional(CONF_AUTHENTICATION): vol.In( [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] ), - vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}), - vol.Optional(CONF_PARAMS): vol.Schema({cv.string: cv.string}), + vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.template}), + vol.Optional(CONF_PARAMS): vol.Schema({cv.string: cv.template}), vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(METHODS), vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 9f8c33ad6df..422ce84cc46 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -11,8 +11,10 @@ from homeassistant.components.sensor import ( CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, + SensorDeviceClass, SensorEntity, ) +from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, @@ -186,4 +188,13 @@ class RestSensor(RestEntity, SensorEntity): value, None ) - self._state = value + if value is None or self.device_class not in ( + SensorDeviceClass.DATE, + SensorDeviceClass.TIMESTAMP, + ): + self._state = value + return + + self._state = async_parse_date_datetime( + value, self.entity_id, self.device_class + ) diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index e6b16de40aa..3e5fd7e2c68 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -24,8 +24,8 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) CONF_BODY_OFF = "body_off" @@ -46,8 +46,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_RESOURCE): cv.url, vol.Optional(CONF_STATE_RESOURCE): cv.url, - vol.Optional(CONF_HEADERS): {cv.string: cv.string}, - vol.Optional(CONF_PARAMS): {cv.string: cv.string}, + vol.Optional(CONF_HEADERS): {cv.string: cv.template}, + vol.Optional(CONF_PARAMS): {cv.string: cv.template}, vol.Optional(CONF_BODY_OFF, default=DEFAULT_BODY_OFF): cv.template, vol.Optional(CONF_BODY_ON, default=DEFAULT_BODY_ON): cv.template, vol.Optional(CONF_IS_ON_TEMPLATE): cv.template, @@ -90,6 +90,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= body_on.hass = hass if body_off is not None: body_off.hass = hass + + template.attach(hass, headers) + template.attach(hass, params) timeout = config.get(CONF_TIMEOUT) try: @@ -204,13 +207,16 @@ class RestSwitch(SwitchEntity): """Send a state update to the device.""" websession = async_get_clientsession(self.hass, self._verify_ssl) - with async_timeout.timeout(self._timeout): + rendered_headers = template.render_complex(self._headers, parse_result=False) + rendered_params = template.render_complex(self._params) + + async with async_timeout.timeout(self._timeout): req = await getattr(websession, self._method)( self._resource, auth=self._auth, data=bytes(body, "utf-8"), - headers=self._headers, - params=self._params, + headers=rendered_headers, + params=rendered_params, ) return req @@ -227,12 +233,15 @@ class RestSwitch(SwitchEntity): """Get the latest data from REST API and update the state.""" websession = async_get_clientsession(hass, self._verify_ssl) - with async_timeout.timeout(self._timeout): + rendered_headers = template.render_complex(self._headers, parse_result=False) + rendered_params = template.render_complex(self._params) + + async with async_timeout.timeout(self._timeout): req = await websession.get( self._state_resource, auth=self._auth, - headers=self._headers, - params=self._params, + headers=rendered_headers, + params=rendered_params, ) text = await req.text() diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 9cff8377c35..6a59212d6c1 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -270,7 +270,7 @@ async def async_setup(hass, config): ) try: - with async_timeout.timeout(CONNECTION_TIMEOUT): + async with async_timeout.timeout(CONNECTION_TIMEOUT): transport, protocol = await connection except ( @@ -580,9 +580,7 @@ class SwitchableRflinkDevice(RflinkCommand, RestoreEntity): async def async_added_to_hass(self): """Restore RFLink device state (ON/OFF).""" await super().async_added_to_hass() - - old_state = await self.async_get_last_state() - if old_state is not None: + if (old_state := await self.async_get_last_state()) is not None: self._state = old_state.state == STATE_ON def _handle_event(self, event): diff --git a/homeassistant/components/rflink/cover.py b/homeassistant/components/rflink/cover.py index 2e6837d21ea..b98fb0fe4a9 100644 --- a/homeassistant/components/rflink/cover.py +++ b/homeassistant/components/rflink/cover.py @@ -117,9 +117,7 @@ class RflinkCover(RflinkCommand, CoverEntity, RestoreEntity): async def async_added_to_hass(self): """Restore RFLink cover state (OPEN/CLOSE).""" await super().async_added_to_hass() - - old_state = await self.async_get_last_state() - if old_state is not None: + if (old_state := await self.async_get_last_state()) is not None: self._state = old_state.state == STATE_OPEN def _handle_event(self, event): diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index 5a0d6766179..a4017275dea 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -254,8 +254,7 @@ class ToggleRflinkLight(SwitchableRflinkDevice, LightEntity): """Adjust state if Rflink picks up a remote command for this device.""" self.cancel_queued_send_commands() - command = event["command"] - if command == "on": + if event["command"] == "on": # if the state is unknown or false, it gets set as true # if the state is true, it gets set as false self._state = self._state in [None, False] diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 9a5b7ecf57a..d7de0f5b7be 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -30,7 +30,6 @@ from .const import ( COMMAND_GROUP_LIST, CONF_AUTOMATIC_ADD, CONF_DATA_BITS, - CONF_FIRE_EVENT, CONF_REMOVE_DEVICE, DATA_CLEANUP_CALLBACKS, DATA_LISTENER, @@ -124,8 +123,7 @@ def _get_device_lookup(devices): """Get a lookup structure for devices.""" lookup = {} for event_code, event_config in devices.items(): - event = get_rfx_object(event_code) - if event is None: + if (event := get_rfx_object(event_code)) is None: continue device_id = get_device_id( event.device, data_bits=event_config.get(CONF_DATA_BITS) @@ -187,9 +185,7 @@ async def async_setup_internal(hass, entry: config_entries.ConfigEntry): hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_EVENT, event, device_id) # Signal event to any other listeners - fire_event = devices.get(device_id, {}).get(CONF_FIRE_EVENT) - if fire_event: - hass.bus.async_fire(EVENT_RFXTRX_EVENT, event_data) + hass.bus.async_fire(EVENT_RFXTRX_EVENT, event_data) @callback def _add_device(event, device_id): @@ -313,10 +309,12 @@ def find_possible_pt2262_device(device_ids, device_id): def get_device_id(device, data_bits=None): """Calculate a device id for device.""" id_string = device.id_string - if data_bits and device.packettype == DEVICE_PACKET_TYPE_LIGHTING4: - masked_id = get_pt2262_deviceid(id_string, data_bits) - if masked_id: - id_string = masked_id.decode("ASCII") + if ( + data_bits + and device.packettype == DEVICE_PACKET_TYPE_LIGHTING4 + and (masked_id := get_pt2262_deviceid(id_string, data_bits)) + ): + id_string = masked_id.decode("ASCII") return (f"{device.packettype:x}", f"{device.subtype:x}", id_string) diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index 914303f1468..cc11e94c526 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -114,8 +114,7 @@ async def async_setup_entry( return description for packet_id, entity_info in discovery_info[CONF_DEVICES].items(): - event = get_rfx_object(packet_id) - if event is None: + if (event := get_rfx_object(packet_id)) is None: _LOGGER.error("Invalid device: %s", packet_id) continue if not supported(event): diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index be4ec111390..cf1069a7907 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -35,7 +35,6 @@ from .binary_sensor import supported as binary_supported from .const import ( CONF_AUTOMATIC_ADD, CONF_DATA_BITS, - CONF_FIRE_EVENT, CONF_OFF_DELAY, CONF_REMOVE_DEVICE, CONF_REPLACE_DEVICE, @@ -208,7 +207,6 @@ class OptionsFlow(config_entries.OptionsFlow): devices = {} device = { CONF_DEVICE_ID: device_id, - CONF_FIRE_EVENT: user_input.get(CONF_FIRE_EVENT, False), CONF_SIGNAL_REPETITIONS: user_input.get(CONF_SIGNAL_REPETITIONS, 1), } @@ -235,11 +233,7 @@ class OptionsFlow(config_entries.OptionsFlow): device_data = self._selected_device - data_schema = { - vol.Optional( - CONF_FIRE_EVENT, default=device_data.get(CONF_FIRE_EVENT, False) - ): bool, - } + data_schema = {} if binary_supported(self._selected_device_object): if device_data.get(CONF_OFF_DELAY): diff --git a/homeassistant/components/rfxtrx/const.py b/homeassistant/components/rfxtrx/const.py index 17f54ef24c9..20f6fd75dc2 100644 --- a/homeassistant/components/rfxtrx/const.py +++ b/homeassistant/components/rfxtrx/const.py @@ -1,6 +1,5 @@ """Constants for RFXtrx integration.""" -CONF_FIRE_EVENT = "fire_event" CONF_DATA_BITS = "data_bits" CONF_AUTOMATIC_ADD = "automatic_add" CONF_SIGNAL_REPETITIONS = "signal_repetitions" diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py index 26a938141a2..4dc89577542 100644 --- a/homeassistant/components/rfxtrx/cover.py +++ b/homeassistant/components/rfxtrx/cover.py @@ -49,8 +49,7 @@ async def async_setup_entry( entities = [] for packet_id, entity_info in discovery_info[CONF_DEVICES].items(): - event = get_rfx_object(packet_id) - if event is None: + if (event := get_rfx_object(packet_id)) is None: _LOGGER.error("Invalid device: %s", packet_id) continue if not supported(event): @@ -66,7 +65,7 @@ async def async_setup_entry( entity = RfxtrxCover( event.device, device_id, - signal_repetitions=entity_info[CONF_SIGNAL_REPETITIONS], + signal_repetitions=entity_info.get(CONF_SIGNAL_REPETITIONS, 1), venetian_blind_mode=entity_info.get(CONF_VENETIAN_BLIND_MODE), ) entities.append(entity) diff --git a/homeassistant/components/rfxtrx/helpers.py b/homeassistant/components/rfxtrx/helpers.py index ad7d049fb4c..7e567cff1cd 100644 --- a/homeassistant/components/rfxtrx/helpers.py +++ b/homeassistant/components/rfxtrx/helpers.py @@ -3,13 +3,12 @@ from RFXtrx import get_device -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.typing import HomeAssistantType @callback -def async_get_device_object(hass: HomeAssistantType, device_id): +def async_get_device_object(hass: HomeAssistant, device_id): """Get a device for the given device registry id.""" device_registry = dr.async_get(hass) registry_device = device_registry.async_get(device_id) diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index ea197b5ebc4..c67213ed6f8 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -50,8 +50,7 @@ async def async_setup_entry( # Add switch from config file entities = [] for packet_id, entity_info in discovery_info[CONF_DEVICES].items(): - event = get_rfx_object(packet_id) - if event is None: + if (event := get_rfx_object(packet_id)) is None: _LOGGER.error("Invalid device: %s", packet_id) continue if not supported(event): diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index afd1d6a12ce..d4d37bcd07b 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -8,23 +8,14 @@ import logging from RFXtrx import ControlEvent, SensorEvent from homeassistant.components.sensor import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_TEMPERATURE, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( CONF_DEVICES, DEGREE, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_VOLTAGE, ELECTRIC_CURRENT_AMPERE, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, @@ -77,94 +68,94 @@ class RfxtrxSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES = ( RfxtrxSensorEntityDescription( key="Barometer", - device_class=DEVICE_CLASS_PRESSURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PRESSURE_HPA, ), RfxtrxSensorEntityDescription( key="Battery numeric", - device_class=DEVICE_CLASS_BATTERY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, convert=_battery_convert, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), RfxtrxSensorEntityDescription( key="Current", - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, ), RfxtrxSensorEntityDescription( key="Current Ch. 1", - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, ), RfxtrxSensorEntityDescription( key="Current Ch. 2", - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, ), RfxtrxSensorEntityDescription( key="Current Ch. 3", - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, ), RfxtrxSensorEntityDescription( key="Energy usage", - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=POWER_WATT, ), RfxtrxSensorEntityDescription( key="Humidity", - device_class=DEVICE_CLASS_HUMIDITY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), RfxtrxSensorEntityDescription( key="Rssi numeric", - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, convert=_rssi_convert, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), RfxtrxSensorEntityDescription( key="Temperature", - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=TEMP_CELSIUS, ), RfxtrxSensorEntityDescription( key="Temperature2", - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=TEMP_CELSIUS, ), RfxtrxSensorEntityDescription( key="Total usage", - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), RfxtrxSensorEntityDescription( key="Voltage", - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, ), RfxtrxSensorEntityDescription( key="Wind direction", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=DEGREE, ), RfxtrxSensorEntityDescription( key="Rain rate", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, ), RfxtrxSensorEntityDescription( @@ -175,33 +166,33 @@ SENSOR_TYPES = ( ), RfxtrxSensorEntityDescription( key="Count", - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement="count", ), RfxtrxSensorEntityDescription( key="Counter value", - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement="count", ), RfxtrxSensorEntityDescription( key="Chill", - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=TEMP_CELSIUS, ), RfxtrxSensorEntityDescription( key="Wind average speed", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=SPEED_METERS_PER_SECOND, ), RfxtrxSensorEntityDescription( key="Wind gust", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=SPEED_METERS_PER_SECOND, ), RfxtrxSensorEntityDescription( key="Rain total", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=LENGTH_MILLIMETERS, ), RfxtrxSensorEntityDescription( @@ -215,7 +206,7 @@ SENSOR_TYPES = ( ), RfxtrxSensorEntityDescription( key="UV", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UV_INDEX, ), ) @@ -237,8 +228,7 @@ async def async_setup_entry( entities = [] for packet_id, entity_info in discovery_info[CONF_DEVICES].items(): - event = get_rfx_object(packet_id) - if event is None: + if (event := get_rfx_object(packet_id)) is None: _LOGGER.error("Invalid device: %s", packet_id) continue if not supported(event): diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index 75c0de88f13..eb3a9ba699c 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -49,7 +49,6 @@ }, "set_device_options": { "data": { - "fire_event": "Enable device event", "off_delay": "Off delay", "off_delay_enabled": "Enable off delay", "data_bit": "Number of data bits", diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index 2a09d027345..21e9e06b802 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -49,8 +49,7 @@ async def async_setup_entry( # Add switch from config file entities = [] for packet_id, entity_info in discovery_info[CONF_DEVICES].items(): - event = get_rfx_object(packet_id) - if event is None: + if (event := get_rfx_object(packet_id)) is None: _LOGGER.error("Invalid device: %s", packet_id) continue if not supported(event): diff --git a/homeassistant/components/rfxtrx/translations/bg.json b/homeassistant/components/rfxtrx/translations/bg.json index e35eaf15bd2..82061aa125d 100644 --- a/homeassistant/components/rfxtrx/translations/bg.json +++ b/homeassistant/components/rfxtrx/translations/bg.json @@ -1,6 +1,7 @@ { "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.", "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" }, "error": { diff --git a/homeassistant/components/rfxtrx/translations/id.json b/homeassistant/components/rfxtrx/translations/id.json index 9836d252c68..5ef4220f84a 100644 --- a/homeassistant/components/rfxtrx/translations/id.json +++ b/homeassistant/components/rfxtrx/translations/id.json @@ -35,6 +35,16 @@ } } }, + "device_automation": { + "action_type": { + "send_command": "Kirim perintah: {subtype}", + "send_status": "Kirim pembaruan status: {subtype}" + }, + "trigger_type": { + "command": "Perintah yang diterima: {subtype}", + "status": "Status yang diterima: {subtype}" + } + }, "options": { "error": { "already_configured_device": "Perangkat sudah dikonfigurasi", diff --git a/homeassistant/components/rfxtrx/translations/ja.json b/homeassistant/components/rfxtrx/translations/ja.json new file mode 100644 index 00000000000..bfbf14b31fb --- /dev/null +++ b/homeassistant/components/rfxtrx/translations/ja.json @@ -0,0 +1,84 @@ +{ + "config": { + "abort": { + "already_configured": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "setup_network": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "title": "\u63a5\u7d9a\u30a2\u30c9\u30ec\u30b9\u306e\u9078\u629e" + }, + "setup_serial": { + "data": { + "device": "\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e" + }, + "title": "\u30c7\u30d0\u30a4\u30b9" + }, + "setup_serial_manual_path": { + "data": { + "device": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "title": "\u30d1\u30b9" + }, + "user": { + "data": { + "type": "\u63a5\u7d9a\u30bf\u30a4\u30d7" + }, + "title": "\u63a5\u7d9a\u30bf\u30a4\u30d7\u306e\u9078\u629e" + } + } + }, + "device_automation": { + "action_type": { + "send_command": "\u30b3\u30de\u30f3\u30c9\u3092\u9001\u4fe1: {subtype}", + "send_status": "\u30b9\u30c6\u30fc\u30bf\u30b9\u66f4\u65b0\u306e\u9001\u4fe1: {subtype}" + }, + "trigger_type": { + "command": "\u30b3\u30de\u30f3\u30c9\u3092\u53d7\u4fe1: {subtype}", + "status": "\u53d7\u4fe1\u30b9\u30c6\u30fc\u30bf\u30b9: {subtype}" + } + }, + "options": { + "error": { + "already_configured_device": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "invalid_event_code": "\u7121\u52b9\u306a\u30a4\u30d9\u30f3\u30c8\u30b3\u30fc\u30c9", + "invalid_input_2262_off": "\u30b3\u30de\u30f3\u30c9 \u30aa\u30d5\u306e\u5165\u529b\u304c\u7121\u52b9", + "invalid_input_2262_on": "\u30b3\u30de\u30f3\u30c9 \u30aa\u30f3\u306e\u5165\u529b\u304c\u7121\u52b9", + "invalid_input_off_delay": "\u30aa\u30d5 \u30c7\u30a3\u30ec\u30a4\u306e\u5165\u529b\u304c\u7121\u52b9", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "prompt_options": { + "data": { + "automatic_add": "\u81ea\u52d5\u8ffd\u52a0\u3092\u6709\u52b9\u306b\u3059\u308b", + "debug": "\u30c7\u30d0\u30c3\u30b0\u306e\u6709\u52b9\u5316", + "device": "\u8a2d\u5b9a\u3059\u308b\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e", + "event_code": "\u30a4\u30d9\u30f3\u30c8\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u8ffd\u52a0", + "remove_device": "\u524a\u9664\u3059\u308b\u30c7\u30d0\u30a4\u30b9\u306e\u9078\u629e" + }, + "title": "Rfxtrx\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + }, + "set_device_options": { + "data": { + "command_off": "\u30b3\u30de\u30f3\u30c9\u30aa\u30d5\u306e\u30c7\u30fc\u30bf\u30d3\u30c3\u30c8\u5024", + "command_on": "\u30b3\u30de\u30f3\u30c9\u30aa\u30f3\u306e\u30c7\u30fc\u30bf\u30d3\u30c3\u30c8\u5024", + "data_bit": "\u30c7\u30fc\u30bf\u30d3\u30c3\u30c8\u6570", + "fire_event": "\u30c7\u30d0\u30a4\u30b9 \u30a4\u30d9\u30f3\u30c8\u3092\u6709\u52b9\u306b\u3059\u308b", + "off_delay": "\u30aa\u30d5\u9045\u5ef6", + "off_delay_enabled": "\u30aa\u30d5\u9045\u5ef6\u3092\u6709\u52b9\u306b\u3059\u308b", + "replace_device": "\u4ea4\u63db\u3059\u308b\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e", + "signal_repetitions": "\u4fe1\u53f7\u306e\u30ea\u30d4\u30fc\u30c8\u6570", + "venetian_blind_mode": "Venetian blind\u30e2\u30fc\u30c9" + }, + "title": "\u30c7\u30d0\u30a4\u30b9\u30aa\u30d7\u30b7\u30e7\u30f3\u306e\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rfxtrx/translations/pl.json b/homeassistant/components/rfxtrx/translations/pl.json index 0334ca217f8..be3e39fca70 100644 --- a/homeassistant/components/rfxtrx/translations/pl.json +++ b/homeassistant/components/rfxtrx/translations/pl.json @@ -44,8 +44,13 @@ } }, "device_automation": { + "action_type": { + "send_command": "wy\u015blij polecenie: {subtype}", + "send_status": "wy\u015blij aktualizacj\u0119 statusu: {subtype}" + }, "trigger_type": { - "command": "Otrzymane polecenie: {subtype}" + "command": "otrzymane polecenie {subtype}", + "status": "otrzymany status: {subtype}" } }, "few": "kilka", diff --git a/homeassistant/components/rfxtrx/translations/tr.json b/homeassistant/components/rfxtrx/translations/tr.json index 1c3ad8b9e05..2d720196874 100644 --- a/homeassistant/components/rfxtrx/translations/tr.json +++ b/homeassistant/components/rfxtrx/translations/tr.json @@ -5,28 +5,86 @@ "cannot_connect": "Ba\u011flanma hatas\u0131" }, "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "one": "Bo\u015f", + "other": "Bo\u015f" }, "step": { + "one": "Bo\u015f", + "other": "Bo\u015f", "setup_network": { "data": { "host": "Ana Bilgisayar", "port": "Port" - } + }, + "title": "Ba\u011flant\u0131 adresini se\u00e7in" + }, + "setup_serial": { + "data": { + "device": "Cihaz se\u00e7" + }, + "title": "Cihaz" + }, + "setup_serial_manual_path": { + "data": { + "device": "USB Cihaz Yolu" + }, + "title": "Yol" + }, + "user": { + "data": { + "type": "Ba\u011flant\u0131 t\u00fcr\u00fc" + }, + "title": "Ba\u011flant\u0131 t\u00fcr\u00fcn\u00fc se\u00e7in" } } }, + "device_automation": { + "action_type": { + "send_command": "Komut g\u00f6nder: {subtype}", + "send_status": "Durum g\u00fcncellemesi g\u00f6nder: {subtype}" + }, + "trigger_type": { + "command": "Al\u0131nan komut: {subtype}", + "status": "Al\u0131nan durum: {subtype}" + } + }, + "one": "Bo\u015f", "options": { "error": { "already_configured_device": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "invalid_event_code": "Ge\u00e7ersiz etkinlik kodunu", + "invalid_input_2262_off": "Komut kapatma i\u00e7in ge\u00e7ersiz giri\u015f", + "invalid_input_2262_on": "A\u00e7ma komutu i\u00e7in ge\u00e7ersiz giri\u015f", + "invalid_input_off_delay": "Kapanma gecikmesi i\u00e7in ge\u00e7ersiz giri\u015f", "unknown": "Beklenmeyen hata" }, "step": { + "prompt_options": { + "data": { + "automatic_add": "Otomatik eklemeyi etkinle\u015ftir", + "debug": "Hata ay\u0131klamay\u0131 etkinle\u015ftir", + "device": "Yap\u0131land\u0131rmak i\u00e7in cihaz\u0131 se\u00e7in", + "event_code": "Eklemek i\u00e7in etkinlik kodunu girin", + "remove_device": "Silinecek cihaz\u0131 se\u00e7in" + }, + "title": "Rfxtrx Se\u00e7enekleri" + }, "set_device_options": { "data": { + "command_off": "Komut kapatma i\u00e7in veri bitleri de\u011feri", + "command_on": "Komut i\u00e7in veri bitleri de\u011feri", + "data_bit": "Veri biti say\u0131s\u0131", + "fire_event": "Cihaz etkinli\u011fini etkinle\u015ftir", + "off_delay": "Kapanma gecikmesi", + "off_delay_enabled": "Kapatma gecikmesini etkinle\u015ftir", + "replace_device": "De\u011fi\u015ftirilecek cihaz\u0131 se\u00e7in", + "signal_repetitions": "Sinyal tekrar\u0131 say\u0131s\u0131", "venetian_blind_mode": "Jaluzi modu" - } + }, + "title": "Cihaz se\u00e7eneklerini yap\u0131land\u0131r\u0131n" } } - } + }, + "other": "Bo\u015f" } \ No newline at end of file diff --git a/homeassistant/components/ridwell/__init__.py b/homeassistant/components/ridwell/__init__.py new file mode 100644 index 00000000000..4aa5ea3e162 --- /dev/null +++ b/homeassistant/components/ridwell/__init__.py @@ -0,0 +1,84 @@ +"""The Ridwell integration.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta + +from aioridwell import async_get_client +from aioridwell.client import RidwellAccount, RidwellPickupEvent +from aioridwell.errors import InvalidCredentialsError, RidwellError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DATA_ACCOUNT, DATA_COORDINATOR, DOMAIN, LOGGER + +DEFAULT_UPDATE_INTERVAL = timedelta(hours=1) + +PLATFORMS: list[str] = ["sensor"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Ridwell from a config entry.""" + session = aiohttp_client.async_get_clientsession(hass) + + try: + client = await async_get_client( + entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session + ) + except InvalidCredentialsError as err: + raise ConfigEntryAuthFailed("Invalid username/password") from err + except RidwellError as err: + raise ConfigEntryNotReady(err) from err + + accounts = await client.async_get_accounts() + + async def async_update_data() -> dict[str, RidwellPickupEvent]: + """Get the latest pickup events.""" + data = {} + + async def async_get_pickups(account: RidwellAccount) -> None: + """Get the latest pickups for an account.""" + data[account.account_id] = await account.async_get_next_pickup_event() + + tasks = [async_get_pickups(account) for account in accounts.values()] + results = await asyncio.gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, InvalidCredentialsError): + raise ConfigEntryAuthFailed("Invalid username/password") from result + if isinstance(result, RidwellError): + raise UpdateFailed(result) from result + + return data + + coordinator = DataUpdateCoordinator( + hass, + LOGGER, + name=entry.title, + update_interval=DEFAULT_UPDATE_INTERVAL, + update_method=async_update_data, + ) + + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + DATA_ACCOUNT: accounts, + DATA_COORDINATOR: coordinator, + } + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/ridwell/config_flow.py b/homeassistant/components/ridwell/config_flow.py new file mode 100644 index 00000000000..1ca5a9b5941 --- /dev/null +++ b/homeassistant/components/ridwell/config_flow.py @@ -0,0 +1,121 @@ +"""Config flow for Ridwell integration.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from aioridwell import async_get_client +from aioridwell.errors import InvalidCredentialsError, RidwellError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, LOGGER + +STEP_REAUTH_CONFIRM_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): cv.string, + } +) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for WattTime.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize.""" + self._password: str | None = None + self._username: str | None = None + + async def _async_validate( + self, error_step_id: str, error_schema: vol.Schema + ) -> FlowResult: + """Validate input credentials and proceed accordingly.""" + errors = {} + session = aiohttp_client.async_get_clientsession(self.hass) + + if TYPE_CHECKING: + assert self._password + assert self._username + + try: + await async_get_client(self._username, self._password, session=session) + except InvalidCredentialsError: + errors["base"] = "invalid_auth" + except RidwellError as err: + LOGGER.error("Unknown Ridwell error: %s", err) + errors["base"] = "unknown" + + if errors: + return self.async_show_form( + step_id=error_step_id, + data_schema=error_schema, + errors=errors, + description_placeholders={CONF_USERNAME: self._username}, + ) + + if existing_entry := await self.async_set_unique_id(self._username): + self.hass.config_entries.async_update_entry( + existing_entry, + data={**existing_entry.data, CONF_PASSWORD: self._password}, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(existing_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_create_entry( + title=self._username, + data={CONF_USERNAME: self._username, CONF_PASSWORD: self._password}, + ) + + async def async_step_reauth(self, config: ConfigType) -> FlowResult: + """Handle configuration by re-auth.""" + self._username = config[CONF_USERNAME] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle re-auth completion.""" + if not user_input: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_CONFIRM_DATA_SCHEMA, + description_placeholders={CONF_USERNAME: self._username}, + ) + + self._password = user_input[CONF_PASSWORD] + + return await self._async_validate( + "reauth_confirm", STEP_REAUTH_CONFIRM_DATA_SCHEMA + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if not user_input: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] + + return await self._async_validate("user", STEP_USER_DATA_SCHEMA) diff --git a/homeassistant/components/ridwell/const.py b/homeassistant/components/ridwell/const.py new file mode 100644 index 00000000000..8d280bf2cc0 --- /dev/null +++ b/homeassistant/components/ridwell/const.py @@ -0,0 +1,9 @@ +"""Constants for the Ridwell integration.""" +import logging + +DOMAIN = "ridwell" + +LOGGER = logging.getLogger(__package__) + +DATA_ACCOUNT = "account" +DATA_COORDINATOR = "coordinator" diff --git a/homeassistant/components/ridwell/manifest.json b/homeassistant/components/ridwell/manifest.json new file mode 100644 index 00000000000..8c9b2e71304 --- /dev/null +++ b/homeassistant/components/ridwell/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "ridwell", + "name": "Ridwell", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ridwell", + "requirements": [ + "aioridwell==0.2.0" + ], + "codeowners": [ + "@bachya" + ], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/ridwell/sensor.py b/homeassistant/components/ridwell/sensor.py new file mode 100644 index 00000000000..f80ddc45176 --- /dev/null +++ b/homeassistant/components/ridwell/sensor.py @@ -0,0 +1,83 @@ +"""Support for Ridwell sensors.""" +from __future__ import annotations + +from collections.abc import Mapping +from datetime import date, datetime +from typing import Any + +from aioridwell.client import RidwellAccount, RidwellPickupEvent + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_CLASS_DATE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DATA_ACCOUNT, DATA_COORDINATOR, DOMAIN + +ATTR_CATEGORY = "category" +ATTR_PICKUP_STATE = "pickup_state" +ATTR_PICKUP_TYPES = "pickup_types" +ATTR_QUANTITY = "quantity" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up WattTime sensors based on a config entry.""" + accounts = hass.data[DOMAIN][entry.entry_id][DATA_ACCOUNT] + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + async_add_entities( + [RidwellSensor(coordinator, account) for account in accounts.values()] + ) + + +class RidwellSensor(CoordinatorEntity, SensorEntity): + """Define a Ridwell pickup sensor.""" + + _attr_device_class = DEVICE_CLASS_DATE + + def __init__( + self, coordinator: DataUpdateCoordinator, account: RidwellAccount + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self._account = account + self._attr_name = f"Ridwell Pickup ({account.address['street1']})" + self._attr_unique_id = account.account_id + + @property + def extra_state_attributes(self) -> Mapping[str, Any]: + """Return entity specific state attributes.""" + event = self.coordinator.data[self._account.account_id] + + attrs: dict[str, Any] = { + ATTR_PICKUP_TYPES: {}, + ATTR_PICKUP_STATE: event.state, + } + + for pickup in event.pickups: + if pickup.name not in attrs[ATTR_PICKUP_TYPES]: + attrs[ATTR_PICKUP_TYPES][pickup.name] = { + ATTR_CATEGORY: pickup.category, + ATTR_QUANTITY: pickup.quantity, + } + else: + # Ridwell's API will return distinct objects, even if they have the + # same name (e.g. two pickups of Latex Paint will show up as two + # objects) – so, we sum the quantities: + attrs[ATTR_PICKUP_TYPES][pickup.name][ATTR_QUANTITY] += pickup.quantity + + return attrs + + @property + def native_value(self) -> StateType | date | datetime: + """Return the value reported by the sensor.""" + event: RidwellPickupEvent = self.coordinator.data[self._account.account_id] + return event.pickup_date diff --git a/homeassistant/components/ridwell/strings.json b/homeassistant/components/ridwell/strings.json new file mode 100644 index 00000000000..3f4cc1806a4 --- /dev/null +++ b/homeassistant/components/ridwell/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Please re-enter the password for {username}:", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + }, + "user": { + "description": "Input your username and password:", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/ridwell/translations/af.json b/homeassistant/components/ridwell/translations/af.json new file mode 100644 index 00000000000..84f128a6d22 --- /dev/null +++ b/homeassistant/components/ridwell/translations/af.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r be van \u00e1ll\u00edtva" + }, + "error": { + "invalid_auth": "Sikertelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3" + }, + "description": "Add meg a jelsz\u00f3t \u00fajra" + }, + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "Add meg a felhaszn\u00e1l\u00f3nevet \u00e9s a jelsz\u00f3t" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/bg.json b/homeassistant/components/ridwell/translations/bg.json new file mode 100644 index 00000000000..a0418dd4af0 --- /dev/null +++ b/homeassistant/components/ridwell/translations/bg.json @@ -0,0 +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": { + "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": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + }, + "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u043f\u0430\u0440\u043e\u043b\u0430\u0442\u0430 \u0437\u0430 {username}:", + "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": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + }, + "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e\u0442\u043e \u0441\u0438 \u0438\u043c\u0435 \u0438 \u043f\u0430\u0440\u043e\u043b\u0430:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/ca.json b/homeassistant/components/ridwell/translations/ca.json new file mode 100644 index 00000000000..68e355cbf9e --- /dev/null +++ b/homeassistant/components/ridwell/translations/ca.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Contrasenya" + }, + "description": "Torna a introduir la contrasenya de {username}:", + "title": "Reautenticaci\u00f3 de la integraci\u00f3" + }, + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Introdueix el nom d'usuari i la contrasenya:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/de.json b/homeassistant/components/ridwell/translations/de.json new file mode 100644 index 00000000000..d7b6cefa827 --- /dev/null +++ b/homeassistant/components/ridwell/translations/de.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + }, + "error": { + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Passwort" + }, + "description": "Bitte gib das Passwort f\u00fcr {username} erneut ein:", + "title": "Integration erneut authentifizieren" + }, + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Gib deinen Benutzernamen und dein Passwort ein:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/en.json b/homeassistant/components/ridwell/translations/en.json new file mode 100644 index 00000000000..8399f6242cd --- /dev/null +++ b/homeassistant/components/ridwell/translations/en.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Please re-enter the password for {username}:", + "title": "Reauthenticate Integration" + }, + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Input your username and password:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/et.json b/homeassistant/components/ridwell/translations/et.json new file mode 100644 index 00000000000..ffee1fa8727 --- /dev/null +++ b/homeassistant/components/ridwell/translations/et.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba h\u00e4\u00e4lestatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" + }, + "error": { + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Salas\u00f5na" + }, + "description": "Sisesta uuesti {username} salas\u00f5na:", + "title": "Taastuvasta sidumine" + }, + "user": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "Sisesta oma kasutajanimi ja salas\u00f5na:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/fr.json b/homeassistant/components/ridwell/translations/fr.json new file mode 100644 index 00000000000..3324689e3d3 --- /dev/null +++ b/homeassistant/components/ridwell/translations/fr.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" + }, + "error": { + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Mot de passe" + }, + "title": "R\u00e9-authentifier l'int\u00e9gration" + }, + "user": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/he.json b/homeassistant/components/ridwell/translations/he.json new file mode 100644 index 00000000000..608c61a9687 --- /dev/null +++ b/homeassistant/components/ridwell/translations/he.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "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": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "reauth_confirm": { + "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", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/hu.json b/homeassistant/components/ridwell/translations/hu.json new file mode 100644 index 00000000000..19046e3e0b8 --- /dev/null +++ b/homeassistant/components/ridwell/translations/hu.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r be van \u00e1ll\u00edtva", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." + }, + "error": { + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3" + }, + "description": "K\u00e9rem, adja meg \u00fajra a jelsz\u00f3t {username} r\u00e9sz\u00e9re:", + "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" + }, + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "Adja meg felhaszn\u00e1l\u00f3nev\u00e9t \u00e9s jelszav\u00e1t:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/id.json b/homeassistant/components/ridwell/translations/id.json new file mode 100644 index 00000000000..acbf2e35dad --- /dev/null +++ b/homeassistant/components/ridwell/translations/id.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Kata Sandi" + }, + "description": "Masukkan kembali kata sandi untuk {username} :", + "title": "Autentikasi Ulang Integrasi" + }, + "user": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + }, + "description": "Masukkan nama pengguna dan kata sandi Anda:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/it.json b/homeassistant/components/ridwell/translations/it.json new file mode 100644 index 00000000000..b603f14a1ac --- /dev/null +++ b/homeassistant/components/ridwell/translations/it.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" + }, + "error": { + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Inserisci nuovamente la password per {username}:", + "title": "Autenticare nuovamente l'integrazione" + }, + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "description": "Inserisci il tuo nome utente e password:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/ja.json b/homeassistant/components/ridwell/translations/ja.json new file mode 100644 index 00000000000..33d7e12ebd4 --- /dev/null +++ b/homeassistant/components/ridwell/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "{username} \u306e\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u518d\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044:", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u30e6\u30fc\u30b6\u30fc\u540d\u3068\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/nl.json b/homeassistant/components/ridwell/translations/nl.json new file mode 100644 index 00000000000..afec8a578f5 --- /dev/null +++ b/homeassistant/components/ridwell/translations/nl.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" + }, + "error": { + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Wachtwoord" + }, + "description": "Voer het wachtwoord voor {username} opnieuw in:", + "title": "Verifieer de integratie opnieuw" + }, + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Voer uw gebruikersnaam en wachtwoord in:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/no.json b/homeassistant/components/ridwell/translations/no.json new file mode 100644 index 00000000000..0e161b5d614 --- /dev/null +++ b/homeassistant/components/ridwell/translations/no.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + }, + "error": { + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Passord" + }, + "description": "Vennligst skriv inn passordet for {username} :", + "title": "Godkjenne integrering p\u00e5 nytt" + }, + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Skriv inn brukernavn og passord:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/pl.json b/homeassistant/components/ridwell/translations/pl.json new file mode 100644 index 00000000000..65832ab9fd5 --- /dev/null +++ b/homeassistant/components/ridwell/translations/pl.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + }, + "error": { + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Has\u0142o" + }, + "description": "Wprowad\u017a ponownie has\u0142o dla u\u017cytkownika {username}:", + "title": "Ponownie uwierzytelnij integracj\u0119" + }, + "user": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Wprowad\u017a swoj\u0105 nazw\u0119 u\u017cytkownika i has\u0142o:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/pt-BR.json b/homeassistant/components/ridwell/translations/pt-BR.json new file mode 100644 index 00000000000..befa822057f --- /dev/null +++ b/homeassistant/components/ridwell/translations/pt-BR.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositivo j\u00e1 configurado", + "reauth_successful": "A reautentica\u00e7\u00e3o foi feita com sucesso" + }, + "error": { + "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", + "unknown": "Erro inesperado" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Senha" + }, + "description": "Por favor, digite novamente a senha para {username}:", + "title": "Reautenticar integra\u00e7\u00e3o" + }, + "user": { + "data": { + "password": "Senha", + "username": "Nome de usu\u00e1rio" + }, + "description": "Digite seu nome de usu\u00e1rio e senha:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/ru.json b/homeassistant/components/ridwell/translations/ru.json new file mode 100644 index 00000000000..2a319f1bb70 --- /dev/null +++ b/homeassistant/components/ridwell/translations/ru.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\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.", + "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": { + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \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": { + "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 \u0438\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0438 \u043f\u0430\u0440\u043e\u043b\u044c:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/sl.json b/homeassistant/components/ridwell/translations/sl.json new file mode 100644 index 00000000000..db0d9f2afc4 --- /dev/null +++ b/homeassistant/components/ridwell/translations/sl.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Ra\u010dun \u017ee nastavljen" + }, + "error": { + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Geslo" + } + }, + "user": { + "data": { + "password": "Geslo", + "username": "Uporabni\u0161ko ime" + }, + "description": "Vnesite svoje uporabni\u0161ko ime in geslo:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/th.json b/homeassistant/components/ridwell/translations/th.json new file mode 100644 index 00000000000..a30127b4e9e --- /dev/null +++ b/homeassistant/components/ridwell/translations/th.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e19\u0e35\u0e49\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e04\u0e48\u0e32\u0e41\u0e25\u0e49\u0e27" + }, + "error": { + "unknown": "\u0e02\u0e49\u0e2d\u0e1c\u0e34\u0e14\u0e1e\u0e25\u0e32\u0e14\u0e17\u0e35\u0e48\u0e44\u0e21\u0e48\u0e04\u0e32\u0e14\u0e04\u0e34\u0e14" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19" + } + }, + "user": { + "data": { + "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19", + "username": "\u0e0a\u0e37\u0e48\u0e2d\u0e1c\u0e39\u0e49\u0e43\u0e0a\u0e49" + }, + "description": "\u0e1b\u0e49\u0e2d\u0e19\u0e0a\u0e37\u0e48\u0e2d\u0e1c\u0e39\u0e49\u0e43\u0e0a\u0e49 \u0e41\u0e25\u0e30 \u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/tr.json b/homeassistant/components/ridwell/translations/tr.json new file mode 100644 index 00000000000..d9ab1290edb --- /dev/null +++ b/homeassistant/components/ridwell/translations/tr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u015eifre" + }, + "description": "L\u00fctfen {username} parolas\u0131n\u0131 yeniden girin:", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, + "user": { + "data": { + "password": "\u015eifre", + "username": "Kullan\u0131c\u0131 ad\u0131" + }, + "description": "Kullan\u0131c\u0131 ad\u0131n\u0131z\u0131 ve \u015fifrenizi girin:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/zh-Hant.json b/homeassistant/components/ridwell/translations/zh-Hant.json new file mode 100644 index 00000000000..358e0f26518 --- /dev/null +++ b/homeassistant/components/ridwell/translations/zh-Hant.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + }, + "error": { + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u78bc" + }, + "description": "\u8acb\u91cd\u65b0\u8f38\u5165 {username} \u5bc6\u78bc\uff1a", + "title": "\u91cd\u65b0\u8a8d\u8b49\u6574\u5408" + }, + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8f38\u5165\u4f7f\u7528\u8005\u540d\u7a31\u8207\u5bc6\u78bc\uff1a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index c36b44f5ee5..2745c69d50a 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -164,7 +164,7 @@ class HistoryRingSensor(RingSensor): if self._latest_event is None: return None - return self._latest_event["created_at"].isoformat() + return self._latest_event["created_at"] @property def extra_state_attributes(self): diff --git a/homeassistant/components/ring/translations/bg.json b/homeassistant/components/ring/translations/bg.json new file mode 100644 index 00000000000..90fdd44ba71 --- /dev/null +++ b/homeassistant/components/ring/translations/bg.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "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": { + "2fa": { + "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" + }, + "user": { + "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" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ring/translations/ja.json b/homeassistant/components/ring/translations/ja.json new file mode 100644 index 00000000000..55dc9328e04 --- /dev/null +++ b/homeassistant/components/ring/translations/ja.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "2fa": { + "data": { + "2fa": "2\u8981\u7d20\u30b3\u30fc\u30c9" + }, + "title": "2\u8981\u7d20\u8a8d\u8a3c" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Ring\u30a2\u30ab\u30a6\u30f3\u30c8\u3067\u30b5\u30a4\u30f3\u30a4\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ring/translations/tr.json b/homeassistant/components/ring/translations/tr.json index caba385d7fa..690d60e8e09 100644 --- a/homeassistant/components/ring/translations/tr.json +++ b/homeassistant/components/ring/translations/tr.json @@ -8,11 +8,18 @@ "unknown": "Beklenmeyen hata" }, "step": { + "2fa": { + "data": { + "2fa": "\u0130ki ad\u0131ml\u0131 kimlik do\u011frulama kodu" + }, + "title": "\u0130ki fakt\u00f6rl\u00fc kimlik do\u011frulama" + }, "user": { "data": { "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "Ring hesab\u0131yla oturum a\u00e7\u0131n" } } } diff --git a/homeassistant/components/ripple/sensor.py b/homeassistant/components/ripple/sensor.py index 2746f5789cd..a99bb86c5ef 100644 --- a/homeassistant/components/ripple/sensor.py +++ b/homeassistant/components/ripple/sensor.py @@ -62,7 +62,5 @@ class RippleSensor(SensorEntity): def update(self): """Get the latest state of the sensor.""" - - balance = get_balance(self.address) - if balance is not None: + if (balance := get_balance(self.address)) is not None: self._state = balance diff --git a/homeassistant/components/risco/translations/bg.json b/homeassistant/components/risco/translations/bg.json index ac95bcfebf8..b9092f75d6c 100644 --- a/homeassistant/components/risco/translations/bg.json +++ b/homeassistant/components/risco/translations/bg.json @@ -1,7 +1,12 @@ { "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\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \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": { diff --git a/homeassistant/components/risco/translations/ja.json b/homeassistant/components/risco/translations/ja.json new file mode 100644 index 00000000000..1e574a07a32 --- /dev/null +++ b/homeassistant/components/risco/translations/ja.json @@ -0,0 +1,55 @@ +{ + "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "pin": "PIN\u30b3\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + }, + "options": { + "step": { + "ha_to_risco": { + "data": { + "armed_away": "\u8b66\u6212 \u96e2\u5e2d(away)", + "armed_custom_bypass": "\u8b66\u6212 \u30ab\u30b9\u30bf\u30e0 \u30d0\u30a4\u30d1\u30b9", + "armed_home": "\u8b66\u6212 \u5728\u5b85", + "armed_night": "\u8b66\u6212 \u591c" + }, + "description": "Home Assistant\u306e\u30a2\u30e9\u30fc\u30e0\u3067\u8b66\u6212\u3059\u308b\u969b\u306b\u3001Risco\u306e\u30a2\u30e9\u30fc\u30e0\u72b6\u614b\u3092\u3069\u306e\u3088\u3046\u306b\u8a2d\u5b9a\u3059\u308b\u304b\u306e\u9078\u629e", + "title": "Risco states\u3092Home Assistant\u306b\u30de\u30c3\u30d7" + }, + "init": { + "data": { + "code_arm_required": "\u8b66\u6212\u306b\u306f\u3001PIN\u30b3\u30fc\u30c9\u304c\u5fc5\u8981", + "code_disarm_required": "\u89e3\u9664\u306b\u306f\u3001PIN\u30b3\u30fc\u30c9\u304c\u5fc5\u8981", + "scan_interval": "Risco\u3092\u30dd\u30fc\u30ea\u30f3\u30b0\u3059\u308b\u983b\u5ea6(\u79d2\u5358\u4f4d)" + }, + "title": "\u30aa\u30d7\u30b7\u30e7\u30f3\u306e\u8a2d\u5b9a" + }, + "risco_to_ha": { + "data": { + "A": "\u30b0\u30eb\u30fc\u30d7A", + "B": "\u30b0\u30eb\u30fc\u30d7B", + "C": "\u30b0\u30eb\u30fc\u30d7C", + "D": "\u30b0\u30eb\u30fc\u30d7D", + "arm": "\u8b66\u6212 (\u96e2\u5e2d(AWAY))", + "partial_arm": "\u90e8\u5206\u7684\u306b\u8b66\u6212 (\u6ede\u5728(STAY))" + }, + "description": "Risco\u306b\u3088\u3063\u3066\u5831\u544a\u3055\u308c\u305f\u3059\u3079\u3066\u306e\u72b6\u614b\u306b\u3064\u3044\u3066\u3001Home Assistant\u30a2\u30e9\u30fc\u30e0\u304c\u3001\u3069\u306e\u72b6\u614b\u3060\u3068\u5831\u544a\u3059\u308b\u304b\u3092\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "Risco states\u3092Home Assistant\u306e\u72b6\u614b\u306b\u30de\u30c3\u30d7\u3059\u308b" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/risco/translations/tr.json b/homeassistant/components/risco/translations/tr.json index 280de17f77d..168fc621e05 100644 --- a/homeassistant/components/risco/translations/tr.json +++ b/homeassistant/components/risco/translations/tr.json @@ -12,6 +12,7 @@ "user": { "data": { "password": "Parola", + "pin": "PIN Kodu", "username": "Kullan\u0131c\u0131 Ad\u0131" } } @@ -26,9 +27,15 @@ "armed_home": "Evde Modu Aktif", "armed_night": "Gece Modu Aktif" }, - "description": "Home Assistant alarm\u0131n\u0131 kurarken Risco alarm\u0131n\u0131z\u0131 hangi duruma ayarlayaca\u011f\u0131n\u0131z\u0131 se\u00e7in" + "description": "Home Assistant alarm\u0131n\u0131 kurarken Risco alarm\u0131n\u0131z\u0131 hangi duruma ayarlayaca\u011f\u0131n\u0131z\u0131 se\u00e7in", + "title": "Home Assistant eyaletlerini Risco eyaletleriyle e\u015fleyin" }, "init": { + "data": { + "code_arm_required": "Devreye almak i\u00e7in PIN Kodu gerektir", + "code_disarm_required": "Devre d\u0131\u015f\u0131 b\u0131rakmak i\u00e7in PIN Kodu", + "scan_interval": "Risco'ya ne s\u0131kl\u0131kla anket yap\u0131l\u0131r (saniye cinsinden)" + }, "title": "Se\u00e7enekleri yap\u0131land\u0131r\u0131n" }, "risco_to_ha": { @@ -36,8 +43,12 @@ "A": "Grup A", "B": "Grup B", "C": "Grup C", - "D": "Grup D" - } + "D": "Grup D", + "arm": "Aktif (UZAKTA)", + "partial_arm": "K\u0131smen Aktif (KAL)" + }, + "description": "Risco taraf\u0131ndan bildirilen her durum i\u00e7in Home Assistant alarm\u0131n\u0131z\u0131n hangi durumu bildirece\u011fini se\u00e7in", + "title": "Risco eyaletlerini Home Assistant eyaletleriyle e\u015fleyin" } } } diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index a529ff3dca6..8592c1fcfb8 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -4,11 +4,12 @@ from __future__ import annotations from pyrituals import Diffuser from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_BATTERY_CHARGING, + BinarySensorDeviceClass, BinarySensorEntity, ) 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 RitualsDataUpdateCoordinator @@ -37,7 +38,8 @@ async def async_setup_entry( class DiffuserBatteryChargingBinarySensor(DiffuserEntity, BinarySensorEntity): """Representation of a diffuser battery charging binary sensor.""" - _attr_device_class = DEVICE_CLASS_BATTERY_CHARGING + _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index eac95ee5ed4..265ed292c2a 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -7,6 +7,7 @@ from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import AREA_SQUARE_METERS from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RitualsDataUpdateCoordinator @@ -36,6 +37,7 @@ class DiffuserRoomSize(DiffuserEntity, SelectEntity): _attr_icon = "mdi:ruler-square" _attr_unit_of_measurement = AREA_SQUARE_METERS _attr_options = ["15", "30", "60", "100"] + _attr_entity_category = EntityCategory.CONFIG def __init__( self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index 878fb2f1a86..0172df4b1c1 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -3,14 +3,11 @@ from __future__ import annotations from pyrituals import Diffuser -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_SIGNAL_STRENGTH, - PERCENTAGE, -) +from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RitualsDataUpdateCoordinator @@ -90,8 +87,9 @@ class DiffuserFillSensor(DiffuserEntity, SensorEntity): class DiffuserBatterySensor(DiffuserEntity, SensorEntity): """Representation of a diffuser battery sensor.""" - _attr_device_class = DEVICE_CLASS_BATTERY + _attr_device_class = SensorDeviceClass.BATTERY _attr_native_unit_of_measurement = PERCENTAGE + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator @@ -108,8 +106,9 @@ class DiffuserBatterySensor(DiffuserEntity, SensorEntity): class DiffuserWifiSensor(DiffuserEntity, SensorEntity): """Representation of a diffuser wifi sensor.""" - _attr_device_class = DEVICE_CLASS_SIGNAL_STRENGTH + _attr_device_class = SensorDeviceClass.SIGNAL_STRENGTH _attr_native_unit_of_measurement = PERCENTAGE + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator diff --git a/homeassistant/components/rituals_perfume_genie/translations/ja.json b/homeassistant/components/rituals_perfume_genie/translations/ja.json new file mode 100644 index 00000000000..b341e8a40c0 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "title": "Rituals\u30a2\u30ab\u30a6\u30f3\u30c8\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rituals_perfume_genie/translations/tr.json b/homeassistant/components/rituals_perfume_genie/translations/tr.json new file mode 100644 index 00000000000..ee1316315ec --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/translations/tr.json @@ -0,0 +1,21 @@ +{ + "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": { + "email": "E-posta", + "password": "Parola" + }, + "title": "Rituals hesab\u0131n\u0131za ba\u011flan\u0131n" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rmvtransport/manifest.json b/homeassistant/components/rmvtransport/manifest.json index a8e2b253324..bcbb96c7034 100644 --- a/homeassistant/components/rmvtransport/manifest.json +++ b/homeassistant/components/rmvtransport/manifest.json @@ -2,11 +2,7 @@ "domain": "rmvtransport", "name": "RMV", "documentation": "https://www.home-assistant.io/integrations/rmvtransport", - "requirements": [ - "PyRMVtransport==0.3.2" - ], - "codeowners": [ - "@cgtobi" - ], + "requirements": ["PyRMVtransport==0.3.3"], + "codeowners": ["@cgtobi"], "iot_class": "cloud_polling" -} \ No newline at end of file +} diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index b9e93b4f008..dff0f7d53c6 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -7,17 +7,12 @@ from urllib.parse import urlparse from rokuecp import Roku, RokuError import voluptuous as vol -from homeassistant.components.ssdp import ( - ATTR_SSDP_LOCATION, - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_SERIAL, -) +from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import DiscoveryInfoType from .const import DOMAIN @@ -84,14 +79,16 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=info["title"], data=user_input) - async def async_step_homekit(self, discovery_info): + async def async_step_homekit( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle a flow initialized by homekit discovery.""" # If we already have the host configured do # not open connections to it if we can avoid it. - self._async_abort_entries_match({CONF_HOST: discovery_info[CONF_HOST]}) + self._async_abort_entries_match({CONF_HOST: discovery_info.host}) - self.discovery_info.update({CONF_HOST: discovery_info[CONF_HOST]}) + self.discovery_info.update({CONF_HOST: discovery_info.host}) try: info = await validate_input(self.hass, self.discovery_info) @@ -104,7 +101,7 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(info["serial_number"]) self._abort_if_unique_id_configured( - updates={CONF_HOST: discovery_info[CONF_HOST]}, + updates={CONF_HOST: discovery_info.host}, ) self.context.update({"title_placeholders": {"name": info["title"]}}) @@ -112,11 +109,11 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() - async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a flow initialized by discovery.""" - host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname - name = discovery_info[ATTR_UPNP_FRIENDLY_NAME] - serial_number = discovery_info[ATTR_UPNP_SERIAL] + host = urlparse(discovery_info.ssdp_location).hostname + name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] await self.async_set_unique_id(serial_number) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 81e3af86bb5..7756d917e73 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -2,7 +2,7 @@ "domain": "roku", "name": "Roku", "documentation": "https://www.home-assistant.io/integrations/roku", - "requirements": ["rokuecp==0.8.1"], + "requirements": ["rokuecp==0.8.4"], "homekit": { "models": ["3810X", "4660X", "7820X", "C105X", "C135X"] }, diff --git a/homeassistant/components/roku/translations/de.json b/homeassistant/components/roku/translations/de.json index ce8ec9e4595..5ff560809c8 100644 --- a/homeassistant/components/roku/translations/de.json +++ b/homeassistant/components/roku/translations/de.json @@ -15,10 +15,6 @@ "title": "Roku" }, "ssdp_confirm": { - "data": { - "one": "eins", - "other": "andere" - }, "description": "M\u00f6chtest du {name} einrichten?", "title": "Roku" }, diff --git a/homeassistant/components/roku/translations/ja.json b/homeassistant/components/roku/translations/ja.json new file mode 100644 index 00000000000..65f2ac2272a --- /dev/null +++ b/homeassistant/components/roku/translations/ja.json @@ -0,0 +1,29 @@ +{ + "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", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name}", + "step": { + "discovery_confirm": { + "description": "{name} \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f", + "title": "Roku" + }, + "ssdp_confirm": { + "description": "{name} \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f", + "title": "Roku" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "Roku\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/roku/translations/tr.json b/homeassistant/components/roku/translations/tr.json index 0dca1a028b2..23b33b69efe 100644 --- a/homeassistant/components/roku/translations/tr.json +++ b/homeassistant/components/roku/translations/tr.json @@ -2,23 +2,35 @@ "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", "unknown": "Beklenmeyen hata" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, + "flow_title": "{name}", "step": { "discovery_confirm": { + "data": { + "one": "Bo\u015f", + "other": "Bo\u015f" + }, "description": "{name} kurmak istiyor musunuz?", "title": "Roku" }, "ssdp_confirm": { - "description": "{name} kurmak istiyor musunuz?" + "data": { + "one": "Bo\u015f", + "other": "Bo\u015f" + }, + "description": "{name} kurmak istiyor musunuz?", + "title": "Roku" }, "user": { "data": { "host": "Ana Bilgisayar" - } + }, + "description": "Roku bilgilerinizi girin." } } } diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index 3936d3f6d1d..2911f46d55d 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -80,7 +80,7 @@ async def async_connect_or_timeout(hass, roomba): """Connect to vacuum.""" try: name = None - with async_timeout.timeout(10): + async with async_timeout.timeout(10): _LOGGER.debug("Initialize connection to vacuum") await hass.async_add_executor_job(roomba.connect) while not roomba.roomba_connected or name is None: @@ -104,7 +104,7 @@ async def async_connect_or_timeout(hass, roomba): async def async_disconnect_or_timeout(hass, roomba): """Disconnect to vacuum.""" _LOGGER.debug("Disconnect vacuum") - with async_timeout.timeout(3): + async with async_timeout.timeout(3): await hass.async_add_executor_job(roomba.disconnect) return True diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index f17eb0a07c0..2dbecdf32f2 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -8,9 +8,10 @@ from roombapy.getpassword import RoombaPassword import voluptuous as vol from homeassistant import config_entries, core -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS +from homeassistant.components import dhcp from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from . import CannotConnect, async_connect_or_timeout, async_disconnect_or_timeout from .const import ( @@ -77,15 +78,15 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) - async def async_step_dhcp(self, discovery_info): + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" - self._async_abort_entries_match({CONF_HOST: discovery_info[IP_ADDRESS]}) + self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) - if not discovery_info[HOSTNAME].startswith(("irobot-", "roomba-")): + if not discovery_info.hostname.startswith(("irobot-", "roomba-")): return self.async_abort(reason="not_irobot_device") - self.host = discovery_info[IP_ADDRESS] - self.blid = _async_blid_from_hostname(discovery_info[HOSTNAME]) + self.host = discovery_info.ip + self.blid = _async_blid_from_hostname(discovery_info.hostname) await self.async_set_unique_id(self.blid) self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 2aaa1f6762e..907026fd77e 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -3,7 +3,7 @@ "name": "iRobot Roomba and Braava", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/roomba", - "requirements": ["roombapy==1.6.3"], + "requirements": ["roombapy==1.6.4"], "codeowners": ["@pschmitt", "@cyr-ius", "@shenxn"], "dhcp": [ { diff --git a/homeassistant/components/roomba/translations/ja.json b/homeassistant/components/roomba/translations/ja.json new file mode 100644 index 00000000000..d2ae42b977d --- /dev/null +++ b/homeassistant/components/roomba/translations/ja.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "already_configured": "\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", + "not_irobot_device": "\u691c\u51fa\u3055\u308c\u305f\u30c7\u30d0\u30a4\u30b9\u306fiRobot\u793e\u306e\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002", + "short_blid": "BLID\u304c\u5207\u308a\u6368\u3066\u3089\u308c\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name} ({host})", + "step": { + "init": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "\u30eb\u30f3\u30d0\u307e\u305f\u306f\u30d6\u30e9\u30fc\u30d0\u3092\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "\u81ea\u52d5\u7684\u306b\u30c7\u30d0\u30a4\u30b9\u306b\u63a5\u7d9a\u3059\u308b" + }, + "link": { + "description": "{name} \u306e\u30db\u30fc\u30e0\u30dc\u30bf\u30f3\u3092\u3001\u30c7\u30d0\u30a4\u30b9\u304c\u97f3\u3092\u51fa\u3059\u307e\u3067(\u7d042\u79d2)\u62bc\u3057\u7d9a\u3051\u300130\u79d2\u4ee5\u5185\u306b\u9001\u4fe1(submit)\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30d1\u30b9\u30ef\u30fc\u30c9\u306e\u53d6\u5f97" + }, + "link_manual": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u30c7\u30d0\u30a4\u30b9\u304b\u3089\u81ea\u52d5\u7684\u306b\u53d6\u5f97\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002\u6b21\u306e\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u306b\u8a18\u8f09\u3055\u308c\u3066\u3044\u308b\u624b\u9806\u306b\u5f93\u3063\u3066\u304f\u3060\u3055\u3044: {auth_help_url}", + "title": "\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b" + }, + "manual": { + "data": { + "blid": "BLID", + "host": "\u30db\u30b9\u30c8" + }, + "description": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u3001\u30eb\u30f3\u30d0\u3084\u30d6\u30e9\u30fc\u30d0\u304c\u767a\u898b\u3055\u308c\u307e\u305b\u3093\u3067\u3057\u305f\u3002", + "title": "\u30c7\u30d0\u30a4\u30b9\u306b\u624b\u52d5\u3067\u63a5\u7d9a\u3059\u308b" + }, + "user": { + "data": { + "blid": "BLID", + "continuous": "\u9023\u7d9a", + "delay": "\u9045\u5ef6", + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u30eb\u30f3\u30d0\u307e\u305f\u306f\u30d6\u30e9\u30fc\u30d0\u3092\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "\u81ea\u52d5\u7684\u306b\u30c7\u30d0\u30a4\u30b9\u306b\u63a5\u7d9a\u3059\u308b" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "continuous": "\u9023\u7d9a", + "delay": "\u9045\u5ef6" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/roomba/translations/pl.json b/homeassistant/components/roomba/translations/pl.json index 5f6a3a23333..c56c00a98b0 100644 --- a/homeassistant/components/roomba/translations/pl.json +++ b/homeassistant/components/roomba/translations/pl.json @@ -19,7 +19,7 @@ "title": "Po\u0142\u0105cz si\u0119 automatycznie z urz\u0105dzeniem" }, "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 prze\u015blij w ci\u0105gu 30 sekund.", + "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.", "title": "Odzyskiwanie has\u0142a" }, "link_manual": { diff --git a/homeassistant/components/roomba/translations/tr.json b/homeassistant/components/roomba/translations/tr.json index 3d85144c188..695ab709749 100644 --- a/homeassistant/components/roomba/translations/tr.json +++ b/homeassistant/components/roomba/translations/tr.json @@ -3,12 +3,13 @@ "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "cannot_connect": "Ba\u011flanma hatas\u0131", - "not_irobot_device": "Bulunan cihaz bir iRobot cihaz\u0131 de\u011fil" + "not_irobot_device": "Bulunan cihaz bir iRobot cihaz\u0131 de\u011fil", + "short_blid": "BLID kesildi" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, - "flow_title": "iRobot {name} ( {host} )", + "flow_title": "{name} ({host})", "step": { "init": { "data": { @@ -31,18 +32,20 @@ "manual": { "data": { "blid": "BLID", - "host": "Ana Bilgisayar" + "host": "Ana bilgisayar" }, + "description": "A\u011f\u0131n\u0131zda Roomba veya Braava bulunamad\u0131.", "title": "Cihaza manuel olarak ba\u011flan\u0131n" }, "user": { "data": { + "blid": "BLID", "continuous": "S\u00fcrekli", "delay": "Gecikme", - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "password": "Parola" }, - "description": "\u015eu anda BLID ve parola alma manuel bir i\u015flemdir. L\u00fctfen a\u015fa\u011f\u0131daki belgelerde belirtilen ad\u0131mlar\u0131 izleyin: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials", + "description": "Roomba veya Braava'y\u0131 se\u00e7in.", "title": "Cihaza ba\u011flan\u0131n" } } diff --git a/homeassistant/components/roon/__init__.py b/homeassistant/components/roon/__init__.py index c9dbe86ee4b..94c51abf705 100644 --- a/homeassistant/components/roon/__init__.py +++ b/homeassistant/components/roon/__init__.py @@ -16,7 +16,7 @@ async def async_setup_entry(hass, entry): return False hass.data[DOMAIN][entry.entry_id] = roonserver - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.entry_id)}, diff --git a/homeassistant/components/roon/translations/bg.json b/homeassistant/components/roon/translations/bg.json new file mode 100644 index 00000000000..36b9a0b4bff --- /dev/null +++ b/homeassistant/components/roon/translations/bg.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/roon/translations/ja.json b/homeassistant/components/roon/translations/ja.json new file mode 100644 index 00000000000..dba8ccc9237 --- /dev/null +++ b/homeassistant/components/roon/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" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "link": { + "description": "Roon\u3067Home Assistant\u3092\u8a8d\u8a3c\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\u9001\u4fe1(submit) \u3092\u30af\u30ea\u30c3\u30af\u3057\u305f\u5f8c\u3001Roon Core\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u3067\u3001\u8a2d\u5b9a(Settings )\u3092\u958b\u304d\u3001\u6a5f\u80fd\u62e1\u5f35\u30bf\u30d6(extensions tab)\u3067Home Assistant\u3092\u6709\u52b9(enable )\u306b\u3057\u307e\u3059\u3002", + "title": "Roon\u3067HomeAssistant\u3092\u8a8d\u8a3c\u3059\u308b" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "Roon server\u3092\u691c\u51fa\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3001 \u30db\u30b9\u30c8\u540d\u307e\u305f\u306f\u3001IP\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/roon/translations/pl.json b/homeassistant/components/roon/translations/pl.json index 7ebfd0ad777..fab5fb9657e 100644 --- a/homeassistant/components/roon/translations/pl.json +++ b/homeassistant/components/roon/translations/pl.json @@ -9,7 +9,7 @@ }, "step": { "link": { - "description": "Musisz autoryzowa\u0107 Home Assistant w Roon. Po klikni\u0119ciu przycisku \"Prze\u015blij\", przejd\u017a do aplikacji Roon Core, otw\u00f3rz \"Ustawienia\" i w\u0142\u0105cz Home Assistant w karcie \"Rozszerzenia\" (Extensions).", + "description": "Musisz autoryzowa\u0107 Home Assistant w Roon. Po klikni\u0119ciu przycisku \"Zatwierd\u017a\", przejd\u017a do aplikacji Roon Core, otw\u00f3rz \"Ustawienia\" i w\u0142\u0105cz Home Assistant w karcie \"Rozszerzenia\" (Extensions).", "title": "Autoryzuj Home Assistant w Roon" }, "user": { diff --git a/homeassistant/components/roon/translations/tr.json b/homeassistant/components/roon/translations/tr.json index 94e452e48bb..75fedf6383e 100644 --- a/homeassistant/components/roon/translations/tr.json +++ b/homeassistant/components/roon/translations/tr.json @@ -15,7 +15,8 @@ "user": { "data": { "host": "Ana Bilgisayar" - } + }, + "description": "Roon sunucusu bulunamad\u0131, l\u00fctfen Ana Bilgisayar Ad\u0131n\u0131z\u0131 veya IP'nizi girin." } } } diff --git a/homeassistant/components/rova/sensor.py b/homeassistant/components/rova/sensor.py index 54e2c315a4e..60d0fbe6df0 100644 --- a/homeassistant/components/rova/sensor.py +++ b/homeassistant/components/rova/sensor.py @@ -20,6 +20,7 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle +from homeassistant.util.dt import get_time_zone, now # Config for rova requests. CONF_ZIP_CODE = "zip_code" @@ -116,7 +117,7 @@ class RovaSensor(SensorEntity): self.data_service.update() pickup_date = self.data_service.data.get(self.entity_description.key) if pickup_date is not None: - self._attr_native_value = pickup_date.isoformat() + self._attr_native_value = pickup_date class RovaData: @@ -140,10 +141,12 @@ class RovaData: self.data = {} for item in items: - date = datetime.strptime(item["Date"], "%Y-%m-%dT%H:%M:%S") + date = datetime.strptime(item["Date"], "%Y-%m-%dT%H:%M:%S").replace( + tzinfo=get_time_zone("Europe/Amsterdam") + ) code = item["GarbageTypeCode"].lower() - if code not in self.data and date > datetime.now(): + if code not in self.data and date > now(): self.data[code] = date _LOGGER.debug("Updated Rova calendar: %s", self.data) diff --git a/homeassistant/components/rpi_gpio_pwm/light.py b/homeassistant/components/rpi_gpio_pwm/light.py index 0673047c6a7..efd3f03c9c0 100644 --- a/homeassistant/components/rpi_gpio_pwm/light.py +++ b/homeassistant/components/rpi_gpio_pwm/light.py @@ -117,8 +117,7 @@ class PwmSimpleLed(LightEntity, RestoreEntity): async def async_added_to_hass(self): """Handle entity about to be added to hass event.""" await super().async_added_to_hass() - last_state = await self.async_get_last_state() - if last_state: + if last_state := await self.async_get_last_state(): self._is_on = last_state.state == STATE_ON self._brightness = last_state.attributes.get( "brightness", DEFAULT_BRIGHTNESS @@ -193,8 +192,7 @@ class PwmRgbLed(PwmSimpleLed): async def async_added_to_hass(self): """Handle entity about to be added to hass event.""" await super().async_added_to_hass() - last_state = await self.async_get_last_state() - if last_state: + if last_state := await self.async_get_last_state(): self._color = last_state.attributes.get("hs_color", DEFAULT_COLOR) @property diff --git a/homeassistant/components/rpi_power/translations/ja.json b/homeassistant/components/rpi_power/translations/ja.json new file mode 100644 index 00000000000..26aee66af5b --- /dev/null +++ b/homeassistant/components/rpi_power/translations/ja.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u3053\u306e\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u306b\u5fc5\u8981\u306a\u30b7\u30b9\u30c6\u30e0\u30af\u30e9\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3002\u30ab\u30fc\u30cd\u30eb\u304c\u6700\u65b0\u3067\u3001\u30cf\u30fc\u30c9\u30a6\u30a7\u30a2\u304c\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "confirm": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + } + } + }, + "title": "\u30e9\u30ba\u30d9\u30ea\u30fc\u30d1\u30a4\u306e\u96fb\u6e90\u30c1\u30a7\u30c3\u30ab\u30fc" +} \ No newline at end of file diff --git a/homeassistant/components/rpi_power/translations/tr.json b/homeassistant/components/rpi_power/translations/tr.json index f1dfcf16667..5ad414a1be8 100644 --- a/homeassistant/components/rpi_power/translations/tr.json +++ b/homeassistant/components/rpi_power/translations/tr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "no_devices_found": "Bu bile\u015fen i\u00e7in gereken sistem s\u0131n\u0131f\u0131n\u0131 bulam\u0131yorum, \u00e7ekirde\u011finizin yeni oldu\u011fundan ve donan\u0131m\u0131n desteklendi\u011finden emin olun", "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, "step": { diff --git a/homeassistant/components/ruckus_unleashed/translations/ja.json b/homeassistant/components/ruckus_unleashed/translations/ja.json new file mode 100644 index 00000000000..a9d2ddfd3ac --- /dev/null +++ b/homeassistant/components/ruckus_unleashed/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/safe_mode/manifest.json b/homeassistant/components/safe_mode/manifest.json index 78a656511bd..5ce7c3abf7b 100644 --- a/homeassistant/components/safe_mode/manifest.json +++ b/homeassistant/components/safe_mode/manifest.json @@ -4,5 +4,6 @@ "config_flow": false, "documentation": "https://www.home-assistant.io/integrations/safe_mode", "dependencies": ["frontend", "persistent_notification", "cloud"], - "codeowners": ["@home-assistant/core"] + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" } diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index c75086322da..fe9e4384884 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Samsung TV.""" from __future__ import annotations +from functools import partial import socket from types import MappingProxyType from typing import Any @@ -10,13 +11,7 @@ import getmac import voluptuous as vol from homeassistant import config_entries, data_entry_flow -from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS -from homeassistant.components.ssdp import ( - ATTR_SSDP_LOCATION, - ATTR_UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME, - ATTR_UPNP_UDN, -) +from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.const import ( CONF_HOST, CONF_MAC, @@ -27,7 +22,6 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.typing import DiscoveryInfoType from .bridge import ( SamsungTVBridge, @@ -37,7 +31,6 @@ from .bridge import ( mac_from_device_info, ) from .const import ( - ATTR_PROPERTIES, CONF_MANUFACTURER, CONF_MODEL, DEFAULT_MANUFACTURER, @@ -168,7 +161,9 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._udn = _strip_uuid(dev_info.get("udn", info["id"])) if mac := mac_from_device_info(info): self._mac = mac - elif mac := getmac.get_mac_address(ip=self._host): + elif mac := await self.hass.async_add_executor_job( + partial(getmac.get_mac_address, ip=self._host) + ): self._mac = mac self._device_info = info return True @@ -264,16 +259,16 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): raise data_entry_flow.AbortFlow(RESULT_NOT_SUPPORTED) async def async_step_ssdp( - self, discovery_info: DiscoveryInfoType + self, discovery_info: ssdp.SsdpServiceInfo ) -> data_entry_flow.FlowResult: """Handle a flow initialized by ssdp discovery.""" LOGGER.debug("Samsung device found via SSDP: %s", discovery_info) - model_name: str = discovery_info.get(ATTR_UPNP_MODEL_NAME) or "" - self._udn = _strip_uuid(discovery_info[ATTR_UPNP_UDN]) - if hostname := urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname: + model_name: str = discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) or "" + self._udn = _strip_uuid(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + if hostname := urlparse(discovery_info.ssdp_location or "").hostname: self._host = hostname await self._async_set_unique_id_from_udn() - self._manufacturer = discovery_info[ATTR_UPNP_MANUFACTURER] + self._manufacturer = discovery_info.upnp[ssdp.ATTR_UPNP_MANUFACTURER] self._abort_if_manufacturer_is_not_samsung() if not await self._async_get_and_check_device_info(): # If we cannot get device info for an SSDP discovery @@ -285,24 +280,24 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_confirm() async def async_step_dhcp( - self, discovery_info: DiscoveryInfoType + self, discovery_info: dhcp.DhcpServiceInfo ) -> data_entry_flow.FlowResult: """Handle a flow initialized by dhcp discovery.""" LOGGER.debug("Samsung device found via DHCP: %s", discovery_info) - self._mac = discovery_info[MAC_ADDRESS] - self._host = discovery_info[IP_ADDRESS] + self._mac = discovery_info.macaddress + self._host = discovery_info.ip await self._async_start_discovery_with_mac_address() await self._async_set_device_unique_id() self.context["title_placeholders"] = {"device": self._title} return await self.async_step_confirm() async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> data_entry_flow.FlowResult: """Handle a flow initialized by zeroconf discovery.""" LOGGER.debug("Samsung device found via ZEROCONF: %s", discovery_info) - self._mac = format_mac(discovery_info[ATTR_PROPERTIES]["deviceid"]) - self._host = discovery_info[CONF_HOST] + self._mac = format_mac(discovery_info.properties["deviceid"]) + self._host = discovery_info.host await self._async_start_discovery_with_mac_address() await self._async_set_device_unique_id() self.context["title_placeholders"] = {"device": self._title} diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index cab6435af95..8bbb97925f5 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -99,16 +99,34 @@ class SamsungTVDevice(MediaPlayerEntity): self._config_entry = config_entry self._host: str | None = config_entry.data[CONF_HOST] self._mac: str | None = config_entry.data.get(CONF_MAC) - self._manufacturer: str | None = config_entry.data.get(CONF_MANUFACTURER) - self._model: str | None = config_entry.data.get(CONF_MODEL) - self._name: str | None = config_entry.data.get(CONF_NAME) self._on_script = on_script - self._uuid = config_entry.unique_id - # Assume that the TV is not muted - self._muted: bool = False # Assume that the TV is in Play mode self._playing: bool = True - self._state: str | None = None + + self._attr_name: str | None = config_entry.data.get(CONF_NAME) + self._attr_state: str | None = None + self._attr_unique_id = config_entry.unique_id + self._attr_is_volume_muted: bool = False + self._attr_device_class = DEVICE_CLASS_TV + self._attr_source_list = list(SOURCES) + + if self._on_script or self._mac: + self._attr_supported_features = SUPPORT_SAMSUNGTV | SUPPORT_TURN_ON + else: + self._attr_supported_features = SUPPORT_SAMSUNGTV + + self._attr_device_info = DeviceInfo( + name=self.name, + manufacturer=config_entry.data.get(CONF_MANUFACTURER), + model=config_entry.data.get(CONF_MODEL), + ) + if self.unique_id: + self._attr_device_info["identifiers"] = {(DOMAIN, self.unique_id)} + if self._mac: + self._attr_device_info["connections"] = { + (CONNECTION_NETWORK_MAC, self._mac) + } + # Mark the end of a shutdown command (need to wait 15 seconds before # sending the next command to avoid turning the TV back ON). self._end_of_power_off: datetime | None = None @@ -136,9 +154,9 @@ class SamsungTVDevice(MediaPlayerEntity): if self._auth_failed or self.hass.is_stopping: return if self._power_off_in_progress(): - self._state = STATE_OFF + self._attr_state = STATE_OFF else: - self._state = STATE_ON if self._bridge.is_on() else STATE_OFF + self._attr_state = STATE_ON if self._bridge.is_on() else STATE_OFF def send_key(self, key: str) -> None: """Send a key to the tv and handles exceptions.""" @@ -153,69 +171,18 @@ class SamsungTVDevice(MediaPlayerEntity): and self._end_of_power_off > dt_util.utcnow() ) - @property - def unique_id(self) -> str | None: - """Return the unique ID of the device.""" - return self._uuid - - @property - def name(self) -> str | None: - """Return the name of the device.""" - return self._name - - @property - def state(self) -> str | None: - """Return the state of the device.""" - return self._state - @property def available(self) -> bool: """Return the availability of the device.""" if self._auth_failed: return False return ( - self._state == STATE_ON + self._attr_state == STATE_ON or self._on_script is not None or self._mac is not None or self._power_off_in_progress() ) - @property - def device_info(self) -> DeviceInfo | None: - """Return device specific attributes.""" - info: DeviceInfo = { - "name": self.name, - "manufacturer": self._manufacturer, - "model": self._model, - } - if self.unique_id: - info["identifiers"] = {(DOMAIN, self.unique_id)} - if self._mac: - info["connections"] = {(CONNECTION_NETWORK_MAC, self._mac)} - return info - - @property - def is_volume_muted(self) -> bool: - """Boolean if volume is currently muted.""" - return self._muted - - @property - def source_list(self) -> list: - """List of available input sources.""" - return list(SOURCES) - - @property - def supported_features(self) -> int: - """Flag media player features that are supported.""" - if self._on_script or self._mac: - return SUPPORT_SAMSUNGTV | SUPPORT_TURN_ON - return SUPPORT_SAMSUNGTV - - @property - def device_class(self) -> str: - """Set the device class to TV.""" - return DEVICE_CLASS_TV - def turn_off(self) -> None: """Turn off media player.""" self._end_of_power_off = dt_util.utcnow() + SCAN_INTERVAL_PLUS_OFF_TIME diff --git a/homeassistant/components/samsungtv/translations/bg.json b/homeassistant/components/samsungtv/translations/bg.json index 1432000c6b9..332b6b62236 100644 --- a/homeassistant/components/samsungtv/translations/bg.json +++ b/homeassistant/components/samsungtv/translations/bg.json @@ -1,10 +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", "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" }, - "flow_title": "{device}" + "flow_title": "{device}", + "step": { + "confirm": { + "title": "Samsung TV" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "name": "\u0418\u043c\u0435" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/samsungtv/translations/da.json b/homeassistant/components/samsungtv/translations/da.json index 9ca829b7679..d41a62c5e7b 100644 --- a/homeassistant/components/samsungtv/translations/da.json +++ b/homeassistant/components/samsungtv/translations/da.json @@ -9,7 +9,7 @@ "flow_title": "Samsung-tv: {model}", "step": { "confirm": { - "description": "Vil du konfigurere Samsung-tv {model}? Hvis du aldrig har oprettet forbindelse til Home Assistant f\u00f8r, b\u00f8r du se en popup p\u00e5 dit tv, der beder om godkendelse. Manuelle konfigurationer for dette tv vil blive overskrevet.", + "description": "Vil du konfigurere Samsung-tv {device}? Hvis du aldrig har oprettet forbindelse til Home Assistant f\u00f8r, b\u00f8r du se en popup p\u00e5 dit tv, der beder om godkendelse. Manuelle konfigurationer for dette tv vil blive overskrevet.", "title": "Samsung-tv" }, "user": { diff --git a/homeassistant/components/samsungtv/translations/es-419.json b/homeassistant/components/samsungtv/translations/es-419.json index dfe7793a235..179965fda80 100644 --- a/homeassistant/components/samsungtv/translations/es-419.json +++ b/homeassistant/components/samsungtv/translations/es-419.json @@ -9,7 +9,7 @@ "flow_title": "Televisi\u00f3n Samsung: {model}", "step": { "confirm": { - "description": "\u00bfDesea configurar la televisi\u00f3n Samsung {model}? Si nunca conect\u00f3 Home Assistant antes, deber\u00eda ver una ventana emergente en su televisor pidiendo autorizaci\u00f3n. Las configuraciones manuales para este televisor se sobrescribir\u00e1n.", + "description": "\u00bfDesea configurar la televisi\u00f3n Samsung {device}? Si nunca conect\u00f3 Home Assistant antes, deber\u00eda ver una ventana emergente en su televisor pidiendo autorizaci\u00f3n. Las configuraciones manuales para este televisor se sobrescribir\u00e1n.", "title": "Samsung TV" }, "user": { diff --git a/homeassistant/components/samsungtv/translations/fr.json b/homeassistant/components/samsungtv/translations/fr.json index 75b6bab6676..03d6eb2b3c6 100644 --- a/homeassistant/components/samsungtv/translations/fr.json +++ b/homeassistant/components/samsungtv/translations/fr.json @@ -17,7 +17,7 @@ "flow_title": "Samsung TV: {model}", "step": { "confirm": { - "description": "Voulez vous installer la TV {model} Samsung? Si vous n'avez jamais connect\u00e9 Home Assistant avant, vous devriez voir une fen\u00eatre contextuelle sur votre t\u00e9l\u00e9viseur demandant une authentification. Les configurations manuelles de ce t\u00e9l\u00e9viseur seront \u00e9cras\u00e9es.", + "description": "Voulez vous installer la TV {device} Samsung? Si vous n'avez jamais connect\u00e9 Home Assistant avant, vous devriez voir une fen\u00eatre contextuelle sur votre t\u00e9l\u00e9viseur demandant une authentification. Les configurations manuelles de ce t\u00e9l\u00e9viseur seront \u00e9cras\u00e9es.", "title": "TV Samsung" }, "reauth_confirm": { diff --git a/homeassistant/components/samsungtv/translations/ja.json b/homeassistant/components/samsungtv/translations/ja.json new file mode 100644 index 00000000000..0cfad60efcb --- /dev/null +++ b/homeassistant/components/samsungtv/translations/ja.json @@ -0,0 +1,35 @@ +{ + "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", + "auth_missing": "Home Assistant\u306f\u3001\u3053\u306eSamsungTV\u3078\u306e\u63a5\u7d9a\u3092\u8a31\u53ef\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\u30c6\u30ec\u30d3\u306e\u5916\u90e8\u30c7\u30d0\u30a4\u30b9\u30de\u30cd\u30fc\u30b8\u30e3\u30fc\u306e\u8a2d\u5b9a\u3092\u78ba\u8a8d\u3057\u3066\u3001Home Assistant\u3092\u8a8d\u8a3c\u3057\u307e\u3059\u3002", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "id_missing": "\u3053\u306eSamsung\u30c7\u30d0\u30a4\u30b9\u306b\u306f\u30b7\u30ea\u30a2\u30eb\u756a\u53f7\u304c\u3042\u308a\u307e\u305b\u3093\u3002", + "missing_config_entry": "\u3053\u306eSamsung\u30c7\u30d0\u30a4\u30b9\u306b\u306f\u69cb\u6210\u30a8\u30f3\u30c8\u30ea\u30fc\u304c\u3042\u308a\u307e\u305b\u3093\u3002", + "not_supported": "\u3053\u306eSamsung\u30c7\u30d0\u30a4\u30b9\u306f\u73fe\u5728\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "auth_missing": "Home Assistant\u306f\u3001\u3053\u306eSamsungTV\u3078\u306e\u63a5\u7d9a\u3092\u8a31\u53ef\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\u30c6\u30ec\u30d3\u306e\u5916\u90e8\u30c7\u30d0\u30a4\u30b9\u30de\u30cd\u30fc\u30b8\u30e3\u30fc\u306e\u8a2d\u5b9a\u3092\u78ba\u8a8d\u3057\u3066\u3001Home Assistant\u3092\u8a8d\u8a3c\u3057\u307e\u3059\u3002" + }, + "flow_title": "{device}", + "step": { + "confirm": { + "description": "{device} \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f\u3053\u308c\u307e\u3067\u306bHome Assistant\u3092\u4e00\u5ea6\u3082\u63a5\u7d9a\u3057\u305f\u3053\u3068\u304c\u306a\u3044\u5834\u5408\u306f\u3001\u30c6\u30ec\u30d3\u306b\u8a8d\u8a3c\u3092\u6c42\u3081\u308b\u30dd\u30c3\u30d7\u30a2\u30c3\u30d7\u304c\u8868\u793a\u3055\u308c\u307e\u3059\u3002", + "title": "Samsung TV" + }, + "reauth_confirm": { + "description": "\u9001\u4fe1(submit)\u3001\u8a8d\u8a3c\u3092\u8981\u6c42\u3059\u308b {device} \u306e\u30dd\u30c3\u30d7\u30a2\u30c3\u30d7\u3092\u300130\u79d2\u4ee5\u5185\u306b\u53d7\u3051\u5165\u308c\u307e\u3059\u3002" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d" + }, + "description": "Samsung TV\u306e\u60c5\u5831\u3092\u5165\u529b\u3057\u307e\u3059\u3002\u3053\u308c\u307e\u3067\u306bHome Assistant\u306b\u4e00\u5ea6\u3082\u63a5\u7d9a\u3057\u305f\u3053\u3068\u304c\u306a\u3044\u5834\u5408\u306f\u3001\u30c6\u30ec\u30d3\u306b\u8a8d\u8a3c\u3092\u6c42\u3081\u308b\u30dd\u30c3\u30d7\u30a2\u30c3\u30d7\u304c\u8868\u793a\u3055\u308c\u307e\u3059\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/samsungtv/translations/ko.json b/homeassistant/components/samsungtv/translations/ko.json index 7efb88bf7eb..4f62bbf6237 100644 --- a/homeassistant/components/samsungtv/translations/ko.json +++ b/homeassistant/components/samsungtv/translations/ko.json @@ -10,7 +10,7 @@ "flow_title": "\uc0bc\uc131 TV: {model}", "step": { "confirm": { - "description": "{model} \uc0bc\uc131 TV\ub97c \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c? Home Assistant\ub97c \uc5f0\uacb0\ud55c \uc801\uc774 \uc5c6\ub2e4\uba74 TV\uc5d0\uc11c \uc778\uc99d\uc744 \uc694\uccad\ud558\ub294 \ud31d\uc5c5\uc774 \ud45c\uc2dc\ub429\ub2c8\ub2e4. \uc774 TV\uc758 \uc218\ub3d9\uc73c\ub85c \uad6c\uc131\ub41c \ub0b4\uc6a9\uc740 \ub36e\uc5b4\uc50c\uc6cc\uc9d1\ub2c8\ub2e4.", + "description": "{device} \uc0bc\uc131 TV\ub97c \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c? Home Assistant\ub97c \uc5f0\uacb0\ud55c \uc801\uc774 \uc5c6\ub2e4\uba74 TV\uc5d0\uc11c \uc778\uc99d\uc744 \uc694\uccad\ud558\ub294 \ud31d\uc5c5\uc774 \ud45c\uc2dc\ub429\ub2c8\ub2e4. \uc774 TV\uc758 \uc218\ub3d9\uc73c\ub85c \uad6c\uc131\ub41c \ub0b4\uc6a9\uc740 \ub36e\uc5b4\uc50c\uc6cc\uc9d1\ub2c8\ub2e4.", "title": "\uc0bc\uc131 TV" }, "user": { diff --git a/homeassistant/components/samsungtv/translations/lb.json b/homeassistant/components/samsungtv/translations/lb.json index 110b063b6e8..0740ab119a4 100644 --- a/homeassistant/components/samsungtv/translations/lb.json +++ b/homeassistant/components/samsungtv/translations/lb.json @@ -10,7 +10,7 @@ "flow_title": "Samsnung TV:{model}", "step": { "confirm": { - "description": "W\u00ebllt dir de Samsung TV {model} ariichten?. Falls dir Home Assistant nach ni domat verbonnen hutt misst den TV eng Meldung mat enger Authentifiz\u00e9ierung uweisen. Manuell Konfiguratioun fir d\u00ebse TV g\u00ebtt iwwerschriwwen.", + "description": "W\u00ebllt dir de Samsung TV {device} ariichten?. Falls dir Home Assistant nach ni domat verbonnen hutt misst den TV eng Meldung mat enger Authentifiz\u00e9ierung uweisen. Manuell Konfiguratioun fir d\u00ebse TV g\u00ebtt iwwerschriwwen.", "title": "Samsnung TV" }, "user": { diff --git a/homeassistant/components/samsungtv/translations/pl.json b/homeassistant/components/samsungtv/translations/pl.json index ed117c60d21..1f810248f92 100644 --- a/homeassistant/components/samsungtv/translations/pl.json +++ b/homeassistant/components/samsungtv/translations/pl.json @@ -6,12 +6,13 @@ "auth_missing": "Home Assistant nie ma uprawnie\u0144 do po\u0142\u0105czenia si\u0119 z tym telewizorem Samsung. Sprawd\u017a ustawienia \"Mened\u017cera urz\u0105dze\u0144 zewn\u0119trznych\", aby autoryzowa\u0107 Home Assistant.", "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "id_missing": "To urz\u0105dzenie Samsung nie ma numeru seryjnego.", + "missing_config_entry": "To urz\u0105dzenie Samsung nie ma wpisu konfiguracyjnego.", "not_supported": "To urz\u0105dzenie Samsung nie jest obecnie obs\u0142ugiwane", "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", "unknown": "Nieoczekiwany b\u0142\u0105d" }, "error": { - "auth_missing": "[%key::component::samsungtv::config::abort::auth_missing%]" + "auth_missing": "Home Assistant nie ma uprawnie\u0144 do po\u0142\u0105czenia si\u0119 z tym telewizorem Samsung. Sprawd\u017a ustawienia \"Mened\u017cera urz\u0105dze\u0144 zewn\u0119trznych\", aby autoryzowa\u0107 Home Assistant." }, "flow_title": "{device}", "step": { diff --git a/homeassistant/components/samsungtv/translations/sl.json b/homeassistant/components/samsungtv/translations/sl.json index 1a94ce59833..41536f72b16 100644 --- a/homeassistant/components/samsungtv/translations/sl.json +++ b/homeassistant/components/samsungtv/translations/sl.json @@ -9,7 +9,7 @@ "flow_title": "Samsung TV: {model}", "step": { "confirm": { - "description": "Vnesite podatke o televizorju Samsung. \u010ce \u0161e nikoli niste povezali Home Assistant, bi morali na televizorju videli pojavno okno, ki zahteva va\u0161e dovoljenje. Ro\u010dna konfiguracija za ta TV bo prepisana.", + "description": "Vnesite podatke o televizorju Samsung {device}. \u010ce \u0161e nikoli niste povezali Home Assistant, bi morali na televizorju videli pojavno okno, ki zahteva va\u0161e dovoljenje. Ro\u010dna konfiguracija za ta TV bo prepisana.", "title": "Samsung TV" }, "user": { diff --git a/homeassistant/components/samsungtv/translations/sv.json b/homeassistant/components/samsungtv/translations/sv.json index 3558559c4b6..835bb5e5c9b 100644 --- a/homeassistant/components/samsungtv/translations/sv.json +++ b/homeassistant/components/samsungtv/translations/sv.json @@ -9,7 +9,7 @@ "flow_title": "Samsung TV: {model}", "step": { "confirm": { - "description": "Vill du st\u00e4lla in Samsung TV {model}? Om du aldrig har anslutit Home Assistant innan du ska se ett popup-f\u00f6nster p\u00e5 tv:n och be om auktorisering. Manuella konfigurationer f\u00f6r den h\u00e4r TV:n skrivs \u00f6ver.", + "description": "Vill du st\u00e4lla in Samsung TV {device}? Om du aldrig har anslutit Home Assistant innan du ska se ett popup-f\u00f6nster p\u00e5 tv:n och be om auktorisering. Manuella konfigurationer f\u00f6r den h\u00e4r TV:n skrivs \u00f6ver.", "title": "Samsung TV" }, "user": { diff --git a/homeassistant/components/samsungtv/translations/tr.json b/homeassistant/components/samsungtv/translations/tr.json index 6b3900e9aa5..862fe05414e 100644 --- a/homeassistant/components/samsungtv/translations/tr.json +++ b/homeassistant/components/samsungtv/translations/tr.json @@ -1,20 +1,31 @@ { "config": { "abort": { - "already_configured": "Bu Samsung TV zaten ayarlanm\u0131\u015f.", - "already_in_progress": "Samsung TV ayar\u0131 zaten s\u00fcr\u00fcyor.", + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", "auth_missing": "Home Assistant'\u0131n bu Samsung TV'ye ba\u011flanma izni yok. Home Assistant'\u0131 yetkilendirmek i\u00e7in l\u00fctfen TV'nin ayarlar\u0131n\u0131 kontrol et.", "cannot_connect": "Ba\u011flanma hatas\u0131", - "not_supported": "Bu Samsung TV cihaz\u0131 \u015fu anda desteklenmiyor." + "id_missing": "Bu Samsung cihaz\u0131n\u0131n Seri Numaras\u0131 yok.", + "missing_config_entry": "Bu Samsung cihaz\u0131nda bir yap\u0131land\u0131rma giri\u015fi yok.", + "not_supported": "Bu Samsung TV cihaz\u0131 \u015fu anda desteklenmiyor.", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu", + "unknown": "Beklenmeyen hata" }, - "flow_title": "Samsung TV: {model}", + "error": { + "auth_missing": "Home Assistant'\u0131n bu Samsung TV'ye ba\u011flanma izni yok. Home Assistant'\u0131 yetkilendirmek i\u00e7in l\u00fctfen TV'nin ayarlar\u0131n\u0131 kontrol et." + }, + "flow_title": "{device}", "step": { "confirm": { + "description": "{device} kurulumunu yapmak istiyor musunuz? Home Assistant'\u0131 daha \u00f6nce hi\u00e7 ba\u011flamad\u0131ysan\u0131z, TV'nizde yetki isteyen bir a\u00e7\u0131l\u0131r pencere g\u00f6rmelisiniz.", "title": "Samsung TV" }, + "reauth_confirm": { + "description": "G\u00f6nderdikten sonra, 30 saniye i\u00e7inde yetkilendirme isteyen {device} \u00fczerindeki a\u00e7\u0131l\u0131r pencereyi kabul edin." + }, "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "name": "Ad" }, "description": "Samsung TV bilgilerini gir. Daha \u00f6nce hi\u00e7 Home Assistant'a ba\u011flamad\u0131ysan, TV'nde izin isteyen bir pencere g\u00f6receksindir." diff --git a/homeassistant/components/samsungtv/translations/uk.json b/homeassistant/components/samsungtv/translations/uk.json index 83bb18e76f1..f6aa504ccc8 100644 --- a/homeassistant/components/samsungtv/translations/uk.json +++ b/homeassistant/components/samsungtv/translations/uk.json @@ -10,7 +10,7 @@ "flow_title": "Samsung TV: {model}", "step": { "confirm": { - "description": "\u0425\u043e\u0447\u0435\u0442\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u0442\u0438 \u0442\u0435\u043b\u0435\u0432\u0456\u0437\u043e\u0440 Samsung {model}? \u042f\u043a\u0449\u043e \u0446\u0435\u0439 \u0442\u0435\u043b\u0435\u0432\u0456\u0437\u043e\u0440 \u0440\u0430\u043d\u0456\u0448\u0435 \u043d\u0435 \u0431\u0443\u0432 \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0439 \u0434\u043e Home Assistant, \u043d\u0430 \u0435\u043a\u0440\u0430\u043d\u0456 \u0442\u0435\u043b\u0435\u0432\u0456\u0437\u043e\u0440\u0430 \u043c\u0430\u0454 \u0437'\u044f\u0432\u0438\u0442\u0438\u0441\u044f \u0441\u043f\u043b\u0438\u0432\u0430\u044e\u0447\u0435 \u0432\u0456\u043a\u043d\u043e \u0456\u0437 \u0437\u0430\u043f\u0438\u0442\u043e\u043c \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0456\u0457. \u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u0434\u043b\u044f \u0446\u044c\u043e\u0433\u043e \u0442\u0435\u043b\u0435\u0432\u0456\u0437\u043e\u0440\u0430, \u0432\u0438\u043a\u043e\u043d\u0430\u043d\u0456 \u0432\u0440\u0443\u0447\u043d\u0443, \u0431\u0443\u0434\u0443\u0442\u044c \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0438\u0441\u0430\u043d\u0456.", + "description": "\u0425\u043e\u0447\u0435\u0442\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u0442\u0438 \u0442\u0435\u043b\u0435\u0432\u0456\u0437\u043e\u0440 Samsung {device}? \u042f\u043a\u0449\u043e \u0446\u0435\u0439 \u0442\u0435\u043b\u0435\u0432\u0456\u0437\u043e\u0440 \u0440\u0430\u043d\u0456\u0448\u0435 \u043d\u0435 \u0431\u0443\u0432 \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0439 \u0434\u043e Home Assistant, \u043d\u0430 \u0435\u043a\u0440\u0430\u043d\u0456 \u0442\u0435\u043b\u0435\u0432\u0456\u0437\u043e\u0440\u0430 \u043c\u0430\u0454 \u0437'\u044f\u0432\u0438\u0442\u0438\u0441\u044f \u0441\u043f\u043b\u0438\u0432\u0430\u044e\u0447\u0435 \u0432\u0456\u043a\u043d\u043e \u0456\u0437 \u0437\u0430\u043f\u0438\u0442\u043e\u043c \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0456\u0457. \u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u0434\u043b\u044f \u0446\u044c\u043e\u0433\u043e \u0442\u0435\u043b\u0435\u0432\u0456\u0437\u043e\u0440\u0430, \u0432\u0438\u043a\u043e\u043d\u0430\u043d\u0456 \u0432\u0440\u0443\u0447\u043d\u0443, \u0431\u0443\u0434\u0443\u0442\u044c \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0438\u0441\u0430\u043d\u0456.", "title": "\u0422\u0435\u043b\u0435\u0432\u0456\u0437\u043e\u0440 Samsung" }, "user": { diff --git a/homeassistant/components/scene/translations/id.json b/homeassistant/components/scene/translations/id.json index 827c0c81f38..10ece853e7b 100644 --- a/homeassistant/components/scene/translations/id.json +++ b/homeassistant/components/scene/translations/id.json @@ -1,3 +1,3 @@ { - "title": "Scene" + "title": "Skenario" } \ No newline at end of file diff --git a/homeassistant/components/scene/translations/ja.json b/homeassistant/components/scene/translations/ja.json new file mode 100644 index 00000000000..7ea3c93bca0 --- /dev/null +++ b/homeassistant/components/scene/translations/ja.json @@ -0,0 +1,3 @@ +{ + "title": "\u30b7\u30fc\u30f3" +} \ No newline at end of file diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 894a8a5e527..72c1f162552 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -1,5 +1,4 @@ """The Screenlogic integration.""" -import asyncio from datetime import timedelta import logging @@ -11,6 +10,7 @@ from screenlogicpy.const import ( SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT, + ScreenLogicWarning, ) from homeassistant.config_entries import ConfigEntry @@ -20,7 +20,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -28,35 +28,35 @@ from homeassistant.helpers.update_coordinator import ( ) from .config_flow import async_discover_gateways_by_unique_id, name_for_mac -from .const import DEFAULT_SCAN_INTERVAL, DISCOVERED_GATEWAYS, DOMAIN +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN from .services import async_load_screenlogic_services, async_unload_screenlogic_services _LOGGER = logging.getLogger(__name__) -REQUEST_REFRESH_DELAY = 1 +REQUEST_REFRESH_DELAY = 2 +HEATER_COOLDOWN_DELAY = 6 -PLATFORMS = ["switch", "sensor", "binary_sensor", "climate", "light"] +# These seem to be constant across all controller models +PRIMARY_CIRCUIT_IDS = [500, 505] # [Spa, Pool] - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Screenlogic component.""" - domain_data = hass.data[DOMAIN] = {} - domain_data[DISCOVERED_GATEWAYS] = await async_discover_gateways_by_unique_id(hass) - return True +PLATFORMS = ["binary_sensor", "climate", "light", "number", "sensor", "switch"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Screenlogic from a config entry.""" + connect_info = await async_get_connect_info(hass, entry) - gateway = await hass.async_add_executor_job(get_new_gateway, hass, entry) + gateway = ScreenLogicGateway(**connect_info) - # The api library uses a shared socket connection and does not handle concurrent - # requests very well. - api_lock = asyncio.Lock() + try: + await gateway.async_connect() + except ScreenLogicError as ex: + _LOGGER.error("Error while connecting to the gateway %s: %s", connect_info, ex) + raise ConfigEntryNotReady from ex coordinator = ScreenlogicDataUpdateCoordinator( - hass, config_entry=entry, gateway=gateway, api_lock=api_lock + hass, config_entry=entry, gateway=gateway ) async_load_screenlogic_services(hass) @@ -65,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(async_update_listener)) - hass.data[DOMAIN][entry.entry_id] = coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -75,8 +75,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.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN][entry.entry_id]["listener"]() if unload_ok: + coordinator = hass.data[DOMAIN][entry.entry_id] + await coordinator.gateway.async_disconnect() hass.data[DOMAIN].pop(entry.entry_id) async_unload_screenlogic_services(hass) @@ -89,11 +90,11 @@ async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry): await hass.config_entries.async_reload(entry.entry_id) -def get_connect_info(hass: HomeAssistant, entry: ConfigEntry): +async def async_get_connect_info(hass: HomeAssistant, entry: ConfigEntry): """Construct connect_info from configuration entry and returns it to caller.""" mac = entry.unique_id - # Attempt to re-discover named gateway to follow IP changes - discovered_gateways = hass.data[DOMAIN][DISCOVERED_GATEWAYS] + # Attempt to rediscover gateway to follow IP changes + discovered_gateways = await async_discover_gateways_by_unique_id(hass) if mac in discovered_gateways: connect_info = discovered_gateways[mac] else: @@ -108,28 +109,13 @@ def get_connect_info(hass: HomeAssistant, entry: ConfigEntry): return connect_info -def get_new_gateway(hass: HomeAssistant, entry: ConfigEntry): - """Instantiate a new ScreenLogicGateway, connect to it and return it to caller.""" - - connect_info = get_connect_info(hass, entry) - - try: - gateway = ScreenLogicGateway(**connect_info) - except ScreenLogicError as ex: - _LOGGER.error("Error while connecting to the gateway %s: %s", connect_info, ex) - raise ConfigEntryNotReady from ex - - return gateway - - class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage the data update for the Screenlogic component.""" - def __init__(self, hass, *, config_entry, gateway, api_lock): + def __init__(self, hass, *, config_entry, gateway): """Initialize the Screenlogic Data Update Coordinator.""" self.config_entry = config_entry self.gateway = gateway - self.api_lock = api_lock self.screenlogic_data = {} interval = timedelta( @@ -140,41 +126,39 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): _LOGGER, name=DOMAIN, update_interval=interval, - # We don't want an immediate refresh since the device - # takes a moment to reflect the state change + # Debounced option since the device takes + # a moment to reflect the knock-on changes request_refresh_debouncer=Debouncer( hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False ), ) - def reconnect_gateway(self): - """Instantiate a new ScreenLogicGateway, connect to it and update. Return new gateway to caller.""" - - connect_info = get_connect_info(self.hass, self.config_entry) - - try: - gateway = ScreenLogicGateway(**connect_info) - gateway.update() - except ScreenLogicError as error: - raise UpdateFailed(error) from error - - return gateway - async def _async_update_data(self): """Fetch data from the Screenlogic gateway.""" try: - async with self.api_lock: - await self.hass.async_add_executor_job(self.gateway.update) + await self.gateway.async_update() except ScreenLogicError as error: - _LOGGER.warning("ScreenLogicError - attempting reconnect: %s", error) - - async with self.api_lock: - self.gateway = await self.hass.async_add_executor_job( - self.reconnect_gateway - ) + _LOGGER.warning("Update error - attempting reconnect: %s", error) + await self._async_reconnect_update_data() + except ScreenLogicWarning as warn: + raise UpdateFailed(f"Incomplete update: {warn}") from warn return self.gateway.get_data() + async def _async_reconnect_update_data(self): + """Attempt to reconnect to the gateway and fetch data.""" + try: + # Clean up the previous connection as we're about to create a new one + await self.gateway.async_disconnect() + + connect_info = await async_get_connect_info(self.hass, self.config_entry) + self.gateway = ScreenLogicGateway(**connect_info) + + await self.gateway.async_update() + + except (ScreenLogicError, ScreenLogicWarning) as ex: + raise UpdateFailed(ex) from ex + class ScreenlogicEntity(CoordinatorEntity): """Base class for all ScreenLogic entities.""" @@ -233,6 +217,17 @@ class ScreenlogicEntity(CoordinatorEntity): name=self.gateway_name, ) + async def _async_refresh(self): + """Refresh the data from the gateway.""" + await self.coordinator.async_refresh() + # Second debounced refresh to catch any secondary + # changes in the device + await self.coordinator.async_request_refresh() + + async def _async_refresh_timed(self, now): + """Refresh from a timed called.""" + await self.coordinator.async_request_refresh() + class ScreenLogicCircuitEntity(ScreenlogicEntity): """ScreenLogic circuit entity.""" @@ -255,15 +250,18 @@ class ScreenLogicCircuitEntity(ScreenlogicEntity): """Send the OFF command.""" await self._async_set_circuit(ON_OFF.OFF) - async def _async_set_circuit(self, circuit_value) -> None: - async with self.coordinator.api_lock: - success = await self.hass.async_add_executor_job( - self.gateway.set_circuit, self._data_key, circuit_value + # Turning off spa or pool circuit may require more time for the + # heater to reflect changes depending on the pool controller, + # so we schedule an extra refresh a bit farther out + if self._data_key in PRIMARY_CIRCUIT_IDS: + async_call_later( + self.hass, HEATER_COOLDOWN_DELAY, self._async_refresh_timed ) - if success: + async def _async_set_circuit(self, circuit_value) -> None: + if await self.gateway.async_set_circuit(self._data_key, circuit_value): _LOGGER.debug("Turn %s %s", self._data_key, circuit_value) - await self.coordinator.async_request_refresh() + await self._async_refresh() else: _LOGGER.warning( "Failed to set_circuit %s %s", self._data_key, circuit_value diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index dc9e185ca9f..65200bb7dda 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -138,13 +138,10 @@ class ScreenLogicClimate(ScreenlogicEntity, ClimateEntity, RestoreEntity): if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: raise ValueError(f"Expected attribute {ATTR_TEMPERATURE}") - async with self.coordinator.api_lock: - success = await self.hass.async_add_executor_job( - self.gateway.set_heat_temp, int(self._data_key), int(temperature) - ) - - if success: - await self.coordinator.async_request_refresh() + if await self.gateway.async_set_heat_temp( + int(self._data_key), int(temperature) + ): + await self._async_refresh() else: raise HomeAssistantError( f"Failed to set_temperature {temperature} on body {self.body['body_type']['value']}" @@ -157,13 +154,8 @@ class ScreenLogicClimate(ScreenlogicEntity, ClimateEntity, RestoreEntity): else: mode = HEAT_MODE.NUM_FOR_NAME[self.preset_mode] - async with self.coordinator.api_lock: - success = await self.hass.async_add_executor_job( - self.gateway.set_heat_mode, int(self._data_key), int(mode) - ) - - if success: - await self.coordinator.async_request_refresh() + if await self.gateway.async_set_heat_mode(int(self._data_key), int(mode)): + await self._async_refresh() else: raise HomeAssistantError( f"Failed to set_hvac_mode {mode} on body {self.body['body_type']['value']}" @@ -176,13 +168,8 @@ class ScreenLogicClimate(ScreenlogicEntity, ClimateEntity, RestoreEntity): if self.hvac_mode == HVAC_MODE_OFF: return - async with self.coordinator.api_lock: - success = await self.hass.async_add_executor_job( - self.gateway.set_heat_mode, int(self._data_key), int(mode) - ) - - if success: - await self.coordinator.async_request_refresh() + if await self.gateway.async_set_heat_mode(int(self._data_key), int(mode)): + await self._async_refresh() else: raise HomeAssistantError( f"Failed to set_preset_mode {mode} on body {self.body['body_type']['value']}" diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 1fc01a2e854..d9feec629e2 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -7,9 +7,10 @@ from screenlogicpy.requests import login import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS +from homeassistant.components import dhcp from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac @@ -55,18 +56,6 @@ def name_for_mac(mac): return f"Pentair: {short_mac(mac)}" -async def async_get_mac_address(hass, ip_address, port): - """Connect to a screenlogic gateway and return the mac address.""" - connected_socket = await hass.async_add_executor_job( - login.create_socket, - ip_address, - port, - ) - if not connected_socket: - raise ScreenLogicError("Unknown socket error") - return await hass.async_add_executor_job(login.gateway_connect, connected_socket) - - class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow to setup screen logic devices.""" @@ -88,15 +77,15 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.discovered_gateways = await async_discover_gateways_by_unique_id(self.hass) return await self.async_step_gateway_select() - async def async_step_dhcp(self, discovery_info): + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" - mac = _extract_mac_from_name(discovery_info[HOSTNAME]) + mac = _extract_mac_from_name(discovery_info.hostname) await self.async_set_unique_id(mac) self._abort_if_unique_id_configured( - updates={CONF_IP_ADDRESS: discovery_info[IP_ADDRESS]} + updates={CONF_IP_ADDRESS: discovery_info.ip} ) - self.discovered_ip = discovery_info[IP_ADDRESS] - self.context["title_placeholders"] = {"name": discovery_info[HOSTNAME]} + self.discovered_ip = discovery_info.ip + self.context["title_placeholders"] = {"name": discovery_info.hostname} return await self.async_step_gateway_entry() async def async_step_gateway_select(self, user_input=None): @@ -154,9 +143,7 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ip_address = user_input[CONF_IP_ADDRESS] port = user_input[CONF_PORT] try: - mac = format_mac( - await async_get_mac_address(self.hass, ip_address, port) - ) + mac = format_mac(await login.async_get_mac_address(ip_address, port)) except ScreenLogicError as ex: _LOGGER.debug(ex) errors[CONF_IP_ADDRESS] = "cannot_connect" diff --git a/homeassistant/components/screenlogic/const.py b/homeassistant/components/screenlogic/const.py index 2a1a3c23d2e..bfa9d09cab8 100644 --- a/homeassistant/components/screenlogic/const.py +++ b/homeassistant/components/screenlogic/const.py @@ -14,5 +14,3 @@ SUPPORTED_COLOR_MODES = { } LIGHT_CIRCUIT_FUNCTIONS = {CIRCUIT_FUNCTION.INTELLIBRITE, CIRCUIT_FUNCTION.LIGHT} - -DISCOVERED_GATEWAYS = "_discovered_gateways" diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index abef9ec99ed..b6134216049 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -3,7 +3,7 @@ "name": "Pentair ScreenLogic", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/screenlogic", - "requirements": ["screenlogicpy==0.4.1"], + "requirements": ["screenlogicpy==0.5.3"], "codeowners": ["@dieselrabbit"], "dhcp": [ { diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py new file mode 100644 index 00000000000..0072962a7d7 --- /dev/null +++ b/homeassistant/components/screenlogic/number.py @@ -0,0 +1,80 @@ +"""Support for a ScreenLogic number entity.""" +import logging + +from screenlogicpy.const import BODY_TYPE, DATA as SL_DATA, EQUIPMENT, SCG + +from homeassistant.components.number import NumberEntity + +from . import ScreenlogicEntity +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + +SUPPORTED_SCG_NUMBERS = ( + "scg_level1", + "scg_level2", +) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + equipment_flags = coordinator.data[SL_DATA.KEY_CONFIG]["equipment_flags"] + if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: + async_add_entities( + [ + ScreenLogicNumber(coordinator, scg_level) + for scg_level in coordinator.data[SL_DATA.KEY_SCG] + if scg_level in SUPPORTED_SCG_NUMBERS + ] + ) + + +class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): + """Class to represent a ScreenLogic Number.""" + + def __init__(self, coordinator, data_key, enabled=True): + """Initialize of the entity.""" + super().__init__(coordinator, data_key, enabled) + self._body_type = SUPPORTED_SCG_NUMBERS.index(self._data_key) + self._attr_max_value = SCG.LIMIT_FOR_BODY[self._body_type] + self._attr_name = f"{self.gateway_name} {self.sensor['name']}" + self._attr_unit_of_measurement = self.sensor["unit"] + + @property + def value(self) -> float: + """Return the current value.""" + return self.sensor["value"] + + async def async_set_value(self, value: float) -> None: + """Update the current value.""" + # Need to set both levels at the same time, so we gather + # both existing level values and override the one that changed. + levels = {} + for level in SUPPORTED_SCG_NUMBERS: + levels[level] = self.coordinator.data[SL_DATA.KEY_SCG][level]["value"] + levels[self._data_key] = int(value) + + if await self.coordinator.gateway.async_set_scg_config( + levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]], + levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]], + ): + _LOGGER.debug( + "Set SCG to %i, %i", + levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]], + levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]], + ) + await self._async_refresh() + else: + _LOGGER.warning( + "Failed to set_scg to %i, %i", + levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]], + levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]], + ) + + @property + def sensor(self) -> dict: + """Shortcut to access the level sensor data.""" + return self.coordinator.data[SL_DATA.KEY_SCG][self._data_key] diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index cd7ba068be0..7ab20164400 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -34,8 +34,6 @@ SUPPORTED_CHEM_SENSORS = ( ) SUPPORTED_SCG_SENSORS = ( - "scg_level1", - "scg_level2", "scg_salt_ppm", "scg_super_chlor_timer", ) diff --git a/homeassistant/components/screenlogic/services.py b/homeassistant/components/screenlogic/services.py index d5edda12abb..c046a4478fe 100644 --- a/homeassistant/components/screenlogic/services.py +++ b/homeassistant/components/screenlogic/services.py @@ -59,13 +59,13 @@ def async_load_screenlogic_services(hass: HomeAssistant): color_num, ) try: - async with coordinator.api_lock: - if not await hass.async_add_executor_job( - coordinator.gateway.set_color_lights, color_num - ): - raise HomeAssistantError( - f"Failed to call service '{SERVICE_SET_COLOR_MODE}'" - ) + if not await coordinator.gateway.async_set_color_lights(color_num): + raise HomeAssistantError( + f"Failed to call service '{SERVICE_SET_COLOR_MODE}'" + ) + # Debounced refresh to catch any secondary + # changes in the device + await coordinator.async_request_refresh() except ScreenLogicError as error: raise HomeAssistantError(error) from error diff --git a/homeassistant/components/screenlogic/translations/ja.json b/homeassistant/components/screenlogic/translations/ja.json new file mode 100644 index 00000000000..91ded1b3200 --- /dev/null +++ b/homeassistant/components/screenlogic/translations/ja.json @@ -0,0 +1,39 @@ +{ + "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" + }, + "flow_title": "{name}", + "step": { + "gateway_entry": { + "data": { + "ip_address": "IP\u30a2\u30c9\u30ec\u30b9", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "ScreenLogic gateway\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "ScreenLogic" + }, + "gateway_select": { + "data": { + "selected_gateway": "\u30b2\u30fc\u30c8\u30a6\u30a7\u30a4" + }, + "description": "\u6b21\u306eScreenLogic gateways\u304c\u691c\u51fa\u3055\u308c\u307e\u3057\u305f\u3002\u69cb\u6210\u3059\u308b\u3082\u306e\u30921\u3064\u9078\u629e\u3059\u308b\u304b\u3001ScreenLogic gateways\u3092\u624b\u52d5\u3067\u69cb\u6210\u3059\u308b\u3053\u3068\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "ScreenLogic" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u30b9\u30ad\u30e3\u30f3\u9593\u306e\u79d2\u6570" + }, + "description": "{gateway_name} \u8a2d\u5b9a\u3092\u6307\u5b9a", + "title": "ScreenLogic" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/screenlogic/translations/tr.json b/homeassistant/components/screenlogic/translations/tr.json new file mode 100644 index 00000000000..c56eac9b02d --- /dev/null +++ b/homeassistant/components/screenlogic/translations/tr.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "flow_title": "{name}", + "step": { + "gateway_entry": { + "data": { + "ip_address": "IP Adresi", + "port": "Port" + }, + "description": "ScreenLogic Gateway bilgilerinizi girin.", + "title": "ScreenLogic" + }, + "gateway_select": { + "data": { + "selected_gateway": "A\u011f ge\u00e7idi" + }, + "description": "A\u015fa\u011f\u0131daki ScreenLogic a\u011f ge\u00e7itleri ke\u015ffedildi. L\u00fctfen yap\u0131land\u0131rmak i\u00e7in birini se\u00e7in veya bir ScreenLogic a\u011f ge\u00e7idini manuel olarak yap\u0131land\u0131rmay\u0131 se\u00e7in.", + "title": "ScreenLogic" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Taramalar aras\u0131ndaki saniyeler" + }, + "description": "{gateway_name} i\u00e7in ayarlar\u0131 belirtin", + "title": "ScreenLogic" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index b07cc2325b1..a8ffb271336 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -96,9 +96,7 @@ def entities_in_script(hass: HomeAssistant, entity_id: str) -> list[str]: component = hass.data[DOMAIN] - script_entity = component.get_entity(entity_id) - - if script_entity is None: + if (script_entity := component.get_entity(entity_id)) is None: return [] return list(script_entity.script.referenced_entities) @@ -127,9 +125,7 @@ def devices_in_script(hass: HomeAssistant, entity_id: str) -> list[str]: component = hass.data[DOMAIN] - script_entity = component.get_entity(entity_id) - - if script_entity is None: + if (script_entity := component.get_entity(entity_id)) is None: return [] return list(script_entity.script.referenced_devices) @@ -158,9 +154,7 @@ def areas_in_script(hass: HomeAssistant, entity_id: str) -> list[str]: component = hass.data[DOMAIN] - script_entity = component.get_entity(entity_id) - - if script_entity is None: + if (script_entity := component.get_entity(entity_id)) is None: return [] return list(script_entity.script.referenced_areas) @@ -178,8 +172,7 @@ async def async_setup(hass, config): async def reload_service(service): """Call a service to reload scripts.""" - conf = await component.async_prepare_reload() - if conf is None: + if (conf := await component.async_prepare_reload()) is None: return await _async_process_config(hass, conf, component) diff --git a/homeassistant/components/script/config.py b/homeassistant/components/script/config.py index 44b739e84c7..24c5f86078b 100644 --- a/homeassistant/components/script/config.py +++ b/homeassistant/components/script/config.py @@ -1,5 +1,4 @@ """Config validation helper for the script integration.""" -import asyncio from contextlib import suppress import voluptuous as vol @@ -24,7 +23,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform, config_validation as cv from homeassistant.helpers.script import ( SCRIPT_MODE_SINGLE, - async_validate_action_config, + async_validate_actions_config, make_script_schema, ) from homeassistant.helpers.selector import validate_selector @@ -72,11 +71,8 @@ async def async_validate_config_item(hass, config, full_config=None): return await blueprints.async_inputs_from_config(config) config = SCRIPT_ENTITY_SCHEMA(config) - config[CONF_SEQUENCE] = await asyncio.gather( - *( - async_validate_action_config(hass, action) - for action in config[CONF_SEQUENCE] - ) + config[CONF_SEQUENCE] = await async_validate_actions_config( + hass, config[CONF_SEQUENCE] ) return config diff --git a/homeassistant/components/script/translations/ca.json b/homeassistant/components/script/translations/ca.json index 905805e21fe..4369856606f 100644 --- a/homeassistant/components/script/translations/ca.json +++ b/homeassistant/components/script/translations/ca.json @@ -1,8 +1,8 @@ { "state": { "_": { - "off": "off", - "on": "on" + "off": "OFF", + "on": "ON" } }, "title": "Programa (script)" diff --git a/homeassistant/components/scsgate/switch.py b/homeassistant/components/scsgate/switch.py index 175f40c3eba..366a2930764 100644 --- a/homeassistant/components/scsgate/switch.py +++ b/homeassistant/components/scsgate/switch.py @@ -180,7 +180,9 @@ class SCSGateScenarioSwitch: elif isinstance(message, ScenarioTriggeredMessage): scenario_id = message.scenario else: - self._logger.warn("Scenario switch: received unknown message %s", message) + self._logger.warning( + "Scenario switch: received unknown message %s", message + ) return self._hass.bus.fire( diff --git a/homeassistant/components/season/translations/sensor.ja.json b/homeassistant/components/season/translations/sensor.ja.json new file mode 100644 index 00000000000..bf4d103999e --- /dev/null +++ b/homeassistant/components/season/translations/sensor.ja.json @@ -0,0 +1,16 @@ +{ + "state": { + "season__season": { + "autumn": "\u79cb", + "spring": "\u6625", + "summer": "\u590f", + "winter": "\u51ac" + }, + "season__season__": { + "autumn": "\u79cb", + "spring": "\u6625", + "summer": "\u590f", + "winter": "\u51ac" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/season/translations/sensor.tr.json b/homeassistant/components/season/translations/sensor.tr.json new file mode 100644 index 00000000000..ba08a57a363 --- /dev/null +++ b/homeassistant/components/season/translations/sensor.tr.json @@ -0,0 +1,16 @@ +{ + "state": { + "season__season": { + "autumn": "Sonbahar", + "spring": "\u0130lkbahar", + "summer": "Yaz", + "winter": "K\u0131\u015f" + }, + "season__season__": { + "autumn": "Sonbahar", + "spring": "\u0130lkbahar", + "summer": "Yaz", + "winter": "K\u0131\u015f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/select/device_condition.py b/homeassistant/components/select/device_condition.py index 4f650ddadda..aa22f117ea8 100644 --- a/homeassistant/components/select/device_condition.py +++ b/homeassistant/components/select/device_condition.py @@ -53,11 +53,9 @@ async def async_get_conditions( @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" - if config_validation: - config = CONDITION_SCHEMA(config) @callback def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: diff --git a/homeassistant/components/select/device_trigger.py b/homeassistant/components/select/device_trigger.py index 6dabacf34e5..2c05b59c5d5 100644 --- a/homeassistant/components/select/device_trigger.py +++ b/homeassistant/components/select/device_trigger.py @@ -14,8 +14,8 @@ from homeassistant.components.homeassistant.triggers.state import ( CONF_FOR, CONF_FROM, CONF_TO, - TRIGGER_SCHEMA as STATE_TRIGGER_SCHEMA, async_attach_trigger as async_attach_state_trigger, + async_validate_trigger_config as async_validate_state_trigger_config, ) from homeassistant.components.select.const import ATTR_OPTIONS from homeassistant.const import ( @@ -84,7 +84,7 @@ async def async_attach_trigger( if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] - state_config = STATE_TRIGGER_SCHEMA(state_config) + state_config = await async_validate_state_trigger_config(hass, state_config) return await async_attach_state_trigger( hass, state_config, action, automation_info, platform_type="device" ) diff --git a/homeassistant/components/select/translations/ja.json b/homeassistant/components/select/translations/ja.json new file mode 100644 index 00000000000..7900a1c8806 --- /dev/null +++ b/homeassistant/components/select/translations/ja.json @@ -0,0 +1,14 @@ +{ + "device_automation": { + "action_type": { + "select_option": "{entity_name} \u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u5909\u66f4" + }, + "condition_type": { + "selected_option": "\u73fe\u5728\u9078\u629e\u3055\u308c\u3066\u3044\u308b {entity_name} \u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + }, + "trigger_type": { + "current_option_changed": "{entity_name} \u30aa\u30d7\u30b7\u30e7\u30f3\u304c\u5909\u5316\u3057\u307e\u3057\u305f" + } + }, + "title": "\u9078\u629e" +} \ No newline at end of file diff --git a/homeassistant/components/select/translations/pl.json b/homeassistant/components/select/translations/pl.json index 3d19c05a80a..26c82097ead 100644 --- a/homeassistant/components/select/translations/pl.json +++ b/homeassistant/components/select/translations/pl.json @@ -1,7 +1,7 @@ { "device_automation": { "action_type": { - "select_option": "Zmie\u0144 opcj\u0119 {entity_name}" + "select_option": "zmie\u0144 opcj\u0119 {entity_name}" }, "condition_type": { "selected_option": "aktualnie wybrana opcja dla {entity_name}" diff --git a/homeassistant/components/select/translations/tr.json b/homeassistant/components/select/translations/tr.json new file mode 100644 index 00000000000..466770a2681 --- /dev/null +++ b/homeassistant/components/select/translations/tr.json @@ -0,0 +1,14 @@ +{ + "device_automation": { + "action_type": { + "select_option": "{entity_name} se\u00e7ene\u011fini de\u011fi\u015ftirin" + }, + "condition_type": { + "selected_option": "Ge\u00e7erli {entity_name} se\u00e7ili se\u00e7enek" + }, + "trigger_type": { + "current_option_changed": "{entity_name} se\u00e7ene\u011fi de\u011fi\u015fti" + } + }, + "title": "Se\u00e7" +} \ No newline at end of file diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 940902851e2..b8d499c8522 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -2,7 +2,7 @@ "domain": "sense", "name": "Sense", "documentation": "https://www.home-assistant.io/integrations/sense", - "requirements": ["sense_energy==0.9.2"], + "requirements": ["sense_energy==0.9.3"], "codeowners": ["@kbickar"], "config_flow": true, "dhcp": [ diff --git a/homeassistant/components/sense/translations/af.json b/homeassistant/components/sense/translations/af.json new file mode 100644 index 00000000000..f369e42e25e --- /dev/null +++ b/homeassistant/components/sense/translations/af.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sense/translations/ca.json b/homeassistant/components/sense/translations/ca.json index 096b4419dae..aff80de710d 100644 --- a/homeassistant/components/sense/translations/ca.json +++ b/homeassistant/components/sense/translations/ca.json @@ -12,7 +12,8 @@ "user": { "data": { "email": "Correu electr\u00f2nic", - "password": "Contrasenya" + "password": "Contrasenya", + "timeout": "Temps d'espera" }, "title": "Connexi\u00f3 amb Sense Energy Monitor" } diff --git a/homeassistant/components/sense/translations/de.json b/homeassistant/components/sense/translations/de.json index df36684c8b4..d0290abdf98 100644 --- a/homeassistant/components/sense/translations/de.json +++ b/homeassistant/components/sense/translations/de.json @@ -12,7 +12,8 @@ "user": { "data": { "email": "E-Mail", - "password": "Passwort" + "password": "Passwort", + "timeout": "Zeit\u00fcberschreitung" }, "title": "Stelle eine Verbindung zu deinem Sense Energy Monitor her" } diff --git a/homeassistant/components/sense/translations/et.json b/homeassistant/components/sense/translations/et.json index aec4f3655a5..8438be5c677 100644 --- a/homeassistant/components/sense/translations/et.json +++ b/homeassistant/components/sense/translations/et.json @@ -12,7 +12,8 @@ "user": { "data": { "email": "E-post", - "password": "Salas\u00f5na" + "password": "Salas\u00f5na", + "timeout": "Ajal\u00f5pp" }, "title": "\u00dchendu oma Sense Energy Monitor'iga" } diff --git a/homeassistant/components/sense/translations/he.json b/homeassistant/components/sense/translations/he.json index ecb8a74bc6f..c4e87259193 100644 --- a/homeassistant/components/sense/translations/he.json +++ b/homeassistant/components/sense/translations/he.json @@ -12,7 +12,8 @@ "user": { "data": { "email": "\u05d3\u05d5\u05d0\"\u05dc", - "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "timeout": "\u05e4\u05e1\u05e7 \u05d6\u05de\u05df" } } } diff --git a/homeassistant/components/sense/translations/hu.json b/homeassistant/components/sense/translations/hu.json index acd67b9e6f9..9defa2971bb 100644 --- a/homeassistant/components/sense/translations/hu.json +++ b/homeassistant/components/sense/translations/hu.json @@ -12,7 +12,8 @@ "user": { "data": { "email": "E-mail", - "password": "Jelsz\u00f3" + "password": "Jelsz\u00f3", + "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s" }, "title": "Csatlakoztassa a Sense Energy Monitort" } diff --git a/homeassistant/components/sense/translations/id.json b/homeassistant/components/sense/translations/id.json index 8d0d996e510..6767f1a54ca 100644 --- a/homeassistant/components/sense/translations/id.json +++ b/homeassistant/components/sense/translations/id.json @@ -12,7 +12,8 @@ "user": { "data": { "email": "Email", - "password": "Kata Sandi" + "password": "Kata Sandi", + "timeout": "Tenggang waktu" }, "title": "Hubungkan ke Sense Energy Monitor Anda" } diff --git a/homeassistant/components/sense/translations/it.json b/homeassistant/components/sense/translations/it.json index 277e2e1539b..2ab80941a6a 100644 --- a/homeassistant/components/sense/translations/it.json +++ b/homeassistant/components/sense/translations/it.json @@ -12,7 +12,8 @@ "user": { "data": { "email": "E-mail", - "password": "Password" + "password": "Password", + "timeout": "Tempo scaduto" }, "title": "Connettiti al tuo Sense Energy Monitor" } diff --git a/homeassistant/components/sense/translations/ja.json b/homeassistant/components/sense/translations/ja.json new file mode 100644 index 00000000000..60fe4c88e20 --- /dev/null +++ b/homeassistant/components/sense/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" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "timeout": "\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8" + }, + "title": "Sense Energy Monitor\u306b\u63a5\u7d9a\u3059\u308b" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sense/translations/nl.json b/homeassistant/components/sense/translations/nl.json index df64e83da16..59e0e3ade8a 100644 --- a/homeassistant/components/sense/translations/nl.json +++ b/homeassistant/components/sense/translations/nl.json @@ -12,7 +12,8 @@ "user": { "data": { "email": "E-mail", - "password": "Wachtwoord" + "password": "Wachtwoord", + "timeout": "Timeout" }, "title": "Maak verbinding met uw Sense Energy Monitor" } diff --git a/homeassistant/components/sense/translations/no.json b/homeassistant/components/sense/translations/no.json index ec9502eed23..11f92bfccb4 100644 --- a/homeassistant/components/sense/translations/no.json +++ b/homeassistant/components/sense/translations/no.json @@ -12,7 +12,8 @@ "user": { "data": { "email": "E-post", - "password": "Passord" + "password": "Passord", + "timeout": "Tidsavbrudd" }, "title": "Koble til din Sense Energy Monitor" } diff --git a/homeassistant/components/sense/translations/pl.json b/homeassistant/components/sense/translations/pl.json index b4d8708c38f..8bc58118a23 100644 --- a/homeassistant/components/sense/translations/pl.json +++ b/homeassistant/components/sense/translations/pl.json @@ -12,7 +12,8 @@ "user": { "data": { "email": "Adres e-mail", - "password": "Has\u0142o" + "password": "Has\u0142o", + "timeout": "Limit czasu" }, "title": "Po\u0142\u0105czenie z monitorem energii Sense" } diff --git a/homeassistant/components/sense/translations/pt-BR.json b/homeassistant/components/sense/translations/pt-BR.json index d04d91c034b..b61651bf441 100644 --- a/homeassistant/components/sense/translations/pt-BR.json +++ b/homeassistant/components/sense/translations/pt-BR.json @@ -7,6 +7,13 @@ "cannot_connect": "Falha ao conectar, tente novamente", "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", "unknown": "Erro inesperado" + }, + "step": { + "user": { + "data": { + "timeout": "Tempo limite" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/sense/translations/ru.json b/homeassistant/components/sense/translations/ru.json index 0bb299e2208..c113c06a021 100644 --- a/homeassistant/components/sense/translations/ru.json +++ b/homeassistant/components/sense/translations/ru.json @@ -12,7 +12,8 @@ "user": { "data": { "email": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b", - "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "timeout": "\u0422\u0430\u0439\u043c-\u0430\u0443\u0442" }, "title": "Sense Energy Monitor" } diff --git a/homeassistant/components/sense/translations/sl.json b/homeassistant/components/sense/translations/sl.json index 8720f80a2c5..5d7c26c38d9 100644 --- a/homeassistant/components/sense/translations/sl.json +++ b/homeassistant/components/sense/translations/sl.json @@ -12,7 +12,8 @@ "user": { "data": { "email": "E-po\u0161tni naslov", - "password": "Geslo" + "password": "Geslo", + "timeout": "Timeout" }, "title": "Pove\u017eite se s svojim Sense Energy monitor-jem" } diff --git a/homeassistant/components/sense/translations/th.json b/homeassistant/components/sense/translations/th.json new file mode 100644 index 00000000000..01a1bc8a513 --- /dev/null +++ b/homeassistant/components/sense/translations/th.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "timeout": "\u0e2b\u0e21\u0e14\u0e40\u0e27\u0e25\u0e32" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sense/translations/tr.json b/homeassistant/components/sense/translations/tr.json index 0e335265325..3261bbec3b9 100644 --- a/homeassistant/components/sense/translations/tr.json +++ b/homeassistant/components/sense/translations/tr.json @@ -12,8 +12,10 @@ "user": { "data": { "email": "E-posta", - "password": "Parola" - } + "password": "Parola", + "timeout": "Zaman a\u015f\u0131m\u0131" + }, + "title": "Sense Enerji Monit\u00f6r\u00fcn\u00fcze ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/sense/translations/zh-Hant.json b/homeassistant/components/sense/translations/zh-Hant.json index c97983c0b03..5ca9a9f847d 100644 --- a/homeassistant/components/sense/translations/zh-Hant.json +++ b/homeassistant/components/sense/translations/zh-Hant.json @@ -12,7 +12,8 @@ "user": { "data": { "email": "\u96fb\u5b50\u90f5\u4ef6", - "password": "\u5bc6\u78bc" + "password": "\u5bc6\u78bc", + "timeout": "\u903e\u6642" }, "title": "\u9023\u7dda\u81f3 Sense \u80fd\u6e90\u76e3\u63a7" } diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index b0b211e4a7d..7104d9ebff7 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -91,7 +91,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) devices = [] try: - with async_timeout.timeout(TIMEOUT): + async with async_timeout.timeout(TIMEOUT): for dev in await client.async_get_devices(_INITIAL_FETCH_FIELDS): if config[CONF_ID] == ALL or dev["id"] in config[CONF_ID]: devices.append( @@ -363,7 +363,7 @@ class SensiboClimate(ClimateEntity): async def async_update(self): """Retrieve latest state.""" try: - with async_timeout.timeout(TIMEOUT): + async with async_timeout.timeout(TIMEOUT): data = await self._client.async_get_device(self._id, _FETCH_FIELDS) except ( aiohttp.client_exceptions.ClientError, @@ -389,7 +389,7 @@ class SensiboClimate(ClimateEntity): async def _async_set_ac_state_property(self, name, value, assumed_state=False): """Set AC state.""" try: - with async_timeout.timeout(TIMEOUT): + async with async_timeout.timeout(TIMEOUT): await self._client.async_set_ac_state_property( self._id, name, value, self._ac_states, assumed_state ) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 91bff740ffd..75db36b91b2 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -4,15 +4,17 @@ from __future__ import annotations from collections.abc import Mapping from contextlib import suppress from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta, timezone import inspect import logging from typing import Any, Final, cast, final +import ciso8601 import voluptuous as vol +from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 DEVICE_CLASS_AQI, DEVICE_CLASS_BATTERY, DEVICE_CLASS_CO, @@ -20,6 +22,7 @@ from homeassistant.const import ( DEVICE_CLASS_CURRENT, DEVICE_CLASS_DATE, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, @@ -64,52 +67,125 @@ DOMAIN: Final = "sensor" ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" SCAN_INTERVAL: Final = timedelta(seconds=30) -DEVICE_CLASSES: Final[list[str]] = [ - DEVICE_CLASS_AQI, # Air Quality Index - DEVICE_CLASS_BATTERY, # % of battery that is left - DEVICE_CLASS_CO, # ppm (parts per million) Carbon Monoxide gas concentration - DEVICE_CLASS_CO2, # ppm (parts per million) Carbon Dioxide gas concentration - DEVICE_CLASS_CURRENT, # current (A) - DEVICE_CLASS_DATE, # date (ISO8601) - DEVICE_CLASS_ENERGY, # energy (kWh, Wh) - DEVICE_CLASS_HUMIDITY, # % of humidity in the air - DEVICE_CLASS_ILLUMINANCE, # current light level (lx/lm) - DEVICE_CLASS_MONETARY, # Amount of money (currency) - DEVICE_CLASS_OZONE, # Amount of O3 (µg/m³) - DEVICE_CLASS_NITROGEN_DIOXIDE, # Amount of NO2 (µg/m³) - DEVICE_CLASS_NITROUS_OXIDE, # Amount of N2O (µg/m³) - DEVICE_CLASS_NITROGEN_MONOXIDE, # Amount of NO (µg/m³) - DEVICE_CLASS_PM1, # Particulate matter <= 0.1 μm (µg/m³) - DEVICE_CLASS_PM10, # Particulate matter <= 10 μm (µg/m³) - DEVICE_CLASS_PM25, # Particulate matter <= 2.5 μm (µg/m³) - DEVICE_CLASS_SIGNAL_STRENGTH, # signal strength (dB/dBm) - DEVICE_CLASS_SULPHUR_DIOXIDE, # Amount of SO2 (µg/m³) - DEVICE_CLASS_TEMPERATURE, # temperature (C/F) - DEVICE_CLASS_TIMESTAMP, # timestamp (ISO8601) - DEVICE_CLASS_PRESSURE, # pressure (hPa/mbar) - DEVICE_CLASS_POWER, # power (W/kW) - DEVICE_CLASS_POWER_FACTOR, # power factor (%) - DEVICE_CLASS_VOLTAGE, # voltage (V) - DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, # Amount of VOC (µg/m³) - DEVICE_CLASS_GAS, # gas (m³ or ft³) -] -DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) -# The state represents a measurement in present time +class SensorDeviceClass(StrEnum): + """Device class for sensors.""" + + # Air Quality Index + AQI = "aqi" + + # % of battery that is left + BATTERY = "battery" + + # ppm (parts per million) Carbon Monoxide gas concentration + CO = "carbon_monoxide" + + # ppm (parts per million) Carbon Dioxide gas concentration + CO2 = "carbon_dioxide" + + # current (A) + CURRENT = "current" + + # date (ISO8601) + DATE = "date" + + # energy (kWh, Wh) + ENERGY = "energy" + + # frequency (Hz, kHz, MHz, GHz) + FREQUENCY = "frequency" + + # gas (m³ or ft³) + GAS = "gas" + + # % of humidity in the air + HUMIDITY = "humidity" + + # current light level (lx/lm) + ILLUMINANCE = "illuminance" + + # Amount of money (currency) + MONETARY = "monetary" + + # Amount of NO2 (µg/m³) + NITROGEN_DIOXIDE = "nitrogen_dioxide" + + # Amount of NO (µg/m³) + NITROGEN_MONOXIDE = "nitrogen_monoxide" + + # Amount of N2O (µg/m³) + NITROUS_OXIDE = "nitrous_oxide" + + # Amount of O3 (µg/m³) + OZONE = "ozone" + + # Particulate matter <= 0.1 μm (µg/m³) + PM1 = "pm1" + + # Particulate matter <= 10 μm (µg/m³) + PM10 = "pm10" + + # Particulate matter <= 2.5 μm (µg/m³) + PM25 = "pm25" + + # power factor (%) + POWER_FACTOR = "power_factor" + + # power (W/kW) + POWER = "power" + + # pressure (hPa/mbar) + PRESSURE = "pressure" + + # signal strength (dB/dBm) + SIGNAL_STRENGTH = "signal_strength" + + # Amount of SO2 (µg/m³) + SULPHUR_DIOXIDE = "sulphur_dioxide" + + # temperature (C/F) + TEMPERATURE = "temperature" + + # timestamp (ISO8601) + TIMESTAMP = "timestamp" + + # Amount of VOC (µg/m³) + VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" + + # voltage (V) + VOLTAGE = "voltage" + + +DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorDeviceClass)) + +# DEVICE_CLASSES is deprecated as of 2021.12 +# use the SensorDeviceClass enum instead. +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 total amount, e.g. net energy consumption + TOTAL = "total" + + # The state represents a monotonically increasing total, e.g. an amount of consumed gas + TOTAL_INCREASING = "total_increasing" + + +STATE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorStateClass)) + + +# STATE_CLASS* is deprecated as of 2021.12 +# use the SensorStateClass enum instead. STATE_CLASS_MEASUREMENT: Final = "measurement" -# The state represents a total amount, e.g. net energy consumption STATE_CLASS_TOTAL: Final = "total" -# The state represents a monotonically increasing total, e.g. an amount of consumed gas STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing" - -STATE_CLASSES: Final[list[str]] = [ - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL, - STATE_CLASS_TOTAL_INCREASING, -] - -STATE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.In(STATE_CLASSES)) +STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -138,9 +214,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class SensorEntityDescription(EntityDescription): """A class that describes sensor entities.""" + device_class: SensorDeviceClass | str | None = None last_reset: datetime | None = None # Deprecated, to be removed in 2021.11 native_unit_of_measurement: str | None = None - state_class: str | None = None + state_class: SensorStateClass | str | None = None unit_of_measurement: None = None # Type override, use native_unit_of_measurement def __post_init__(self) -> None: @@ -169,10 +246,11 @@ class SensorEntity(Entity): """Base class for sensor entities.""" entity_description: SensorEntityDescription + _attr_device_class: SensorDeviceClass | str | None _attr_last_reset: datetime | None # Deprecated, to be removed in 2021.11 _attr_native_unit_of_measurement: str | None - _attr_native_value: StateType = None - _attr_state_class: str | None + _attr_native_value: StateType | date | datetime = None + _attr_state_class: SensorStateClass | str | None _attr_state: None = None # Subclasses of SensorEntity should not set this _attr_unit_of_measurement: None = ( None # Subclasses of SensorEntity should not set this @@ -180,9 +258,21 @@ class SensorEntity(Entity): _last_reset_reported = False _temperature_conversion_reported = False + # Temporary private attribute to track if deprecation has been logged. + __datetime_as_string_deprecation_logged = False + @property - def state_class(self) -> str | None: - """Return the state class of this entity, from STATE_CLASSES, if any.""" + def device_class(self) -> SensorDeviceClass | str | None: + """Return the class of this entity.""" + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class + return None + + @property + def state_class(self) -> SensorStateClass | str | None: + """Return the state class of this entity, if any.""" if hasattr(self, "_attr_state_class"): return self._attr_state_class if hasattr(self, "entity_description"): @@ -212,7 +302,7 @@ class SensorEntity(Entity): """Return state attributes.""" if last_reset := self.last_reset: if ( - self.state_class == STATE_CLASS_MEASUREMENT + self.state_class == SensorStateClass.MEASUREMENT and not self._last_reset_reported ): self._last_reset_reported = True @@ -234,7 +324,7 @@ class SensorEntity(Entity): return None @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | date | datetime: """Return the value reported by the sensor.""" return self._attr_native_value @@ -271,6 +361,77 @@ class SensorEntity(Entity): """Return the state of the sensor and perform unit conversions, if needed.""" unit_of_measurement = self.native_unit_of_measurement value = self.native_value + device_class = self.device_class + + # We have an old non-datetime value, warn about it and convert it during + # the deprecation period. + if ( + value is not None + and device_class in (DEVICE_CLASS_DATE, DEVICE_CLASS_TIMESTAMP) + and not isinstance(value, (date, datetime)) + ): + # Deprecation warning for date/timestamp device classes + if not self.__datetime_as_string_deprecation_logged: + report_issue = self._suggest_report_issue() + _LOGGER.warning( + "%s is providing a string for its state, while the device " + "class is '%s', this is not valid and will be unsupported " + "from Home Assistant 2022.2. Please %s", + self.entity_id, + device_class, + report_issue, + ) + self.__datetime_as_string_deprecation_logged = True + + # Anyways, lets validate the date at least.. + try: + value = ciso8601.parse_datetime(str(value)) + except (ValueError, IndexError) as error: + raise ValueError( + f"Invalid date/datetime: {self.entity_id} provide state '{value}', " + f"while it has device class '{device_class}'" + ) from error + + if value.tzinfo is not None and value.tzinfo != timezone.utc: + value = value.astimezone(timezone.utc) + + # Convert the date object to a standardized state string. + if device_class == DEVICE_CLASS_DATE: + return value.date().isoformat() + + return value.isoformat(timespec="seconds") + + # Received a datetime + if value is not None and device_class == DEVICE_CLASS_TIMESTAMP: + try: + # We cast the value, to avoid using isinstance, but satisfy + # typechecking. The errors are guarded in this try. + value = cast(datetime, value) + if value.tzinfo is None: + raise ValueError( + f"Invalid datetime: {self.entity_id} provides state '{value}', " + "which is missing timezone information" + ) + + if value.tzinfo != timezone.utc: + value = value.astimezone(timezone.utc) + + return value.isoformat(timespec="seconds") + except (AttributeError, TypeError) as err: + raise ValueError( + f"Invalid datetime: {self.entity_id} has a timestamp device class" + f"but does not provide a datetime state but {type(value)}" + ) from err + + # Received a date value + if value is not None and device_class == DEVICE_CLASS_DATE: + try: + return value.isoformat() # type: ignore + except (AttributeError, TypeError) as err: + raise ValueError( + f"Invalid date: {self.entity_id} has a date device class" + f"but does not provide a date state but {type(value)}" + ) from err units = self.hass.config.units if ( @@ -302,7 +463,7 @@ class SensorEntity(Entity): prec = len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 # Suppress ValueError (Could not convert sensor_value to float) with suppress(ValueError): - temp = units.temperature(float(value), unit_of_measurement) + temp = units.temperature(float(value), unit_of_measurement) # type: ignore value = round(temp) if prec == 0 else round(temp, prec) return value diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index ffa59271d79..612ebe0abd5 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -16,6 +16,7 @@ from homeassistant.const import ( DEVICE_CLASS_CO2, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, @@ -55,6 +56,7 @@ CONF_IS_CO = "is_carbon_monoxide" CONF_IS_CO2 = "is_carbon_dioxide" CONF_IS_CURRENT = "is_current" CONF_IS_ENERGY = "is_energy" +CONF_IS_FREQUENCY = "is_frequency" CONF_IS_HUMIDITY = "is_humidity" CONF_IS_GAS = "is_gas" CONF_IS_ILLUMINANCE = "is_illuminance" @@ -81,6 +83,7 @@ ENTITY_CONDITIONS = { DEVICE_CLASS_CO2: [{CONF_TYPE: CONF_IS_CO2}], DEVICE_CLASS_CURRENT: [{CONF_TYPE: CONF_IS_CURRENT}], DEVICE_CLASS_ENERGY: [{CONF_TYPE: CONF_IS_ENERGY}], + DEVICE_CLASS_FREQUENCY: [{CONF_TYPE: CONF_IS_FREQUENCY}], DEVICE_CLASS_GAS: [{CONF_TYPE: CONF_IS_GAS}], DEVICE_CLASS_HUMIDITY: [{CONF_TYPE: CONF_IS_HUMIDITY}], DEVICE_CLASS_ILLUMINANCE: [{CONF_TYPE: CONF_IS_ILLUMINANCE}], @@ -115,6 +118,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_CO2, CONF_IS_CURRENT, CONF_IS_ENERGY, + CONF_IS_FREQUENCY, CONF_IS_GAS, CONF_IS_HUMIDITY, CONF_IS_ILLUMINANCE, @@ -183,11 +187,9 @@ async def async_get_conditions( @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Evaluate state based on configuration.""" - if config_validation: - config = CONDITION_SCHEMA(config) numeric_state_config = { condition.CONF_CONDITION: "numeric_state", condition.CONF_ENTITY_ID: config[CONF_ENTITY_ID], @@ -197,6 +199,10 @@ def async_condition_from_config( if CONF_BELOW in config: numeric_state_config[condition.CONF_BELOW] = config[CONF_BELOW] + numeric_state_config = cv.NUMERIC_STATE_CONDITION_SCHEMA(numeric_state_config) + numeric_state_config = condition.numeric_state_validate_config( + hass, numeric_state_config + ) return condition.async_numeric_state_from_config(numeric_state_config) diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 189b098bea0..a9d014e6856 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -19,6 +19,7 @@ from homeassistant.const import ( DEVICE_CLASS_CO2, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, @@ -54,6 +55,7 @@ CONF_CO = "carbon_monoxide" CONF_CO2 = "carbon_dioxide" CONF_CURRENT = "current" CONF_ENERGY = "energy" +CONF_FREQUENCY = "frequency" CONF_GAS = "gas" CONF_HUMIDITY = "humidity" CONF_ILLUMINANCE = "illuminance" @@ -80,6 +82,7 @@ ENTITY_TRIGGERS = { DEVICE_CLASS_CO2: [{CONF_TYPE: CONF_CO2}], DEVICE_CLASS_CURRENT: [{CONF_TYPE: CONF_CURRENT}], DEVICE_CLASS_ENERGY: [{CONF_TYPE: CONF_ENERGY}], + DEVICE_CLASS_FREQUENCY: [{CONF_TYPE: CONF_FREQUENCY}], DEVICE_CLASS_GAS: [{CONF_TYPE: CONF_GAS}], DEVICE_CLASS_HUMIDITY: [{CONF_TYPE: CONF_HUMIDITY}], DEVICE_CLASS_ILLUMINANCE: [{CONF_TYPE: CONF_ILLUMINANCE}], @@ -115,6 +118,7 @@ TRIGGER_SCHEMA = vol.All( CONF_CO2, CONF_CURRENT, CONF_ENERGY, + CONF_FREQUENCY, CONF_GAS, CONF_HUMIDITY, CONF_ILLUMINANCE, @@ -158,7 +162,9 @@ async def async_attach_trigger(hass, config, action, automation_info): if CONF_FOR in config: numeric_state_config[CONF_FOR] = config[CONF_FOR] - numeric_state_config = numeric_state_trigger.TRIGGER_SCHEMA(numeric_state_config) + numeric_state_config = await numeric_state_trigger.async_validate_trigger_config( + hass, numeric_state_config + ) return await numeric_state_trigger.async_attach_trigger( hass, numeric_state_config, action, automation_info, platform_type="device" ) diff --git a/homeassistant/components/sensor/helpers.py b/homeassistant/components/sensor/helpers.py new file mode 100644 index 00000000000..a3f5e3827bf --- /dev/null +++ b/homeassistant/components/sensor/helpers.py @@ -0,0 +1,38 @@ +"""Helpers for sensor entities.""" +from __future__ import annotations + +from datetime import date, datetime +import logging + +from homeassistant.core import callback +from homeassistant.util import dt as dt_util + +from . import SensorDeviceClass + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_parse_date_datetime( + value: str, entity_id: str, device_class: SensorDeviceClass | str | None +) -> datetime | date | None: + """Parse datetime string to a data or datetime.""" + if device_class == SensorDeviceClass.TIMESTAMP: + if (parsed_timestamp := dt_util.parse_datetime(value)) is None: + _LOGGER.warning("%s rendered invalid timestamp: %s", entity_id, value) + return None + + if parsed_timestamp.tzinfo is None: + _LOGGER.warning( + "%s rendered timestamp without timezone: %s", entity_id, value + ) + return None + + return parsed_timestamp + + # Date device class + if (parsed_date := dt_util.parse_date(value)) is not None: + return parsed_date + + _LOGGER.warning("%s rendered invalid date %s", entity_id, value) + return None diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 8bddc74693e..3cfe8d45b70 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -266,7 +266,12 @@ def _normalize_states( hass.data[WARN_UNSUPPORTED_UNIT] = set() if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) - _LOGGER.warning("%s has unknown unit %s", entity_id, unit) + _LOGGER.warning( + "%s has unit %s which is unsupported for device_class %s", + entity_id, + unit, + device_class, + ) continue fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state)) @@ -512,7 +517,9 @@ def _compile_statistics( # noqa: C901 last_reset = old_last_reset = None new_state = old_state = None _sum = 0.0 - last_stats = statistics.get_last_statistics(hass, 1, entity_id, False) + last_stats = statistics.get_last_short_term_statistics( + hass, 1, entity_id, False + ) if entity_id in last_stats: # We have compiled history for this sensor before, use that as a starting point last_reset = old_last_reset = last_stats[entity_id][0]["last_reset"] diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 1dec2b60e20..df2dc3560af 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -22,6 +22,7 @@ "is_temperature": "Current {entity_name} temperature", "is_current": "Current {entity_name} current", "is_energy": "Current {entity_name} energy", + "is_frequency": "Current {entity_name} frequency", "is_power_factor": "Current {entity_name} power factor", "is_volatile_organic_compounds": "Current {entity_name} volatile organic compounds concentration level", "is_voltage": "Current {entity_name} voltage", @@ -48,6 +49,7 @@ "temperature": "{entity_name} temperature changes", "current": "{entity_name} current changes", "energy": "{entity_name} energy changes", + "frequency": "{entity_name} frequency changes", "power_factor": "{entity_name} power factor changes", "volatile_organic_compounds": "{entity_name} volatile organic compounds concentration changes", "voltage": "{entity_name} voltage changes", diff --git a/homeassistant/components/sensor/translations/ca.json b/homeassistant/components/sensor/translations/ca.json index ddf8f50a010..3e914526e12 100644 --- a/homeassistant/components/sensor/translations/ca.json +++ b/homeassistant/components/sensor/translations/ca.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "Concentraci\u00f3 actual de mon\u00f2xid de carboni de {entity_name}", "is_current": "Intensitat 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}", "is_humidity": "Humitat actual de {entity_name}", "is_illuminance": "Il\u00b7luminaci\u00f3 actual de {entity_name}", @@ -32,6 +33,7 @@ "carbon_monoxide": "Canvia la concentraci\u00f3 de mon\u00f2xid de carboni de {entity_name}", "current": "Canvia la intensitat 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}", "humidity": "Canvia la humitat de {entity_name}", "illuminance": "Canvia la il\u00b7luminaci\u00f3 de {entity_name}", @@ -55,8 +57,8 @@ }, "state": { "_": { - "off": "off", - "on": "on" + "off": "OFF", + "on": "ON" } }, "title": "Sensor" diff --git a/homeassistant/components/sensor/translations/de.json b/homeassistant/components/sensor/translations/de.json index a042e3102f9..237259ffc11 100644 --- a/homeassistant/components/sensor/translations/de.json +++ b/homeassistant/components/sensor/translations/de.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "Aktuelle {entity_name} Kohlenstoffmonoxid-Konzentration", "is_current": "Aktueller Strom von {entity_name}", "is_energy": "Aktuelle Energie von {entity_name}", + "is_frequency": "Aktuelle {entity_name} Frequenz", "is_gas": "Aktuelles {entity_name} Gas", "is_humidity": "{entity_name} Feuchtigkeit", "is_illuminance": "Aktuelle {entity_name} Helligkeit", @@ -32,6 +33,7 @@ "carbon_monoxide": "{entity_name} Kohlenstoffmonoxid-Konzentrations\u00e4nderung", "current": "{entity_name} Stromver\u00e4nderung", "energy": "{entity_name} Energie\u00e4nderungen", + "frequency": "{entity_name} Frequenz\u00e4nderungen", "gas": "{entity_name} Gas\u00e4nderungen", "humidity": "{entity_name} Feuchtigkeits\u00e4nderungen", "illuminance": "{entity_name} Helligkeits\u00e4nderungen", diff --git a/homeassistant/components/sensor/translations/en.json b/homeassistant/components/sensor/translations/en.json index b5cb2f5a27f..531016b2007 100644 --- a/homeassistant/components/sensor/translations/en.json +++ b/homeassistant/components/sensor/translations/en.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "Current {entity_name} carbon monoxide concentration level", "is_current": "Current {entity_name} current", "is_energy": "Current {entity_name} energy", + "is_frequency": "Current {entity_name} frequency", "is_gas": "Current {entity_name} gas", "is_humidity": "Current {entity_name} humidity", "is_illuminance": "Current {entity_name} illuminance", @@ -32,6 +33,7 @@ "carbon_monoxide": "{entity_name} carbon monoxide concentration changes", "current": "{entity_name} current changes", "energy": "{entity_name} energy changes", + "frequency": "{entity_name} frequency changes", "gas": "{entity_name} gas changes", "humidity": "{entity_name} humidity changes", "illuminance": "{entity_name} illuminance changes", diff --git a/homeassistant/components/sensor/translations/et.json b/homeassistant/components/sensor/translations/et.json index 5cfa6a94852..06fbe321091 100644 --- a/homeassistant/components/sensor/translations/et.json +++ b/homeassistant/components/sensor/translations/et.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "{entity_name} praegune vingugaasi tase", "is_current": "Praegune {entity_name} voolutugevus", "is_energy": "Praegune {entity_name} v\u00f5imsus", + "is_frequency": "Praegune {entity_name} sagedus", "is_gas": "Praegune {entity_name} gaas", "is_humidity": "Praegune {entity_name} niiskus", "is_illuminance": "Praegune {entity_name} valgustatus", @@ -32,6 +33,7 @@ "carbon_monoxide": "{entity_name} vingugaasi tase muutus", "current": "{entity_name} voolutugevus muutub", "energy": "{entity_name} v\u00f5imsus muutub", + "frequency": "{entity_name} sagedus muutub", "gas": "{entity_name} gaasivahetus", "humidity": "{entity_name} niiskus muutub", "illuminance": "{entity_name} valgustustugevus muutub", diff --git a/homeassistant/components/sensor/translations/hu.json b/homeassistant/components/sensor/translations/hu.json index 1e33cc18355..c8650cd1579 100644 --- a/homeassistant/components/sensor/translations/hu.json +++ b/homeassistant/components/sensor/translations/hu.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "Jelenlegi {entity_name} sz\u00e9n-monoxid koncentr\u00e1ci\u00f3 szint", "is_current": "Jelenlegi {entity_name} \u00e1ram", "is_energy": "A jelenlegi {entity_name} energia", + "is_frequency": "Aktu\u00e1lis {entity_name} gyakoris\u00e1g", "is_gas": "Jelenlegi {entity_name} g\u00e1z", "is_humidity": "{entity_name} aktu\u00e1lis p\u00e1ratartalma", "is_illuminance": "{entity_name} aktu\u00e1lis megvil\u00e1g\u00edt\u00e1sa", @@ -32,6 +33,7 @@ "carbon_monoxide": "{entity_name} sz\u00e9n-monoxid koncentr\u00e1ci\u00f3ja megv\u00e1ltozik", "current": "{entity_name} aktu\u00e1lis v\u00e1ltoz\u00e1sai", "energy": "{entity_name} energiav\u00e1ltoz\u00e1sa", + "frequency": "{entity_name} gyakoris\u00e1gi v\u00e1ltoz\u00e1sok", "gas": "{entity_name} g\u00e1z v\u00e1ltoz\u00e1sok", "humidity": "{entity_name} p\u00e1ratartalma v\u00e1ltozik", "illuminance": "{entity_name} megvil\u00e1g\u00edt\u00e1sa v\u00e1ltozik", diff --git a/homeassistant/components/sensor/translations/id.json b/homeassistant/components/sensor/translations/id.json index cea3f890430..e65fe367ca2 100644 --- a/homeassistant/components/sensor/translations/id.json +++ b/homeassistant/components/sensor/translations/id.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "Level konsentasi karbonmonoksida {entity_name} saat ini", "is_current": "Arus {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", "is_humidity": "Kelembaban {entity_name} saat ini", "is_illuminance": "Pencahayaan {entity_name} saat ini", @@ -32,6 +33,7 @@ "carbon_monoxide": "Perubahan konsentrasi karbonmonoksida {entity_name}", "current": "Perubahan arus {entity_name}", "energy": "Perubahan energi {entity_name}", + "frequency": "Perubahan frekuensi {entity_name}", "gas": "Perubahan gas {entity_name}", "humidity": "Perubahan kelembaban {entity_name}", "illuminance": "Perubahan pencahayaan {entity_name}", diff --git a/homeassistant/components/sensor/translations/it.json b/homeassistant/components/sensor/translations/it.json index cc5d3534715..7985cee7cd4 100644 --- a/homeassistant/components/sensor/translations/it.json +++ b/homeassistant/components/sensor/translations/it.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "Livello attuale di concentrazione di monossido di carbonio in {entity_name}", "is_current": "Corrente 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}", "is_humidity": "Umidit\u00e0 attuale di {entity_name}", "is_illuminance": "Illuminazione attuale di {entity_name}", @@ -32,6 +33,7 @@ "carbon_monoxide": "Variazioni nella concentrazione di monossido di carbonio di {entity_name}", "current": "variazioni di corrente di {entity_name}", "energy": "variazioni di energia di {entity_name}", + "frequency": "{entity_name} cambiamenti di frequenza", "gas": "Variazioni di gas di {entity_name}", "humidity": "variazioni di umidit\u00e0 di {entity_name} ", "illuminance": "variazioni dell'illuminazione di {entity_name}", diff --git a/homeassistant/components/sensor/translations/ja.json b/homeassistant/components/sensor/translations/ja.json index 0497959372c..fccbedad2b2 100644 --- a/homeassistant/components/sensor/translations/ja.json +++ b/homeassistant/components/sensor/translations/ja.json @@ -1,4 +1,60 @@ { + "device_automation": { + "condition_type": { + "is_battery_level": "\u73fe\u5728\u306e {entity_name} \u96fb\u6c60\u6b8b\u91cf", + "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_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", + "is_humidity": "\u73fe\u5728\u306e {entity_name} \u6e7f\u5ea6", + "is_illuminance": "\u73fe\u5728\u306e {entity_name} \u7167\u5ea6", + "is_nitrogen_dioxide": "\u73fe\u5728\u306e {entity_name} \u4e8c\u9178\u5316\u7a92\u7d20\u6fc3\u5ea6\u30ec\u30d9\u30eb", + "is_nitrogen_monoxide": "\u73fe\u5728\u306e {entity_name} \u4e00\u9178\u5316\u7a92\u7d20\u6fc3\u5ea6\u30ec\u30d9\u30eb", + "is_nitrous_oxide": "\u73fe\u5728\u306e {entity_name} \u4e9c\u9178\u5316\u7a92\u7d20\u6fc3\u5ea6\u30ec\u30d9\u30eb", + "is_ozone": "\u73fe\u5728\u306e {entity_name} \u30aa\u30be\u30f3\u6fc3\u5ea6\u30ec\u30d9\u30eb", + "is_pm1": "\u73fe\u5728\u306e {entity_name} PM1\u6fc3\u5ea6\u30ec\u30d9\u30eb", + "is_pm10": "\u73fe\u5728\u306e {entity_name} PM10\u6fc3\u5ea6\u30ec\u30d9\u30eb", + "is_pm25": "\u73fe\u5728\u306e {entity_name} PM2.5\u6fc3\u5ea6\u30ec\u30d9\u30eb", + "is_power": "\u73fe\u5728\u306e {entity_name} \u96fb\u6e90", + "is_power_factor": "\u73fe\u5728\u306e {entity_name} \u529b\u7387", + "is_pressure": "\u73fe\u5728\u306e {entity_name} \u5727\u529b", + "is_signal_strength": "\u73fe\u5728\u306e {entity_name} \u4fe1\u53f7\u5f37\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", + "is_volatile_organic_compounds": "\u73fe\u5728\u306e {entity_name} \u63ee\u767a\u6027\u6709\u6a5f\u5316\u5408\u7269\u306e\u6fc3\u5ea6\u30ec\u30d9\u30eb", + "is_voltage": "\u73fe\u5728\u306e {entity_name} \u96fb\u5727" + }, + "trigger_type": { + "battery_level": "{entity_name} \u96fb\u6c60\u6b8b\u91cf\u306e\u5909\u5316", + "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", + "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", + "humidity": "{entity_name} \u6e7f\u5ea6\u306e\u5909\u5316", + "illuminance": "{entity_name} \u7167\u5ea6\u306e\u5909\u5316", + "nitrogen_dioxide": "{entity_name} \u4e8c\u9178\u5316\u7a92\u7d20\u6fc3\u5ea6\u306e\u5909\u5316", + "nitrogen_monoxide": "{entity_name} \u4e00\u9178\u5316\u7a92\u7d20\u6fc3\u5ea6\u306e\u5909\u5316", + "nitrous_oxide": "{entity_name} \u4e9c\u9178\u5316\u7a92\u7d20\u6fc3\u5ea6\u306e\u5909\u5316", + "ozone": "{entity_name} \u30aa\u30be\u30f3\u6fc3\u5ea6\u306e\u5909\u5316", + "pm1": "{entity_name} PM1\u6fc3\u5ea6\u306e\u5909\u5316", + "pm10": "{entity_name} PM10\u6fc3\u5ea6\u306e\u5909\u5316", + "pm25": "{entity_name} PM2.5\u6fc3\u5ea6\u306e\u5909\u5316", + "power": "{entity_name} \u96fb\u6e90(power)\u306e\u5909\u5316", + "power_factor": "{entity_name} \u529b\u7387\u304c\u5909\u5316", + "pressure": "{entity_name} \u5727\u529b\u306e\u5909\u5316", + "signal_strength": "{entity_name} \u4fe1\u53f7\u5f37\u5ea6\u306e\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", + "volatile_organic_compounds": "{entity_name} \u63ee\u767a\u6027\u6709\u6a5f\u5316\u5408\u7269\u6fc3\u5ea6\u306e\u5909\u5316", + "voltage": "{entity_name} \u96fb\u5727\u306e\u5909\u5316" + } + }, "state": { "_": { "off": "\u30aa\u30d5", diff --git a/homeassistant/components/sensor/translations/nl.json b/homeassistant/components/sensor/translations/nl.json index c55f1547642..fea321fe221 100644 --- a/homeassistant/components/sensor/translations/nl.json +++ b/homeassistant/components/sensor/translations/nl.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "Huidig niveau {entity_name} koolmonoxideconcentratie", "is_current": "Huidige {entity_name} stroom", "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", @@ -32,6 +33,7 @@ "carbon_monoxide": "{entity_name} koolmonoxideconcentratie gewijzigd", "current": "{entity_name} huidige wijzigingen", "energy": "{entity_name} energieveranderingen", + "frequency": "{entity_name} frequentie verandert", "gas": "{entity_name} gas verandert", "humidity": "{entity_name} vochtigheidsgraad gewijzigd", "illuminance": "{entity_name} verlichtingssterkte gewijzigd", diff --git a/homeassistant/components/sensor/translations/no.json b/homeassistant/components/sensor/translations/no.json index 1580a716dee..df3786b1415 100644 --- a/homeassistant/components/sensor/translations/no.json +++ b/homeassistant/components/sensor/translations/no.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "Gjeldende {entity_name} karbonmonoksid konsentrasjonsniv\u00e5", "is_current": "Gjeldende {entity_name} str\u00f8m", "is_energy": "Gjeldende {entity_name} effekt", + "is_frequency": "Gjeldende {entity_name} -frekvens", "is_gas": "Gjeldende {entity_name} gass", "is_humidity": "Gjeldende {entity_name} fuktighet", "is_illuminance": "Gjeldende {entity_name} belysningsstyrke", @@ -32,6 +33,7 @@ "carbon_monoxide": "{entity_name} endringer i konsentrasjonen av karbonmonoksid", "current": "{entity_name} gjeldende endringer", "energy": "{entity_name} effektendringer", + "frequency": "{entity_name} frekvensendringer", "gas": "{entity_name} gass endres", "humidity": "{entity_name} fuktighets endringer", "illuminance": "{entity_name} belysningsstyrke endringer", diff --git a/homeassistant/components/sensor/translations/pl.json b/homeassistant/components/sensor/translations/pl.json index def1be5e06d..c844f9d3221 100644 --- a/homeassistant/components/sensor/translations/pl.json +++ b/homeassistant/components/sensor/translations/pl.json @@ -6,6 +6,7 @@ "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_energy": "obecna energia {entity_name}", + "is_frequency": "Obecna cz\u0119stotliwo\u015b\u0107 {entity_name}", "is_gas": "obecny poziom gazu {entity_name}", "is_humidity": "obecna wilgotno\u015b\u0107 {entity_name}", "is_illuminance": "obecne nat\u0119\u017cenie o\u015bwietlenia {entity_name}", @@ -32,6 +33,7 @@ "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}", "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", "humidity": "zmieni si\u0119 wilgotno\u015b\u0107 {entity_name}", "illuminance": "zmieni si\u0119 nat\u0119\u017cenie o\u015bwietlenia {entity_name}", diff --git a/homeassistant/components/sensor/translations/ru.json b/homeassistant/components/sensor/translations/ru.json index 821622ae20c..b7e8a912a11 100644 --- a/homeassistant/components/sensor/translations/ru.json +++ b/homeassistant/components/sensor/translations/ru.json @@ -6,6 +6,7 @@ "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_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", "is_humidity": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_illuminance": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", @@ -32,6 +33,7 @@ "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", "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", "humidity": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "illuminance": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", diff --git a/homeassistant/components/sensor/translations/tr.json b/homeassistant/components/sensor/translations/tr.json index bfdd538306f..db5d774e453 100644 --- a/homeassistant/components/sensor/translations/tr.json +++ b/homeassistant/components/sensor/translations/tr.json @@ -1,26 +1,57 @@ { "device_automation": { "condition_type": { + "is_battery_level": "Mevcut {entity_name} pil seviyesi", + "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_energy": "Mevcut {entity_name} enerjisi", + "is_frequency": "Ge\u00e7erli {entity_name} frekans\u0131", + "is_gas": "Mevcut {entity_name} gaz\u0131", + "is_humidity": "Ge\u00e7erli {entity_name} nem oran\u0131", + "is_illuminance": "Mevcut {entity_name} ayd\u0131nlatma d\u00fczeyi", + "is_nitrogen_dioxide": "Mevcut {entity_name} nitrojen dioksit konsantrasyon seviyesi", + "is_nitrogen_monoxide": "Mevcut {entity_name} nitrojen monoksit konsantrasyon seviyesi", + "is_nitrous_oxide": "Ge\u00e7erli {entity_name} azot oksit konsantrasyon seviyesi", + "is_ozone": "Mevcut {entity_name} ozon konsantrasyon seviyesi", + "is_pm1": "Mevcut {entity_name} PM1 konsantrasyon seviyesi", + "is_pm10": "Mevcut {entity_name} PM10 konsantrasyon seviyesi", + "is_pm25": "Mevcut {entity_name} PM2.5 konsantrasyon seviyesi", + "is_power": "Mevcut {entity_name} g\u00fcc\u00fc", "is_power_factor": "Mevcut {entity_name} g\u00fc\u00e7 fakt\u00f6r\u00fc", + "is_pressure": "Ge\u00e7erli {entity_name} bas\u0131nc\u0131", "is_signal_strength": "Mevcut {entity_name} sinyal g\u00fcc\u00fc", + "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" }, "trigger_type": { "battery_level": "{entity_name} pil seviyesi de\u011fi\u015fiklikleri", + "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", "energy": "{entity_name} enerji de\u011fi\u015fiklikleri", + "frequency": "{entity_name} frekans de\u011fi\u015fiklikleri", + "gas": "{entity_name} gaz de\u011fi\u015fiklikleri", "humidity": "{entity_name} nem de\u011fi\u015fiklikleri", "illuminance": "{entity_name} ayd\u0131nlatma de\u011fi\u015fiklikleri", + "nitrogen_dioxide": "{entity_name} nitrojen dioksit konsantrasyonu de\u011fi\u015fiklikleri", + "nitrogen_monoxide": "{entity_name} nitrojen monoksit konsantrasyonu de\u011fi\u015fiklikleri", + "nitrous_oxide": "{entity_name} nitr\u00f6z oksit konsantrasyonu de\u011fi\u015fiklikleri", + "ozone": "{entity_name} ozon konsantrasyonu de\u011fi\u015fiklikleri", + "pm1": "{entity_name} PM1 konsantrasyonu de\u011fi\u015fiklikleri", + "pm10": "{entity_name} PM10 konsantrasyon de\u011fi\u015fiklikleri", + "pm25": "{entity_name} PM2.5 konsantrasyon de\u011fi\u015fiklikleri", "power": "{entity_name} g\u00fc\u00e7 de\u011fi\u015fiklikleri", "power_factor": "{entity_name} g\u00fc\u00e7 fakt\u00f6r\u00fc de\u011fi\u015fiklikleri", "pressure": "{entity_name} bas\u0131n\u00e7 de\u011fi\u015fiklikleri", "signal_strength": "{entity_name} sinyal g\u00fcc\u00fc 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" } }, diff --git a/homeassistant/components/sensor/translations/zh-Hant.json b/homeassistant/components/sensor/translations/zh-Hant.json index 5c36491941a..f549d18dbc7 100644 --- a/homeassistant/components/sensor/translations/zh-Hant.json +++ b/homeassistant/components/sensor/translations/zh-Hant.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "\u76ee\u524d {entity_name} \u4e00\u6c27\u5316\u78b3\u6fc3\u5ea6\u72c0\u614b", "is_current": "\u76ee\u524d{entity_name}\u96fb\u6d41", "is_energy": "\u76ee\u524d{entity_name}\u96fb\u529b", + "is_frequency": "\u76ee\u524d{entity_name}\u983b\u7387", "is_gas": "\u76ee\u524d{entity_name}\u6c23\u9ad4", "is_humidity": "\u76ee\u524d{entity_name}\u6fd5\u5ea6", "is_illuminance": "\u76ee\u524d{entity_name}\u7167\u5ea6", @@ -32,6 +33,7 @@ "carbon_monoxide": "{entity_name} \u4e00\u6c27\u5316\u78b3\u6fc3\u5ea6\u8b8a\u5316", "current": "\u76ee\u524d{entity_name}\u96fb\u6d41\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", "humidity": "{entity_name}\u6fd5\u5ea6\u8b8a\u66f4", "illuminance": "{entity_name}\u7167\u5ea6\u8b8a\u66f4", diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 68e6e05405b..9d8575dbc4e 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -3,7 +3,7 @@ "name": "Sentry", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sentry", - "requirements": ["sentry-sdk==1.4.3"], + "requirements": ["sentry-sdk==1.5.0"], "codeowners": ["@dcramer", "@frenck"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/sentry/translations/bg.json b/homeassistant/components/sentry/translations/bg.json new file mode 100644 index 00000000000..70cbc98dd7d --- /dev/null +++ b/homeassistant/components/sentry/translations/bg.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, + "error": { + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "dsn": "DSN" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sentry/translations/ja.json b/homeassistant/components/sentry/translations/ja.json new file mode 100644 index 00000000000..49cb304dad2 --- /dev/null +++ b/homeassistant/components/sentry/translations/ja.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "bad_dsn": "\u7121\u52b9\u306aDSN", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "dsn": "DSN" + }, + "description": "Sentry DSN\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "Sentry" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "environment": "\u74b0\u5883\u540d(\u7701\u7565\u53ef\u80fd)", + "event_custom_components": "\u30ab\u30b9\u30bf\u30e0\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u304b\u3089\u306e\u30a4\u30d9\u30f3\u30c8\u3092\u9001\u4fe1\u3059\u308b", + "event_handled": "\u51e6\u7406\u3055\u308c\u305f\u30a4\u30d9\u30f3\u30c8\u3092\u9001\u4fe1", + "event_third_party_packages": "\u30b5\u30fc\u30c9\u30d1\u30fc\u30c6\u30a3\u88fd\u30d1\u30c3\u30b1\u30fc\u30b8\u304b\u3089\u306e\u30a4\u30d9\u30f3\u30c8\u3092\u9001\u4fe1\u3059\u308b", + "logging_event_level": "Sentry\u304c\u30a4\u30d9\u30f3\u30c8\u3092\u767b\u9332\u3059\u308b\u969b\u306e\u30ed\u30b0\u30ec\u30d9\u30eb", + "tracing": "\u30d1\u30d5\u30a9\u30fc\u30de\u30f3\u30b9\u30c8\u30ec\u30fc\u30b9\u3092\u6709\u52b9\u306b\u3059\u308b", + "tracing_sample_rate": "\u30c8\u30ec\u30fc\u30b9\u306e\u30b5\u30f3\u30d7\u30eb\u30ec\u30fc\u30c8; 0.0 \u304b\u3089 1.0(1.0 = 100%)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sentry/translations/tr.json b/homeassistant/components/sentry/translations/tr.json index 4dab23fbd94..fa3f39e9cde 100644 --- a/homeassistant/components/sentry/translations/tr.json +++ b/homeassistant/components/sentry/translations/tr.json @@ -11,7 +11,9 @@ "user": { "data": { "dsn": "DSN" - } + }, + "description": "Sentry DSN'nizi girin", + "title": "Sentry" } } }, @@ -19,7 +21,14 @@ "step": { "init": { "data": { - "environment": "Ortam\u0131n iste\u011fe ba\u011fl\u0131 ad\u0131." + "environment": "Ortam\u0131n iste\u011fe ba\u011fl\u0131 ad\u0131.", + "event_custom_components": "\u00d6zel bile\u015fenlerden olay g\u00f6nder", + "event_handled": "\u0130\u015flenen etkinlikleri g\u00f6nder", + "event_third_party_packages": "\u00dc\u00e7\u00fcnc\u00fc taraf paketlerden etkinlik g\u00f6nder", + "logging_event_level": "G\u00fcnl\u00fck seviyesi Sentry, a\u015fa\u011f\u0131dakiler i\u00e7in bir olay kaydedecektir:", + "logging_level": "G\u00fcnl\u00fck seviyesi Sentry, g\u00fcnl\u00fckleri i\u00e7erik par\u00e7alar\u0131 olarak kaydeder.", + "tracing": "Performans izlemeyi etkinle\u015ftir", + "tracing_sample_rate": "\u00d6rnekleme h\u0131z\u0131n\u0131n izlenmesi; 0.0 ile 1.0 aras\u0131nda (1.0 = %100)" } } } diff --git a/homeassistant/components/serial_pm/sensor.py b/homeassistant/components/serial_pm/sensor.py index 9332f268308..bffb052f653 100644 --- a/homeassistant/components/serial_pm/sensor.py +++ b/homeassistant/components/serial_pm/sensor.py @@ -58,12 +58,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ParticulateMatterSensor(SensorEntity): """Representation of an Particulate matter sensor.""" - def __init__(self, pmDataCollector, name, pmname): + def __init__(self, pm_data_collector, name, pmname): """Initialize a new PM sensor.""" self._name = name self._pmname = pmname self._state = None - self._collector = pmDataCollector + self._collector = pm_data_collector @property def name(self): diff --git a/homeassistant/components/seventeentrack/manifest.json b/homeassistant/components/seventeentrack/manifest.json index 15a94a4230f..01fdb22395c 100644 --- a/homeassistant/components/seventeentrack/manifest.json +++ b/homeassistant/components/seventeentrack/manifest.json @@ -2,7 +2,7 @@ "domain": "seventeentrack", "name": "17TRACK", "documentation": "https://www.home-assistant.io/integrations/seventeentrack", - "requirements": ["py17track==3.2.1"], + "requirements": ["py17track==2021.12.2"], "codeowners": [], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/sharkiq/__init__.py b/homeassistant/components/sharkiq/__init__.py index ed5c7ae1b54..d3246b5ac9e 100644 --- a/homeassistant/components/sharkiq/__init__.py +++ b/homeassistant/components/sharkiq/__init__.py @@ -26,7 +26,7 @@ class CannotConnect(exceptions.HomeAssistantError): async def async_connect_or_timeout(ayla_api: AylaApi) -> bool: """Connect to vacuum.""" try: - with async_timeout.timeout(API_TIMEOUT): + async with async_timeout.timeout(API_TIMEOUT): _LOGGER.debug("Initialize connection to Ayla networks API") await ayla_api.async_sign_in() except SharkIqAuthError: @@ -71,10 +71,11 @@ async def async_setup_entry(hass, config_entry): async def async_disconnect_or_timeout(coordinator: SharkIqUpdateCoordinator): """Disconnect to vacuum.""" _LOGGER.debug("Disconnecting from Ayla Api") - with async_timeout.timeout(5), suppress( - SharkIqAuthError, SharkIqAuthExpiringError, SharkIqNotAuthedError - ): - await coordinator.ayla_api.async_sign_out() + async with async_timeout.timeout(5): + with suppress( + SharkIqAuthError, SharkIqAuthExpiringError, SharkIqNotAuthedError + ): + await coordinator.ayla_api.async_sign_out() async def async_update_options(hass, config_entry): diff --git a/homeassistant/components/sharkiq/config_flow.py b/homeassistant/components/sharkiq/config_flow.py index 8fef217a609..53306e15d12 100644 --- a/homeassistant/components/sharkiq/config_flow.py +++ b/homeassistant/components/sharkiq/config_flow.py @@ -27,7 +27,7 @@ async def validate_input(hass: core.HomeAssistant, data): ) try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): _LOGGER.debug("Initialize connection to Ayla networks API") await ayla_api.async_sign_in() except (asyncio.TimeoutError, aiohttp.ClientError) as errors: diff --git a/homeassistant/components/sharkiq/translations/ja.json b/homeassistant/components/sharkiq/translations/ja.json new file mode 100644 index 00000000000..1f59ff9ae41 --- /dev/null +++ b/homeassistant/components/sharkiq/translations/ja.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "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": { + "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": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sharkiq/update_coordinator.py b/homeassistant/components/sharkiq/update_coordinator.py index 16ed0e14d9a..f8559bcc31f 100644 --- a/homeassistant/components/sharkiq/update_coordinator.py +++ b/homeassistant/components/sharkiq/update_coordinator.py @@ -54,7 +54,7 @@ class SharkIqUpdateCoordinator(DataUpdateCoordinator): """Asynchronously update the data for a single vacuum.""" dsn = sharkiq.serial_number _LOGGER.debug("Updating sharkiq data for device DSN %s", dsn) - with timeout(API_TIMEOUT): + async with timeout(API_TIMEOUT): await sharkiq.async_update() async def _async_update_data(self) -> bool: diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 6b15e4e730d..4109130ab80 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -29,6 +29,7 @@ from homeassistant.helpers.typing import ConfigType from .const import ( AIOSHELLY_DEVICE_TIMEOUT_SEC, + ATTR_BETA, ATTR_CHANNEL, ATTR_CLICK_TYPE, ATTR_DEVICE, @@ -36,6 +37,7 @@ from .const import ( BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, BLOCK, CONF_COAP_PORT, + CONF_SLEEP_PERIOD, DATA_CONFIG_ENTRY, DEFAULT_COAP_PORT, DEVICE, @@ -44,6 +46,7 @@ from .const import ( ENTRY_RELOAD_COOLDOWN, EVENT_SHELLY_CLICK, INPUTS_EVENTS_DICT, + MODELS_SUPPORTING_LIGHT_EFFECTS, POLLING_TIMEOUT_SEC, REST, REST_SENSORS_UPDATE_INTERVAL, @@ -62,9 +65,17 @@ from .utils import ( get_rpc_device_name, ) -BLOCK_PLATFORMS: Final = ["binary_sensor", "cover", "light", "sensor", "switch"] +BLOCK_PLATFORMS: Final = [ + "binary_sensor", + "button", + "climate", + "cover", + "light", + "sensor", + "switch", +] BLOCK_SLEEPING_PLATFORMS: Final = ["binary_sensor", "sensor"] -RPC_PLATFORMS: Final = ["binary_sensor", "light", "sensor", "switch"] +RPC_PLATFORMS: Final = ["binary_sensor", "button", "light", "sensor", "switch"] _LOGGER: Final = logging.getLogger(__name__) COAP_SCHEMA: Final = vol.Schema( @@ -143,7 +154,7 @@ async def async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> bo if device_entry and entry.entry_id not in device_entry.config_entries: device_entry = None - sleep_period = entry.data.get("sleep_period") + sleep_period = entry.data.get(CONF_SLEEP_PERIOD) @callback def _async_device_online(_: Any) -> None: @@ -152,7 +163,7 @@ async def async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> bo if sleep_period is None: data = {**entry.data} - data["sleep_period"] = get_block_device_sleep_period(device.settings) + data[CONF_SLEEP_PERIOD] = get_block_device_sleep_period(device.settings) data["model"] = device.settings["device"]["type"] hass.config_entries.async_update_entry(entry, data=data) @@ -164,8 +175,12 @@ async def async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> bo try: async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): await device.initialize() - except (asyncio.TimeoutError, OSError) as err: - raise ConfigEntryNotReady from err + 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 await async_block_device_setup(hass, entry, device) elif sleep_period is None or device_entry is None: @@ -194,7 +209,7 @@ async def async_block_device_setup( platforms = BLOCK_SLEEPING_PLATFORMS - if not entry.data.get("sleep_period"): + if not entry.data.get(CONF_SLEEP_PERIOD): hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ REST ] = ShellyDeviceRestWrapper(hass, device) @@ -217,8 +232,10 @@ async def async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool device = await RpcDevice.create( aiohttp_client.async_get_clientsession(hass), options ) - except (asyncio.TimeoutError, OSError) as err: - raise ConfigEntryNotReady from err + 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 device_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ RPC @@ -239,7 +256,7 @@ class BlockDeviceWrapper(update_coordinator.DataUpdateCoordinator): """Initialize the Shelly device wrapper.""" self.device_id: str | None = None - if sleep_period := entry.data["sleep_period"]: + if sleep_period := entry.data[CONF_SLEEP_PERIOD]: update_interval = SLEEP_PERIOD_MULTIPLIER * sleep_period else: update_interval = ( @@ -312,11 +329,12 @@ class BlockDeviceWrapper(update_coordinator.DataUpdateCoordinator): # 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 and self.model != "SHRGBW2": + 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 @@ -369,7 +387,7 @@ class BlockDeviceWrapper(update_coordinator.DataUpdateCoordinator): async def _async_update_data(self) -> None: """Fetch data.""" - if sleep_period := self.entry.data.get("sleep_period"): + 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" @@ -408,6 +426,41 @@ class BlockDeviceWrapper(update_coordinator.DataUpdateCoordinator): 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 or schedule an ota update.""" + update_data = self.device.status["update"] + _LOGGER.debug("OTA update service - update_data: %s", update_data) + + if not update_data["has_update"] and not beta: + _LOGGER.warning("No OTA update available for device %s", self.name) + return + + 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, + ) + 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() @@ -477,7 +530,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: platforms = BLOCK_SLEEPING_PLATFORMS - if not entry.data.get("sleep_period"): + if not entry.data.get(CONF_SLEEP_PERIOD): hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][REST] = None platforms = BLOCK_PLATFORMS @@ -645,6 +698,42 @@ class RpcDeviceWrapper(update_coordinator.DataUpdateCoordinator): 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) + + 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 + + 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, + ) + result = None + 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) + async def shutdown(self) -> None: """Shutdown the wrapper.""" await self.device.shutdown() diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index d7e4983df77..4a918506b00 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -17,6 +17,7 @@ from homeassistant.components.binary_sensor import ( STATE_ON, BinarySensorEntity, ) +from homeassistant.components.shelly.const import CONF_SLEEP_PERIOD from homeassistant.config_entries import ConfigEntry from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC from homeassistant.core import HomeAssistant @@ -125,6 +126,7 @@ REST_SENSORS: Final = { extra_state_attributes=lambda status: { "latest_stable_version": status["update"]["new_version"], "installed_version": status["update"]["old_version"], + "beta_version": status["update"].get("beta_version", ""), }, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), @@ -174,7 +176,7 @@ async def async_setup_entry( hass, config_entry, async_add_entities, RPC_SENSORS, RpcBinarySensor ) - if config_entry.data["sleep_period"]: + if config_entry.data[CONF_SLEEP_PERIOD]: await async_setup_entry_attribute_entities( hass, config_entry, diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py new file mode 100644 index 00000000000..ec308814fd8 --- /dev/null +++ b/homeassistant/components/shelly/button.py @@ -0,0 +1,113 @@ +"""Button for Shelly.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final, cast + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_CONFIG +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify + +from . import BlockDeviceWrapper, RpcDeviceWrapper +from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN, RPC +from .utils import get_block_device_name, get_device_entry_gen, get_rpc_device_name + + +@dataclass +class ShellyButtonDescriptionMixin: + """Mixin to describe a Button entity.""" + + press_action: Callable + + +@dataclass +class ShellyButtonDescription(ButtonEntityDescription, ShellyButtonDescriptionMixin): + """Class to describe a Button entity.""" + + +BUTTONS: Final = [ + ShellyButtonDescription( + key="ota_update", + name="OTA Update", + device_class=ButtonDeviceClass.UPDATE, + entity_category=ENTITY_CATEGORY_CONFIG, + press_action=lambda wrapper: wrapper.async_trigger_ota_update(), + ), + ShellyButtonDescription( + key="ota_update_beta", + name="OTA Update Beta", + device_class=ButtonDeviceClass.UPDATE, + entity_registry_enabled_default=False, + entity_category=ENTITY_CATEGORY_CONFIG, + press_action=lambda wrapper: wrapper.async_trigger_ota_update(beta=True), + ), + ShellyButtonDescription( + key="reboot", + name="Reboot", + device_class=ButtonDeviceClass.RESTART, + entity_category=ENTITY_CATEGORY_CONFIG, + press_action=lambda wrapper: wrapper.device.trigger_reboot(), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set buttons for device.""" + wrapper: RpcDeviceWrapper | BlockDeviceWrapper | 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) + else: + if block_wrapper := hass.data[DOMAIN][DATA_CONFIG_ENTRY][ + config_entry.entry_id + ].get(BLOCK): + wrapper = cast(BlockDeviceWrapper, block_wrapper) + + if wrapper is not None: + async_add_entities([ShellyButton(wrapper, button) for button in BUTTONS]) + + +class ShellyButton(ButtonEntity): + """Defines a Shelly OTA update base button.""" + + entity_description: ShellyButtonDescription + + def __init__( + self, + wrapper: RpcDeviceWrapper | BlockDeviceWrapper, + description: ShellyButtonDescription, + ) -> None: + """Initialize Shelly OTA update button.""" + self.entity_description = description + self.wrapper = wrapper + + if isinstance(wrapper, RpcDeviceWrapper): + device_name = get_rpc_device_name(wrapper.device) + else: + device_name = get_block_device_name(wrapper.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)} + ) + + async def async_press(self) -> None: + """Triggers the OTA update service.""" + await self.entity_description.press_action(self.wrapper) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py new file mode 100644 index 00000000000..f2db157ecf2 --- /dev/null +++ b/homeassistant/components/shelly/climate.py @@ -0,0 +1,188 @@ +"""Climate support for Shelly.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Final, cast + +from aioshelly.block_device import Block +import async_timeout + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_NONE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.components.shelly import BlockDeviceWrapper +from homeassistant.components.shelly.entity import ShellyBlockEntity +from homeassistant.components.shelly.utils import get_device_entry_gen +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import ( + AIOSHELLY_DEVICE_TIMEOUT_SEC, + BLOCK, + DATA_CONFIG_ENTRY, + DOMAIN, + SHTRV_01_TEMPERATURE_SETTINGS, +) + +_LOGGER: Final = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up climate device.""" + + if get_device_entry_gen(config_entry) == 2: + return + + device_block: Block | None = None + sensor_block: Block | None = None + + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK] + for block in wrapper.device.blocks: + if block.type == "device": + device_block = block + if hasattr(block, "targetTemp"): + sensor_block = block + + if sensor_block and device_block: + async_add_entities([ShellyClimate(wrapper, sensor_block, device_block)]) + + +class ShellyClimate(ShellyBlockEntity, RestoreEntity, ClimateEntity): + """Representation of a Shelly climate device.""" + + _attr_hvac_modes = [HVAC_MODE_OFF, HVAC_MODE_HEAT] + _attr_icon = "mdi:thermostat" + _attr_max_temp = SHTRV_01_TEMPERATURE_SETTINGS["max"] + _attr_min_temp = SHTRV_01_TEMPERATURE_SETTINGS["min"] + _attr_supported_features: int = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE + _attr_target_temperature_step = SHTRV_01_TEMPERATURE_SETTINGS["step"] + _attr_temperature_unit = TEMP_CELSIUS + + def __init__( + self, wrapper: BlockDeviceWrapper, sensor_block: Block, device_block: Block + ) -> None: + """Initialize climate.""" + super().__init__(wrapper, sensor_block) + + self.device_block = device_block + + assert self.block.channel + + self.control_result: dict[str, Any] | None = None + + self._attr_name = self.wrapper.name + self._attr_unique_id = self.wrapper.mac + self._attr_preset_modes: list[str] = [ + PRESET_NONE, + *wrapper.device.settings["thermostats"][int(self.block.channel)][ + "schedule_profile_names" + ], + ] + + @property + def target_temperature(self) -> float | None: + """Set target temperature.""" + return cast(float, self.block.targetTemp) + + @property + def current_temperature(self) -> float | None: + """Return current temperature.""" + return cast(float, self.block.temp) + + @property + def available(self) -> bool: + """Device availability.""" + return not cast(bool, self.device_block.valveError) + + @property + def hvac_mode(self) -> str: + """HVAC current mode.""" + if self.device_block.mode is None or self._check_is_off(): + return HVAC_MODE_OFF + + return HVAC_MODE_HEAT + + @property + def preset_mode(self) -> str | None: + """Preset current mode.""" + if self.device_block.mode is None: + return None + return self._attr_preset_modes[cast(int, self.device_block.mode)] + + @property + def hvac_action(self) -> str | None: + """HVAC current action.""" + if self.device_block.status is None or self._check_is_off(): + return CURRENT_HVAC_OFF + + return ( + CURRENT_HVAC_IDLE if self.device_block.status == "0" else CURRENT_HVAC_HEAT + ) + + def _check_is_off(self) -> bool: + """Return if valve is off or on.""" + return bool( + self.target_temperature is None + or (self.target_temperature <= self._attr_min_temp) + ) + + async def set_state_full_path(self, **kwargs: Any) -> Any: + """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.block.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), + ) + self.wrapper.last_update_success = False + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (current_temp := kwargs.get(ATTR_TEMPERATURE)) is None: + return + await self.set_state_full_path(target_t_enabled=1, target_t=f"{current_temp}") + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set hvac mode.""" + if hvac_mode == HVAC_MODE_OFF: + await self.set_state_full_path( + target_t_enabled=1, target_t=f"{self._attr_min_temp}" + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set preset mode.""" + if not self._attr_preset_modes: + return + + preset_index = self._attr_preset_modes.index(preset_mode) + + if preset_index == 0: + await self.set_state_full_path(schedule=0) + else: + await self.set_state_full_path( + schedule=1, schedule_profile=f"{preset_index}" + ) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index b77868296bd..521fca79dc9 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -14,13 +14,13 @@ import async_timeout import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.typing import DiscoveryInfoType -from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, DOMAIN +from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, CONF_SLEEP_PERIOD, DOMAIN from .utils import ( get_block_device_name, get_block_device_sleep_period, @@ -63,7 +63,7 @@ async def validate_input( await rpc_device.shutdown() return { "title": get_rpc_device_name(rpc_device), - "sleep_period": 0, + CONF_SLEEP_PERIOD: 0, "model": rpc_device.model, "gen": 2, } @@ -78,7 +78,7 @@ async def validate_input( block_device.shutdown() return { "title": get_block_device_name(block_device), - "sleep_period": get_block_device_sleep_period(block_device.settings), + CONF_SLEEP_PERIOD: get_block_device_sleep_period(block_device.settings), "model": block_device.model, "gen": 1, } @@ -130,7 +130,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): title=device_info["title"], data={ **user_input, - "sleep_period": device_info["sleep_period"], + CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], "model": device_info["model"], "gen": device_info["gen"], }, @@ -166,7 +166,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data={ **user_input, CONF_HOST: self.host, - "sleep_period": device_info["sleep_period"], + CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], "model": device_info["model"], "gen": device_info["gen"], }, @@ -186,23 +186,22 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" + host = discovery_info.host try: - self.info = await self._async_get_info(discovery_info["host"]) + self.info = await self._async_get_info(host) except HTTP_CONNECT_ERRORS: return self.async_abort(reason="cannot_connect") except aioshelly.exceptions.FirmwareUnsupported: return self.async_abort(reason="unsupported_firmware") await self.async_set_unique_id(self.info["mac"]) - self._abort_if_unique_id_configured({CONF_HOST: discovery_info["host"]}) - self.host = discovery_info["host"] + self._abort_if_unique_id_configured({CONF_HOST: host}) + self.host = host - self.context["title_placeholders"] = { - "name": discovery_info.get("name", "").split(".")[0] - } + self.context["title_placeholders"] = {"name": discovery_info.name.split(".")[0]} if get_info_auth(self.info): return await self.async_step_credentials() @@ -224,7 +223,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): title=self.device_info["title"], data={ "host": self.host, - "sleep_period": self.device_info["sleep_period"], + CONF_SLEEP_PERIOD: self.device_info[CONF_SLEEP_PERIOD], "model": self.device_info["model"], "gen": self.device_info["gen"], }, diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 50f81511062..8ef3ed5f1ac 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -35,14 +35,18 @@ MODELS_SUPPORTING_LIGHT_TRANSITION: Final = ( "SHVIN-1", ) -# Bulbs that support white & color modes -DUAL_MODE_LIGHT_MODELS: Final = ( - "SHBDUO-1", +MODELS_SUPPORTING_LIGHT_EFFECTS: Final = ( "SHBLB-1", "SHCB-1", "SHRGBW2", ) +# Bulbs that support white & color modes +DUAL_MODE_LIGHT_MODELS: Final = ( + "SHBLB-1", + "SHCB-1", +) + # Used in "_async_update_data" as timeout for polling data from devices. POLLING_TIMEOUT_SEC: Final = 18 @@ -54,6 +58,7 @@ 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" # Multiplier used to calculate the "update_interval" for non-sleeping devices. UPDATE_PERIOD_MULTIPLIER: Final = 2.2 @@ -85,6 +90,8 @@ ATTR_CHANNEL: Final = "channel" ATTR_DEVICE: Final = "device" ATTR_GENERATION: Final = "generation" CONF_SUBTYPE: Final = "subtype" +ATTR_BETA: Final = "beta" +CONF_OTA_BETA_CHANNEL: Final = "ota_beta_channel" BASIC_INPUTS_EVENTS_TYPES: Final = {"single", "long"} @@ -136,6 +143,12 @@ SHBLB_1_RGB_EFFECTS: Final = { 6: "Red/Green Change", } +SHTRV_01_TEMPERATURE_SETTINGS: Final = { + "min": 4, + "max": 31, + "step": 1, +} + # Kelvin value for colorTemp KELVIN_MAX_VALUE: Final = 6500 KELVIN_MIN_VALUE_WHITE: Final = 2700 diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 0a610545180..65ce8eeba56 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -135,7 +135,7 @@ async def async_restore_block_attribute_entities( name="", icon=entry.original_icon, unit=entry.unit_of_measurement, - device_class=entry.device_class, + device_class=entry.original_device_class, ) entities.append( @@ -437,9 +437,7 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): @property def attribute_value(self) -> StateType: """Value of sensor.""" - value = getattr(self.block, self.attribute) - - if value is None: + if (value := getattr(self.block, self.attribute)) is None: return None return cast(StateType, self.description.value(value)) diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 3e0fce43681..3b96cd61f32 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -1,12 +1,10 @@ """Light for Shelly.""" from __future__ import annotations -import asyncio import logging from typing import Any, Final, cast from aioshelly.block_device import Block -import async_timeout from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -35,10 +33,10 @@ from homeassistant.util.color import ( from . import BlockDeviceWrapper, RpcDeviceWrapper from .const import ( - AIOSHELLY_DEVICE_TIMEOUT_SEC, BLOCK, DATA_CONFIG_ENTRY, DOMAIN, + DUAL_MODE_LIGHT_MODELS, FIRMWARE_PATTERN, KELVIN_MAX_VALUE, KELVIN_MIN_VALUE_COLOR, @@ -136,7 +134,6 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): """Initialize light.""" super().__init__(wrapper, block) self.control_result: dict[str, Any] | None = None - self.mode_result: dict[str, Any] | None = None self._supported_color_modes: set[str] = set() self._supported_features: int = 0 self._min_kelvin: int = KELVIN_MIN_VALUE_WHITE @@ -185,8 +182,8 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): @property def mode(self) -> str: """Return the color mode of the light.""" - if self.mode_result: - return cast(str, self.mode_result["mode"]) + if self.control_result and self.control_result.get("mode"): + return cast(str, self.control_result["mode"]) if hasattr(self.block, "mode"): return cast(str, self.block.mode) @@ -369,9 +366,14 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): self.wrapper.model, ) - if await self.set_light_mode(set_mode): - self.control_result = await self.set_state(**params) + if ( + set_mode + and set_mode != self.mode + and self.wrapper.model in DUAL_MODE_LIGHT_MODELS + ): + params["mode"] = set_mode + self.control_result = await self.set_state(**params) self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: @@ -387,32 +389,10 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): self.async_write_ha_state() - async def set_light_mode(self, set_mode: str | None) -> bool: - """Change device mode color/white if mode has changed.""" - if set_mode is None or self.mode == set_mode: - return True - - _LOGGER.debug("Setting light mode for entity %s, mode: %s", self.name, set_mode) - try: - async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - self.mode_result = await self.wrapper.device.switch_light_mode(set_mode) - except (asyncio.TimeoutError, OSError) as err: - _LOGGER.error( - "Setting light mode for entity %s failed, state: %s, error: %s", - self.name, - set_mode, - repr(err), - ) - self.wrapper.last_update_success = False - return False - - return True - @callback def _update_callback(self) -> None: """When device updates, clear control & mode result that overrides state.""" self.control_result = None - self.mode_result = None super()._update_callback() diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 389d31ac195..7d4da653d51 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -3,7 +3,7 @@ "name": "Shelly", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/shelly", - "requirements": ["aioshelly==1.0.4"], + "requirements": ["aioshelly==1.0.5"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index f61df56eaa7..7fcf456b658 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import SHAIR_MAX_WORK_HOURS +from .const import CONF_SLEEP_PERIOD, SHAIR_MAX_WORK_HOURS from .entity import ( BlockAttributeDescription, RestAttributeDescription, @@ -317,7 +317,7 @@ async def async_setup_entry( hass, config_entry, async_add_entities, RPC_SENSORS, RpcSensor ) - if config_entry.data["sleep_period"]: + if config_entry.data[CONF_SLEEP_PERIOD]: await async_setup_entry_attribute_entities( hass, config_entry, async_add_entities, SENSORS, BlockSleepingSensor ) diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 0291258b511..9114587a910 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -74,6 +74,7 @@ async def async_setup_rpc_entry( ) -> 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") switch_ids = [] diff --git a/homeassistant/components/shelly/translations/de.json b/homeassistant/components/shelly/translations/de.json index 75487b5047a..a8d8bdbdf99 100644 --- a/homeassistant/components/shelly/translations/de.json +++ b/homeassistant/components/shelly/translations/de.json @@ -40,13 +40,13 @@ "btn_down": "{subtype} Taste nach unten", "btn_up": "{subtype} Taste nach oben", "double": "{subtype} zweifach bet\u00e4tigt", - "double_push": "{subtype} Doppelter Push", + "double_push": "{subtype} Doppel-Druck", "long": "{subtype} lange angeklickt", "long_push": "{subtype} langer Druck", "long_single": "{subtype} gehalten und dann einfach bet\u00e4tigt", "single": "{subtype} einfach bet\u00e4tigt", "single_long": "{subtype} einfach bet\u00e4tigt und dann gehalten", - "single_push": "{subtype} einzelner Push", + "single_push": "{subtype} einfacher Druck", "triple": "{subtype} dreifach bet\u00e4tigt" } } diff --git a/homeassistant/components/shelly/translations/es-419.json b/homeassistant/components/shelly/translations/es-419.json new file mode 100644 index 00000000000..6f31b87105f --- /dev/null +++ b/homeassistant/components/shelly/translations/es-419.json @@ -0,0 +1,7 @@ +{ + "device_automation": { + "trigger_type": { + "long_push": "Pulsaci\u00f3n larga {subtype}" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/shelly/translations/fr.json b/homeassistant/components/shelly/translations/fr.json index e4bdc99db1e..68dc5de667a 100644 --- a/homeassistant/components/shelly/translations/fr.json +++ b/homeassistant/components/shelly/translations/fr.json @@ -33,14 +33,17 @@ "button": "Bouton", "button1": "Premier bouton", "button2": "Deuxi\u00e8me bouton", - "button3": "Troisi\u00e8me bouton" + "button3": "Troisi\u00e8me bouton", + "button4": "Quatri\u00e8me bouton" }, "trigger_type": { "double": "{subtype} double-cliqu\u00e9", - "long": " {sous-type} long cliqu\u00e9", + "long": "{subtype} long cliqu\u00e9", + "long_push": "{subtype} appui long", "long_single": "{subtype} clic long et simple clic", "single": "{subtype} simple clic", "single_long": "{subtype} simple clic, puis un clic long", + "single_push": "{subtype} simple pression", "triple": "{subtype} cliqu\u00e9 trois fois" } } diff --git a/homeassistant/components/shelly/translations/id.json b/homeassistant/components/shelly/translations/id.json index 2f385796fd1..9ed75694de0 100644 --- a/homeassistant/components/shelly/translations/id.json +++ b/homeassistant/components/shelly/translations/id.json @@ -33,12 +33,16 @@ "button": "Tombol", "button1": "Tombol pertama", "button2": "Tombol kedua", - "button3": "Tombol ketiga" + "button3": "Tombol ketiga", + "button4": "Tombol keempat" }, "trigger_type": { + "btn_down": "Tombol \"{subtype}\" ditekan", + "btn_up": "Tombol \"{subtype}\" dilepas", "double": "{subtype} diklik dua kali", + "double_push": "Push ganda {subtype}", "long": "{subtype} diklik lama", - "long_push": "Push lama {subtype}", + "long_push": "Push lama {subtype}", "long_single": "{subtype} diklik lama kemudian diklik sekali", "single": "{subtype} diklik sekali", "single_long": "{subtype} diklik sekali kemudian diklik lama", diff --git a/homeassistant/components/shelly/translations/ja.json b/homeassistant/components/shelly/translations/ja.json new file mode 100644 index 00000000000..12a97c8508a --- /dev/null +++ b/homeassistant/components/shelly/translations/ja.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "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": { + "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" + }, + "flow_title": "{name}", + "step": { + "confirm_discovery": { + "description": "{model} {host} \u3092\u8a2d\u5b9a\u3057\u307e\u3059\u304b\uff1f \n\n\u30d1\u30b9\u30ef\u30fc\u30c9\u3067\u4fdd\u8b77\u3055\u308c\u3066\u3044\u308b\u30d0\u30c3\u30c6\u30ea\u30fc\u99c6\u52d5\u306e\u30c7\u30d0\u30a4\u30b9\u306f\u3001\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u7d9a\u3051\u308b\u524d\u306b\u30a6\u30a7\u30a4\u30af\u30a2\u30c3\u30d7\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\n\u30d1\u30b9\u30ef\u30fc\u30c9\u3067\u4fdd\u8b77\u3055\u308c\u3066\u3044\u306a\u3044\u30d0\u30c3\u30c6\u30ea\u30fc\u99c6\u52d5\u306e\u30c7\u30d0\u30a4\u30b9\u306f\u3001\u30c7\u30d0\u30a4\u30b9\u304c\u30a6\u30a7\u30a4\u30af\u30a2\u30c3\u30d7\u3057\u305f\u3068\u304d\u306b\u8ffd\u52a0\u3055\u308c\u307e\u3059\u3002\u30c7\u30d0\u30a4\u30b9\u306e\u30dc\u30bf\u30f3\u3092\u4f7f\u7528\u3057\u3066\u624b\u52d5\u3067\u30c7\u30d0\u30a4\u30b9\u3092\u30a6\u30a7\u30a4\u30af\u30a2\u30c3\u30d7\u3055\u305b\u308b\u304b\u3001\u30c7\u30d0\u30a4\u30b9\u304b\u3089\u306e\u6b21\u306e\u30c7\u30fc\u30bf\u66f4\u65b0\u3092\u5f85\u3061\u307e\u3059\u3002" + }, + "credentials": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3059\u308b\u524d\u306b\u3001\u30d0\u30c3\u30c6\u30ea\u30fc\u99c6\u52d5\u306e\u30c7\u30d0\u30a4\u30b9\u3092\u30a6\u30a7\u30a4\u30af\u30a2\u30c3\u30d7\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\u30c7\u30d0\u30a4\u30b9\u306e\u30dc\u30bf\u30f3\u3067\u30a6\u30a7\u30a4\u30af\u30a2\u30c3\u30d7\u3067\u304d\u307e\u3059\u3002" + } + } + }, + "device_automation": { + "trigger_subtype": { + "button": "\u30dc\u30bf\u30f3", + "button1": "1\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "button2": "2\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "button3": "3\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "button4": "4\u756a\u76ee\u306e\u30dc\u30bf\u30f3" + }, + "trigger_type": { + "btn_down": "{subtype} button down", + "btn_up": "{subtype} button up", + "double": "{subtype} \u30c0\u30d6\u30eb\u30af\u30ea\u30c3\u30af", + "double_push": "{subtype} \u30c0\u30d6\u30eb\u30d7\u30c3\u30b7\u30e5", + "long": "{subtype} \u30ed\u30f3\u30b0\u30af\u30ea\u30c3\u30af", + "long_push": "{subtype} long push", + "long_single": "{subtype} \u30ed\u30f3\u30b0\u30af\u30ea\u30c3\u30af\u3057\u3066\u304b\u3089\u30b7\u30f3\u30b0\u30eb\u30af\u30ea\u30c3\u30af", + "single": "{subtype} \u30b7\u30f3\u30b0\u30eb\u30af\u30ea\u30c3\u30af", + "single_long": "{subtype} \u30b7\u30f3\u30b0\u30eb\u30af\u30ea\u30c3\u30af\u3057\u3066\u304b\u3089\u30ed\u30f3\u30b0\u30af\u30ea\u30c3\u30af", + "single_push": "{subtype} \u30b7\u30f3\u30b0\u30eb\u30d7\u30c3\u30b7\u30e5", + "triple": "{subtype} 3\u56de\u30af\u30ea\u30c3\u30af" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/shelly/translations/pl.json b/homeassistant/components/shelly/translations/pl.json index 25d00cf1e53..062cc73de37 100644 --- a/homeassistant/components/shelly/translations/pl.json +++ b/homeassistant/components/shelly/translations/pl.json @@ -37,11 +37,16 @@ "button4": "Czwarty przycisk" }, "trigger_type": { + "btn_down": "zostanie wci\u015bni\u0119ty przycisk \"w d\u00f3\u0142\" {subtype}", + "btn_up": "zostanie wci\u015bni\u0119ty przycisk \"do g\u00f3ry\" {subtype}", "double": "przycisk \"{subtype}\" zostanie dwukrotnie naci\u015bni\u0119ty", + "double_push": "przycisk \"{subtype}\" zostanie dwukrotnie naci\u015bni\u0119ty", "long": "przycisk \"{subtype}\" zostanie d\u0142ugo naci\u015bni\u0119ty", + "long_push": "przycisk {subtype} zostanie d\u0142ugo naci\u015bni\u0119ty", "long_single": "przycisk \"{subtype}\" zostanie d\u0142ugo naci\u015bni\u0119ty, a nast\u0119pnie pojedynczo naci\u015bni\u0119ty", "single": "przycisk \"{subtype}\" zostanie pojedynczo naci\u015bni\u0119ty", "single_long": "przycisk \"{subtype}\" pojedynczo naci\u015bni\u0119ty, a nast\u0119pnie d\u0142ugo naci\u015bni\u0119ty", + "single_push": "przycisk \"{subtype}\" zostanie pojedynczo naci\u015bni\u0119ty", "triple": "przycisk \"{subtype}\" zostanie trzykrotnie naci\u015bni\u0119ty" } } diff --git a/homeassistant/components/shelly/translations/tr.json b/homeassistant/components/shelly/translations/tr.json index f577c73787f..c03f0a50987 100644 --- a/homeassistant/components/shelly/translations/tr.json +++ b/homeassistant/components/shelly/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", + "unsupported_firmware": "Cihaz, desteklenmeyen bir versiyon s\u00fcr\u00fcm\u00fc kullan\u0131yor." }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", @@ -10,6 +11,9 @@ }, "flow_title": "{name}", "step": { + "confirm_discovery": { + "description": "{model} 'i {host} kurmak istiyor musunuz? \n\n Parola korumal\u0131 pille \u00e7al\u0131\u015fan cihazlar, kuruluma devam etmeden \u00f6nce uyand\u0131r\u0131lmal\u0131d\u0131r.\n Parola korumal\u0131 olmayan pille \u00e7al\u0131\u015fan cihazlar, cihaz uyand\u0131\u011f\u0131nda eklenecektir, art\u0131k \u00fczerindeki bir d\u00fc\u011fmeyi kullanarak cihaz\u0131 manuel olarak uyand\u0131rabilir veya cihazdan bir sonraki veri g\u00fcncellemesini bekleyebilirsiniz." + }, "credentials": { "data": { "password": "Parola", @@ -18,8 +22,9 @@ }, "user": { "data": { - "host": "Ana Bilgisayar" - } + "host": "Ana bilgisayar" + }, + "description": "Kurulumdan \u00f6nce pille \u00e7al\u0131\u015fan cihazlar uyand\u0131r\u0131lmal\u0131d\u0131r, art\u0131k \u00fczerindeki bir d\u00fc\u011fmeyi kullanarak cihaz\u0131 uyand\u0131rabilirsiniz." } } }, @@ -28,13 +33,20 @@ "button": "D\u00fc\u011fme", "button1": "\u0130lk d\u00fc\u011fme", "button2": "\u0130kinci d\u00fc\u011fme", - "button3": "\u00dc\u00e7\u00fcnc\u00fc d\u00fc\u011fme" + "button3": "\u00dc\u00e7\u00fcnc\u00fc d\u00fc\u011fme", + "button4": "D\u00f6rd\u00fcnc\u00fc d\u00fc\u011fme" }, "trigger_type": { + "btn_down": "{subtype} a\u015fa\u011f\u0131 d\u00fc\u011fme", + "btn_up": "{subtype} d\u00fc\u011fmesi yukar\u0131", "double": "{subtype} \u00e7ift t\u0131kland\u0131", + "double_push": "{subtype} \u00e7ift basma", "long": "{subtype} uzun t\u0131kland\u0131", + "long_push": "{subtype} uzun basma", "long_single": "{subtype} uzun t\u0131kland\u0131 ve ard\u0131ndan tek t\u0131kland\u0131", "single": "{subtype} tek t\u0131kland\u0131", + "single_long": "{subtype} tek t\u0131kland\u0131 ve ard\u0131ndan uzun t\u0131kland\u0131", + "single_push": "{subtype} tek basma", "triple": "{subtype} \u00fc\u00e7 kez t\u0131kland\u0131" } } diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 783153e2746..a77f338a51e 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -57,9 +57,7 @@ def get_block_device_name(device: BlockDevice) -> str: def get_rpc_device_name(device: RpcDevice) -> str: """Naming for device.""" - # Gen2 does not support setting device name - # AP SSID name is used as a nicely formatted device name - return cast(str, device.config["wifi"]["ap"]["ssid"] or device.hostname) + return cast(str, device.config["sys"]["device"].get("name") or device.hostname) def get_number_of_channels(device: BlockDevice, block: Block) -> int: @@ -153,16 +151,15 @@ def is_block_momentary_input(settings: dict[str, Any], block: Block) -> bool: return button_type in ["momentary", "momentary_on_release"] -def get_device_uptime(uptime: float, last_uptime: str | None) -> str: +def get_device_uptime(uptime: float, last_uptime: datetime | None) -> datetime: """Return device uptime string, tolerate up to 5 seconds deviation.""" delta_uptime = utcnow() - timedelta(seconds=uptime) if ( not last_uptime - or abs((delta_uptime - datetime.fromisoformat(last_uptime)).total_seconds()) - > UPTIME_DEVIATION + or abs((delta_uptime - last_uptime).total_seconds()) > UPTIME_DEVIATION ): - return delta_uptime.replace(microsecond=0).isoformat() + return delta_uptime return last_uptime diff --git a/homeassistant/components/shopping_list/translations/ja.json b/homeassistant/components/shopping_list/translations/ja.json new file mode 100644 index 00000000000..dd5609b8b32 --- /dev/null +++ b/homeassistant/components/shopping_list/translations/ja.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "step": { + "user": { + "description": "\u30b7\u30e7\u30c3\u30d4\u30f3\u30b0\u30ea\u30b9\u30c8\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u304b\uff1f", + "title": "\u30b7\u30e7\u30c3\u30d4\u30f3\u30b0\u30ea\u30b9\u30c8" + } + } + }, + "title": "\u30b7\u30e7\u30c3\u30d4\u30f3\u30b0\u30ea\u30b9\u30c8" +} \ No newline at end of file diff --git a/homeassistant/components/sia/__init__.py b/homeassistant/components/sia/__init__.py index 9bca9a5f5b2..dbbb12f29ce 100644 --- a/homeassistant/components/sia/__init__.py +++ b/homeassistant/components/sia/__init__.py @@ -11,7 +11,7 @@ from .hub import SIAHub async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up sia from a config entry.""" hub: SIAHub = SIAHub(hass, entry) - await hub.async_setup_hub() + hub.async_setup_hub() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = hub diff --git a/homeassistant/components/sia/binary_sensor.py b/homeassistant/components/sia/binary_sensor.py index eec4f9b2717..980596367b2 100644 --- a/homeassistant/components/sia/binary_sensor.py +++ b/homeassistant/components/sia/binary_sensor.py @@ -8,9 +8,7 @@ from typing import Any from pysiaalarm import SIAEvent from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_POWER, - DEVICE_CLASS_SMOKE, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -81,11 +79,11 @@ class SIABinarySensorBase(SIABaseEntity, BinarySensorEntity): entry: ConfigEntry, account_data: dict[str, Any], zone: int, - device_class: str, + device_class: BinarySensorDeviceClass, ) -> None: """Initialize a base binary sensor.""" - super().__init__(entry, account_data, zone, device_class) - + super().__init__(entry, account_data, zone) + self._attr_device_class = device_class self._attr_unique_id = SIA_UNIQUE_ID_FORMAT_BINARY.format( self._entry.entry_id, self._account, self._zone, self._attr_device_class ) @@ -111,7 +109,7 @@ class SIABinarySensorMoisture(SIABinarySensorBase): zone: int, ) -> None: """Initialize a Moisture binary sensor.""" - super().__init__(entry, account_data, zone, DEVICE_CLASS_MOISTURE) + super().__init__(entry, account_data, zone, BinarySensorDeviceClass.MOISTURE) self._attr_entity_registry_enabled_default = False def update_state(self, sia_event: SIAEvent) -> None: @@ -132,7 +130,7 @@ class SIABinarySensorSmoke(SIABinarySensorBase): zone: int, ) -> None: """Initialize a Smoke binary sensor.""" - super().__init__(entry, account_data, zone, DEVICE_CLASS_SMOKE) + super().__init__(entry, account_data, zone, BinarySensorDeviceClass.SMOKE) self._attr_entity_registry_enabled_default = False def update_state(self, sia_event: SIAEvent) -> None: @@ -152,7 +150,9 @@ class SIABinarySensorPower(SIABinarySensorBase): account_data: dict[str, Any], ) -> None: """Initialize a Power binary sensor.""" - super().__init__(entry, account_data, SIA_HUB_ZONE, DEVICE_CLASS_POWER) + super().__init__( + entry, account_data, SIA_HUB_ZONE, BinarySensorDeviceClass.POWER + ) self._attr_entity_registry_enabled_default = True def update_state(self, sia_event: SIAEvent) -> None: diff --git a/homeassistant/components/sia/hub.py b/homeassistant/components/sia/hub.py index 387c2273606..7db432256f9 100644 --- a/homeassistant/components/sia/hub.py +++ b/homeassistant/components/sia/hub.py @@ -9,7 +9,7 @@ from pysiaalarm.aio import CommunicationsProtocol, SIAAccount, SIAClient, SIAEve from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT, CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -50,10 +50,11 @@ class SIAHub: self.sia_accounts: list[SIAAccount] | None = None self.sia_client: SIAClient = None - async def async_setup_hub(self) -> None: + @callback + def async_setup_hub(self) -> None: """Add a device to the device_registry, register shutdown listener, load reactions.""" self.update_accounts() - device_registry = await dr.async_get_registry(self._hass) + device_registry = dr.async_get(self._hass) for acc in self._accounts: account = acc[CONF_ACCOUNT] device_registry.async_get_or_create( diff --git a/homeassistant/components/sia/sia_entity_base.py b/homeassistant/components/sia/sia_entity_base.py index 14334d1f8cb..f4937e326eb 100644 --- a/homeassistant/components/sia/sia_entity_base.py +++ b/homeassistant/components/sia/sia_entity_base.py @@ -29,13 +29,13 @@ class SIABaseEntity(RestoreEntity): entry: ConfigEntry, account_data: dict[str, Any], zone: int, - device_class: str, + device_class: str | None = None, ) -> None: """Create SIABaseEntity object.""" self._entry: ConfigEntry = entry self._account_data: dict[str, Any] = account_data self._zone: int = zone - self._attr_device_class: str = device_class + self._attr_device_class = device_class self._port: int = self._entry.data[CONF_PORT] self._account: str = self._account_data[CONF_ACCOUNT] diff --git a/homeassistant/components/sia/translations/ja.json b/homeassistant/components/sia/translations/ja.json new file mode 100644 index 00000000000..9286548ddb4 --- /dev/null +++ b/homeassistant/components/sia/translations/ja.json @@ -0,0 +1,50 @@ +{ + "config": { + "error": { + "invalid_account_format": "\u30a2\u30ab\u30a6\u30f3\u30c8\u304c\u300116\u9032\u6570\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u30020\u301c9\u3068A\uff5eF\u306e\u307f\u3092\u4f7f\u7528\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "invalid_account_length": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306b\u306f\u30013\uff5e16\u6587\u5b57\u304c\u5fc5\u8981\u306a\u306e\u3067\u9069\u5207\u306a\u9577\u3055\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002", + "invalid_key_format": "\u30ad\u30fc\u304c\u300116\u9032\u6570\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u30020\u301c9\u3068A\uff5eF\u306e\u307f\u3092\u4f7f\u7528\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "invalid_key_length": "\u30ad\u30fc\u306e\u9577\u3055\u304c\u9069\u5207\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u300216\u300124\u3001\u307e\u305f\u306f32\u306e16\u9032\u6570\u3067\u3042\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "invalid_ping": "ping\u9593\u9694\u306f1\u301c1440\u5206\u3067\u3042\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "invalid_zones": "\u5c11\u306a\u304f\u3068\u30821\u3064\u306e\u30be\u30fc\u30f3\u304c\u5fc5\u8981\u3067\u3059\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "additional_account": { + "data": { + "account": "\u30a2\u30ab\u30a6\u30f3\u30c8ID", + "additional_account": "\u8ffd\u52a0\u306e\u30a2\u30ab\u30a6\u30f3\u30c8", + "encryption_key": "\u6697\u53f7\u5316\u30ad\u30fc", + "ping_interval": "Ping\u30a4\u30f3\u30bf\u30fc\u30d0\u30eb(\u5206)", + "zones": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306e\u30be\u30fc\u30f3\u6570" + }, + "title": "\u73fe\u5728\u306e\u30dd\u30fc\u30c8\u306b\u5225\u306e\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u8ffd\u52a0\u3057\u307e\u3059\u3002" + }, + "user": { + "data": { + "account": "\u30a2\u30ab\u30a6\u30f3\u30c8ID", + "additional_account": "\u8ffd\u52a0\u306e\u30a2\u30ab\u30a6\u30f3\u30c8", + "encryption_key": "\u6697\u53f7\u5316\u30ad\u30fc", + "ping_interval": "Ping\u30a4\u30f3\u30bf\u30fc\u30d0\u30eb(\u5206)", + "port": "\u30dd\u30fc\u30c8", + "protocol": "\u30d7\u30ed\u30c8\u30b3\u30eb", + "zones": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306e\u30be\u30fc\u30f3\u6570" + }, + "title": "SIA\u30d9\u30fc\u30b9\u306e\u30a2\u30e9\u30fc\u30e0\u30b7\u30b9\u30c6\u30e0\u306e\u63a5\u7d9a\u3092\u4f5c\u6210\u3057\u307e\u3059\u3002" + } + } + }, + "options": { + "step": { + "options": { + "data": { + "ignore_timestamps": "SIA\u30a4\u30d9\u30f3\u30c8\u306e\u30bf\u30a4\u30e0\u30b9\u30bf\u30f3\u30d7 \u30c1\u30a7\u30c3\u30af\u3092\u7121\u8996\u3059\u308b", + "zones": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306e\u30be\u30fc\u30f3\u6570" + }, + "description": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a: {account}", + "title": "SIA\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u3002" + } + } + }, + "title": "SIA\u30a2\u30e9\u30fc\u30e0\u30b7\u30b9\u30c6\u30e0" +} \ No newline at end of file diff --git a/homeassistant/components/sia/translations/tr.json b/homeassistant/components/sia/translations/tr.json new file mode 100644 index 00000000000..8d5a1d9dbcb --- /dev/null +++ b/homeassistant/components/sia/translations/tr.json @@ -0,0 +1,50 @@ +{ + "config": { + "error": { + "invalid_account_format": "Hesap onalt\u0131l\u0131k bir de\u011fer de\u011fil, l\u00fctfen sadece 0-9 ve AF kullan\u0131n.", + "invalid_account_length": "Hesap do\u011fru uzunlukta de\u011fil, 3 ile 16 karakter aras\u0131nda olmas\u0131 gerekiyor.", + "invalid_key_format": "Anahtar onalt\u0131l\u0131k bir de\u011fer de\u011fildir, l\u00fctfen yaln\u0131zca 0-9 ve AF kullan\u0131n.", + "invalid_key_length": "Anahtar do\u011fru uzunlukta de\u011fil, 16, 24 veya 32 onalt\u0131l\u0131k karakterden olu\u015fmal\u0131d\u0131r.", + "invalid_ping": "Ping aral\u0131\u011f\u0131 1 ile 1440 dakika aras\u0131nda olmal\u0131d\u0131r.", + "invalid_zones": "En az 1 b\u00f6lge olmas\u0131 gerekir.", + "unknown": "Beklenmeyen hata" + }, + "step": { + "additional_account": { + "data": { + "account": "Hesap Kimli\u011fi", + "additional_account": "Ek hesaplar", + "encryption_key": "\u015eifreleme anahtar\u0131", + "ping_interval": "Ping Aral\u0131\u011f\u0131 (dk)", + "zones": "Hesab\u0131n b\u00f6lge say\u0131s\u0131" + }, + "title": "Ge\u00e7erli ba\u011flant\u0131 noktas\u0131na ba\u015fka bir hesap ekleyin." + }, + "user": { + "data": { + "account": "Hesap Kimli\u011fi", + "additional_account": "Ek hesaplar", + "encryption_key": "\u015eifreleme anahtar\u0131", + "ping_interval": "Ping Aral\u0131\u011f\u0131 (dk)", + "port": "Port", + "protocol": "Protokol", + "zones": "Hesab\u0131n b\u00f6lge say\u0131s\u0131" + }, + "title": "SIA tabanl\u0131 alarm sistemleri i\u00e7in ba\u011flant\u0131 olu\u015fturun." + } + } + }, + "options": { + "step": { + "options": { + "data": { + "ignore_timestamps": "SIA olaylar\u0131n\u0131n zaman damgas\u0131 kontrol\u00fcn\u00fc yoksay", + "zones": "Hesab\u0131n b\u00f6lge say\u0131s\u0131" + }, + "description": "Hesap i\u00e7in se\u00e7enekleri ayarlay\u0131n: {account}", + "title": "SIA Kurulumu i\u00e7in se\u00e7enekler." + } + } + }, + "title": "SIA Alarm Sistemleri" +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 7db92252780..cd04de5d34c 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Iterable +from collections.abc import Callable, Iterable from datetime import timedelta from typing import TYPE_CHECKING, Any, cast @@ -14,13 +14,17 @@ from simplipy.errors import ( SimplipyError, ) from simplipy.system import SystemNotification -from simplipy.system.v2 import SystemV2 from simplipy.system.v3 import ( - VOLUME_HIGH, - VOLUME_LOW, - VOLUME_MEDIUM, - VOLUME_OFF, + MAX_ALARM_DURATION, + MAX_ENTRY_DELAY_AWAY, + MAX_ENTRY_DELAY_HOME, + MAX_EXIT_DELAY_AWAY, + MAX_EXIT_DELAY_HOME, + MIN_ALARM_DURATION, + MIN_ENTRY_DELAY_AWAY, + MIN_EXIT_DELAY_AWAY, SystemV3, + Volume, ) from simplipy.websocket import ( EVENT_AUTOMATIC_TEST, @@ -40,9 +44,10 @@ from simplipy.websocket import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_CODE, + ATTR_DEVICE_ID, CONF_CODE, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP, @@ -80,10 +85,10 @@ from .const import ( ATTR_LIGHT, ATTR_VOICE_PROMPT_VOLUME, CONF_USER_ID, - DATA_CLIENT, DOMAIN, LOGGER, ) +from .typing import SystemType ATTR_CATEGORY = "category" ATTR_LAST_EVENT_CHANGED_BY = "last_event_changed_by" @@ -101,13 +106,13 @@ ATTR_PIN_VALUE = "pin" ATTR_SYSTEM_ID = "system_id" ATTR_TIMESTAMP = "timestamp" +DEFAULT_CONFIG_URL = "https://webapp.simplisafe.com/new/#/dashboard" DEFAULT_ENTITY_MODEL = "alarm_control_panel" DEFAULT_ENTITY_NAME = "Alarm Control Panel" -DEFAULT_REST_API_ERROR_COUNT = 2 +DEFAULT_ERROR_THRESHOLD = 2 DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) DEFAULT_SOCKET_MIN_RETRY = 15 - DISPATCHER_TOPIC_WEBSOCKET_EVENT = "simplisafe_websocket_event_{0}" EVENT_SIMPLISAFE_EVENT = "SIMPLISAFE_EVENT" @@ -121,52 +126,104 @@ PLATFORMS = ( ) VOLUME_MAP = { - "high": VOLUME_HIGH, - "low": VOLUME_LOW, - "medium": VOLUME_MEDIUM, - "off": VOLUME_OFF, + "high": Volume.HIGH, + "low": Volume.LOW, + "medium": Volume.MEDIUM, + "off": Volume.OFF, } -SERVICE_BASE_SCHEMA = vol.Schema({vol.Required(ATTR_SYSTEM_ID): cv.positive_int}) +SERVICE_NAME_CLEAR_NOTIFICATIONS = "clear_notifications" +SERVICE_NAME_REMOVE_PIN = "remove_pin" +SERVICE_NAME_SET_PIN = "set_pin" +SERVICE_NAME_SET_SYSTEM_PROPERTIES = "set_system_properties" -SERVICE_REMOVE_PIN_SCHEMA = SERVICE_BASE_SCHEMA.extend( - {vol.Required(ATTR_PIN_LABEL_OR_VALUE): cv.string} +SERVICES = ( + SERVICE_NAME_CLEAR_NOTIFICATIONS, + SERVICE_NAME_REMOVE_PIN, + SERVICE_NAME_SET_PIN, + SERVICE_NAME_SET_SYSTEM_PROPERTIES, ) -SERVICE_SET_PIN_SCHEMA = SERVICE_BASE_SCHEMA.extend( - {vol.Required(ATTR_PIN_LABEL): cv.string, vol.Required(ATTR_PIN_VALUE): cv.string} +SERVICE_CLEAR_NOTIFICATIONS_SCHEMA = vol.All( + cv.deprecated(ATTR_SYSTEM_ID), + vol.Schema( + { + vol.Optional(ATTR_DEVICE_ID): cv.string, + vol.Optional(ATTR_SYSTEM_ID): cv.string, + } + ), + cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_SYSTEM_ID), ) -SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA = SERVICE_BASE_SCHEMA.extend( - { - vol.Optional(ATTR_ALARM_DURATION): vol.All( - cv.time_period, - lambda value: value.total_seconds(), - vol.Range(min=30, max=480), - ), - vol.Optional(ATTR_ALARM_VOLUME): vol.All(vol.In(VOLUME_MAP), VOLUME_MAP.get), - vol.Optional(ATTR_CHIME_VOLUME): vol.All(vol.In(VOLUME_MAP), VOLUME_MAP.get), - vol.Optional(ATTR_ENTRY_DELAY_AWAY): vol.All( - cv.time_period, - lambda value: value.total_seconds(), - vol.Range(min=30, max=255), - ), - vol.Optional(ATTR_ENTRY_DELAY_HOME): vol.All( - cv.time_period, lambda value: value.total_seconds(), vol.Range(max=255) - ), - vol.Optional(ATTR_EXIT_DELAY_AWAY): vol.All( - cv.time_period, - lambda value: value.total_seconds(), - vol.Range(min=45, max=255), - ), - vol.Optional(ATTR_EXIT_DELAY_HOME): vol.All( - cv.time_period, lambda value: value.total_seconds(), vol.Range(max=255) - ), - vol.Optional(ATTR_LIGHT): cv.boolean, - vol.Optional(ATTR_VOICE_PROMPT_VOLUME): vol.All( - vol.In(VOLUME_MAP), VOLUME_MAP.get - ), - } +SERVICE_REMOVE_PIN_SCHEMA = vol.All( + cv.deprecated(ATTR_SYSTEM_ID), + vol.Schema( + { + vol.Optional(ATTR_DEVICE_ID): cv.string, + vol.Optional(ATTR_SYSTEM_ID): cv.string, + vol.Required(ATTR_PIN_LABEL_OR_VALUE): cv.string, + } + ), + cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_SYSTEM_ID), +) + +SERVICE_SET_PIN_SCHEMA = vol.All( + cv.deprecated(ATTR_SYSTEM_ID), + vol.Schema( + { + vol.Optional(ATTR_DEVICE_ID): cv.string, + vol.Optional(ATTR_SYSTEM_ID): cv.string, + vol.Required(ATTR_PIN_LABEL): cv.string, + vol.Required(ATTR_PIN_VALUE): cv.string, + }, + ), + cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_SYSTEM_ID), +) + +SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA = vol.All( + cv.deprecated(ATTR_SYSTEM_ID), + vol.Schema( + { + vol.Optional(ATTR_DEVICE_ID): cv.string, + vol.Optional(ATTR_SYSTEM_ID): cv.string, + vol.Optional(ATTR_ALARM_DURATION): vol.All( + cv.time_period, + lambda value: value.total_seconds(), + vol.Range(min=MIN_ALARM_DURATION, max=MAX_ALARM_DURATION), + ), + vol.Optional(ATTR_ALARM_VOLUME): vol.All( + vol.In(VOLUME_MAP), VOLUME_MAP.get + ), + vol.Optional(ATTR_CHIME_VOLUME): vol.All( + vol.In(VOLUME_MAP), VOLUME_MAP.get + ), + vol.Optional(ATTR_ENTRY_DELAY_AWAY): vol.All( + cv.time_period, + lambda value: value.total_seconds(), + vol.Range(min=MIN_ENTRY_DELAY_AWAY, max=MAX_ENTRY_DELAY_AWAY), + ), + vol.Optional(ATTR_ENTRY_DELAY_HOME): vol.All( + cv.time_period, + lambda value: value.total_seconds(), + vol.Range(max=MAX_ENTRY_DELAY_HOME), + ), + vol.Optional(ATTR_EXIT_DELAY_AWAY): vol.All( + cv.time_period, + lambda value: value.total_seconds(), + vol.Range(min=MIN_EXIT_DELAY_AWAY, max=MAX_EXIT_DELAY_AWAY), + ), + vol.Optional(ATTR_EXIT_DELAY_HOME): vol.All( + cv.time_period, + lambda value: value.total_seconds(), + vol.Range(max=MAX_EXIT_DELAY_HOME), + ), + vol.Optional(ATTR_LIGHT): cv.boolean, + vol.Optional(ATTR_VOICE_PROMPT_VOLUME): vol.All( + vol.In(VOLUME_MAP), VOLUME_MAP.get + ), + } + ), + cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_SYSTEM_ID), ) WEBSOCKET_EVENTS_REQUIRING_SERIAL = [EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED] @@ -183,6 +240,67 @@ WEBSOCKET_EVENTS_TO_FIRE_HASS_EVENT = [ CONFIG_SCHEMA = cv.deprecated(DOMAIN) +@callback +def _async_get_system_for_service_call( + hass: HomeAssistant, call: ServiceCall +) -> SystemType: + """Get the SimpliSafe system related to a service call (by device ID).""" + if ATTR_SYSTEM_ID in call.data: + for entry in hass.config_entries.async_entries(DOMAIN): + simplisafe = hass.data[DOMAIN][entry.entry_id] + if ( + system := simplisafe.systems.get(int(call.data[ATTR_SYSTEM_ID])) + ) is None: + continue + return cast(SystemType, system) + + device_id = call.data[ATTR_DEVICE_ID] + device_registry = dr.async_get(hass) + + if ( + alarm_control_panel_device_entry := device_registry.async_get(device_id) + ) is None: + raise vol.Invalid("Invalid device ID specified") + + if TYPE_CHECKING: + assert alarm_control_panel_device_entry.via_device_id + + if ( + base_station_device_entry := device_registry.async_get( + alarm_control_panel_device_entry.via_device_id + ) + ) is None: + raise ValueError("No base station registered for alarm control panel") + + [system_id] = [ + identity[1] + for identity in base_station_device_entry.identifiers + if identity[0] == DOMAIN + ] + + for entry_id in base_station_device_entry.config_entries: + if (simplisafe := hass.data[DOMAIN].get(entry_id)) is None: + continue + return cast(SystemType, simplisafe.systems[system_id]) + + raise ValueError(f"No system for device ID: {device_id}") + + +@callback +def _async_register_base_station( + hass: HomeAssistant, entry: ConfigEntry, system: SystemType +) -> None: + """Register a new bridge.""" + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, system.system_id)}, + manufacturer="SimpliSafe", + model=system.version, + name=system.address, + ) + + @callback def _async_standardize_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Bring a config entry up to current standards.""" @@ -208,25 +326,8 @@ def _async_standardize_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> hass.config_entries.async_update_entry(entry, **entry_updates) -async def async_register_base_station( - hass: HomeAssistant, entry: ConfigEntry, system: SystemV2 | SystemV3 -) -> None: - """Register a new bridge.""" - device_registry = await dr.async_get_registry(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, system.system_id)}, - manufacturer="SimpliSafe", - model=system.version, - name=system.address, - ) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SimpliSafe as config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} - _async_standardize_config_entry(hass, entry) _verify_domain_control = verify_domain_control(hass, DOMAIN) @@ -249,100 +350,95 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SimplipyError as err: raise ConfigEntryNotReady from err - hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] = simplisafe + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = simplisafe + hass.config_entries.async_setup_platforms(entry, PLATFORMS) @callback - def verify_system_exists( - coro: Callable[..., Awaitable] - ) -> Callable[..., Awaitable]: - """Log an error if a service call uses an invalid system ID.""" + def extract_system(func: Callable) -> Callable: + """Define a decorator to get the correct system for a service call.""" - async def decorator(call: ServiceCall) -> None: - """Decorate.""" - system_id = int(call.data[ATTR_SYSTEM_ID]) - if system_id not in simplisafe.systems: - LOGGER.error("Unknown system ID in service call: %s", system_id) - return - await coro(call) + async def wrapper(call: ServiceCall) -> None: + """Wrap the service function.""" + system = _async_get_system_for_service_call(hass, call) - return decorator + try: + await func(call, system) + except SimplipyError as err: + LOGGER.error("Error while executing %s: %s", func.__name__, err) - @callback - def v3_only(coro: Callable[..., Awaitable]) -> Callable[..., Awaitable]: - """Log an error if the decorated coroutine is called with a v2 system.""" + return wrapper - async def decorator(call: ServiceCall) -> None: - """Decorate.""" - system = simplisafe.systems[int(call.data[ATTR_SYSTEM_ID])] - if system.version != 3: - LOGGER.error("Service only available on V3 systems") - return - await coro(call) - - return decorator - - @verify_system_exists @_verify_domain_control - async def clear_notifications(call: ServiceCall) -> None: + @extract_system + async def async_clear_notifications(call: ServiceCall, system: SystemType) -> None: """Clear all active notifications.""" - system = simplisafe.systems[call.data[ATTR_SYSTEM_ID]] - try: - await system.async_clear_notifications() - except SimplipyError as err: - LOGGER.error("Error during service call: %s", err) + await system.async_clear_notifications() - @verify_system_exists @_verify_domain_control - async def remove_pin(call: ServiceCall) -> None: + @extract_system + async def async_remove_pin(call: ServiceCall, system: SystemType) -> None: """Remove a PIN.""" - system = simplisafe.systems[call.data[ATTR_SYSTEM_ID]] - try: - await system.async_remove_pin(call.data[ATTR_PIN_LABEL_OR_VALUE]) - except SimplipyError as err: - LOGGER.error("Error during service call: %s", err) + await system.async_remove_pin(call.data[ATTR_PIN_LABEL_OR_VALUE]) - @verify_system_exists @_verify_domain_control - async def set_pin(call: ServiceCall) -> None: + @extract_system + async def async_set_pin(call: ServiceCall, system: SystemType) -> None: """Set a PIN.""" - system = simplisafe.systems[call.data[ATTR_SYSTEM_ID]] - try: - await system.async_set_pin( - call.data[ATTR_PIN_LABEL], call.data[ATTR_PIN_VALUE] - ) - except SimplipyError as err: - LOGGER.error("Error during service call: %s", err) + await system.async_set_pin(call.data[ATTR_PIN_LABEL], call.data[ATTR_PIN_VALUE]) - @verify_system_exists - @v3_only @_verify_domain_control - async def set_system_properties(call: ServiceCall) -> None: + @extract_system + async def async_set_system_properties( + call: ServiceCall, system: SystemType + ) -> None: """Set one or more system parameters.""" - system = cast(SystemV3, simplisafe.systems[call.data[ATTR_SYSTEM_ID]]) - try: - await system.async_set_properties( - { - prop: value - for prop, value in call.data.items() - if prop != ATTR_SYSTEM_ID - } - ) - except SimplipyError as err: - LOGGER.error("Error during service call: %s", err) + if not isinstance(system, SystemV3): + LOGGER.error("Can only set system properties on V3 systems") + return + + await system.async_set_properties( + {prop: value for prop, value in call.data.items() if prop != ATTR_DEVICE_ID} + ) for service, method, schema in ( - ("clear_notifications", clear_notifications, None), - ("remove_pin", remove_pin, SERVICE_REMOVE_PIN_SCHEMA), - ("set_pin", set_pin, SERVICE_SET_PIN_SCHEMA), ( - "set_system_properties", - set_system_properties, + SERVICE_NAME_CLEAR_NOTIFICATIONS, + async_clear_notifications, + SERVICE_CLEAR_NOTIFICATIONS_SCHEMA, + ), + (SERVICE_NAME_REMOVE_PIN, async_remove_pin, SERVICE_REMOVE_PIN_SCHEMA), + (SERVICE_NAME_SET_PIN, async_set_pin, SERVICE_SET_PIN_SCHEMA), + ( + SERVICE_NAME_SET_SYSTEM_PROPERTIES, + async_set_system_properties, SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA, ), ): + if hass.services.has_service(DOMAIN, service): + continue async_register_admin_service(hass, DOMAIN, service, method, schema=schema) + current_options = {**entry.options} + + async def async_reload_entry(_: HomeAssistant, updated_entry: ConfigEntry) -> None: + """Handle an options update. + + This method will get called in two scenarios: + 1. When SimpliSafeOptionsFlowHandler is initiated + 2. When a new refresh token is saved to the config entry data + + We only want #1 to trigger an actual reload. + """ + nonlocal current_options + updated_options = {**updated_entry.options} + + if updated_options == current_options: + return + + await hass.config_entries.async_reload(entry.entry_id) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True @@ -354,14 +450,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) + loaded_entries = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + if len(loaded_entries) == 1: + # If this is the last loaded instance of SimpliSafe, deregister any services + # defined during integration setup: + for service_name in SERVICES: + hass.services.async_remove(DOMAIN, service_name) + return unload_ok -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle an options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - class SimpliSafe: """Define a SimpliSafe data object.""" @@ -372,13 +474,13 @@ class SimpliSafe: self._system_notifications: dict[int, set[SystemNotification]] = {} self.entry = entry self.initial_event_to_use: dict[int, dict[str, Any]] = {} - self.systems: dict[int, SystemV2 | SystemV3] = {} + self.systems: dict[int, SystemType] = {} # This will get filled in by async_init: self.coordinator: DataUpdateCoordinator | None = None @callback - def _async_process_new_notifications(self, system: SystemV2 | SystemV3) -> None: + def _async_process_new_notifications(self, system: SystemType) -> None: """Act on any new system notifications.""" if self._hass.state != CoreState.running: # If HASS isn't fully running yet, it may cause the SIMPLISAFE_NOTIFICATION @@ -458,8 +560,8 @@ class SimpliSafe: assert self._api.refresh_token assert self._api.websocket - self._api.websocket.add_connect_listener(self._async_websocket_on_connect) - self._api.websocket.add_event_listener(self._async_websocket_on_event) + self._api.websocket.add_connect_callback(self._async_websocket_on_connect) + self._api.websocket.add_event_callback(self._async_websocket_on_event) asyncio.create_task(self._api.websocket.async_connect()) async def async_websocket_disconnect_listener(_: Event) -> None: @@ -480,9 +582,7 @@ class SimpliSafe: for system in self.systems.values(): self._system_notifications[system.system_id] = set() - self._hass.async_create_task( - async_register_base_station(self._hass, self.entry, system) - ) + _async_register_base_station(self._hass, self.entry, system) # Future events will come from the websocket, but since subscription to the # websocket doesn't provide the most recent event, we grab it from the REST @@ -513,7 +613,7 @@ class SimpliSafe: ) self.entry.async_on_unload( - self._api.add_refresh_token_listener(async_save_refresh_token) + self._api.add_refresh_token_callback(async_save_refresh_token) ) async_save_refresh_token(self._api.refresh_token) @@ -521,7 +621,7 @@ class SimpliSafe: async def async_update(self) -> None: """Get updated data from SimpliSafe.""" - async def async_update_system(system: SystemV2 | SystemV3) -> None: + async def async_update_system(system: SystemType) -> None: """Update a system.""" await system.async_update(cached=system.version != 3) self._async_process_new_notifications(system) @@ -549,7 +649,7 @@ class SimpliSafeEntity(CoordinatorEntity): def __init__( self, simplisafe: SimpliSafe, - system: SystemV2 | SystemV3, + system: SystemType, *, device: Device | None = None, additional_websocket_events: Iterable[str] | None = None, @@ -558,7 +658,11 @@ class SimpliSafeEntity(CoordinatorEntity): assert simplisafe.coordinator super().__init__(simplisafe.coordinator) - self._rest_api_errors = 0 + # SimpliSafe can incorrectly return an error state when there isn't any + # error. This can lead to entities having an unknown state frequently. + # To protect against that, we measure an error count for each entity and only + # mark the state as unavailable if we detect a few in a row: + self._error_count = 0 if device: model = device.type.name @@ -569,24 +673,26 @@ class SimpliSafeEntity(CoordinatorEntity): device_name = DEFAULT_ENTITY_NAME serial = system.serial - try: - device_type = DeviceTypes( - simplisafe.initial_event_to_use[system.system_id].get("sensorType") - ) - except ValueError: - device_type = DeviceTypes.unknown - event = simplisafe.initial_event_to_use[system.system_id] + if raw_type := event.get("sensorType"): + try: + device_type = DeviceTypes(raw_type) + except ValueError: + device_type = DeviceTypes.UNKNOWN + else: + device_type = DeviceTypes.UNKNOWN + self._attr_extra_state_attributes = { ATTR_LAST_EVENT_INFO: event.get("info"), ATTR_LAST_EVENT_SENSOR_NAME: event.get("sensorName"), - ATTR_LAST_EVENT_SENSOR_TYPE: device_type.name, + ATTR_LAST_EVENT_SENSOR_TYPE: device_type.name.lower(), ATTR_LAST_EVENT_TIMESTAMP: event.get("eventTimestamp"), ATTR_SYSTEM_ID: system.system_id, } self._attr_device_info = DeviceInfo( + configuration_url=DEFAULT_CONFIG_URL, identifiers={(DOMAIN, serial)}, manufacturer="SimpliSafe", model=model, @@ -623,7 +729,7 @@ class SimpliSafeEntity(CoordinatorEntity): system_offline = False return ( - self._rest_api_errors < DEFAULT_REST_API_ERROR_COUNT + self._error_count < DEFAULT_ERROR_THRESHOLD and self._online and not system_offline ) @@ -631,14 +737,10 @@ class SimpliSafeEntity(CoordinatorEntity): @callback def _handle_coordinator_update(self) -> None: """Update the entity with new REST API data.""" - # SimpliSafe can incorrectly return an error state when there isn't any - # error. This can lead to the system having an unknown state frequently. - # To protect against that, we measure how many "error states" we receive - # and only alter the state if we detect a few in a row: if self.coordinator.last_update_success: - self._rest_api_errors = 0 + self.async_reset_error_count() else: - self._rest_api_errors += 1 + self.async_increment_error_count() self.async_update_from_rest_api() self.async_write_ha_state() @@ -706,6 +808,21 @@ class SimpliSafeEntity(CoordinatorEntity): self.async_update_from_rest_api() + @callback + def async_increment_error_count(self) -> None: + """Increment this entity's error count.""" + LOGGER.debug('Error for entity "%s" (total: %s)', self.name, self._error_count) + self._error_count += 1 + + @callback + def async_reset_error_count(self) -> None: + """Reset this entity's error count.""" + if self._error_count == 0: + return + + LOGGER.debug('Resetting error count for "%s"', self.name) + self._error_count = 0 + @callback def async_update_from_rest_api(self) -> None: """Update the entity when new data comes from the REST API.""" diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index 5d2ffdcbd98..ac3d4721ccb 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -5,14 +5,7 @@ from typing import TYPE_CHECKING from simplipy.errors import SimplipyError from simplipy.system import SystemStates -from simplipy.system.v2 import SystemV2 -from simplipy.system.v3 import ( - VOLUME_HIGH, - VOLUME_LOW, - VOLUME_MEDIUM, - VOLUME_OFF, - SystemV3, -) +from simplipy.system.v3 import SystemV3 from simplipy.websocket import ( EVENT_ALARM_CANCELED, EVENT_ALARM_TRIGGERED, @@ -44,6 +37,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMING, STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback @@ -60,10 +54,10 @@ from .const import ( ATTR_EXIT_DELAY_HOME, ATTR_LIGHT, ATTR_VOICE_PROMPT_VOLUME, - DATA_CLIENT, DOMAIN, LOGGER, ) +from .typing import SystemType ATTR_BATTERY_BACKUP_POWER_LEVEL = "battery_backup_power_level" ATTR_GSM_STRENGTH = "gsm_strength" @@ -72,20 +66,14 @@ ATTR_RF_JAMMING = "rf_jamming" ATTR_WALL_POWER_LEVEL = "wall_power_level" ATTR_WIFI_STRENGTH = "wifi_strength" -VOLUME_STRING_MAP = { - VOLUME_HIGH: "high", - VOLUME_LOW: "low", - VOLUME_MEDIUM: "medium", - VOLUME_OFF: "off", -} - STATE_MAP_FROM_REST_API = { - SystemStates.alarm: STATE_ALARM_TRIGGERED, - SystemStates.away: STATE_ALARM_ARMED_AWAY, - SystemStates.away_count: STATE_ALARM_ARMING, - SystemStates.exit_delay: STATE_ALARM_ARMING, - SystemStates.home: STATE_ALARM_ARMED_HOME, - SystemStates.off: STATE_ALARM_DISARMED, + SystemStates.ALARM: STATE_ALARM_TRIGGERED, + SystemStates.ALARM_COUNT: STATE_ALARM_PENDING, + SystemStates.AWAY: STATE_ALARM_ARMED_AWAY, + SystemStates.AWAY_COUNT: STATE_ALARM_ARMING, + SystemStates.EXIT_DELAY: STATE_ALARM_ARMING, + SystemStates.HOME: STATE_ALARM_ARMED_HOME, + SystemStates.OFF: STATE_ALARM_DISARMED, } STATE_MAP_FROM_WEBSOCKET_EVENT = { @@ -121,7 +109,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up a SimpliSafe alarm control panel based on a config entry.""" - simplisafe = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + simplisafe = hass.data[DOMAIN][entry.entry_id] async_add_entities( [SimpliSafeAlarm(simplisafe, system) for system in simplisafe.systems.values()], True, @@ -131,7 +119,7 @@ async def async_setup_entry( class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): """Representation of a SimpliSafe alarm.""" - def __init__(self, simplisafe: SimpliSafe, system: SystemV2 | SystemV3) -> None: + def __init__(self, simplisafe: SimpliSafe, system: SystemType) -> None: """Initialize the SimpliSafe alarm.""" super().__init__( simplisafe, @@ -168,11 +156,14 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): """Set the state based on the latest REST API data.""" if self._system.alarm_going_off: self._attr_state = STATE_ALARM_TRIGGERED + elif self._system.state == SystemStates.ERROR: + self.async_increment_error_count() elif state := STATE_MAP_FROM_REST_API.get(self._system.state): self._attr_state = state + self.async_reset_error_count() else: LOGGER.error("Unknown system state (REST API): %s", self._system.state) - self._attr_state = None + self.async_increment_error_count() async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" @@ -227,9 +218,9 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): self._attr_extra_state_attributes.update( { ATTR_ALARM_DURATION: self._system.alarm_duration, - ATTR_ALARM_VOLUME: VOLUME_STRING_MAP[self._system.alarm_volume], + ATTR_ALARM_VOLUME: self._system.alarm_volume.name.lower(), ATTR_BATTERY_BACKUP_POWER_LEVEL: self._system.battery_backup_power_level, - ATTR_CHIME_VOLUME: VOLUME_STRING_MAP[self._system.chime_volume], + ATTR_CHIME_VOLUME: self._system.chime_volume.name.lower(), ATTR_ENTRY_DELAY_AWAY: self._system.entry_delay_away, ATTR_ENTRY_DELAY_HOME: self._system.entry_delay_home, ATTR_EXIT_DELAY_AWAY: self._system.exit_delay_away, @@ -237,9 +228,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): ATTR_GSM_STRENGTH: self._system.gsm_strength, ATTR_LIGHT: self._system.light, ATTR_RF_JAMMING: self._system.rf_jamming, - ATTR_VOICE_PROMPT_VOLUME: VOLUME_STRING_MAP[ - self._system.voice_prompt_volume - ], + ATTR_VOICE_PROMPT_VOLUME: self._system.voice_prompt_volume.name.lower(), ATTR_WALL_POWER_LEVEL: self._system.wall_power_level, ATTR_WIFI_STRENGTH: self._system.wifi_strength, } @@ -253,4 +242,9 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): self._attr_changed_by = event.changed_by if TYPE_CHECKING: assert event.event_type - self._attr_state = STATE_MAP_FROM_WEBSOCKET_EVENT.get(event.event_type) + if state := STATE_MAP_FROM_WEBSOCKET_EVENT.get(event.event_type): + self._attr_state = state + self.async_reset_error_count() + else: + LOGGER.error("Unknown alarm websocket event: %s", event.event_type) + self.async_increment_error_count() diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index f276a5fea66..240ff24c6c8 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -21,28 +21,28 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SimpliSafe, SimpliSafeEntity -from .const import DATA_CLIENT, DOMAIN, LOGGER +from .const import DOMAIN, LOGGER SUPPORTED_BATTERY_SENSOR_TYPES = [ - DeviceTypes.carbon_monoxide, - DeviceTypes.entry, - DeviceTypes.glass_break, - DeviceTypes.leak, - DeviceTypes.lock_keypad, - DeviceTypes.motion, - DeviceTypes.siren, - DeviceTypes.smoke, - DeviceTypes.temperature, + DeviceTypes.CARBON_MONOXIDE, + DeviceTypes.ENTRY, + DeviceTypes.GLASS_BREAK, + DeviceTypes.LEAK, + DeviceTypes.LOCK_KEYPAD, + DeviceTypes.MOTION, + DeviceTypes.SIREN, + DeviceTypes.SMOKE, + DeviceTypes.TEMPERATURE, ] TRIGGERED_SENSOR_TYPES = { - DeviceTypes.carbon_monoxide: DEVICE_CLASS_GAS, - DeviceTypes.entry: DEVICE_CLASS_DOOR, - DeviceTypes.glass_break: DEVICE_CLASS_SAFETY, - DeviceTypes.leak: DEVICE_CLASS_MOISTURE, - DeviceTypes.motion: DEVICE_CLASS_MOTION, - DeviceTypes.siren: DEVICE_CLASS_SAFETY, - DeviceTypes.smoke: DEVICE_CLASS_SMOKE, + DeviceTypes.CARBON_MONOXIDE: DEVICE_CLASS_GAS, + DeviceTypes.ENTRY: DEVICE_CLASS_DOOR, + DeviceTypes.GLASS_BREAK: DEVICE_CLASS_SAFETY, + DeviceTypes.LEAK: DEVICE_CLASS_MOISTURE, + DeviceTypes.MOTION: DEVICE_CLASS_MOTION, + DeviceTypes.SIREN: DEVICE_CLASS_SAFETY, + DeviceTypes.SMOKE: DEVICE_CLASS_SMOKE, } @@ -50,7 +50,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up SimpliSafe binary sensors based on a config entry.""" - simplisafe = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + simplisafe = hass.data[DOMAIN][entry.entry_id] sensors: list[BatteryBinarySensor | TriggeredBinarySensor] = [] diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index 8f8ec6cdc16..3a3d1963e0e 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -23,8 +23,13 @@ from homeassistant.helpers.typing import ConfigType from .const import CONF_USER_ID, DOMAIN, LOGGER CONF_AUTH_CODE = "auth_code" +CONF_DOCS_URL = "docs_url" -STEP_INPUT_AUTH_CODE_SCHEMA = vol.Schema( +AUTH_DOCS_URL = ( + "http://home-assistant.io/integrations/simplisafe#getting-an-authorization-code" +) + +STEP_USER_SCHEMA = vol.Schema( { vol.Required(CONF_AUTH_CODE): cv.string, } @@ -54,8 +59,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" - self._errors: dict[str, Any] = {} - self._oauth_values: SimpliSafeOAuthValues = async_get_simplisafe_oauth_values() + self._oauth_values: SimpliSafeOAuthValues | None = None self._reauth: bool = False self._username: str | None = None @@ -67,19 +71,37 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Define the config flow to handle options.""" return SimpliSafeOptionsFlowHandler(config_entry) - async def async_step_input_auth_code( + def _async_show_form(self, *, errors: dict[str, Any] | None = None) -> FlowResult: + """Show the form.""" + self._oauth_values = async_get_simplisafe_oauth_values() + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_SCHEMA, + errors=errors or {}, + description_placeholders={ + CONF_URL: self._oauth_values.auth_url, + CONF_DOCS_URL: AUTH_DOCS_URL, + }, + ) + + async def async_step_reauth(self, config: ConfigType) -> FlowResult: + """Handle configuration by re-auth.""" + self._username = config.get(CONF_USERNAME) + self._reauth = True + return await self.async_step_user() + + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Handle the input of a SimpliSafe OAuth authorization code.""" + """Handle the start of the config flow.""" if user_input is None: - return self.async_show_form( - step_id="input_auth_code", data_schema=STEP_INPUT_AUTH_CODE_SCHEMA - ) + return self._async_show_form() if TYPE_CHECKING: assert self._oauth_values - self._errors = {} + errors = {} session = aiohttp_client.async_get_clientsession(self.hass) try: @@ -89,13 +111,13 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): session=session, ) except InvalidCredentialsError: - self._errors = {"base": "invalid_auth"} + errors = {"base": "invalid_auth"} except SimplipyError as err: LOGGER.error("Unknown error while logging into SimpliSafe: %s", err) - self._errors = {"base": "unknown"} + errors = {"base": "unknown"} - if self._errors: - return await self.async_step_user() + if errors: + return self._async_show_form(errors=errors) data = {CONF_USER_ID: simplisafe.user_id, CONF_TOKEN: simplisafe.refresh_token} unique_id = str(simplisafe.user_id) @@ -122,25 +144,6 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry(title=unique_id, data=data) - async def async_step_reauth(self, config: ConfigType) -> FlowResult: - """Handle configuration by re-auth.""" - self._username = config.get(CONF_USERNAME) - self._reauth = True - return await self.async_step_user() - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the start of the config flow.""" - if user_input is None: - return self.async_show_form( - step_id="user", - errors=self._errors, - description_placeholders={CONF_URL: self._oauth_values.auth_url}, - ) - - return await self.async_step_input_auth_code() - class SimpliSafeOptionsFlowHandler(config_entries.OptionsFlow): """Handle a SimpliSafe options flow.""" diff --git a/homeassistant/components/simplisafe/const.py b/homeassistant/components/simplisafe/const.py index a0073fa8122..658ddfc13a6 100644 --- a/homeassistant/components/simplisafe/const.py +++ b/homeassistant/components/simplisafe/const.py @@ -16,5 +16,3 @@ ATTR_LIGHT = "light" ATTR_VOICE_PROMPT_VOLUME = "voice_prompt_volume" CONF_USER_ID = "user_id" - -DATA_CLIENT = "client" diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index dc09eb0b62e..435b60af44b 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -6,12 +6,7 @@ from typing import TYPE_CHECKING, Any from simplipy.device.lock import Lock, LockStates from simplipy.errors import SimplipyError from simplipy.system.v3 import SystemV3 -from simplipy.websocket import ( - EVENT_LOCK_ERROR, - EVENT_LOCK_LOCKED, - EVENT_LOCK_UNLOCKED, - WebsocketEvent, -) +from simplipy.websocket import EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED, WebsocketEvent from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry @@ -19,13 +14,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SimpliSafe, SimpliSafeEntity -from .const import DATA_CLIENT, DOMAIN, LOGGER +from .const import DOMAIN, LOGGER ATTR_LOCK_LOW_BATTERY = "lock_low_battery" ATTR_PIN_PAD_LOW_BATTERY = "pin_pad_low_battery" STATE_MAP_FROM_WEBSOCKET_EVENT = { - EVENT_LOCK_ERROR: None, EVENT_LOCK_LOCKED: True, EVENT_LOCK_UNLOCKED: False, } @@ -37,7 +31,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up SimpliSafe locks based on a config entry.""" - simplisafe = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + simplisafe = hass.data[DOMAIN][entry.entry_id] locks = [] for system in simplisafe.systems.values(): @@ -90,6 +84,9 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): @callback def async_update_from_rest_api(self) -> None: """Update the entity with the provided REST API data.""" + self._attr_is_jammed = self._device.state == LockStates.JAMMED + self._attr_is_locked = self._device.state == LockStates.LOCKED + self._attr_extra_state_attributes.update( { ATTR_LOCK_LOW_BATTERY: self._device.lock_low_battery, @@ -97,12 +94,14 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): } ) - self._attr_is_jammed = self._device.state == LockStates.jammed - self._attr_is_locked = self._device.state == LockStates.locked - @callback def async_update_from_websocket_event(self, event: WebsocketEvent) -> None: """Update the entity when new data comes from the websocket.""" if TYPE_CHECKING: assert event.event_type - self._attr_is_locked = STATE_MAP_FROM_WEBSOCKET_EVENT[event.event_type] + if state := STATE_MAP_FROM_WEBSOCKET_EVENT.get(event.event_type) is not None: + self._attr_is_locked = state + self.async_reset_error_count() + else: + LOGGER.error("Unknown lock websocket event: %s", event.event_type) + self.async_increment_error_count() diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 956157a237d..0b6cb385be6 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,13 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==12.0.2"], + "requirements": ["simplisafe-python==2021.12.1"], "codeowners": ["@bachya"], - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "dhcp": [ + { + "hostname": "simplisafe*", + "macaddress": "30AEA4*" + } + ] } diff --git a/homeassistant/components/simplisafe/sensor.py b/homeassistant/components/simplisafe/sensor.py index 97edd3008dd..b2b0a432bd6 100644 --- a/homeassistant/components/simplisafe/sensor.py +++ b/homeassistant/components/simplisafe/sensor.py @@ -12,14 +12,14 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SimpliSafe, SimpliSafeEntity -from .const import DATA_CLIENT, DOMAIN, LOGGER +from .const import DOMAIN, LOGGER async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up SimpliSafe freeze sensors based on a config entry.""" - simplisafe = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + simplisafe = hass.data[DOMAIN][entry.entry_id] sensors = [] for system in simplisafe.systems.values(): @@ -28,7 +28,7 @@ async def async_setup_entry( continue for sensor in system.sensors.values(): - if sensor.type == DeviceTypes.temperature: + if sensor.type == DeviceTypes.TEMPERATURE: sensors.append(SimplisafeFreezeSensor(simplisafe, system, sensor)) async_add_entities(sensors) diff --git a/homeassistant/components/simplisafe/services.yaml b/homeassistant/components/simplisafe/services.yaml index b9ee798f464..3d0965b4b0b 100644 --- a/homeassistant/components/simplisafe/services.yaml +++ b/homeassistant/components/simplisafe/services.yaml @@ -1,15 +1,28 @@ # Describes the format for available SimpliSafe services +clear_notifications: + name: Clear notifications + description: Clear any active SimpliSafe notifications + fields: + device_id: + name: System + description: The system to remove the PIN from + required: true + selector: + device: + integration: simplisafe + model: alarm_control_panel remove_pin: name: Remove PIN description: Remove a PIN by its label or value. fields: - system_id: - name: System ID - description: The SimpliSafe system ID to affect. + device_id: + name: System + description: The system to remove the PIN from required: true - example: 123987 selector: - text: + device: + integration: simplisafe + model: alarm_control_panel label_or_pin: name: Label/PIN description: The label/value to remove. @@ -21,13 +34,14 @@ set_pin: name: Set PIN description: Set/update a PIN fields: - system_id: - name: System ID - description: The SimpliSafe system ID to affect + device_id: + name: System + description: The system to set the PIN on required: true - example: 123987 selector: - text: + device: + integration: simplisafe + model: alarm_control_panel label: name: Label description: The label of the PIN @@ -46,6 +60,14 @@ set_system_properties: name: Set system properties description: Set one or more system properties fields: + device_id: + name: System + description: The system whose properties should be set + required: true + selector: + device: + integration: simplisafe + model: alarm_control_panel alarm_duration: name: Alarm duration description: The length of a triggered alarm diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json index 55a916bfe6b..a0ff28fd689 100644 --- a/homeassistant/components/simplisafe/strings.json +++ b/homeassistant/components/simplisafe/strings.json @@ -1,15 +1,11 @@ { "config": { "step": { - "input_auth_code": { - "title": "Finish Authorization", - "description": "Input the authorization code from the SimpliSafe web app URL:", + "user": { + "description": "SimpliSafe authenticates with Home Assistant via the SimpliSafe web app. Due to technical limitations, there is a manual step at the end of this process; please ensure that you read the [documentation]({docs_url}) before starting.\n\n1. Click [here]({url}) to open the SimpliSafe web app and input your credentials.\n\n2. When the login process is complete, return here and input the authorization code below.", "data": { "auth_code": "Authorization Code" } - }, - "user": { - "description": "Starting in 2021, SimpliSafe has moved to a new authentication mechanism via its web app. Due to technical limitations, there is a manual step at the end of this process; please ensure that you read the [documentation](http://home-assistant.io/integrations/simplisafe#getting-an-authorization-code) before starting.\n\nWhen you are ready, click [here]({url}) to open the SimpliSafe web app and input your credentials. When the process is complete, return here and click Submit." } }, "error": { diff --git a/homeassistant/components/simplisafe/translations/bg.json b/homeassistant/components/simplisafe/translations/bg.json index 4013449a082..75f9480f5fd 100644 --- a/homeassistant/components/simplisafe/translations/bg.json +++ b/homeassistant/components/simplisafe/translations/bg.json @@ -1,8 +1,12 @@ { "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": { "identifier_exists": "\u041f\u0440\u043e\u0444\u0438\u043b\u044a\u0442 \u0435 \u0432\u0435\u0447\u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d", - "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\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": { "input_auth_code": { @@ -10,6 +14,12 @@ "auth_code": "\u041a\u043e\u0434 \u0437\u0430 \u043e\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f" } }, + "reauth_confirm": { + "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 \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/simplisafe/translations/en.json b/homeassistant/components/simplisafe/translations/en.json index 66843f86d27..5829c68301f 100644 --- a/homeassistant/components/simplisafe/translations/en.json +++ b/homeassistant/components/simplisafe/translations/en.json @@ -12,32 +12,11 @@ "unknown": "Unexpected error" }, "step": { - "input_auth_code": { + "user": { "data": { "auth_code": "Authorization Code" }, - "description": "Input the authorization code from the SimpliSafe web app URL:", - "title": "Finish Authorization" - }, - "mfa": { - "description": "Check your email for a link from SimpliSafe. After verifying the link, return here to complete the installation of the integration.", - "title": "SimpliSafe Multi-Factor Authentication" - }, - "reauth_confirm": { - "data": { - "password": "Password" - }, - "description": "Your access has expired or been revoked. Enter your password to re-link your account.", - "title": "Reauthenticate Integration" - }, - "user": { - "data": { - "code": "Code (used in Home Assistant UI)", - "password": "Password", - "username": "Email" - }, - "description": "Starting in 2021, SimpliSafe has moved to a new authentication mechanism via its web app. Due to technical limitations, there is a manual step at the end of this process; please ensure that you read the [documentation](http://home-assistant.io/integrations/simplisafe#getting-an-authorization-code) before starting.\n\nWhen you are ready, click [here]({url}) to open the SimpliSafe web app and input your credentials. When the process is complete, return here and click Submit.", - "title": "Fill in your information." + "description": "SimpliSafe authenticates with Home Assistant via the SimpliSafe web app. Due to technical limitations, there is a manual step at the end of this process; please ensure that you read the [documentation]({docs_url}) before starting.\n\n1. Click [here]({url}) to open the SimpliSafe web app and input your credentials.\n\n2. When the login process is complete, return here and input the authorization code below." } } }, diff --git a/homeassistant/components/simplisafe/translations/id.json b/homeassistant/components/simplisafe/translations/id.json index c9ff0f96bb9..733af744e30 100644 --- a/homeassistant/components/simplisafe/translations/id.json +++ b/homeassistant/components/simplisafe/translations/id.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Akun SimpliSafe ini sudah digunakan.", - "reauth_successful": "Autentikasi ulang berhasil" + "reauth_successful": "Autentikasi ulang berhasil", + "wrong_account": "Kredensial pengguna yang diberikan tidak cocok dengan akun SimpliSafe ini." }, "error": { "identifier_exists": "Akun sudah terdaftar", @@ -11,6 +12,13 @@ "unknown": "Kesalahan yang tidak diharapkan" }, "step": { + "input_auth_code": { + "data": { + "auth_code": "Kode Otorisasi" + }, + "description": "Masukkan kode otorisasi dari URL aplikasi web SimpliSafe:", + "title": "Selesaikan Otorisasi" + }, "mfa": { "description": "Periksa email Anda untuk mendapatkan tautan dari SimpliSafe. Setelah memverifikasi tautan, kembali ke sini untuk menyelesaikan instalasi integrasi.", "title": "Autentikasi Multi-Faktor SimpliSafe" @@ -28,6 +36,7 @@ "password": "Kata Sandi", "username": "Email" }, + "description": "Sejak tahun 2021, SimpliSafe telah berpindah ke mekanisme autentikasi baru melalui aplikasi webnya. Karena keterbatasan teknis, ada langkah manual di akhir proses ini; pastikan Anda membaca [dokumentasi] (http://home-assistant.io/integrations/simplisafe#getting-an-authorization-code) sebelum memulai.\n\nJika siap, klik [di sini]({url}) untuk membuka aplikasi web SimpliSafe dan masukkan kredensial Anda. Setelah proses selesai, kembali ke sini dan klik Kirim.", "title": "Isi informasi Anda." } } diff --git a/homeassistant/components/simplisafe/translations/ja.json b/homeassistant/components/simplisafe/translations/ja.json index 70f15e85dd5..32e8e4f777e 100644 --- a/homeassistant/components/simplisafe/translations/ja.json +++ b/homeassistant/components/simplisafe/translations/ja.json @@ -1,11 +1,53 @@ { "config": { + "abort": { + "already_configured": "\u3053\u306eSimpliSafe account\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f", + "wrong_account": "\u63d0\u4f9b\u3055\u308c\u305f\u30e6\u30fc\u30b6\u30fc\u8a8d\u8a3c\u60c5\u5831\u304c\u3001\u3053\u306eSimpliSafe\u30a2\u30ab\u30a6\u30f3\u30c8\u3068\u4e00\u81f4\u3057\u307e\u305b\u3093\u3002" + }, + "error": { + "identifier_exists": "\u30a2\u30ab\u30a6\u30f3\u30c8\u767b\u9332\u6e08\u307f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "still_awaiting_mfa": "MFA email click\u3092\u307e\u3060\u5f85\u3063\u3066\u3044\u307e\u3059", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, "step": { "input_auth_code": { "data": { "auth_code": "\u8a8d\u8a3c\u30b3\u30fc\u30c9" }, + "description": "SimpliSafe Web\u30a2\u30d7\u30ea\u306eURL\u304b\u3089\u8a8d\u8a3c\u30b3\u30fc\u30c9\u3092\u5165\u529b:", "title": "\u627f\u8a8d\u7d42\u4e86" + }, + "mfa": { + "description": "SimpliSafe\u304b\u3089\u306e\u30ea\u30f3\u30af\u306b\u3064\u3044\u3066\u306f\u30e1\u30fc\u30eb\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u30ea\u30f3\u30af\u3092\u78ba\u8a8d\u3057\u305f\u3089\u3001\u3053\u3053\u306b\u623b\u3063\u3066\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3092\u5b8c\u4e86\u3057\u307e\u3059\u3002", + "title": "SimpliSafe\u591a\u8981\u7d20\u8a8d\u8a3c" + }, + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u30a2\u30af\u30bb\u30b9\u306e\u6709\u52b9\u671f\u9650\u304c\u5207\u308c\u3066\u3044\u308b\u304b\u3001\u53d6\u308a\u6d88\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u3001\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u518d\u30ea\u30f3\u30af\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "code": "\u30b3\u30fc\u30c9(Home Assistant UI\u3067\u4f7f\u7528)", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "E\u30e1\u30fc\u30eb" + }, + "description": "2021\u5e74\u3088\u308a\u3001SimpliSafe\u306fWeb\u30a2\u30d7\u30ea\u306b\u3088\u308b\u65b0\u3057\u3044\u8a8d\u8a3c\u6a5f\u69cb\u306b\u79fb\u884c\u3057\u307e\u3057\u305f\u3002\u6280\u8853\u7684\u306a\u5236\u9650\u306e\u305f\u3081\u3001\u3053\u306e\u30d7\u30ed\u30bb\u30b9\u306e\u6700\u5f8c\u306b\u624b\u52d5\u3067\u306e\u624b\u9806\u304c\u3042\u308a\u307e\u3059\u3002\u958b\u59cb\u3059\u308b\u524d\u306b\u3001[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8](http://home-assistant.io/integrations/simplisafe#getting-an-authorization-code)\u3092\u5fc5\u305a\u304a\u8aad\u307f\u304f\u3060\u3055\u3044\u3002\n\n\u6e96\u5099\u304c\u3067\u304d\u305f\u3089\u3001[\u3053\u3053]({url}) \u3092\u30af\u30ea\u30c3\u30af\u3057\u3066SimpliSafe\u306eWeb\u30a2\u30d7\u30ea\u3092\u958b\u304d\u3001\u8a8d\u8a3c\u60c5\u5831\u3092\u5165\u529b\u3057\u307e\u3059\u3002\u51e6\u7406\u304c\u5b8c\u4e86\u3057\u305f\u3089\u3001\u3053\u3053\u306b\u623b\u3063\u3066\u304d\u3066\u9001\u4fe1(submit) \u3092\u30af\u30ea\u30c3\u30af\u3057\u307e\u3059\u3002", + "title": "\u3042\u306a\u305f\u306e\u60c5\u5831\u3092\u8a18\u5165\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "code": "\u30b3\u30fc\u30c9(Home Assistant UI\u3067\u4f7f\u7528)" + }, + "title": "SimpliSafe\u306e\u8a2d\u5b9a" } } } diff --git a/homeassistant/components/simplisafe/translations/pl.json b/homeassistant/components/simplisafe/translations/pl.json index 260d9d6b148..4ec2afba5c3 100644 --- a/homeassistant/components/simplisafe/translations/pl.json +++ b/homeassistant/components/simplisafe/translations/pl.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "To konto SimpliSafe jest ju\u017c w u\u017cyciu", - "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", + "wrong_account": "Podane dane uwierzytelniaj\u0105ce u\u017cytkownika nie pasuj\u0105 do tego konta SimpliSafe." }, "error": { "identifier_exists": "Konto jest ju\u017c zarejestrowane", @@ -11,6 +12,13 @@ "unknown": "Nieoczekiwany b\u0142\u0105d" }, "step": { + "input_auth_code": { + "data": { + "auth_code": "Kod autoryzacji" + }, + "description": "Wprowad\u017a kod autoryzacyjny z adresu URL aplikacji internetowej SimpliSafe:", + "title": "Zako\u0144cz autoryzacj\u0119" + }, "mfa": { "description": "Sprawd\u017a e-mail od SimpliSafe. Po zweryfikowaniu linka, wr\u00f3\u0107 tutaj, aby doko\u0144czy\u0107 instalacj\u0119 integracji.", "title": "Uwierzytelnianie wielosk\u0142adnikowe SimpliSafe" @@ -28,6 +36,7 @@ "password": "Has\u0142o", "username": "Adres e-mail" }, + "description": "Pocz\u0105wszy od 2021 r. SimpliSafe przesz\u0142o na nowy mechanizm uwierzytelniania za po\u015brednictwem swojej aplikacji internetowej. Ze wzgl\u0119du na ograniczenia techniczne, na ko\u0144cu tego procesu znajduje si\u0119 r\u0119czny krok; upewnij si\u0119, \u017ce przed rozpocz\u0119ciem przeczyta\u0142e\u015b [dokumentacj\u0119](http://home-assistant.io/integrations/simplisafe#getting-an-authorization-code).\n\nKiedy b\u0119dziesz gotowy, kliknij [tutaj]( {url} ), aby otworzy\u0107 aplikacj\u0119 internetow\u0105 SimpliSafe i wprowadzi\u0107 swoje dane uwierzytelniaj\u0105ce. Po zako\u0144czeniu procesu wr\u00f3\u0107 tutaj i kliknij \"Zatwierd\u017a\".", "title": "Wprowad\u017a dane" } } diff --git a/homeassistant/components/simplisafe/translations/sl.json b/homeassistant/components/simplisafe/translations/sl.json index dffa3c4ccef..bbbe8034d06 100644 --- a/homeassistant/components/simplisafe/translations/sl.json +++ b/homeassistant/components/simplisafe/translations/sl.json @@ -1,12 +1,20 @@ { "config": { "abort": { - "already_configured": "Ta ra\u010dun SimpliSafe je \u017ee v uporabi." + "already_configured": "Ta ra\u010dun SimpliSafe je \u017ee v uporabi.", + "wrong_account": "Navedene uporabni\u0161ke poverilnice se ne ujemajo s tem ra\u010dunom SimpliSafe." }, "error": { "identifier_exists": "Ra\u010dun je \u017ee registriran" }, "step": { + "input_auth_code": { + "data": { + "auth_code": "Avtorizacijska koda" + }, + "description": "Vnesite avtorizacijsko kodo iz URL-ja spletne aplikacije SimpliSafe:", + "title": "Dokon\u010daj avtorizacijo" + }, "user": { "data": { "code": "Koda (uporablja se v uporabni\u0161kem vmesniku Home Assistant)", diff --git a/homeassistant/components/simplisafe/translations/tr.json b/homeassistant/components/simplisafe/translations/tr.json index 94506fb426b..6f8f07ccb9d 100644 --- a/homeassistant/components/simplisafe/translations/tr.json +++ b/homeassistant/components/simplisafe/translations/tr.json @@ -2,14 +2,23 @@ "config": { "abort": { "already_configured": "Bu SimpliSafe hesab\u0131 zaten kullan\u0131mda.", - "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu", + "wrong_account": "Sa\u011flanan kullan\u0131c\u0131 kimlik bilgileri bu SimpliSafe hesab\u0131yla e\u015fle\u015fmiyor." }, "error": { + "identifier_exists": "Hesap zaten kay\u0131tl\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "still_awaiting_mfa": "Hala MFA e-posta t\u0131klamas\u0131 bekleniyor", "unknown": "Beklenmeyen hata" }, "step": { + "input_auth_code": { + "data": { + "auth_code": "Yetkilendirme Kodu" + }, + "description": "SimpliSafe web uygulamas\u0131 URL'sinden yetkilendirme kodunu girin:", + "title": "Yetkilendirmeyi Bitir" + }, "mfa": { "description": "SimpliSafe'den bir ba\u011flant\u0131 i\u00e7in e-postan\u0131z\u0131 kontrol edin. Ba\u011flant\u0131y\u0131 do\u011frulad\u0131ktan sonra, entegrasyonun kurulumunu tamamlamak i\u00e7in buraya geri d\u00f6n\u00fcn.", "title": "SimpliSafe \u00c7ok Fakt\u00f6rl\u00fc Kimlik Do\u011frulama" @@ -23,9 +32,22 @@ }, "user": { "data": { + "code": "Kod (Home Assistant kullan\u0131c\u0131 aray\u00fcz\u00fcnde kullan\u0131l\u0131r)", "password": "Parola", "username": "E-posta adresi" - } + }, + "description": "2021'den itibaren SimpliSafe, web uygulamas\u0131 arac\u0131l\u0131\u011f\u0131yla yeni bir kimlik do\u011frulama mekanizmas\u0131na ge\u00e7ti. Teknik s\u0131n\u0131rlamalar nedeniyle bu i\u015flemin sonunda manuel bir ad\u0131m vard\u0131r; l\u00fctfen ba\u015flamadan \u00f6nce [belgeleri](http://home-assistant.io/integrations/simplisafe#getting-an-authorization-code) okudu\u011funuzdan emin olun. \n\n Haz\u0131r oldu\u011funuzda SimpliSafe web uygulamas\u0131n\u0131 a\u00e7mak ve kimlik bilgilerinizi girmek i\u00e7in [buray\u0131]( {url} \u0130\u015flem tamamland\u0131\u011f\u0131nda buraya d\u00f6n\u00fcn ve G\u00f6nder'e t\u0131klay\u0131n.", + "title": "Bilgilerinizi doldurun." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "code": "Kod (Home Assistant kullan\u0131c\u0131 aray\u00fcz\u00fcnde kullan\u0131l\u0131r)" + }, + "title": "SimpliSafe'i yap\u0131land\u0131r\u0131n" } } } diff --git a/homeassistant/components/simplisafe/typing.py b/homeassistant/components/simplisafe/typing.py new file mode 100644 index 00000000000..10f4fadc1c5 --- /dev/null +++ b/homeassistant/components/simplisafe/typing.py @@ -0,0 +1,7 @@ +"""Define typing helpers for SimpliSafe.""" +from typing import Union + +from simplipy.system.v2 import SystemV2 +from simplipy.system.v3 import SystemV3 + +SystemType = Union[SystemV2, SystemV3] diff --git a/homeassistant/components/sky_hub/device_tracker.py b/homeassistant/components/sky_hub/device_tracker.py index fda0b5e3774..8333e8e0cda 100644 --- a/homeassistant/components/sky_hub/device_tracker.py +++ b/homeassistant/components/sky_hub/device_tracker.py @@ -68,9 +68,7 @@ class SkyHubDeviceScanner(DeviceScanner): """Ensure the information from the Sky Hub is up to date.""" _LOGGER.debug("Scanning") - data = await self._hub.async_get_skyhub_data() - - if not data: + if not (data := await self._hub.async_get_skyhub_data()): return self.last_results = data diff --git a/homeassistant/components/sma/__init__.py b/homeassistant/components/sma/__init__.py index 1a48bee7797..06be21d7ac6 100644 --- a/homeassistant/components/sma/__init__.py +++ b/homeassistant/components/sma/__init__.py @@ -20,6 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -143,7 +144,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: # Get updated device info - device_info = await sma.device_info() + sma_device_info = await sma.device_info() # Get all device sensors sensor_def = await sma.get_sensors() except ( @@ -152,6 +153,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) as exc: raise ConfigEntryNotReady from exc + # Create DeviceInfo object from sma_device_info + device_info = DeviceInfo( + configuration_url=url, + identifiers={(DOMAIN, entry.unique_id)}, + manufacturer=sma_device_info["manufacturer"], + model=sma_device_info["type"], + name=sma_device_info["name"], + sw_version=sma_device_info["sw_version"], + ) + # Parse legacy options if initial setup was done from yaml if entry.source == SOURCE_IMPORT: config_sensors = _parse_legacy_options(entry, sensor_def) diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 13e29c8227c..4b48ce6aef1 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.7"], + "requirements": ["pysma==0.6.9"], "codeowners": ["@kellerza", "@rklomp"], "iot_class": "local_polling" } diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 853edee823c..7fa84f25bd2 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -156,7 +156,7 @@ class SMAsensor(CoordinatorEntity, SensorEntity): self, coordinator: DataUpdateCoordinator, config_entry_unique_id: str, - device_info: dict[str, Any], + device_info: DeviceInfo, pysma_sensor: pysma.sensor.Sensor, ) -> None: """Initialize the sensor.""" @@ -164,7 +164,7 @@ class SMAsensor(CoordinatorEntity, SensorEntity): self._sensor = pysma_sensor self._enabled_default = self._sensor.enabled self._config_entry_unique_id = config_entry_unique_id - self._device_info = device_info + self._attr_device_info = device_info if self.native_unit_of_measurement == ENERGY_KILO_WATT_HOUR: self._attr_state_class = STATE_CLASS_TOTAL_INCREASING @@ -199,20 +199,6 @@ class SMAsensor(CoordinatorEntity, SensorEntity): f"{self._config_entry_unique_id}-{self._sensor.key}_{self._sensor.key_idx}" ) - @property - def device_info(self) -> DeviceInfo | None: - """Return the device information.""" - if not self._device_info: - return None - - return DeviceInfo( - identifiers={(DOMAIN, self._config_entry_unique_id)}, - manufacturer=self._device_info["manufacturer"], - model=self._device_info["type"], - name=self._device_info["name"], - sw_version=self._device_info["sw_version"], - ) - @property def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added to the entity registry.""" diff --git a/homeassistant/components/sma/translations/ja.json b/homeassistant/components/sma/translations/ja.json new file mode 100644 index 00000000000..ff26a6caaf5 --- /dev/null +++ b/homeassistant/components/sma/translations/ja.json @@ -0,0 +1,27 @@ +{ + "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" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "cannot_retrieve_device_info": "\u63a5\u7d9a\u306b\u6210\u529f\u3057\u307e\u3057\u305f\u304c\u3001\u30c7\u30d0\u30a4\u30b9\u60c5\u5831\u3092\u53d6\u5f97\u3067\u304d\u307e\u305b\u3093", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "group": "\u30b0\u30eb\u30fc\u30d7", + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "ssl": "SSL\u8a3c\u660e\u66f8\u3092\u4f7f\u7528\u3059\u308b", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + }, + "description": "SMA\u30c7\u30d0\u30a4\u30b9\u306e\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "SMA Solar\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sma/translations/ru.json b/homeassistant/components/sma/translations/ru.json index ab1b7635bc3..c91f79c0fb7 100644 --- a/homeassistant/components/sma/translations/ru.json +++ b/homeassistant/components/sma/translations/ru.json @@ -19,7 +19,7 @@ "ssl": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 SSL", "verify_ssl": "\u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0442\u044c \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 SSL" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c SMA.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c SMA.", "title": "SMA Solar" } } diff --git a/homeassistant/components/sma/translations/tr.json b/homeassistant/components/sma/translations/tr.json new file mode 100644 index 00000000000..dec1abfeaac --- /dev/null +++ b/homeassistant/components/sma/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" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "cannot_retrieve_device_info": "Ba\u015far\u0131yla ba\u011fland\u0131, ancak cihaz bilgileri al\u0131namad\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "group": "Grup", + "host": "Ana bilgisayar", + "password": "Parola", + "ssl": "SSL sertifikas\u0131 kullan\u0131r", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" + }, + "description": "SMA cihaz bilgilerinizi girin.", + "title": "SMA Solar'\u0131 kurun" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smappee/config_flow.py b/homeassistant/components/smappee/config_flow.py index b13e540bae3..e57071b4938 100644 --- a/homeassistant/components/smappee/config_flow.py +++ b/homeassistant/components/smappee/config_flow.py @@ -4,12 +4,13 @@ import logging from pysmappee import helper, mqtt import voluptuous as vol +from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow from . import api from .const import ( - CONF_HOSTNAME, CONF_SERIALNUMBER, DOMAIN, ENV_CLOUD, @@ -36,14 +37,16 @@ class SmappeeFlowHandler( """Return logger.""" return logging.getLogger(__name__) - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle zeroconf discovery.""" - if not discovery_info[CONF_HOSTNAME].startswith(SUPPORTED_LOCAL_DEVICES): + if not discovery_info.hostname.startswith(SUPPORTED_LOCAL_DEVICES): return self.async_abort(reason="invalid_mdns") - serial_number = ( - discovery_info[CONF_HOSTNAME].replace(".local.", "").replace("Smappee", "") + serial_number = discovery_info.hostname.replace(".local.", "").replace( + "Smappee", "" ) # Check if already configured (local) @@ -56,7 +59,7 @@ class SmappeeFlowHandler( self.context.update( { - CONF_IP_ADDRESS: discovery_info["host"], + CONF_IP_ADDRESS: discovery_info.host, CONF_SERIALNUMBER: serial_number, "title_placeholders": {"name": serial_number}, } diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index 276248fd6ae..595cc4da02d 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -250,6 +250,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): description=SmappeeSensorEntityDescription( key="load", name=measurement.name, + native_unit_of_measurement=POWER_WATT, sensor_id=measurement_id, device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, @@ -319,7 +320,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ), ) for actuator_id, actuator in service_location.actuators.items() - if actuator.type == "SWITCH" + if actuator.type == "SWITCH" and not service_location.local_polling ] ) diff --git a/homeassistant/components/smappee/translations/bg.json b/homeassistant/components/smappee/translations/bg.json new file mode 100644 index 00000000000..9173bdc0bc7 --- /dev/null +++ b/homeassistant/components/smappee/translations/bg.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "missing_configuration": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044a\u0442 \u043d\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d. \u041c\u043e\u043b\u044f, \u0441\u043b\u0435\u0434\u0432\u0430\u0439\u0442\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430." + }, + "flow_title": "{name}", + "step": { + "local": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + }, + "pick_implementation": { + "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u043c\u0435\u0442\u043e\u0434 \u0437\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/smappee/translations/ja.json b/homeassistant/components/smappee/translations/ja.json new file mode 100644 index 00000000000..9dff009e3dd --- /dev/null +++ b/homeassistant/components/smappee/translations/ja.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured_device": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_configured_local_device": "\u30ed\u30fc\u30ab\u30eb\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u30af\u30e9\u30a6\u30c9\u30c7\u30d0\u30a4\u30b9\u3092\u8a2d\u5b9a\u3059\u308b\u524d\u306b\u3001\u307e\u305a\u305d\u308c\u3089\u3092\u524a\u9664\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "authorize_url_timeout": "\u8a8d\u8a3cURL\u306e\u751f\u6210\u304c\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_mdns": "Smappee\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u306a\u3044\u30c7\u30d0\u30a4\u30b9\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})" + }, + "flow_title": "{name}", + "step": { + "environment": { + "data": { + "environment": "\u74b0\u5883" + }, + "description": "Smappee\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3001Home Assistant\u3068\u9023\u643a\u3059\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002" + }, + "local": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "\u30db\u30b9\u30c8\u3092\u5165\u529b\u3057\u3066\u3001Smappee local\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u958b\u59cb\u3057\u307e\u3059" + }, + "pick_implementation": { + "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" + }, + "zeroconf_confirm": { + "description": "`{serialnumber}` \u306eSmappee device\u3092Home Assistant\u306b\u8ffd\u52a0\u3057\u307e\u3059\u304b\uff1f", + "title": "Smappee device\u3092\u767a\u898b" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smappee/translations/ru.json b/homeassistant/components/smappee/translations/ru.json index c6191daab0d..284b43cf4fc 100644 --- a/homeassistant/components/smappee/translations/ru.json +++ b/homeassistant/components/smappee/translations/ru.json @@ -15,7 +15,7 @@ "data": { "environment": "\u041e\u043a\u0440\u0443\u0436\u0435\u043d\u0438\u0435" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Smappee." + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Smappee." }, "local": { "data": { diff --git a/homeassistant/components/smappee/translations/tr.json b/homeassistant/components/smappee/translations/tr.json index 4ba8a4da9a6..e75ea381bd1 100644 --- a/homeassistant/components/smappee/translations/tr.json +++ b/homeassistant/components/smappee/translations/tr.json @@ -3,22 +3,31 @@ "abort": { "already_configured_device": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "already_configured_local_device": "Yerel ayg\u0131t (lar) zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. L\u00fctfen bir bulut cihaz\u0131n\u0131 yap\u0131land\u0131rmadan \u00f6nce bunlar\u0131 kald\u0131r\u0131n.", + "authorize_url_timeout": "Yetkilendirme URL'si olu\u015ftururken zaman a\u015f\u0131m\u0131.", "cannot_connect": "Ba\u011flanma hatas\u0131", - "invalid_mdns": "Smappee entegrasyonu i\u00e7in desteklenmeyen cihaz." + "invalid_mdns": "Smappee entegrasyonu i\u00e7in desteklenmeyen cihaz.", + "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})" }, - "flow_title": "Smappee: {name}", + "flow_title": "{name}", "step": { "environment": { "data": { "environment": "\u00c7evre" - } + }, + "description": "Smappee'nizi Home Assistant ile entegre olacak \u015fekilde ayarlay\u0131n." }, "local": { "data": { - "host": "Ana Bilgisayar" - } + "host": "Ana bilgisayar" + }, + "description": "Smappee yerel entegrasyonunu ba\u015flatmak i\u00e7in ana bilgisayar\u0131 girin" + }, + "pick_implementation": { + "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7" }, "zeroconf_confirm": { + "description": "Seri numaras\u0131 ` {serialnumber} ` olan Smappee cihaz\u0131n\u0131 Home Assistant'a eklemek istiyor musunuz?", "title": "Smappee cihaz\u0131 bulundu" } } diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index ed5c84f0bce..e897048660f 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -92,7 +92,6 @@ class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): if self.coordinator.last_update_success: return - last_state = await self.async_get_last_state() - if last_state: + if last_state := await self.async_get_last_state(): self._state = last_state.state self._available = True diff --git a/homeassistant/components/smart_meter_texas/translations/bg.json b/homeassistant/components/smart_meter_texas/translations/bg.json index 2ac8a444100..be758888427 100644 --- a/homeassistant/components/smart_meter_texas/translations/bg.json +++ b/homeassistant/components/smart_meter_texas/translations/bg.json @@ -1,7 +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\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \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": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/smart_meter_texas/translations/ja.json b/homeassistant/components/smart_meter_texas/translations/ja.json new file mode 100644 index 00000000000..323de60808b --- /dev/null +++ b/homeassistant/components/smart_meter_texas/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": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/bg.json b/homeassistant/components/smarthab/translations/bg.json new file mode 100644 index 00000000000..75022ed3005 --- /dev/null +++ b/homeassistant/components/smarthab/translations/bg.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "Email", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/ja.json b/homeassistant/components/smarthab/translations/ja.json new file mode 100644 index 00000000000..826692925d7 --- /dev/null +++ b/homeassistant/components/smarthab/translations/ja.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "service": "SmartHab\u306b\u5230\u9054\u3057\u3088\u3046\u3068\u3057\u3066\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002\u30b5\u30fc\u30d3\u30b9\u304c\u30c0\u30a6\u30f3\u3057\u3066\u3044\u308b\u53ef\u80fd\u6027\u304c\u3042\u308a\u307e\u3059\u3002\u63a5\u7d9a\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "\u6280\u8853\u7684\u306a\u7406\u7531\u304b\u3089\u3001Home Assistant\u306e\u8a2d\u5b9a\u306b\u56fa\u6709\u306e\u30bb\u30ab\u30f3\u30c0\u30ea\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u4f7f\u7528\u3057\u3066\u304f\u3060\u3055\u3044\u3002SmartHab\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u304b\u3089\u4f5c\u6210\u3067\u304d\u307e\u3059\u3002", + "title": "SmartHab\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/tr.json b/homeassistant/components/smarthab/translations/tr.json index 98da6384f8d..699967ce8ee 100644 --- a/homeassistant/components/smarthab/translations/tr.json +++ b/homeassistant/components/smarthab/translations/tr.json @@ -2,6 +2,7 @@ "config": { "error": { "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "service": "SmartHab'a ula\u015fmaya \u00e7al\u0131\u015f\u0131rken hata olu\u015ftu. Servis kapal\u0131 olabilir. Ba\u011flant\u0131n\u0131z\u0131 kontrol edin.", "unknown": "Beklenmeyen hata" }, "step": { @@ -10,6 +11,7 @@ "email": "E-posta", "password": "Parola" }, + "description": "Teknik nedenlerle, Ev Asistan\u0131 kurulumunuza \u00f6zel ikincil bir hesap kulland\u0131\u011f\u0131n\u0131zdan emin olun. SmartHab uygulamas\u0131ndan bir tane olu\u015fturabilirsiniz.", "title": "SmartHab'\u0131 kurun" } } diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index eb3aa9cb0f0..3f10758076f 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -21,7 +21,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType @@ -428,14 +428,15 @@ class SmartThingsEntity(Entity): self._dispatcher_remove() @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Get attributes about the device.""" - return { - "identifiers": {(DOMAIN, self._device.device_id)}, - "name": self._device.label, - "model": self._device.device_type_name, - "manufacturer": "Unavailable", - } + return DeviceInfo( + configuration_url="https://account.smartthings.com", + identifiers={(DOMAIN, self._device.device_id)}, + manufacturer="Unavailable", + model=self._device.device_type_name, + name=self._device.label, + ) @property def name(self) -> str: diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index eab840bd629..fa749e07dfb 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -38,6 +38,7 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, VOLUME_CUBIC_METERS, ) +from homeassistant.util import dt as dt_util from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN @@ -656,7 +657,12 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): @property def native_value(self): """Return the state of the sensor.""" - return self._device.status.attributes[self._attribute].value + value = self._device.status.attributes[self._attribute].value + + if self._device_class != DEVICE_CLASS_TIMESTAMP: + return value + + return dt_util.parse_datetime(value) @property def device_class(self): diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 2086d564753..8feb5f512d6 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -211,8 +211,7 @@ async def setup_smartapp_endpoint(hass: HomeAssistant): # Get/create config to store a unique id for this hass instance. store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) - config = await store.async_load() - if not config: + if not (config := await store.async_load()): # Create config config = { CONF_INSTANCE_ID: str(uuid4()), diff --git a/homeassistant/components/smartthings/translations/ja.json b/homeassistant/components/smartthings/translations/ja.json new file mode 100644 index 00000000000..4d1b8a15cfc --- /dev/null +++ b/homeassistant/components/smartthings/translations/ja.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "invalid_webhook_url": "SmartThings\u304b\u3089\u66f4\u65b0\u3092\u53d7\u4fe1\u3059\u308b\u3088\u3046\u306bHome Assistant\u304c\u6b63\u3057\u304f\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002 Webhook\u306eURL\u304c\u7121\u52b9\u3067\u3059:\n> {webhook_url}\n\n[\u8aac\u660e\u66f8]({component_url}) \u306b\u5f93\u3063\u3066\u8a2d\u5b9a\u3092\u66f4\u65b0\u3057\u3001Home Assistant\u3092\u518d\u8d77\u52d5\u3057\u3066\u304b\u3089\u3082\u3046\u4e00\u5ea6\u8a66\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "no_available_locations": "Home Assistant\u306b\u8a2d\u5b9a\u3067\u304d\u308bSmartThings\u306e\u5834\u6240\u304c\u3042\u308a\u307e\u305b\u3093\u3002" + }, + "error": { + "app_setup_error": "SmartApp\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u304c\u3067\u304d\u307e\u305b\u3093\u3002\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", + "token_forbidden": "\u30c8\u30fc\u30af\u30f3\u306b\u5fc5\u8981\u306aOAuth\u30b9\u30b3\u30fc\u30d7(OAuth scopes)\u304c\u3042\u308a\u307e\u305b\u3093\u3002", + "token_invalid_format": "\u30c8\u30fc\u30af\u30f3\u306f\u3001UID/GUID\u5f62\u5f0f\u3067\u3042\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059", + "token_unauthorized": "\u30c8\u30fc\u30af\u30f3\u304c\u7121\u52b9\u3001\u3082\u3057\u304f\u306f\u8a8d\u8a3c\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002", + "webhook_error": "SmartThings\u304cWebhook URL\u3092\u691c\u8a3c\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002Webhook URL\u304c\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u304b\u3089\u30a2\u30af\u30bb\u30b9\u53ef\u80fd\u3067\u3042\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u3001\u3082\u3046\u4e00\u5ea6\u8a66\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "authorize": { + "title": "Home Assistant\u3092\u8a8d\u8a3c\u3059\u308b" + }, + "pat": { + "data": { + "access_token": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3" + }, + "title": "\u30d1\u30fc\u30bd\u30ca\u30eb \u30a2\u30af\u30bb\u30b9 \u30c8\u30fc\u30af\u30f3\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "select_location": { + "data": { + "location_id": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3" + }, + "description": "Home Assistant\u306b\u8ffd\u52a0\u3057\u305f\u3044SmartThings\u306e\u5834\u6240\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044\u3002 \u3059\u308b\u3068\u3001\u65b0\u3057\u3044\u30a6\u30a3\u30f3\u30c9\u30a6\u304c\u958b\u304f\u306e\u3067\u30ed\u30b0\u30a4\u30f3\u3057\u3066\u3001\u9078\u629e\u3057\u305f\u5834\u6240\u3078\u306eHome Assistant\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3092\u627f\u8a8d\u3059\u308b\u3088\u3046\u6c42\u3081\u3089\u308c\u307e\u3059\u3002", + "title": "\u5834\u6240\u3092\u9078\u629e" + }, + "user": { + "description": "SmartThings\u306f\u3001Home Assistant\u306b\u30d7\u30c3\u30b7\u30e5\u30a2\u30c3\u30d7\u30c7\u30fc\u30c8\u3092\u9001\u4fe1\u3059\u308b\u3088\u3046\u306b\u8a2d\u5b9a\u3055\u308c\u307e\u3059:\n> {webhook_url}\n\n\u3053\u308c\u304c\u6b63\u3057\u304f\u306a\u3044\u5834\u5408\u306f\u3001\u8a2d\u5b9a\u3092\u66f4\u65b0\u3057\u3001Home Assistant\u3092\u518d\u8d77\u52d5\u3057\u3066\u304b\u3089\u518d\u5ea6\u8a66\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30b3\u30fc\u30eb\u30d0\u30c3\u30afURL\u306e\u78ba\u8a8d" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/translations/tr.json b/homeassistant/components/smartthings/translations/tr.json index 5e7463c1c74..83293cc5a03 100644 --- a/homeassistant/components/smartthings/translations/tr.json +++ b/homeassistant/components/smartthings/translations/tr.json @@ -1,16 +1,37 @@ { "config": { + "abort": { + "invalid_webhook_url": "Home Assistant, SmartThings'ten g\u00fcncellemeleri almak i\u00e7in do\u011fru \u015fekilde yap\u0131land\u0131r\u0131lmam\u0131\u015f. Webhook URL'si ge\u00e7ersiz:\n > {webhook_url} \n\n L\u00fctfen yap\u0131land\u0131rman\u0131z\u0131 [talimatlara]( {component_url} ) g\u00f6re g\u00fcncelleyin, Home Assistant'\u0131 yeniden ba\u015flat\u0131n ve tekrar deneyin.", + "no_available_locations": "Home Assistant'ta kurulacak kullan\u0131labilir SmartThings Locations yok." + }, "error": { + "app_setup_error": "SmartApp kurulamad\u0131. L\u00fctfen tekrar deneyin.", + "token_forbidden": "Anahtar, gerekli OAuth kapsam\u0131na sahip de\u011fil.", + "token_invalid_format": "Anahtar UID/GUID bi\u00e7iminde olmal\u0131d\u0131r", + "token_unauthorized": "Anahtar art\u0131k ge\u00e7ersiz veya yetkilendirilmemi\u015f.", "webhook_error": "SmartThings, webhook URL'sini do\u011frulayamad\u0131. L\u00fctfen webhook URL'sinin internetten eri\u015filebilir oldu\u011fundan emin olun ve tekrar deneyin." }, "step": { + "authorize": { + "title": "Home Asistan\u0131'n\u0131 Yetkilendir" + }, "pat": { "data": { - "access_token": "Eri\u015fim Belirteci" - } + "access_token": "Eri\u015fim Anahtar\u0131" + }, + "description": "L\u00fctfen [talimatlar]( {component_url} ) daki gibi olu\u015fturulmu\u015f bir SmartThings [Ki\u015fisel Eri\u015fim Anahtar\u0131]( {token_url} } ) girin. Bu, SmartThings hesab\u0131n\u0131zda Home Assistant entegrasyonunu olu\u015fturmak i\u00e7in kullan\u0131lacakt\u0131r.", + "title": "Ki\u015fisel Eri\u015fim Anahtar\u0131 Girin" }, "select_location": { + "data": { + "location_id": "Konum" + }, + "description": "L\u00fctfen Home Assistant'a eklemek istedi\u011finiz SmartThings Konumunu se\u00e7in. Ard\u0131ndan yeni bir pencere a\u00e7aca\u011f\u0131z ve sizden oturum a\u00e7man\u0131z\u0131 ve Home Assistant entegrasyonunun se\u00e7ilen konuma y\u00fcklenmesine izin vermenizi isteyece\u011fiz.", "title": "Konum Se\u00e7in" + }, + "user": { + "description": "SmartThings, \u015fu adreste Home Assistant'a an\u0131nda iletme g\u00fcncellemeleri g\u00f6nderecek \u015fekilde yap\u0131land\u0131r\u0131lacakt\u0131r:\n > {webhook_url} \n\n Bu do\u011fru de\u011filse, l\u00fctfen yap\u0131land\u0131rman\u0131z\u0131 g\u00fcncelleyin, Home Assistant'\u0131 yeniden ba\u015flat\u0131n ve tekrar deneyin.", + "title": "Geri \u00c7a\u011f\u0131rma URL'sini Onayla" } } } diff --git a/homeassistant/components/smarttub/controller.py b/homeassistant/components/smarttub/controller.py index adb7f3bf720..e62819f122c 100644 --- a/homeassistant/components/smarttub/controller.py +++ b/homeassistant/components/smarttub/controller.py @@ -10,6 +10,7 @@ from smarttub import APIError, LoginFailed, SmartTub from smarttub.api import Account from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -75,7 +76,7 @@ class SmartTubController: await self.coordinator.async_refresh() - await self.async_register_devices(entry) + self.async_register_devices(entry) return True @@ -107,9 +108,10 @@ class SmartTubController: ATTR_ERRORS: errors, } - async def async_register_devices(self, entry): + @callback + def async_register_devices(self, entry): """Register devices with the device registry for all spas.""" - device_registry = await dr.async_get_registry(self._hass) + device_registry = dr.async_get(self._hass) for spa in self.spas: device_registry.async_get_or_create( config_entry_id=entry.entry_id, diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index 713f2a7f7a1..5972d755e11 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/smarttub", "dependencies": [], "codeowners": ["@mdz"], - "requirements": ["python-smarttub==0.0.27"], + "requirements": ["python-smarttub==0.0.28"], "quality_scale": "platinum", "iot_class": "cloud_polling" } diff --git a/homeassistant/components/smarttub/translations/ja.json b/homeassistant/components/smarttub/translations/ja.json new file mode 100644 index 00000000000..dfe2ef1ab26 --- /dev/null +++ b/homeassistant/components/smarttub/translations/ja.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "reauth_confirm": { + "description": "SmartTub\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u306f\u3001\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u518d\u8a8d\u8a3c\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "SmartTub\u306e\u30e1\u30fc\u30eb\u30a2\u30c9\u30ec\u30b9\u3068\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u30ed\u30b0\u30a4\u30f3\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u30ed\u30b0\u30a4\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarttub/translations/tr.json b/homeassistant/components/smarttub/translations/tr.json new file mode 100644 index 00000000000..8917c5903df --- /dev/null +++ b/homeassistant/components/smarttub/translations/tr.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + }, + "step": { + "reauth_confirm": { + "description": "SmartTub entegrasyonunun hesab\u0131n\u0131z\u0131 yeniden do\u011frulamas\u0131 gerekiyor", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, + "user": { + "data": { + "email": "E-posta", + "password": "Parola" + }, + "description": "Oturum a\u00e7mak i\u00e7in SmartTub e-posta adresinizi ve \u015fifrenizi girin", + "title": "Oturum a\u00e7ma" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/translations/ja.json b/homeassistant/components/smhi/translations/ja.json new file mode 100644 index 00000000000..31cf832abc6 --- /dev/null +++ b/homeassistant/components/smhi/translations/ja.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "name_exists": "\u540d\u524d\u306f\u3059\u3067\u306b\u5b58\u5728\u3057\u307e\u3059", + "wrong_location": "\u6240\u5728\u5730 \u30b9\u30a6\u30a7\u30fc\u30c7\u30f3\u306e\u307f" + }, + "step": { + "user": { + "data": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "name": "\u540d\u524d" + }, + "title": "\u30b9\u30a6\u30a7\u30fc\u30c7\u30f3\u3067\u306e\u4f4d\u7f6e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/translations/tr.json b/homeassistant/components/smhi/translations/tr.json index bb50f1e2a8d..380a0da053e 100644 --- a/homeassistant/components/smhi/translations/tr.json +++ b/homeassistant/components/smhi/translations/tr.json @@ -8,8 +8,10 @@ "user": { "data": { "latitude": "Enlem", - "longitude": "Boylam" - } + "longitude": "Boylam", + "name": "Ad" + }, + "title": "\u0130sve\u00e7'teki konum" } } } diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index ec99f2a12ae..ac1ec53f67c 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -125,7 +125,7 @@ class SmhiWeather(WeatherEntity): async def async_update(self) -> None: """Refresh the forecast data from SMHI weather API.""" try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): self._forecasts = await self.get_weather_forecast() self._fail_count = 0 diff --git a/homeassistant/components/sms/translations/bg.json b/homeassistant/components/sms/translations/bg.json new file mode 100644 index 00000000000..6834b67bdd3 --- /dev/null +++ b/homeassistant/components/sms/translations/bg.json @@ -0,0 +1,19 @@ +{ + "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", + "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", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sms/translations/ja.json b/homeassistant/components/sms/translations/ja.json new file mode 100644 index 00000000000..248427ad9bf --- /dev/null +++ b/homeassistant/components/sms/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", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "device": "\u30c7\u30d0\u30a4\u30b9" + }, + "title": "\u30e2\u30c7\u30e0\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sms/translations/tr.json b/homeassistant/components/sms/translations/tr.json index 1ef2efb8121..0488390beed 100644 --- a/homeassistant/components/sms/translations/tr.json +++ b/homeassistant/components/sms/translations/tr.json @@ -10,6 +10,9 @@ }, "step": { "user": { + "data": { + "device": "Cihaz" + }, "title": "Modeme ba\u011flan\u0131n" } } diff --git a/homeassistant/components/snips/__init__.py b/homeassistant/components/snips/__init__.py index 256a4ae8719..4c65bd30a77 100644 --- a/homeassistant/components/snips/__init__.py +++ b/homeassistant/components/snips/__init__.py @@ -6,7 +6,6 @@ import logging import voluptuous as vol from homeassistant.components import mqtt -from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, intent DOMAIN = "snips" @@ -90,22 +89,17 @@ SERVICE_SCHEMA_FEEDBACK = vol.Schema( async def async_setup(hass, config): """Activate Snips component.""" - @callback - def async_set_feedback(site_ids, state): + async def async_set_feedback(site_ids, state): """Set Feedback sound state.""" site_ids = site_ids if site_ids else config[DOMAIN].get(CONF_SITE_IDS) topic = FEEDBACK_ON_TOPIC if state else FEEDBACK_OFF_TOPIC for site_id in site_ids: payload = json.dumps({"siteId": site_id}) - hass.components.mqtt.async_publish( - FEEDBACK_ON_TOPIC, "", qos=0, retain=False - ) - hass.components.mqtt.async_publish( - topic, payload, qos=int(state), retain=state - ) + await mqtt.async_publish(hass, FEEDBACK_ON_TOPIC, "", qos=0, retain=False) + await mqtt.async_publish(hass, topic, payload, qos=int(state), retain=state) if CONF_FEEDBACK in config[DOMAIN]: - async_set_feedback(None, config[DOMAIN][CONF_FEEDBACK]) + await async_set_feedback(None, config[DOMAIN][CONF_FEEDBACK]) async def message_received(msg): """Handle new messages on MQTT.""" @@ -153,7 +147,7 @@ async def async_setup(hass, config): notification["text"] = intent_response.speech["plain"]["speech"] _LOGGER.debug("send_response %s", json.dumps(notification)) - mqtt.async_publish( + await mqtt.async_publish( hass, "hermes/dialogueManager/endSession", json.dumps(notification) ) except intent.UnknownIntent: @@ -163,7 +157,7 @@ async def async_setup(hass, config): except intent.IntentError: _LOGGER.exception("Error while handling intent: %s", intent_type) - await hass.components.mqtt.async_subscribe(INTENT_TOPIC, message_received) + await mqtt.async_subscribe(hass, INTENT_TOPIC, message_received) async def snips_say(call): """Send a Snips notification message.""" @@ -172,7 +166,7 @@ async def async_setup(hass, config): "customData": call.data.get(ATTR_CUSTOM_DATA, ""), "init": {"type": "notification", "text": call.data.get(ATTR_TEXT)}, } - mqtt.async_publish( + await mqtt.async_publish( hass, "hermes/dialogueManager/startSession", json.dumps(notification) ) return @@ -189,18 +183,18 @@ async def async_setup(hass, config): "intentFilter": call.data.get(ATTR_INTENT_FILTER, []), }, } - mqtt.async_publish( + await mqtt.async_publish( hass, "hermes/dialogueManager/startSession", json.dumps(notification) ) return async def feedback_on(call): """Turn feedback sounds on.""" - async_set_feedback(call.data.get(ATTR_SITE_ID), True) + await async_set_feedback(call.data.get(ATTR_SITE_ID), True) async def feedback_off(call): """Turn feedback sounds off.""" - async_set_feedback(call.data.get(ATTR_SITE_ID), False) + await async_set_feedback(call.data.get(ATTR_SITE_ID), False) hass.services.async_register( DOMAIN, SERVICE_SAY, snips_say, schema=SERVICE_SCHEMA_SAY diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 30ec5cd41a3..aeaf3c72e0f 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -86,8 +86,7 @@ class SnmpScanner(DeviceScanner): if not self.success_init: return False - data = self.get_snmp_data() - if not data: + if not (data := self.get_snmp_data()): return False self.last_results = data diff --git a/homeassistant/components/solaredge/translations/ja.json b/homeassistant/components/solaredge/translations/ja.json new file mode 100644 index 00000000000..fa5d4d0c715 --- /dev/null +++ b/homeassistant/components/solaredge/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" + }, + "error": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "could_not_connect": "Solaredge API\u306b\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002", + "invalid_api_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc", + "site_not_active": "\u30b5\u30a4\u30c8\u304c\u30a2\u30af\u30c6\u30a3\u30d6\u3067\u306f\u3042\u308a\u307e\u305b\u3093" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "name": "\u3053\u306e\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u306e\u540d\u524d", + "site_id": "SolarEdge\u306e\u30b5\u30a4\u30c8ID" + }, + "title": "\u3053\u306e\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u306eAPI\u30d1\u30e9\u30e1\u30fc\u30bf\u30fc\u306e\u5b9a\u7fa9" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/translations/tr.json b/homeassistant/components/solaredge/translations/tr.json index b8159be58b4..248a546d2b2 100644 --- a/homeassistant/components/solaredge/translations/tr.json +++ b/homeassistant/components/solaredge/translations/tr.json @@ -12,8 +12,11 @@ "step": { "user": { "data": { - "api_key": "API Anahtar\u0131" - } + "api_key": "API Anahtar\u0131", + "name": "Bu kurulumun ad\u0131", + "site_id": "SolarEdge site kimli\u011fi" + }, + "title": "Bu kurulum i\u00e7in API parametrelerini tan\u0131mlay\u0131n" } } } diff --git a/homeassistant/components/solaredge/translations/zh-Hans.json b/homeassistant/components/solaredge/translations/zh-Hans.json index 7f5039e9f93..eeddc9b59c9 100644 --- a/homeassistant/components/solaredge/translations/zh-Hans.json +++ b/homeassistant/components/solaredge/translations/zh-Hans.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e" + "already_configured": "\u8bbe\u5907\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86" }, "error": { - "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "already_configured": "\u8bbe\u5907\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86", "could_not_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230 SolarEdge API", - "invalid_api_key": "\u65e0\u6548\u7684 API \u5bc6\u94a5", + "invalid_api_key": "API \u5bc6\u94a5\u65e0\u6548", "site_not_active": "\u672a\u6fc0\u6d3b" }, "step": { diff --git a/homeassistant/components/solarlog/translations/ja.json b/homeassistant/components/solarlog/translations/ja.json new file mode 100644 index 00000000000..c682ca60b48 --- /dev/null +++ b/homeassistant/components/solarlog/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": { + "already_configured": "\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" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "Solar-Log sensors\u306b\u4f7f\u7528\u3055\u308c\u308b\u30d7\u30ec\u30d5\u30a3\u30c3\u30af\u30b9" + }, + "title": "Solar-Log connection\u306e\u5b9a\u7fa9" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/solarlog/translations/tr.json b/homeassistant/components/solarlog/translations/tr.json index a11d3815eed..7f69014887b 100644 --- a/homeassistant/components/solarlog/translations/tr.json +++ b/homeassistant/components/solarlog/translations/tr.json @@ -10,8 +10,10 @@ "step": { "user": { "data": { - "host": "Ana Bilgisayar" - } + "host": "Ana Bilgisayar", + "name": "Solar-Log sens\u00f6rleriniz i\u00e7in kullan\u0131lacak \u00f6nek" + }, + "title": "Solar-Log ba\u011flant\u0131n\u0131z\u0131 tan\u0131mlay\u0131n" } } } diff --git a/homeassistant/components/soma/__init__.py b/homeassistant/components/soma/__init__.py index 77ecd3af96b..532e6204ad9 100644 --- a/homeassistant/components/soma/__init__.py +++ b/homeassistant/components/soma/__init__.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import API, DOMAIN, HOST, PORT @@ -89,13 +89,13 @@ class SomaEntity(Entity): return self.device["name"] @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device specific attributes. Implemented by platform classes. """ - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "manufacturer": "Wazombi Labs", - } + return DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + manufacturer="Wazombi Labs", + name=self.name, + ) diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py index 1005bf32f20..43ea60d372e 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -87,5 +87,5 @@ class SomaCover(SomaEntity, CoverEntity): ) self.is_available = False return - self.current_position = 100 - response["position"] + self.current_position = 100 - int(response["position"]) self.is_available = True diff --git a/homeassistant/components/soma/translations/bg.json b/homeassistant/components/soma/translations/bg.json index bfdebd385a6..a8361597baa 100644 --- a/homeassistant/components/soma/translations/bg.json +++ b/homeassistant/components/soma/translations/bg.json @@ -3,6 +3,7 @@ "abort": { "already_setup": "\u041c\u043e\u0436\u0435\u0442\u0435 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u0438\u043d Soma \u0430\u043a\u0430\u0443\u043d\u0442.", "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.", + "connection_error": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "missing_configuration": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044a\u0442 Soma \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." }, "create_entry": { diff --git a/homeassistant/components/soma/translations/fr.json b/homeassistant/components/soma/translations/fr.json index b0a287b1708..8531f839df0 100644 --- a/homeassistant/components/soma/translations/fr.json +++ b/homeassistant/components/soma/translations/fr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_setup": "Vous ne pouvez configurer qu'un seul compte Soma.", + "already_setup": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible.", "authorize_url_timeout": "D\u00e9lai d'attente g\u00e9n\u00e9rant l'autorisation de l'URL.", "connection_error": "Impossible de se connecter \u00e0 SOMA Connect.", "missing_configuration": "Le composant Soma n'est pas configur\u00e9. Veuillez suivre la documentation.", diff --git a/homeassistant/components/soma/translations/hu.json b/homeassistant/components/soma/translations/hu.json index e7ac9d8d71c..89194a7b204 100644 --- a/homeassistant/components/soma/translations/hu.json +++ b/homeassistant/components/soma/translations/hu.json @@ -3,7 +3,7 @@ "abort": { "already_setup": "Csak egy Soma-fi\u00f3k konfigur\u00e1lhat\u00f3.", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", - "connection_error": "Nem siker\u00fclt csatlakozni a SOMA Connecthez.", + "connection_error": "Nem siker\u00fclt csatlakozni.", "missing_configuration": "A Soma \u00f6sszetev\u0151 nincs konfigur\u00e1lva. K\u00e9rj\u00fck, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "result_error": "A SOMA Connect hiba\u00e1llapottal v\u00e1laszolt." }, diff --git a/homeassistant/components/soma/translations/id.json b/homeassistant/components/soma/translations/id.json index d512bd46797..367edae4d35 100644 --- a/homeassistant/components/soma/translations/id.json +++ b/homeassistant/components/soma/translations/id.json @@ -1,14 +1,14 @@ { "config": { "abort": { - "already_setup": "Anda hanya dapat mengonfigurasi satu akun Soma.", + "already_setup": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan.", "authorize_url_timeout": "Tenggang waktu pembuatan URL otorisasi habis.", - "connection_error": "Gagal menyambungkan ke SOMA Connect.", + "connection_error": "Gagal terhubung", "missing_configuration": "Komponen Soma tidak dikonfigurasi. Ikuti petunjuk dalam dokumentasi.", "result_error": "SOMA Connect merespons dengan status kesalahan." }, "create_entry": { - "default": "Berhasil mengautentikasi dengan Soma." + "default": "Berhasil diautentikasi" }, "step": { "user": { diff --git a/homeassistant/components/soma/translations/ja.json b/homeassistant/components/soma/translations/ja.json new file mode 100644 index 00000000000..026499458d8 --- /dev/null +++ b/homeassistant/components/soma/translations/ja.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_setup": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "authorize_url_timeout": "\u8a8d\u8a3cURL\u306e\u751f\u6210\u304c\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002", + "connection_error": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "missing_configuration": "SOMA Connect\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", + "result_error": "SOMAConnect\u306f\u30a8\u30e9\u30fc\u30b9\u30c6\u30fc\u30bf\u30b9\u3067\u5fdc\u7b54\u3057\u307e\u3057\u305f\u3002" + }, + "create_entry": { + "default": "\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "SOMA Connect\u306e\u63a5\u7d9a\u8a2d\u5b9a\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "SOMA Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/soma/translations/tr.json b/homeassistant/components/soma/translations/tr.json index 21a477c75a7..d7dfbff2889 100644 --- a/homeassistant/components/soma/translations/tr.json +++ b/homeassistant/components/soma/translations/tr.json @@ -1,11 +1,23 @@ { "config": { + "abort": { + "already_setup": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr.", + "authorize_url_timeout": "Yetkilendirme URL'si olu\u015ftururken zaman a\u015f\u0131m\u0131.", + "connection_error": "Ba\u011flanma hatas\u0131", + "missing_configuration": "Soma bile\u015feni yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin.", + "result_error": "SOMA Connect hata durumuyla yan\u0131t verdi." + }, + "create_entry": { + "default": "Ba\u015far\u0131yla do\u011fruland\u0131" + }, "step": { "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "port": "Port" - } + }, + "description": "L\u00fctfen SOMA Connect'inizin ba\u011flant\u0131 ayarlar\u0131n\u0131 girin.", + "title": "SOMA Ba\u011flant\u0131s\u0131" } } } diff --git a/homeassistant/components/somfy/__init__.py b/homeassistant/components/somfy/__init__.py index 5efd4bfaa3a..a6bd320edd6 100644 --- a/homeassistant/components/somfy/__init__.py +++ b/homeassistant/components/somfy/__init__.py @@ -97,7 +97,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) coordinator.update_interval = SCAN_INTERVAL_ALL_ASSUMED_STATE - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) hubs = [ device diff --git a/homeassistant/components/somfy/translations/ja.json b/homeassistant/components/somfy/translations/ja.json new file mode 100644 index 00000000000..3c25bd7bb8f --- /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\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "create_entry": { + "default": "\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f" + }, + "step": { + "pick_implementation": { + "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/tr.json b/homeassistant/components/somfy/translations/tr.json index a152eb19468..b3b645cd52d 100644 --- a/homeassistant/components/somfy/translations/tr.json +++ b/homeassistant/components/somfy/translations/tr.json @@ -1,7 +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_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index 79fbf028b16..768d12da45b 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -7,9 +7,10 @@ from somfy_mylink_synergy import SomfyMyLinkSynergy import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import format_mac from .const import ( @@ -58,18 +59,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.mac = None self.ip_address = None - async def async_step_dhcp(self, discovery_info): + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" - self._async_abort_entries_match({CONF_HOST: discovery_info[IP_ADDRESS]}) + self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) - formatted_mac = format_mac(discovery_info[MAC_ADDRESS]) + formatted_mac = format_mac(discovery_info.macaddress) await self.async_set_unique_id(format_mac(formatted_mac)) - self._abort_if_unique_id_configured( - updates={CONF_HOST: discovery_info[IP_ADDRESS]} - ) - self.host = discovery_info[HOSTNAME] + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + self.host = discovery_info.hostname self.mac = formatted_mac - self.ip_address = discovery_info[IP_ADDRESS] + self.ip_address = discovery_info.ip self.context["title_placeholders"] = {"ip": self.ip_address, "mac": self.mac} return await self.async_step_user() diff --git a/homeassistant/components/somfy_mylink/cover.py b/homeassistant/components/somfy_mylink/cover.py index 2725e2da9c7..b4eb847a5e0 100644 --- a/homeassistant/components/somfy_mylink/cover.py +++ b/homeassistant/components/somfy_mylink/cover.py @@ -8,6 +8,7 @@ from homeassistant.components.cover import ( CoverEntity, ) from homeassistant.const import STATE_CLOSED, STATE_OPEN +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.restore_state import RestoreEntity from .const import ( @@ -115,13 +116,13 @@ class SomfyShade(RestoreEntity, CoverEntity): return self._closed @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self._target_id)}, - "name": self._name, - "manufacturer": MANUFACTURER, - } + return DeviceInfo( + identifiers={(DOMAIN, self._target_id)}, + manufacturer=MANUFACTURER, + name=self._name, + ) async def async_close_cover(self, **kwargs): """Close the cover.""" diff --git a/homeassistant/components/somfy_mylink/translations/ja.json b/homeassistant/components/somfy_mylink/translations/ja.json new file mode 100644 index 00000000000..4482dd37db2 --- /dev/null +++ b/homeassistant/components/somfy_mylink/translations/ja.json @@ -0,0 +1,53 @@ +{ + "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{mac} ({ip})", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8", + "system_id": "\u30b7\u30b9\u30c6\u30e0ID" + }, + "description": "\u30b7\u30b9\u30c6\u30e0ID \u306f\u3001\u30af\u30e9\u30a6\u30c9\u4ee5\u5916\u306e\u30b5\u30fc\u30d3\u30b9\u3092\u9078\u629e\u3059\u308b\u3053\u3068\u306b\u3088\u308a\u3001\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e MyLink \u30a2\u30d7\u30ea\u3067\u53d6\u5f97\u3067\u304d\u307e\u3059\u3002" + } + } + }, + "options": { + "abort": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "entity_config": { + "data": { + "reverse": "\u30ab\u30d0\u30fc\u304c\u9006\u306b\u306a\u3063\u3066\u3044\u307e\u3059" + }, + "description": "{entity_id}} \u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a", + "title": "\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u306e\u8a2d\u5b9a" + }, + "init": { + "data": { + "default_reverse": "\u672a\u8a2d\u5b9a\u306e\u30ab\u30d0\u30fc\u306e\u30c7\u30d5\u30a9\u30eb\u30c8\u306e\u53cd\u8ee2\u72b6\u614b", + "entity_id": "\u7279\u5b9a\u306e\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002", + "target_id": "\u30ab\u30d0\u30fc\u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002" + }, + "title": "MyLink\u30aa\u30d7\u30b7\u30e7\u30f3\u306e\u8a2d\u5b9a" + }, + "target_config": { + "data": { + "reverse": "\u30ab\u30d0\u30fc\u304c\u9006\u306b\u306a\u3063\u3066\u3044\u307e\u3059" + }, + "description": "`{target_name}` \u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a\u3059\u308b", + "title": "MyLink\u30ab\u30d0\u30fc\u306e\u8a2d\u5b9a" + } + } + }, + "title": "Somfy MyLink" +} \ No newline at end of file diff --git a/homeassistant/components/somfy_mylink/translations/no.json b/homeassistant/components/somfy_mylink/translations/no.json index 2b629015f36..65c89866f65 100644 --- a/homeassistant/components/somfy_mylink/translations/no.json +++ b/homeassistant/components/somfy_mylink/translations/no.json @@ -30,12 +30,12 @@ "reverse": "Rullegardinet reverseres" }, "description": "Konfigurer alternativer for \"{entity_id}\"", - "title": "Konfigurer enhet" + "title": "Konfigurer entitet" }, "init": { "data": { "default_reverse": "Standard tilbakef\u00f8ringsstatus for ukonfigurerte rullegardiner", - "entity_id": "Konfigurer en bestemt enhet.", + "entity_id": "Konfigurer en bestemt entitet.", "target_id": "Konfigurer alternativer for et rullgardin" }, "title": "Konfigurere MyLink-alternativer" diff --git a/homeassistant/components/somfy_mylink/translations/pl.json b/homeassistant/components/somfy_mylink/translations/pl.json index 3da5c423e1a..c4c10de4f9d 100644 --- a/homeassistant/components/somfy_mylink/translations/pl.json +++ b/homeassistant/components/somfy_mylink/translations/pl.json @@ -30,7 +30,7 @@ "reverse": "Roleta/pokrywa jest odwr\u00f3cona" }, "description": "Konfiguracja opcji dla \"{entity_id}\"", - "title": "Konfigurowanie encji" + "title": "Konfiguracja encji" }, "init": { "data": { diff --git a/homeassistant/components/somfy_mylink/translations/tr.json b/homeassistant/components/somfy_mylink/translations/tr.json index 29530b65659..fb402493e3b 100644 --- a/homeassistant/components/somfy_mylink/translations/tr.json +++ b/homeassistant/components/somfy_mylink/translations/tr.json @@ -8,7 +8,7 @@ "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "unknown": "Beklenmeyen hata" }, - "flow_title": "Somfy MyLink {mac} ( {ip} )", + "flow_title": "{mac} ( {ip} )", "step": { "user": { "data": { @@ -44,7 +44,7 @@ "data": { "reverse": "Kapak ters \u00e7evrildi" }, - "description": "'{target_name}' i\u00e7in se\u00e7enekleri yap\u0131land\u0131r\u0131n", + "description": "{target_name} ` i\u00e7in se\u00e7enekleri yap\u0131land\u0131r\u0131n", "title": "MyLink Kapa\u011f\u0131n\u0131 Yap\u0131land\u0131r\u0131n" } } diff --git a/homeassistant/components/sonarr/entity.py b/homeassistant/components/sonarr/entity.py index 8f3f1188bac..1d0cb2ce6f3 100644 --- a/homeassistant/components/sonarr/entity.py +++ b/homeassistant/components/sonarr/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from sonarr import Sonarr +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DOMAIN @@ -38,6 +39,6 @@ class SonarrEntity(Entity): name="Activity Sensor", manufacturer="Sonarr", sw_version=self.sonarr.app.info.version, - entry_type="service", + entry_type=DeviceEntryType.SERVICE, configuration_url=configuration_url, ) diff --git a/homeassistant/components/sonarr/translations/bg.json b/homeassistant/components/sonarr/translations/bg.json index f370ff8a2fd..29dc5bfd9a9 100644 --- a/homeassistant/components/sonarr/translations/bg.json +++ b/homeassistant/components/sonarr/translations/bg.json @@ -1,14 +1,23 @@ { "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" + "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", + "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" + }, + "flow_title": "{name}", "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": { + "api_key": "API \u043a\u043b\u044e\u0447", + "host": "\u0425\u043e\u0441\u0442", "port": "\u041f\u043e\u0440\u0442" } } diff --git a/homeassistant/components/sonarr/translations/ja.json b/homeassistant/components/sonarr/translations/ja.json new file mode 100644 index 00000000000..64785eed5ec --- /dev/null +++ b/homeassistant/components/sonarr/translations/ja.json @@ -0,0 +1,40 @@ +{ + "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", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "flow_title": "{name}", + "step": { + "reauth_confirm": { + "description": "Sonarr\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306f\u3001\u30db\u30b9\u30c8\u3055\u308c\u3066\u3044\u308bSonarr API\u3067\u624b\u52d5\u3067\u518d\u8a8d\u8a3c\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059: {host}", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "base_path": "API\u3078\u306e\u30d1\u30b9", + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8", + "ssl": "SSL\u8a3c\u660e\u66f8\u3092\u4f7f\u7528\u3059\u308b", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "upcoming_days": "\u8868\u793a\u3059\u308b\u4eca\u5f8c\u306e\u65e5\u6570", + "wanted_max_items": "\u8868\u793a\u3057\u305f\u3044\u30a2\u30a4\u30c6\u30e0\u306e\u6700\u5927\u6570" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sonarr/translations/tr.json b/homeassistant/components/sonarr/translations/tr.json index eadf0100045..d1e961cb2b9 100644 --- a/homeassistant/components/sonarr/translations/tr.json +++ b/homeassistant/components/sonarr/translations/tr.json @@ -9,12 +9,30 @@ "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" }, + "flow_title": "{name}", "step": { + "reauth_confirm": { + "description": "Sonarr entegrasyonunun, \u015fu adreste bar\u0131nd\u0131r\u0131lan Sonarr API ile manuel olarak yeniden do\u011frulanmas\u0131 gerekir: {host}", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, "user": { "data": { "api_key": "API Anahtar\u0131", + "base_path": "API yolu", "host": "Ana Bilgisayar", - "port": "Port" + "port": "Port", + "ssl": "SSL sertifikas\u0131 kullan\u0131r", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "upcoming_days": "G\u00f6r\u00fcnt\u00fclenecek gelecek g\u00fcn say\u0131s\u0131", + "wanted_max_items": "G\u00f6r\u00fcnt\u00fclenecek maksimum istenen \u00f6\u011fe say\u0131s\u0131" } } } diff --git a/homeassistant/components/songpal/config_flow.py b/homeassistant/components/songpal/config_flow.py index 1475e51afb5..10737127e0b 100644 --- a/homeassistant/components/songpal/config_flow.py +++ b/homeassistant/components/songpal/config_flow.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult from .const import CONF_ENDPOINT, DOMAIN @@ -92,16 +93,16 @@ class SongpalConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data={CONF_NAME: self.conf.name, CONF_ENDPOINT: self.conf.endpoint}, ) - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered Songpal device.""" - await self.async_set_unique_id(discovery_info[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) self._abort_if_unique_id_configured() _LOGGER.debug("Discovered: %s", discovery_info) - friendly_name = discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME] - parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) - scalarweb_info = discovery_info["X_ScalarWebAPI_DeviceInfo"] + friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + parsed_url = urlparse(discovery_info.ssdp_location) + scalarweb_info = discovery_info.upnp["X_ScalarWebAPI_DeviceInfo"] endpoint = scalarweb_info["X_ScalarWebAPI_BaseURL"] service_types = scalarweb_info["X_ScalarWebAPI_ServiceList"][ "X_ScalarWebAPI_ServiceType" diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 1746d5ece0d..b13ca99cd5b 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -32,6 +32,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_platform, ) +from homeassistant.helpers.entity import DeviceInfo from .const import CONF_ENDPOINT, DOMAIN, SET_SOUND_SETTING @@ -205,16 +206,16 @@ class SongpalEntity(MediaPlayerEntity): return self._sysinfo.macAddr @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info.""" - return { - "connections": {(dr.CONNECTION_NETWORK_MAC, self._sysinfo.macAddr)}, - "identifiers": {(DOMAIN, self.unique_id)}, - "manufacturer": "Sony Corporation", - "name": self.name, - "sw_version": self._sysinfo.version, - "model": self._model, - } + return DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, self._sysinfo.macAddr)}, + identifiers={(DOMAIN, self.unique_id)}, + manufacturer="Sony Corporation", + model=self._model, + name=self.name, + sw_version=self._sysinfo.version, + ) @property def available(self): diff --git a/homeassistant/components/songpal/translations/ja.json b/homeassistant/components/songpal/translations/ja.json new file mode 100644 index 00000000000..bdc521fbb0d --- /dev/null +++ b/homeassistant/components/songpal/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", + "not_songpal_device": "Songpal\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u3042\u308a\u307e\u305b\u3093" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name} ({host})", + "step": { + "init": { + "description": "{name} ({host}) \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "endpoint": "\u30a8\u30f3\u30c9\u30dd\u30a4\u30f3\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/songpal/translations/tr.json b/homeassistant/components/songpal/translations/tr.json index ab90d4b1067..e1e9c0a4140 100644 --- a/homeassistant/components/songpal/translations/tr.json +++ b/homeassistant/components/songpal/translations/tr.json @@ -1,12 +1,17 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "not_songpal_device": "Songpal cihaz\u0131 de\u011fil" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, + "flow_title": "{name} ({host})", "step": { + "init": { + "description": "{name} ( {host} ) kurmak istiyor musunuz?" + }, "user": { "data": { "endpoint": "Biti\u015f noktas\u0131" diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 72e5a33ca28..32ae234434e 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -5,6 +5,7 @@ import asyncio from collections import OrderedDict import datetime from enum import Enum +from functools import partial import logging import socket from urllib.parse import urlparse @@ -22,17 +23,20 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOSTS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval, call_later from .alarms import SonosAlarms from .const import ( + AVAILABILITY_CHECK_INTERVAL, DATA_SONOS, DATA_SONOS_DISCOVERY_MANAGER, DISCOVERY_INTERVAL, DOMAIN, PLATFORMS, + SONOS_CHECK_ACTIVITY, SONOS_REBOOTED, - SONOS_SEEN, + SONOS_SPEAKER_ACTIVITY, UPNP_ST, ) from .favorites import SonosFavorites @@ -187,7 +191,7 @@ class SonosDiscoveryManager: async def _async_stop_event_listener(self, event: Event | None = None) -> None: await asyncio.gather( - *(speaker.async_unsubscribe() for speaker in self.data.discovered.values()) + *(speaker.async_offline() for speaker in self.data.discovered.values()) ) if events_asyncio.event_listener: await events_asyncio.event_listener.async_stop() @@ -212,7 +216,7 @@ class SonosDiscoveryManager: new_coordinator = coordinator(self.hass, soco.household_id) new_coordinator.setup(soco) coord_dict[soco.household_id] = new_coordinator - speaker.setup() + speaker.setup(self.entry) except (OSError, SoCoException): _LOGGER.warning("Failed to add SonosSpeaker using %s", soco, exc_info=True) @@ -228,16 +232,13 @@ class SonosDiscoveryManager: ), None, ) - - if known_uid: - dispatcher_send(self.hass, f"{SONOS_SEEN}-{known_uid}") - else: + if not known_uid: soco = self._create_soco(ip_addr, SoCoCreationSource.CONFIGURED) if soco and soco.is_visible: self._discovered_player(soco) - self.data.hosts_heartbeat = self.hass.helpers.event.call_later( - DISCOVERY_INTERVAL.total_seconds(), self._manual_hosts + self.data.hosts_heartbeat = call_later( + self.hass, DISCOVERY_INTERVAL.total_seconds(), self._manual_hosts ) def _discovered_ip(self, ip_address): @@ -261,21 +262,31 @@ class SonosDiscoveryManager: ): async_dispatcher_send(self.hass, f"{SONOS_REBOOTED}-{uid}", soco) else: - async_dispatcher_send(self.hass, f"{SONOS_SEEN}-{uid}") + async_dispatcher_send( + self.hass, f"{SONOS_SPEAKER_ACTIVITY}-{uid}", "discovery" + ) - async def _async_ssdp_discovered_player(self, info, change): + async def _async_ssdp_discovered_player( + self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange + ) -> None: if change == ssdp.SsdpChange.BYEBYE: return - uid = info.get(ssdp.ATTR_UPNP_UDN) + uid = info.upnp[ssdp.ATTR_UPNP_UDN] if not uid.startswith("uuid:RINCON_"): return uid = uid[5:] - discovered_ip = urlparse(info[ssdp.ATTR_SSDP_LOCATION]).hostname - boot_seqnum = info.get("X-RINCON-BOOTSEQ") + discovered_ip = urlparse(info.ssdp_location).hostname + boot_seqnum = info.ssdp_headers.get("X-RINCON-BOOTSEQ") self.async_discovered_player( - "SSDP", info, discovered_ip, uid, boot_seqnum, info.get("modelName"), None + "SSDP", + info, + discovered_ip, + uid, + boot_seqnum, + info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME), + None, ) @callback @@ -327,3 +338,15 @@ class SonosDiscoveryManager: self.hass, self._async_ssdp_discovered_player, {"st": UPNP_ST} ) ) + + self.entry.async_on_unload( + async_track_time_interval( + self.hass, + partial( + async_dispatcher_send, + self.hass, + SONOS_CHECK_ACTIVITY, + ), + AVAILABILITY_CHECK_INTERVAL, + ) + ) diff --git a/homeassistant/components/sonos/binary_sensor.py b/homeassistant/components/sonos/binary_sensor.py index 488f29a7be8..615ad24e655 100644 --- a/homeassistant/components/sonos/binary_sensor.py +++ b/homeassistant/components/sonos/binary_sensor.py @@ -33,21 +33,13 @@ class SonosPowerEntity(SonosEntity, BinarySensorEntity): """Representation of a Sonos power entity.""" _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + _attr_device_class = DEVICE_CLASS_BATTERY_CHARGING - @property - def unique_id(self) -> str: - """Return the unique ID of the sensor.""" - return f"{self.soco.uid}-power" - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{self.speaker.zone_name} Power" - - @property - def device_class(self) -> str: - """Return the entity's device class.""" - return DEVICE_CLASS_BATTERY_CHARGING + def __init__(self, speaker: SonosSpeaker) -> None: + """Initialize the power entity binary sensor.""" + super().__init__(speaker) + self._attr_unique_id = f"{self.soco.uid}-power" + self._attr_name = f"{self.speaker.zone_name} Power" async def _async_poll(self) -> None: """Poll the device for the current state.""" diff --git a/homeassistant/components/sonos/config_flow.py b/homeassistant/components/sonos/config_flow.py index 98e1194ebd0..e6d2bb337b8 100644 --- a/homeassistant/components/sonos/config_flow.py +++ b/homeassistant/components/sonos/config_flow.py @@ -1,12 +1,13 @@ """Config flow for SONOS.""" +import dataclasses + import soco from homeassistant import config_entries -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.components import zeroconf from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.config_entry_flow import DiscoveryFlowHandler -from homeassistant.helpers.typing import DiscoveryInfoType from .const import DATA_SONOS_DISCOVERY_MANAGER, DOMAIN from .helpers import hostname_to_uid @@ -26,16 +27,16 @@ class SonosDiscoveryFlowHandler(DiscoveryFlowHandler): super().__init__(DOMAIN, "Sonos", _async_has_devices) async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle a flow initialized by zeroconf.""" - hostname = discovery_info["hostname"] + hostname = discovery_info.hostname if hostname is None or not hostname.lower().startswith("sonos"): return self.async_abort(reason="not_sonos_device") await self.async_set_unique_id(self._domain, raise_on_progress=False) - host = discovery_info[CONF_HOST] - mdns_name = discovery_info[CONF_NAME] - properties = discovery_info["properties"] + host = discovery_info.host + mdns_name = discovery_info.name + properties = discovery_info.properties boot_seqnum = properties.get("bootseq") model = properties.get("model") uid = hostname_to_uid(hostname) @@ -43,7 +44,7 @@ class SonosDiscoveryFlowHandler(DiscoveryFlowHandler): discovery_manager.async_discovered_player( "Zeroconf", properties, host, uid, boot_seqnum, model, mdns_name ) - return await self.async_step_discovery(discovery_info) + return await self.async_step_discovery(dataclasses.asdict(discovery_info)) config_entries.HANDLERS.register(DOMAIN)(SonosDiscoveryFlowHandler) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index abd04652936..523ac9f561b 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -19,6 +19,7 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_PLAYLIST, MEDIA_TYPE_TRACK, ) +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -27,7 +28,13 @@ UPNP_ST = "urn:schemas-upnp-org:device:ZonePlayer:1" DOMAIN = "sonos" DATA_SONOS = "sonos_media_player" DATA_SONOS_DISCOVERY_MANAGER = "sonos_discovery_manager" -PLATFORMS = {BINARY_SENSOR_DOMAIN, MP_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN} +PLATFORMS = { + BINARY_SENSOR_DOMAIN, + MP_DOMAIN, + NUMBER_DOMAIN, + SENSOR_DOMAIN, + SWITCH_DOMAIN, +} SONOS_ARTIST = "artists" SONOS_ALBUM = "albums" @@ -135,26 +142,28 @@ PLAYABLE_MEDIA_TYPES = [ MEDIA_TYPE_TRACK, ] +SONOS_CHECK_ACTIVITY = "sonos_check_activity" SONOS_CREATE_ALARM = "sonos_create_alarm" +SONOS_CREATE_AUDIO_FORMAT_SENSOR = "sonos_create_audio_format_sensor" SONOS_CREATE_BATTERY = "sonos_create_battery" SONOS_CREATE_SWITCHES = "sonos_create_switches" +SONOS_CREATE_LEVELS = "sonos_create_levels" SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player" SONOS_ENTITY_CREATED = "sonos_entity_created" SONOS_POLL_UPDATE = "sonos_poll_update" SONOS_ALARMS_UPDATED = "sonos_alarms_updated" SONOS_FAVORITES_UPDATED = "sonos_favorites_updated" +SONOS_SPEAKER_ACTIVITY = "sonos_speaker_activity" SONOS_SPEAKER_ADDED = "sonos_speaker_added" SONOS_STATE_UPDATED = "sonos_state_updated" SONOS_REBOOTED = "sonos_rebooted" -SONOS_SEEN = "sonos_seen" SOURCE_LINEIN = "Line-in" SOURCE_TV = "TV" +AVAILABILITY_CHECK_INTERVAL = datetime.timedelta(minutes=1) +AVAILABILITY_TIMEOUT = AVAILABILITY_CHECK_INTERVAL.total_seconds() * 4.5 BATTERY_SCAN_INTERVAL = datetime.timedelta(minutes=15) SCAN_INTERVAL = datetime.timedelta(seconds=10) DISCOVERY_INTERVAL = datetime.timedelta(seconds=60) -SEEN_EXPIRE_TIME = 3.5 * DISCOVERY_INTERVAL SUBSCRIPTION_TIMEOUT = 1200 - -MDNS_SERVICE = "_sonos._tcp.local." diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 0579c4f5c9b..d8196ffdfa6 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -39,8 +39,6 @@ class SonosEntity(Entity): async def async_added_to_hass(self) -> None: """Handle common setup when added to hass.""" - await self.speaker.async_seen() - self.async_on_remove( async_dispatcher_connect( self.hass, diff --git a/homeassistant/components/sonos/exception.py b/homeassistant/components/sonos/exception.py index 3d5a1230bcb..d7f1a2e6a96 100644 --- a/homeassistant/components/sonos/exception.py +++ b/homeassistant/components/sonos/exception.py @@ -1,6 +1,11 @@ """Sonos specific exceptions.""" from homeassistant.components.media_player.errors import BrowseError +from homeassistant.exceptions import HomeAssistantError class UnknownMediaType(BrowseError): """Unknown media type.""" + + +class SpeakerUnavailable(HomeAssistantError): + """Speaker is unavailable.""" diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 490bcdefba5..74897a618ea 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -1,43 +1,71 @@ """Helper methods for common tasks.""" from __future__ import annotations -from collections.abc import Callable -import functools as ft import logging -from typing import Any +from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast from soco.exceptions import SoCoException, SoCoUPnPException from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import dispatcher_send + +from .const import SONOS_SPEAKER_ACTIVITY +from .exception import SpeakerUnavailable + +if TYPE_CHECKING: + from .entity import SonosEntity + from .speaker import SonosSpeaker UID_PREFIX = "RINCON_" UID_POSTFIX = "01400" +WrapFuncType = TypeVar("WrapFuncType", bound=Callable[..., Any]) + _LOGGER = logging.getLogger(__name__) -def soco_error(errorcodes: list[str] | None = None) -> Callable: +def soco_error( + errorcodes: list[str] | None = None, raise_on_err: bool = True +) -> Callable: """Filter out specified UPnP errors and raise exceptions for service calls.""" - def decorator(funct: Callable) -> Callable: + def decorator(funct: WrapFuncType) -> WrapFuncType: """Decorate functions.""" - @ft.wraps(funct) - def wrapper(*args: Any, **kwargs: Any) -> Any: + def wrapper(self: SonosSpeaker | SonosEntity, *args: Any, **kwargs: Any) -> Any: """Wrap for all soco UPnP exception.""" try: - return funct(*args, **kwargs) + result = funct(self, *args, **kwargs) + except SpeakerUnavailable: + return None except (OSError, SoCoException, SoCoUPnPException) as err: error_code = getattr(err, "error_code", None) - function = funct.__name__ + function = funct.__qualname__ if errorcodes and error_code in errorcodes: _LOGGER.debug( "Error code %s ignored in call to %s", error_code, function ) - return - raise HomeAssistantError(f"Error calling {function}: {err}") from err + return None - return wrapper + # Prefer the entity_id if available, zone name as a fallback + # Needed as SonosSpeaker instances are not entities + zone_name = getattr(self, "speaker", self).zone_name + target = getattr(self, "entity_id", zone_name) + message = f"Error calling {function} on {target}: {err}" + if raise_on_err: + raise HomeAssistantError(message) from err + + _LOGGER.warning(message) + return None + + dispatcher_send( + self.hass, + f"{SONOS_SPEAKER_ACTIVITY}-{self.soco.uid}", + funct.__qualname__, + ) + return result + + return cast(WrapFuncType, wrapper) return decorator diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 249a6d4cc00..1e31d2004b0 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.24.0"], + "requirements": ["soco==0.25.0"], "dependencies": ["ssdp"], "after_dependencies": ["plex", "zeroconf"], "zeroconf": ["_sonos._tcp.local."], diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 9f2bc829eac..90c33d7a4e6 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -108,7 +108,6 @@ SERVICE_RESTORE = "restore" SERVICE_SET_TIMER = "set_sleep_timer" SERVICE_CLEAR_TIMER = "clear_sleep_timer" SERVICE_UPDATE_ALARM = "update_alarm" -SERVICE_SET_OPTION = "set_option" SERVICE_PLAY_QUEUE = "play_queue" SERVICE_REMOVE_FROM_QUEUE = "remove_from_queue" @@ -120,8 +119,6 @@ ATTR_INCLUDE_LINKED_ZONES = "include_linked_zones" ATTR_MASTER = "master" ATTR_WITH_GROUP = "with_group" ATTR_QUEUE_POSITION = "queue_position" -ATTR_EQ_BASS = "bass_level" -ATTR_EQ_TREBLE = "treble_level" async def async_setup_entry( @@ -225,19 +222,6 @@ async def async_setup_entry( "set_alarm", ) - platform.async_register_entity_service( # type: ignore - SERVICE_SET_OPTION, - { - vol.Optional(ATTR_EQ_BASS): vol.All( - vol.Coerce(int), vol.Range(min=-10, max=10) - ), - vol.Optional(ATTR_EQ_TREBLE): vol.All( - vol.Coerce(int), vol.Range(min=-10, max=10) - ), - }, - "set_option", - ) - platform.async_register_entity_service( # type: ignore SERVICE_PLAY_QUEUE, {vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int}, @@ -254,25 +238,24 @@ async def async_setup_entry( class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Representation of a Sonos entity.""" + _attr_supported_features = SUPPORT_SONOS + _attr_media_content_type = MEDIA_TYPE_MUSIC + + def __init__(self, speaker: SonosSpeaker) -> None: + """Initialize the media player entity.""" + super().__init__(speaker) + self._attr_unique_id = self.soco.uid + self._attr_name = self.speaker.zone_name + @property def coordinator(self) -> SonosSpeaker: """Return the current coordinator SonosSpeaker.""" return self.speaker.coordinator or self.speaker - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self.soco.uid # type: ignore[no-any-return] - def __hash__(self) -> int: """Return a hash of self.""" return hash(self.unique_id) - @property - def name(self) -> str: - """Return the name of the entity.""" - return self.speaker.zone_name # type: ignore[no-any-return] - @property # type: ignore[misc] def state(self) -> str: """Return the state of the entity.""" @@ -338,11 +321,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Content id of current playing media.""" return self.media.uri - @property - def media_content_type(self) -> str: - """Content type of current playing media.""" - return MEDIA_TYPE_MUSIC - @property # type: ignore[misc] def media_duration(self) -> float | None: """Duration of current playing media in seconds.""" @@ -393,11 +371,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Name of the current input source.""" return self.media.source_name or None - @property # type: ignore[misc] - def supported_features(self) -> int: - """Flag media player features that are supported.""" - return SUPPORT_SONOS - @soco_error() def volume_up(self) -> None: """Volume up media player.""" @@ -605,19 +578,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): alarm.include_linked_zones = include_linked_zones alarm.save() - @soco_error() - def set_option( - self, - bass_level: int | None = None, - treble_level: int | None = None, - ) -> None: - """Modify playback options.""" - if bass_level is not None: - self.soco.bass = bass_level - - if treble_level is not None: - self.soco.treble = treble_level - @soco_error() def play_queue(self, queue_position: int = 0) -> None: """Start playing the queue.""" @@ -635,12 +595,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ATTR_SONOS_GROUP: self.speaker.sonos_group_entities } - if self.speaker.bass_level is not None: - attributes[ATTR_EQ_BASS] = self.speaker.bass_level - - if self.speaker.treble_level is not None: - attributes[ATTR_EQ_TREBLE] = self.speaker.treble_level - if self.media.queue_position is not None: attributes[ATTR_QUEUE_POSITION] = self.media.queue_position @@ -663,8 +617,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): media_content_id, MEDIA_TYPES_TO_SONOS[media_content_type], ) - image_url = getattr(item, "album_art_uri", None) - if image_url: + if image_url := getattr(item, "album_art_uri", None): result = await self._async_fetch_image(image_url) # type: ignore[no-untyped-call] return result # type: ignore diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py new file mode 100644 index 00000000000..2bcfe5cd5ec --- /dev/null +++ b/homeassistant/components/sonos/number.py @@ -0,0 +1,58 @@ +"""Entity representing a Sonos number control.""" +from __future__ import annotations + +from homeassistant.components.number import NumberEntity +from homeassistant.const import ENTITY_CATEGORY_CONFIG +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import SONOS_CREATE_LEVELS +from .entity import SonosEntity +from .helpers import soco_error +from .speaker import SonosSpeaker + +LEVEL_TYPES = ("bass", "treble") + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Sonos number platform from a config entry.""" + + @callback + def _async_create_entities(speaker: SonosSpeaker) -> None: + entities = [] + for level_type in LEVEL_TYPES: + entities.append(SonosLevelEntity(speaker, level_type)) + async_add_entities(entities) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SONOS_CREATE_LEVELS, _async_create_entities) + ) + + +class SonosLevelEntity(SonosEntity, NumberEntity): + """Representation of a Sonos level entity.""" + + _attr_entity_category = ENTITY_CATEGORY_CONFIG + _attr_min_value = -10 + _attr_max_value = 10 + + def __init__(self, speaker: SonosSpeaker, level_type: str) -> None: + """Initialize the level entity.""" + super().__init__(speaker) + self._attr_unique_id = f"{self.soco.uid}-{level_type}" + self._attr_name = f"{self.speaker.zone_name} {level_type.capitalize()}" + self.level_type = level_type + + async def _async_poll(self) -> None: + """Poll the value if subscriptions are not working.""" + # Handled by SonosSpeaker + + @soco_error() + def set_value(self, value: float) -> None: + """Set a new value.""" + setattr(self.soco, self.level_type, value) + + @property + def value(self) -> float: + """Return the current value.""" + return getattr(self.speaker, self.level_type) diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py index 599e5434fb4..62017f4d541 100644 --- a/homeassistant/components/sonos/sensor.py +++ b/homeassistant/components/sonos/sensor.py @@ -7,9 +7,10 @@ from homeassistant.const import ( ENTITY_CATEGORY_DIAGNOSTIC, PERCENTAGE, ) +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import SONOS_CREATE_BATTERY +from .const import SONOS_CREATE_AUDIO_FORMAT_SENSOR, SONOS_CREATE_BATTERY from .entity import SonosEntity from .speaker import SonosSpeaker @@ -17,39 +18,42 @@ from .speaker import SonosSpeaker async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Sonos from a config entry.""" - async def _async_create_entity(speaker: SonosSpeaker) -> None: + @callback + def _async_create_audio_format_entity( + speaker: SonosSpeaker, audio_format: str + ) -> None: + entity = SonosAudioInputFormatSensorEntity(speaker, audio_format) + async_add_entities([entity]) + + @callback + def _async_create_battery_sensor(speaker: SonosSpeaker) -> None: entity = SonosBatteryEntity(speaker) async_add_entities([entity]) config_entry.async_on_unload( - async_dispatcher_connect(hass, SONOS_CREATE_BATTERY, _async_create_entity) + async_dispatcher_connect( + hass, SONOS_CREATE_AUDIO_FORMAT_SENSOR, _async_create_audio_format_entity + ) + ) + config_entry.async_on_unload( + async_dispatcher_connect( + hass, SONOS_CREATE_BATTERY, _async_create_battery_sensor + ) ) class SonosBatteryEntity(SonosEntity, SensorEntity): """Representation of a Sonos Battery entity.""" + _attr_device_class = DEVICE_CLASS_BATTERY _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + _attr_native_unit_of_measurement = PERCENTAGE - @property - def unique_id(self) -> str: - """Return the unique ID of the sensor.""" - return f"{self.soco.uid}-battery" - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{self.speaker.zone_name} Battery" - - @property - def device_class(self) -> str: - """Return the entity's device class.""" - return DEVICE_CLASS_BATTERY - - @property - def native_unit_of_measurement(self) -> str: - """Get the unit of measurement.""" - return PERCENTAGE + def __init__(self, speaker: SonosSpeaker) -> None: + """Initialize the battery sensor.""" + super().__init__(speaker) + self._attr_unique_id = f"{self.soco.uid}-battery" + self._attr_name = f"{self.speaker.zone_name} Battery" async def _async_poll(self) -> None: """Poll the device for the current state.""" @@ -64,3 +68,25 @@ class SonosBatteryEntity(SonosEntity, SensorEntity): def available(self) -> bool: """Return whether this device is available.""" return self.speaker.available and self.speaker.power_source + + +class SonosAudioInputFormatSensorEntity(SonosEntity, SensorEntity): + """Representation of a Sonos audio import format sensor entity.""" + + _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + _attr_icon = "mdi:import" + _attr_should_poll = True + + def __init__(self, speaker: SonosSpeaker, audio_format: str) -> None: + """Initialize the audio input format sensor.""" + super().__init__(speaker) + self._attr_unique_id = f"{self.soco.uid}-audio-format" + self._attr_name = f"{self.speaker.zone_name} Audio Input Format" + self._attr_native_value = audio_format + + def update(self) -> None: + """Poll the device for the current state.""" + self._attr_native_value = self.soco.soundbar_audio_input_format + + async def _async_poll(self) -> None: + """Provide a stub for required ABC method.""" diff --git a/homeassistant/components/sonos/services.yaml b/homeassistant/components/sonos/services.yaml index af664f0b367..4f04b2407ff 100644 --- a/homeassistant/components/sonos/services.yaml +++ b/homeassistant/components/sonos/services.yaml @@ -87,30 +87,6 @@ clear_sleep_timer: device: integration: sonos -set_option: - name: Set option - description: Set Sonos sound options. - target: - device: - integration: sonos - fields: - bass_level: - name: Bass Level - description: Bass level for EQ. - selector: - number: - min: -10 - max: 10 - mode: box - treble_level: - name: Treble Level - description: Treble level for EQ. - selector: - number: - min: -10 - max: 10 - mode: box - play_queue: name: Play queue description: Start playing the queue from the first item. diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 66a2b46eb12..e12166d7795 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -7,6 +7,7 @@ import contextlib import datetime from functools import partial import logging +import time from typing import Any import urllib.parse @@ -19,38 +20,41 @@ from soco.music_library import MusicLibrary from soco.plugins.sharelink import ShareLinkPlugin from soco.snapshot import Snapshot -from homeassistant.components import zeroconf from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as ent_reg from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send, - dispatcher_connect, dispatcher_send, ) +from homeassistant.helpers.event import async_track_time_interval, track_time_interval from homeassistant.util import dt as dt_util from .alarms import SonosAlarms from .const import ( + AVAILABILITY_TIMEOUT, BATTERY_SCAN_INTERVAL, DATA_SONOS, DOMAIN, - MDNS_SERVICE, PLATFORMS, SCAN_INTERVAL, - SEEN_EXPIRE_TIME, + SONOS_CHECK_ACTIVITY, SONOS_CREATE_ALARM, + SONOS_CREATE_AUDIO_FORMAT_SENSOR, SONOS_CREATE_BATTERY, + SONOS_CREATE_LEVELS, SONOS_CREATE_MEDIA_PLAYER, SONOS_CREATE_SWITCHES, SONOS_ENTITY_CREATED, SONOS_POLL_UPDATE, SONOS_REBOOTED, - SONOS_SEEN, + SONOS_SPEAKER_ACTIVITY, SONOS_SPEAKER_ADDED, SONOS_STATE_PLAYING, SONOS_STATE_TRANSITIONING, @@ -62,6 +66,7 @@ from .const import ( from .favorites import SonosFavorites from .helpers import soco_error +NEVER_TIME = -1200.0 EVENT_CHARGING = { "CHARGING": True, "NOT_CHARGING": False, @@ -154,9 +159,9 @@ class SonosSpeaker: self.household_id: str = soco.household_id self.media = SonosMedia(soco) self._share_link_plugin: ShareLinkPlugin | None = None + self.available = True # Synchronization helpers - self._is_ready: bool = False self._platforms_ready: set[str] = set() # Subscriptions and events @@ -164,16 +169,13 @@ class SonosSpeaker: self._subscriptions: list[SubscriptionBase] = [] self._resubscription_lock: asyncio.Lock | None = None self._event_dispatchers: dict[str, Callable] = {} + self._last_activity: float = NEVER_TIME # Scheduled callback handles self._poll_timer: Callable | None = None - self._seen_timer: Callable | None = None # Dispatcher handles - self._entity_creation_dispatcher: Callable | None = None - self._group_dispatcher: Callable | None = None - self._reboot_dispatcher: Callable | None = None - self._seen_dispatcher: Callable | None = None + self.dispatchers: list[Callable] = [] # Device information self.mac_address = speaker_info["mac_address"] @@ -193,8 +195,10 @@ class SonosSpeaker: self.night_mode: bool | None = None self.dialog_mode: bool | None = None self.cross_fade: bool | None = None - self.bass_level: int | None = None - self.treble_level: int | None = None + self.bass: int | None = None + self.treble: int | None = None + self.sub_enabled: bool | None = None + self.surround_enabled: bool | None = None # Misc features self.buttons_enabled: bool | None = None @@ -208,32 +212,45 @@ class SonosSpeaker: self.snapshot_group: list[SonosSpeaker] | None = None self._group_members_missing: set[str] = set() - def setup(self) -> None: + async def async_setup_dispatchers(self, entry: ConfigEntry) -> None: + """Connect dispatchers in async context during setup.""" + dispatch_pairs = ( + (SONOS_CHECK_ACTIVITY, self.async_check_activity), + (SONOS_SPEAKER_ADDED, self.update_group_for_uid), + (f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", self.async_handle_new_entity), + (f"{SONOS_REBOOTED}-{self.soco.uid}", self.async_rebooted), + (f"{SONOS_SPEAKER_ACTIVITY}-{self.soco.uid}", self.speaker_activity), + ) + + for (signal, target) in dispatch_pairs: + entry.async_on_unload( + async_dispatcher_connect( + self.hass, + signal, + target, + ) + ) + + def setup(self, entry: ConfigEntry) -> None: """Run initial setup of the speaker.""" self.set_basic_info() + future = asyncio.run_coroutine_threadsafe( + self.async_setup_dispatchers(entry), self.hass.loop + ) + future.result(timeout=10) - self._entity_creation_dispatcher = dispatcher_connect( - self.hass, - f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", - self.async_handle_new_entity, - ) - self._seen_dispatcher = dispatcher_connect( - self.hass, f"{SONOS_SEEN}-{self.soco.uid}", self.async_seen - ) - self._reboot_dispatcher = dispatcher_connect( - self.hass, f"{SONOS_REBOOTED}-{self.soco.uid}", self.async_rebooted - ) - self._group_dispatcher = dispatcher_connect( - self.hass, - SONOS_SPEAKER_ADDED, - self.update_group_for_uid, - ) + dispatcher_send(self.hass, SONOS_CREATE_LEVELS, self) + + if audio_format := self.soco.soundbar_audio_input_format: + dispatcher_send( + self.hass, SONOS_CREATE_AUDIO_FORMAT_SENSOR, self, audio_format + ) if battery_info := fetch_battery_info_or_none(self.soco): self.battery_info = battery_info # Battery events can be infrequent, polling is still necessary - self._battery_poll_timer = self.hass.helpers.event.track_time_interval( - self.async_poll_battery, BATTERY_SCAN_INTERVAL + self._battery_poll_timer = track_time_interval( + self.hass, self.async_poll_battery, BATTERY_SCAN_INTERVAL ) dispatcher_send(self.hass, SONOS_CREATE_BATTERY, self) else: @@ -272,7 +289,6 @@ class SonosSpeaker: if self._platforms_ready == PLATFORMS: self._resubscription_lock = asyncio.Lock() await self.async_subscribe() - self._is_ready = True def write_entity_states(self) -> None: """Write states for associated SonosEntity instances.""" @@ -291,11 +307,6 @@ class SonosSpeaker: # # Properties # - @property - def available(self) -> bool: - """Return whether this speaker is available.""" - return self._seen_timer is not None - @property def alarms(self) -> SonosAlarms: """Return the SonosAlarms instance for this household.""" @@ -335,7 +346,8 @@ class SonosSpeaker: # Create a polling task in case subscriptions fail or callback events do not arrive if not self._poll_timer: - self._poll_timer = self.hass.helpers.event.async_track_time_interval( + self._poll_timer = async_track_time_interval( + self.hass, partial( async_dispatcher_send, self.hass, @@ -403,12 +415,14 @@ class SonosSpeaker: self.zone_name, ) else: + exc_info = exception if _LOGGER.isEnabledFor(logging.DEBUG) else None _LOGGER.error( - "Subscription renewals for %s failed", + "Subscription renewals for %s failed: %s", self.zone_name, - exc_info=exception, + exception, + exc_info=exc_info, ) - await self.async_unseen() + await self.async_offline() @callback def async_dispatch_event(self, event: SonosEvent) -> None: @@ -420,6 +434,8 @@ class SonosSpeaker: self._poll_timer() self._poll_timer = None + self.speaker_activity(f"{event.service.service_type} subscription") + dispatcher = self._event_dispatchers[event.service.service_type] dispatcher(event) @@ -488,11 +504,11 @@ class SonosSpeaker: if "dialog_level" in variables: self.dialog_mode = variables["dialog_level"] == "1" - if "bass_level" in variables: - self.bass_level = variables["bass_level"] + if "bass" in variables: + self.bass = variables["bass"] - if "treble_level" in variables: - self.treble_level = variables["treble_level"] + if "treble" in variables: + self.treble = variables["treble"] self.async_write_entity_states() @@ -500,65 +516,43 @@ class SonosSpeaker: # Speaker availability methods # @callback - def _async_reset_seen_timer(self): - """Reset the _seen_timer scheduler.""" - if self._seen_timer: - self._seen_timer() - self._seen_timer = self.hass.helpers.event.async_call_later( - SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen - ) - - async def async_seen(self, soco: SoCo | None = None) -> None: - """Record that this speaker was seen right now.""" - if soco is not None: - self.soco = soco - + def speaker_activity(self, source): + """Track the last activity on this speaker, set availability and resubscribe.""" + _LOGGER.debug("Activity on %s from %s", self.zone_name, source) + self._last_activity = time.monotonic() was_available = self.available - - self._async_reset_seen_timer() - - if was_available: + self.available = True + if not was_available: self.async_write_entity_states() + self.hass.async_create_task(self.async_subscribe()) + + async def async_check_activity(self, now: datetime.datetime) -> None: + """Validate availability of the speaker based on recent activity.""" + if time.monotonic() - self._last_activity < AVAILABILITY_TIMEOUT: + return + + try: + _ = await self.hass.async_add_executor_job(getattr, self.soco, "volume") + except (OSError, SoCoException): + pass + else: + self.speaker_activity("timeout poll") + return + + if not self.available: return _LOGGER.debug( - "%s [%s] was not available, setting up", + "No recent activity and cannot reach %s, marking unavailable", self.zone_name, - self.soco.ip_address, - ) - - if self._is_ready and not self.subscriptions_failed: - done = await self.async_subscribe() - if not done: - await self.async_unseen() - - self.async_write_entity_states() - - async def async_unseen( - self, callback_timestamp: datetime.datetime | None = None - ) -> None: - """Make this player unavailable when it was not seen recently.""" - data = self.hass.data[DATA_SONOS] - if (zcname := data.mdns_names.get(self.soco.uid)) and callback_timestamp: - # Called by a _seen_timer timeout, check mDNS one more time - # This should not be checked in an "active" unseen scenario - aiozeroconf = await zeroconf.async_get_async_instance(self.hass) - if await aiozeroconf.async_get_service_info(MDNS_SERVICE, zcname): - # We can still see the speaker via zeroconf check again later. - self._async_reset_seen_timer() - return - - _LOGGER.debug( - "No activity and could not locate %s on the network. Marking unavailable", - zcname, ) + await self.async_offline() + async def async_offline(self) -> None: + """Handle removal of speaker when unavailable.""" + self.available = False self._share_link_plugin = None - if self._seen_timer: - self._seen_timer() - self._seen_timer = None - if self._poll_timer: self._poll_timer() self._poll_timer = None @@ -575,11 +569,9 @@ class SonosSpeaker: self.zone_name, soco, ) - await self.async_unsubscribe() + await self.async_offline() self.soco = soco - await self.async_subscribe() - self._async_reset_seen_timer() - self.async_write_entity_states() + self.speaker_activity("reboot") # # Battery management @@ -972,7 +964,7 @@ class SonosSpeaker: return True try: - with async_timeout.timeout(5): + async with async_timeout.timeout(5): while not _test_groups(groups): await hass.data[DATA_SONOS].topology_condition.wait() except asyncio.TimeoutError: @@ -984,25 +976,27 @@ class SonosSpeaker: # # Media and playback state handlers # + @soco_error() def update_volume(self) -> None: """Update information about current volume settings.""" self.volume = self.soco.volume self.muted = self.soco.mute self.night_mode = self.soco.night_mode self.dialog_mode = self.soco.dialog_mode - self.bass_level = self.soco.bass - self.treble_level = self.soco.treble + self.bass = self.soco.bass + self.treble = self.soco.treble try: self.cross_fade = self.soco.cross_fade except SoCoSlaveException: pass + @soco_error() def update_media(self, event: SonosEvent | None = None) -> None: """Update information about currently playing media.""" - variables = event and event.variables + variables = event.variables if event else {} - if variables and "transport_state" in variables: + if "transport_state" in variables: # If the transport has an error then transport_state will # not be set new_status = variables["transport_state"] @@ -1018,7 +1012,7 @@ class SonosSpeaker: update_position = new_status != self.media.playback_status self.media.playback_status = new_status - if variables and "transport_state" in variables: + if "transport_state" in variables: self.media.play_mode = variables["current_play_mode"] track_uri = ( variables["enqueued_transport_uri"] or variables["current_track_uri"] @@ -1066,7 +1060,7 @@ class SonosSpeaker: self.media.title = source self.media.source_name = source - def update_media_radio(self, variables: dict | None) -> None: + def update_media_radio(self, variables: dict) -> None: """Update state when streaming radio.""" self.media.clear_position() radio_title = None diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 830f3b09481..e92263991ab 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -20,6 +20,8 @@ from .const import ( SONOS_CREATE_SWITCHES, ) from .entity import SonosEntity +from .exception import SpeakerUnavailable +from .helpers import soco_error from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -36,6 +38,8 @@ ATTR_CROSSFADE = "cross_fade" ATTR_NIGHT_SOUND = "night_mode" ATTR_SPEECH_ENHANCEMENT = "dialog_mode" ATTR_STATUS_LIGHT = "status_light" +ATTR_SUB_ENABLED = "sub_enabled" +ATTR_SURROUND_ENABLED = "surround_enabled" ATTR_TOUCH_CONTROLS = "buttons_enabled" ALL_FEATURES = ( @@ -43,6 +47,8 @@ ALL_FEATURES = ( ATTR_CROSSFADE, ATTR_NIGHT_SOUND, ATTR_SPEECH_ENHANCEMENT, + ATTR_SUB_ENABLED, + ATTR_SURROUND_ENABLED, ATTR_STATUS_LIGHT, ) @@ -58,6 +64,8 @@ FRIENDLY_NAMES = { ATTR_NIGHT_SOUND: "Night Sound", ATTR_SPEECH_ENHANCEMENT: "Speech Enhancement", ATTR_STATUS_LIGHT: "Status Light", + ATTR_SUB_ENABLED: "Subwoofer Enabled", + ATTR_SURROUND_ENABLED: "Surround Enabled", ATTR_TOUCH_CONTROLS: "Touch Controls", } @@ -66,6 +74,8 @@ FEATURE_ICONS = { ATTR_SPEECH_ENHANCEMENT: "mdi:ear-hearing", ATTR_CROSSFADE: "mdi:swap-horizontal", ATTR_STATUS_LIGHT: "mdi:led-on", + ATTR_SUB_ENABLED: "mdi:dog", + ATTR_SURROUND_ENABLED: "mdi:surround-sound", ATTR_TOUCH_CONTROLS: "mdi:gesture-tap", } @@ -144,8 +154,12 @@ class SonosSwitchEntity(SonosEntity, SwitchEntity): if not self.should_poll: await self.hass.async_add_executor_job(self.update) + @soco_error(raise_on_err=False) def update(self) -> None: """Fetch switch state if necessary.""" + if not self.available: + raise SpeakerUnavailable + state = getattr(self.soco, self.feature_type) setattr(self.speaker, self.feature_type, state) @@ -164,6 +178,7 @@ class SonosSwitchEntity(SonosEntity, SwitchEntity): """Turn the entity off.""" self.send_command(False) + @soco_error() def send_command(self, enable: bool) -> None: """Enable or disable the feature on the device.""" if self.needs_coordinator: @@ -180,11 +195,12 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): """Representation of a Sonos Alarm entity.""" _attr_entity_category = ENTITY_CATEGORY_CONFIG + _attr_icon = "mdi:alarm" def __init__(self, alarm_id: str, speaker: SonosSpeaker) -> None: """Initialize the switch.""" super().__init__(speaker) - + self._attr_unique_id = f"{SONOS_DOMAIN}-{alarm_id}" self.alarm_id = alarm_id self.household_id = speaker.household_id self.entity_id = ENTITY_ID_FORMAT.format(f"sonos_alarm_{self.alarm_id}") @@ -205,16 +221,6 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): """Return the alarm instance.""" return self.hass.data[DATA_SONOS].alarms[self.household_id].get(self.alarm_id) - @property - def unique_id(self) -> str: - """Return the unique ID of the switch.""" - return f"{SONOS_DOMAIN}-{self.alarm_id}" - - @property - def icon(self): - """Return icon of Sonos alarm switch.""" - return "mdi:alarm" - @property def name(self) -> str: """Return the name of the sensor.""" diff --git a/homeassistant/components/sonos/translations/ja.json b/homeassistant/components/sonos/translations/ja.json index 7867cbcbf98..009f19d22b0 100644 --- a/homeassistant/components/sonos/translations/ja.json +++ b/homeassistant/components/sonos/translations/ja.json @@ -1,5 +1,10 @@ { "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "not_sonos_device": "\u691c\u51fa\u3055\u308c\u305f\u30c7\u30d0\u30a4\u30b9\u306fSonos\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u3042\u308a\u307e\u305b\u3093", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, "step": { "confirm": { "description": "Sonos\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" diff --git a/homeassistant/components/sonos/translations/tr.json b/homeassistant/components/sonos/translations/tr.json index 42bd46ce7c0..7ebd593bac0 100644 --- a/homeassistant/components/sonos/translations/tr.json +++ b/homeassistant/components/sonos/translations/tr.json @@ -1,6 +1,8 @@ { "config": { "abort": { + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", + "not_sonos_device": "Bulunan cihaz bir Sonos cihaz\u0131 de\u011fil", "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, "step": { diff --git a/homeassistant/components/speedtestdotnet/const.py b/homeassistant/components/speedtestdotnet/const.py index 57beaf99eb9..323d17cdd84 100644 --- a/homeassistant/components/speedtestdotnet/const.py +++ b/homeassistant/components/speedtestdotnet/const.py @@ -1,8 +1,9 @@ """Constants used by Speedtest.net.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, Final +from typing import Final from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index 06f180a570f..10071bf9054 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -8,6 +8,7 @@ from homeassistant.components.speedtestdotnet import SpeedTestDataCoordinator 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.restore_state import RestoreEntity @@ -64,7 +65,7 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, name=DEFAULT_NAME, - entry_type="service", + entry_type=DeviceEntryType.SERVICE, configuration_url="https://www.speedtest.net/", ) @@ -100,6 +101,5 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() - state = await self.async_get_last_state() - if state: + if state := await self.async_get_last_state(): self._state = state.state diff --git a/homeassistant/components/speedtestdotnet/translations/bg.json b/homeassistant/components/speedtestdotnet/translations/bg.json index 1c6120581b0..0e175ee9902 100644 --- a/homeassistant/components/speedtestdotnet/translations/bg.json +++ b/homeassistant/components/speedtestdotnet/translations/bg.json @@ -2,6 +2,20 @@ "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\u0432\u0430\u043d\u0435\u0442\u043e?" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "server_name": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0442\u0435\u0441\u0442\u043e\u0432 \u0441\u044a\u0440\u0432\u044a\u0440" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/speedtestdotnet/translations/ja.json b/homeassistant/components/speedtestdotnet/translations/ja.json new file mode 100644 index 00000000000..4e254367593 --- /dev/null +++ b/homeassistant/components/speedtestdotnet/translations/ja.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "wrong_server_id": "\u30b5\u30fc\u30d0\u30fcID\u304c\u7121\u52b9\u3067\u3059" + }, + "step": { + "user": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "manual": "\u81ea\u52d5\u66f4\u65b0\u3092\u7121\u52b9\u306b\u3059\u308b", + "scan_interval": "\u66f4\u65b0\u983b\u5ea6(\u5206)", + "server_name": "\u30c6\u30b9\u30c8\u30b5\u30fc\u30d0\u30fc\u306e\u9078\u629e" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/spider/climate.py b/homeassistant/components/spider/climate.py index fbae603a239..4d3b24466f9 100644 --- a/homeassistant/components/spider/climate.py +++ b/homeassistant/components/spider/climate.py @@ -8,6 +8,7 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.helpers.entity import DeviceInfo from .const import DOMAIN @@ -46,14 +47,15 @@ class SpiderThermostat(ClimateEntity): self.support_hvac.append(SPIDER_STATE_TO_HA[operation_value]) @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self.thermostat.id)}, - "name": self.thermostat.name, - "manufacturer": self.thermostat.manufacturer, - "model": self.thermostat.model, - } + return DeviceInfo( + configuration_url="https://mijn.ithodaalderop.nl/", + identifiers={(DOMAIN, self.thermostat.id)}, + manufacturer=self.thermostat.manufacturer, + model=self.thermostat.model, + name=self.thermostat.name, + ) @property def supported_features(self): diff --git a/homeassistant/components/spider/sensor.py b/homeassistant/components/spider/sensor.py index 8b38fdbe6f6..c390e060194 100644 --- a/homeassistant/components/spider/sensor.py +++ b/homeassistant/components/spider/sensor.py @@ -42,12 +42,12 @@ class SpiderPowerPlugEnergy(SensorEntity): @property def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self.power_plug.id)}, - "name": self.power_plug.name, - "manufacturer": self.power_plug.manufacturer, - "model": self.power_plug.model, - } + return DeviceInfo( + identifiers={(DOMAIN, self.power_plug.id)}, + manufacturer=self.power_plug.manufacturer, + model=self.power_plug.model, + name=self.power_plug.name, + ) @property def unique_id(self) -> str: @@ -84,12 +84,12 @@ class SpiderPowerPlugPower(SensorEntity): @property def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self.power_plug.id)}, - "name": self.power_plug.name, - "manufacturer": self.power_plug.manufacturer, - "model": self.power_plug.model, - } + return DeviceInfo( + identifiers={(DOMAIN, self.power_plug.id)}, + manufacturer=self.power_plug.manufacturer, + model=self.power_plug.model, + name=self.power_plug.name, + ) @property def unique_id(self) -> str: diff --git a/homeassistant/components/spider/switch.py b/homeassistant/components/spider/switch.py index ceb814b234a..089421ba447 100644 --- a/homeassistant/components/spider/switch.py +++ b/homeassistant/components/spider/switch.py @@ -1,5 +1,6 @@ """Support for Spider switches.""" from homeassistant.components.switch import SwitchEntity +from homeassistant.helpers.entity import DeviceInfo from .const import DOMAIN @@ -24,14 +25,15 @@ class SpiderPowerPlug(SwitchEntity): self.power_plug = power_plug @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self.power_plug.id)}, - "name": self.power_plug.name, - "manufacturer": self.power_plug.manufacturer, - "model": self.power_plug.model, - } + return DeviceInfo( + configuration_url="https://mijn.ithodaalderop.nl/", + identifiers={(DOMAIN, self.power_plug.id)}, + manufacturer=self.power_plug.manufacturer, + model=self.power_plug.model, + name=self.power_plug.name, + ) @property def unique_id(self): diff --git a/homeassistant/components/spider/translations/bg.json b/homeassistant/components/spider/translations/bg.json new file mode 100644 index 00000000000..bd20e92f8ec --- /dev/null +++ b/homeassistant/components/spider/translations/bg.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, + "error": { + "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": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/spider/translations/ja.json b/homeassistant/components/spider/translations/ja.json new file mode 100644 index 00000000000..9277adceeee --- /dev/null +++ b/homeassistant/components/spider/translations/ja.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "mijn.ithodaalderop.nl\u30a2\u30ab\u30a6\u30f3\u30c8\u3067\u30b5\u30a4\u30f3\u30a4\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/spider/translations/tr.json b/homeassistant/components/spider/translations/tr.json index 9bcc6bb1c41..9d0729ffba4 100644 --- a/homeassistant/components/spider/translations/tr.json +++ b/homeassistant/components/spider/translations/tr.json @@ -12,7 +12,8 @@ "data": { "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "mijn.ithodaalderop.nl hesab\u0131yla oturum a\u00e7\u0131n" } } } diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 4a4a904fe9e..402083aa25d 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -2,7 +2,7 @@ "domain": "spotify", "name": "Spotify", "documentation": "https://www.home-assistant.io/integrations/spotify", - "requirements": ["spotipy==2.18.0"], + "requirements": ["spotipy==2.19.0"], "zeroconf": ["_spotify-connect._tcp.local."], "dependencies": ["http"], "codeowners": ["@frenck"], diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 029f7dc9e8b..7f4865c21aa 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -53,6 +53,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utc_from_timestamp @@ -233,7 +234,7 @@ class SpotifyMediaPlayer(MediaPlayerEntity): self, session: OAuth2Session, spotify: Spotify, - me: dict, + me: dict, # pylint: disable=invalid-name user_id: str, name: str, ) -> None: @@ -267,7 +268,7 @@ class SpotifyMediaPlayer(MediaPlayerEntity): manufacturer="Spotify AB", model=model, name=self._name, - entry_type="service", + entry_type=DeviceEntryType.SERVICE, configuration_url="https://open.spotify.com", ) diff --git a/homeassistant/components/spotify/translations/ja.json b/homeassistant/components/spotify/translations/ja.json new file mode 100644 index 00000000000..d21e6919bf7 --- /dev/null +++ b/homeassistant/components/spotify/translations/ja.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "\u8a8d\u8a3cURL\u306e\u751f\u6210\u304c\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002", + "missing_configuration": "Spotify\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\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})", + "reauth_account_mismatch": "\u8a8d\u8a3c\u3055\u308c\u305fSpotify\u30a2\u30ab\u30a6\u30f3\u30c8\u304c\u3001\u518d\u8a8d\u8a3c\u304c\u5fc5\u8981\u306a\u30a2\u30ab\u30a6\u30f3\u30c8\u3068\u4e00\u81f4\u3057\u307e\u305b\u3093\u3002" + }, + "create_entry": { + "default": "Spotify\u306e\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f\u3002" + }, + "step": { + "pick_implementation": { + "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" + }, + "reauth_confirm": { + "description": "Spotify\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u306f\u3001Spotify\u3067\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u518d\u8a8d\u8a3c\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059: {account}", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + } + } + }, + "system_health": { + "info": { + "api_endpoint_reachable": "Spotify API\u30a8\u30f3\u30c9\u30dd\u30a4\u30f3\u30c8\u306b\u5230\u9054\u53ef\u80fd" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/translations/tr.json b/homeassistant/components/spotify/translations/tr.json index c543f155e4d..32c21899d52 100644 --- a/homeassistant/components/spotify/translations/tr.json +++ b/homeassistant/components/spotify/translations/tr.json @@ -2,7 +2,9 @@ "config": { "abort": { "authorize_url_timeout": "Kimlik do\u011frulama URL'sini olu\u015ftururken zaman a\u015f\u0131m\u0131 ger\u00e7ekle\u015fti.", - "missing_configuration": "Spotify entegrasyonu ayarlanmam\u0131\u015f. L\u00fctfen dok\u00fcmentasyonu takip et." + "missing_configuration": "Spotify entegrasyonu ayarlanmam\u0131\u015f. L\u00fctfen dok\u00fcmentasyonu takip et.", + "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_account_mismatch": "Kimli\u011fi do\u011frulanm\u0131\u015f Spotify hesab\u0131, yeniden kimlik do\u011frulamas\u0131 gereken hesapla e\u015fle\u015fmiyor." }, "create_entry": { "default": "Spotify ile kimlik ba\u015far\u0131yla do\u011fruland\u0131." @@ -10,6 +12,10 @@ "step": { "pick_implementation": { "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7" + }, + "reauth_confirm": { + "description": "Spotify entegrasyonunun \u015fu hesap i\u00e7in Spotify ile yeniden kimlik do\u011frulamas\u0131 yapmas\u0131 gerekiyor: {account}", + "title": "Entegrasyonu Yeniden Do\u011frula" } } }, diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 4796dac11a9..dfc58474366 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.23"], + "requirements": ["sqlalchemy==1.4.27"], "codeowners": ["@dgomes"], "iot_class": "local_polling" } diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index e7e0a691e85..03c5f45e357 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -2,12 +2,13 @@ import asyncio from http import HTTPStatus import logging +from typing import TYPE_CHECKING from pysqueezebox import Server, async_discover import voluptuous as vol from homeassistant import config_entries, data_entry_flow -from homeassistant.components.dhcp import MAC_ADDRESS +from homeassistant.components import dhcp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -184,18 +185,22 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_edit() - async def async_step_dhcp(self, discovery_info): + async def async_step_dhcp( + self, discovery_info: dhcp.DhcpServiceInfo + ) -> data_entry_flow.FlowResult: """Handle dhcp discovery of a Squeezebox player.""" _LOGGER.debug( "Reached dhcp discovery of a player with info: %s", discovery_info ) - await self.async_set_unique_id(format_mac(discovery_info[MAC_ADDRESS])) + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) self._abort_if_unique_id_configured() _LOGGER.debug("Configuring dhcp player with unique id: %s", self.unique_id) registry = async_get(self.hass) + if TYPE_CHECKING: + assert self.unique_id # if we have detected this player, do nothing. if not, there must be a server out there for us to configure, so start the normal user flow (which tries to autodetect server) if registry.async_get_entity_id(MP_DOMAIN, DOMAIN, self.unique_id) is not None: # this player is already known, so do nothing other than mark as configured diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index a0904c3178d..ef54f18bc9c 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -197,8 +197,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): known_players.append(entity) async_add_entities([entity]) - players = await lms.async_get_players() - if players: + if players := await lms.async_get_players(): for player in players: hass.async_create_task(_discovered_player(player)) diff --git a/homeassistant/components/squeezebox/translations/bg.json b/homeassistant/components/squeezebox/translations/bg.json index a9446bb8a17..a9b1dad60c8 100644 --- a/homeassistant/components/squeezebox/translations/bg.json +++ b/homeassistant/components/squeezebox/translations/bg.json @@ -1,9 +1,26 @@ { "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" + }, + "flow_title": "{host}", "step": { "edit": { "data": { - "port": "\u041f\u043e\u0440\u0442" + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "port": "\u041f\u043e\u0440\u0442", + "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/squeezebox/translations/ja.json b/homeassistant/components/squeezebox/translations/ja.json new file mode 100644 index 00000000000..1855d7aef56 --- /dev/null +++ b/homeassistant/components/squeezebox/translations/ja.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "no_server_found": "LMS\u30b5\u30fc\u30d0\u30fc\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "no_server_found": "\u30b5\u30fc\u30d0\u30fc\u3092\u81ea\u52d5\u7684\u306b\u691c\u51fa\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{host}", + "step": { + "edit": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "\u63a5\u7d9a\u60c5\u5831\u306e\u7de8\u96c6" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/squeezebox/translations/tr.json b/homeassistant/components/squeezebox/translations/tr.json index ff249aafa14..7fa98585507 100644 --- a/homeassistant/components/squeezebox/translations/tr.json +++ b/homeassistant/components/squeezebox/translations/tr.json @@ -1,13 +1,16 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "no_server_found": "LMS sunucusu bulunamad\u0131." }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "no_server_found": "Sunucu otomatik olarak ke\u015ffedilemedi.", "unknown": "Beklenmeyen hata" }, + "flow_title": "{host}", "step": { "edit": { "data": { @@ -15,7 +18,8 @@ "password": "Parola", "port": "Port", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "Ba\u011flant\u0131 bilgilerini d\u00fczenle" }, "user": { "data": { diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 4a5e3c33748..747b25faa50 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -44,7 +44,7 @@ async def async_setup_entry(hass, entry, async_add_entities): # Fetch srp_energy data start_date = datetime.now() + timedelta(days=-1) end_date = datetime.now() - with async_timeout.timeout(10): + async with async_timeout.timeout(10): hourly_usage = await hass.async_add_executor_job( api.usage, start_date, diff --git a/homeassistant/components/srp_energy/translations/ja.json b/homeassistant/components/srp_energy/translations/ja.json new file mode 100644 index 00000000000..69fc945db58 --- /dev/null +++ b/homeassistant/components/srp_energy/translations/ja.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_account": "\u30a2\u30ab\u30a6\u30f3\u30c8ID\u306f9\u6841\u306e\u6570\u5b57\u3067\u3042\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "id": "\u30a2\u30ab\u30a6\u30f3\u30c8ID", + "is_tou": "\u4f7f\u7528\u6642\u9593\u30d7\u30e9\u30f3\u3067\u3059", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + }, + "title": "SRP\u30a8\u30cd\u30eb\u30ae\u30fc" +} \ No newline at end of file diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index c937f210368..57948000701 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -2,12 +2,13 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable +from collections.abc import Awaitable, Mapping +from dataclasses import dataclass, field from datetime import timedelta from enum import Enum from ipaddress import IPv4Address, IPv6Address import logging -from typing import Any, Callable, Mapping +from typing import Any, Callable from async_upnp_client.aiohttp import AiohttpSessionRequester from async_upnp_client.const import DeviceOrServiceType, SsdpHeaders, SsdpSource @@ -20,9 +21,11 @@ from homeassistant import config_entries from homeassistant.components import network from homeassistant.const import EVENT_HOMEASSISTANT_STOP, MATCH_ALL 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.frame import report from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_ssdp, bind_hass @@ -56,23 +59,111 @@ ATTR_UPNP_UDN = "UDN" ATTR_UPNP_UPC = "UPC" ATTR_UPNP_PRESENTATION_URL = "presentationURL" # Attributes for accessing info added by Home Assistant -ATTR_HA_MATCHING_DOMAINS = "x-homeassistant-matching-domains" +ATTR_HA_MATCHING_DOMAINS = "x_homeassistant_matching_domains" PRIMARY_MATCH_KEYS = [ATTR_UPNP_MANUFACTURER, "st", ATTR_UPNP_DEVICE_TYPE, "nt"] -DISCOVERY_MAPPING = { - "usn": ATTR_SSDP_USN, - "ext": ATTR_SSDP_EXT, - "server": ATTR_SSDP_SERVER, - "st": ATTR_SSDP_ST, - "location": ATTR_SSDP_LOCATION, - "_udn": ATTR_SSDP_UDN, - "nt": ATTR_SSDP_NT, -} +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class _HaServiceDescription: + """Keys added by HA.""" + + x_homeassistant_matching_domains: set[str] = field(default_factory=set) + + +@dataclass +class _SsdpServiceDescription: + """SSDP info with optional keys.""" + + ssdp_usn: str + ssdp_st: str + ssdp_location: str | None = None + ssdp_nt: str | None = None + ssdp_udn: str | None = None + ssdp_ext: str | None = None + ssdp_server: str | None = None + ssdp_headers: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass +class _UpnpServiceDescription: + """UPnP info.""" + + upnp: Mapping[str, Any] + + +@dataclass +class SsdpServiceInfo( + _HaServiceDescription, + _SsdpServiceDescription, + _UpnpServiceDescription, + BaseServiceInfo, +): + """Prepared info from ssdp/upnp entries.""" + + # Used to prevent log flooding. To be removed in 2022.6 + _warning_logged: bool = False + + def __getitem__(self, name: str) -> Any: + """ + Allow property access by name for compatibility reason. + + Deprecated, and will be removed in version 2022.6. + """ + if not self._warning_logged: + report( + f"accessed discovery_info['{name}'] instead of discovery_info.{name}; this will fail in version 2022.6", + exclude_integrations={DOMAIN}, + error_if_core=False, + ) + self._warning_logged = True + # Use a property if it is available, fallback to upnp data + if hasattr(self, name): + return getattr(self, name) + if name in self.ssdp_headers and name not in self.upnp: + return self.ssdp_headers.get(name) + return self.upnp[name] + + def get(self, name: str, default: Any = None) -> Any: + """ + Enable method for compatibility reason. + + Deprecated, and will be removed in version 2022.6. + """ + if not self._warning_logged: + report( + f"accessed discovery_info.get('{name}') instead of discovery_info.{name}; this will fail in version 2022.6", + exclude_integrations={DOMAIN}, + error_if_core=False, + ) + self._warning_logged = True + if hasattr(self, name): + return getattr(self, name) + return self.upnp.get(name, self.ssdp_headers.get(name, default)) + + def __contains__(self, name: str) -> bool: + """ + Enable method for compatibility reason. + + Deprecated, and will be removed in version 2022.6. + """ + if not self._warning_logged: + report( + "accessed discovery_info.__contains__() instead of discovery_info.upnp.__contains__() " + "or discovery_info.ssdp_headers.__contains__(); this will fail in version 2022.6", + exclude_integrations={DOMAIN}, + error_if_core=False, + ) + self._warning_logged = True + if hasattr(self, name): + return getattr(self, name) is not None + return name in self.upnp or name in self.ssdp_headers + SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE") -SsdpCallback = Callable[[Mapping[str, Any], SsdpChange], Awaitable] - +SsdpCallback = Callable[[SsdpServiceInfo, SsdpChange], Awaitable] SSDP_SOURCE_SSDP_CHANGE_MAPPING: Mapping[SsdpSource, SsdpChange] = { SsdpSource.SEARCH_ALIVE: SsdpChange.ALIVE, @@ -82,8 +173,6 @@ SSDP_SOURCE_SSDP_CHANGE_MAPPING: Mapping[SsdpSource, SsdpChange] = { SsdpSource.ADVERTISEMENT_UPDATE: SsdpChange.UPDATE, } -_LOGGER = logging.getLogger(__name__) - @bind_hass async def async_register_callback( @@ -102,7 +191,7 @@ async def async_register_callback( @bind_hass async def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name hass: HomeAssistant, udn: str, st: str -) -> dict[str, str] | None: +) -> SsdpServiceInfo | None: """Fetch the discovery info cache.""" scanner: Scanner = hass.data[DOMAIN] return await scanner.async_get_discovery_info_by_udn_st(udn, st) @@ -111,7 +200,7 @@ async def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name @bind_hass async def async_get_discovery_info_by_st( # pylint: disable=invalid-name hass: HomeAssistant, st: str -) -> list[dict[str, str]]: +) -> list[SsdpServiceInfo]: """Fetch all the entries matching the st.""" scanner: Scanner = hass.data[DOMAIN] return await scanner.async_get_discovery_info_by_st(st) @@ -120,7 +209,7 @@ async def async_get_discovery_info_by_st( # pylint: disable=invalid-name @bind_hass async def async_get_discovery_info_by_udn( hass: HomeAssistant, udn: str -) -> list[dict[str, str]]: +) -> list[SsdpServiceInfo]: """Fetch all the entries matching the udn.""" scanner: Scanner = hass.data[DOMAIN] return await scanner.async_get_discovery_info_by_udn(udn) @@ -141,7 +230,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _async_process_callbacks( callbacks: list[SsdpCallback], - discovery_info: dict[str, str], + discovery_info: SsdpServiceInfo, ssdp_change: SsdpChange, ) -> None: for callback in callbacks: @@ -400,8 +489,10 @@ class Scanner: if not callbacks and not matching_domains: return - discovery_info = discovery_info_from_headers_and_description(info_with_desc) - discovery_info[ATTR_HA_MATCHING_DOMAINS] = matching_domains + discovery_info = discovery_info_from_headers_and_description( + combined_headers, info_desc + ) + discovery_info.x_homeassistant_matching_domains = matching_domains ssdp_change = SSDP_SOURCE_SSDP_CHANGE_MAPPING[source] await _async_process_callbacks(callbacks, discovery_info, ssdp_change) @@ -409,6 +500,8 @@ class Scanner: if ssdp_change == SsdpChange.BYEBYE: return + _LOGGER.debug("Discovery info: %s", discovery_info) + for domain in matching_domains: _LOGGER.debug("Discovered %s at %s", domain, location) discovery_flow.async_create_flow( @@ -427,7 +520,7 @@ class Scanner: async def _async_headers_to_discovery_info( self, headers: Mapping[str, Any] - ) -> dict[str, Any]: + ) -> SsdpServiceInfo: """Combine the headers and description into discovery_info. Building this is a bit expensive so we only do it on demand. @@ -437,13 +530,11 @@ class Scanner: info_desc = ( await self._description_cache.async_get_description_dict(location) or {} ) - return discovery_info_from_headers_and_description( - CaseInsensitiveDict(headers, **info_desc) - ) + return discovery_info_from_headers_and_description(headers, info_desc) async def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name self, udn: str, st: str - ) -> dict[str, Any] | None: + ) -> SsdpServiceInfo | None: """Return discovery_info for a udn and st.""" if headers := self._all_headers_from_ssdp_devices.get((udn, st)): return await self._async_headers_to_discovery_info(headers) @@ -451,7 +542,7 @@ class Scanner: async def async_get_discovery_info_by_st( # pylint: disable=invalid-name self, st: str - ) -> list[dict[str, Any]]: + ) -> list[SsdpServiceInfo]: """Return matching discovery_infos for a st.""" return [ await self._async_headers_to_discovery_info(headers) @@ -459,7 +550,7 @@ class Scanner: if udn_st[1] == st ] - async def async_get_discovery_info_by_udn(self, udn: str) -> list[dict[str, Any]]: + async def async_get_discovery_info_by_udn(self, udn: str) -> list[SsdpServiceInfo]: """Return matching discovery_infos for a udn.""" return [ await self._async_headers_to_discovery_info(headers) @@ -469,23 +560,36 @@ class Scanner: def discovery_info_from_headers_and_description( - info_with_desc: CaseInsensitiveDict, -) -> dict[str, Any]: + combined_headers: Mapping[str, Any], + info_desc: Mapping[str, Any], +) -> SsdpServiceInfo: """Convert headers and description to discovery_info.""" - info = { - DISCOVERY_MAPPING.get(k.lower(), k): v - for k, v in info_with_desc.as_dict().items() - } + ssdp_usn = combined_headers["usn"] + ssdp_st = combined_headers.get("st") + upnp_info = {**info_desc} - if ATTR_UPNP_UDN not in info and ATTR_SSDP_USN in info: - if udn := _udn_from_usn(info[ATTR_SSDP_USN]): - info[ATTR_UPNP_UDN] = udn + # Increase compatibility: depending on the message type, + # either the ST (Search Target, from M-SEARCH messages) + # or NT (Notification Type, from NOTIFY messages) header is mandatory + if not ssdp_st: + ssdp_st = combined_headers["nt"] - # Increase compatibility. - if ATTR_SSDP_ST not in info and ATTR_SSDP_NT in info: - info[ATTR_SSDP_ST] = info[ATTR_SSDP_NT] + # Ensure UPnP "udn" is set + if ATTR_UPNP_UDN not in upnp_info: + if udn := _udn_from_usn(ssdp_usn): + upnp_info[ATTR_UPNP_UDN] = udn - return info + return SsdpServiceInfo( + ssdp_usn=ssdp_usn, + ssdp_st=ssdp_st, + ssdp_ext=combined_headers.get("ext"), + ssdp_server=combined_headers.get("server"), + ssdp_location=combined_headers.get("location"), + ssdp_udn=combined_headers.get("_udn"), + ssdp_nt=combined_headers.get("nt"), + ssdp_headers=combined_headers, + upnp=upnp_info, + ) def _udn_from_usn(usn: str | None) -> str | None: diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 2017dd9e75c..ea8b8ff73a4 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.22.10"], + "requirements": ["async-upnp-client==0.22.12"], "dependencies": ["network"], "after_dependencies": ["zeroconf"], "codeowners": [], diff --git a/homeassistant/components/starline/account.py b/homeassistant/components/starline/account.py index 9033375ce90..920c9214aec 100644 --- a/homeassistant/components/starline/account.py +++ b/homeassistant/components/starline/account.py @@ -129,13 +129,13 @@ class StarlineAccount: @staticmethod def device_info(device: StarlineDevice) -> DeviceInfo: """Device information for entities.""" - return { - "identifiers": {(DOMAIN, device.device_id)}, - "manufacturer": "StarLine", - "name": device.name, - "sw_version": device.fw_version, - "model": device.typename, - } + return DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + manufacturer="StarLine", + model=device.typename, + name=device.name, + sw_version=device.fw_version, + ) @staticmethod def gps_attrs(device: StarlineDevice) -> dict[str, Any]: diff --git a/homeassistant/components/starline/translations/ja.json b/homeassistant/components/starline/translations/ja.json new file mode 100644 index 00000000000..dc64e26ac64 --- /dev/null +++ b/homeassistant/components/starline/translations/ja.json @@ -0,0 +1,41 @@ +{ + "config": { + "error": { + "error_auth_app": "\u4e0d\u6b63\u306a\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3ID\u307e\u305f\u306f\u30b7\u30fc\u30af\u30ec\u30c3\u30c8", + "error_auth_mfa": "\u4e0d\u9069\u5207\u306a\u30b3\u30fc\u30c9", + "error_auth_user": "\u30e6\u30fc\u30b6\u30fc\u30cd\u30fc\u30e0\u307e\u305f\u306f\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u9055\u3044\u307e\u3059" + }, + "step": { + "auth_app": { + "data": { + "app_id": "App ID", + "app_secret": "\u30b7\u30fc\u30af\u30ec\u30c3\u30c8" + }, + "description": "[StarLine\u958b\u767a\u8005\u30a2\u30ab\u30a6\u30f3\u30c8](https://my.starline.ru/developer)\u306e\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3ID\u3068\u30b7\u30fc\u30af\u30ec\u30c3\u30c8\u30b3\u30fc\u30c9", + "title": "\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u8cc7\u683c\u60c5\u5831" + }, + "auth_captcha": { + "data": { + "captcha_code": "\u753b\u50cf\u304b\u3089\u306e\u30b3\u30fc\u30c9" + }, + "description": "{captcha_img}", + "title": "Captcha" + }, + "auth_mfa": { + "data": { + "mfa_code": "SMS\u30b3\u30fc\u30c9" + }, + "description": "\u96fb\u8a71 {phone_number} \u306b\u9001\u4fe1\u3055\u308c\u305f\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "2\u8981\u7d20\u8a8d\u8a3c" + }, + "auth_user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "StarLine\u30a2\u30ab\u30a6\u30f3\u30c8\u306e\u30e1\u30fc\u30eb\u30a2\u30c9\u30ec\u30b9\u3068\u30d1\u30b9\u30ef\u30fc\u30c9", + "title": "\u30e6\u30fc\u30b6\u30fc\u306e\u8cc7\u683c\u60c5\u5831" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/starline/translations/tr.json b/homeassistant/components/starline/translations/tr.json index 9d52f589e98..b89c9ae587e 100644 --- a/homeassistant/components/starline/translations/tr.json +++ b/homeassistant/components/starline/translations/tr.json @@ -1,10 +1,17 @@ { "config": { "error": { + "error_auth_app": "Yanl\u0131\u015f uygulama kimli\u011fi veya anahtar\u0131", + "error_auth_mfa": "Yanl\u0131\u015f kod", "error_auth_user": "Yanl\u0131\u015f kullan\u0131c\u0131 ad\u0131 ya da parola" }, "step": { "auth_app": { + "data": { + "app_id": "Uygulama Kimli\u011fi", + "app_secret": "Gizli" + }, + "description": "[StarLine geli\u015ftirici hesab\u0131ndan](https://my.starline.ru/developer) uygulama kimli\u011fi ve gizli kod", "title": "Uygulama kimlik bilgileri" }, "auth_captcha": { @@ -26,7 +33,8 @@ "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" }, - "description": "StarLine hesab\u0131 e-postas\u0131 ve parolas\u0131" + "description": "StarLine hesab\u0131 e-postas\u0131 ve parolas\u0131", + "title": "Kullan\u0131c\u0131 kimlik bilgilerini" } } } diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index 931e9eabfc0..135c3eeebda 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -193,7 +193,7 @@ class StartcaData: """Get the Start.ca bandwidth data from the web service.""" _LOGGER.debug("Updating Start.ca usage data") url = f"https://www.start.ca/support/usage/api?key={self.api_key}" - with async_timeout.timeout(REQUEST_TIMEOUT): + async with async_timeout.timeout(REQUEST_TIMEOUT): req = await self.websession.get(url) if req.status != HTTPStatus.OK: _LOGGER.error("Request failed with status: %u", req.status) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 6bf882c861d..f9ed6863af8 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -1,5 +1,6 @@ """Support for statistics for sensor values.""" from collections import deque +import contextlib import logging import statistics @@ -7,12 +8,15 @@ import voluptuous as vol from homeassistant.components.recorder.models import States from homeassistant.components.recorder.util import execute, session_scope -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_ENTITY_ID, CONF_NAME, - EVENT_HOMEASSISTANT_START, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -23,48 +27,123 @@ from homeassistant.helpers.event import ( async_track_state_change_event, ) from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.start import async_at_start from homeassistant.util import dt as dt_util from . import DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) -ATTR_AVERAGE_CHANGE = "average_change" -ATTR_CHANGE = "change" -ATTR_CHANGE_RATE = "change_rate" -ATTR_COUNT = "count" -ATTR_MAX_AGE = "max_age" -ATTR_MAX_VALUE = "max_value" -ATTR_MEAN = "mean" -ATTR_MEDIAN = "median" -ATTR_MIN_AGE = "min_age" -ATTR_MIN_VALUE = "min_value" -ATTR_QUANTILES = "quantiles" -ATTR_SAMPLING_SIZE = "sampling_size" -ATTR_STANDARD_DEVIATION = "standard_deviation" -ATTR_TOTAL = "total" -ATTR_VARIANCE = "variance" +STAT_AGE_COVERAGE_RATIO = "age_coverage_ratio" +STAT_BUFFER_USAGE_RATIO = "buffer_usage_ratio" +STAT_SOURCE_VALUE_VALID = "source_value_valid" -CONF_SAMPLING_SIZE = "sampling_size" +STAT_AVERAGE_LINEAR = "average_linear" +STAT_AVERAGE_STEP = "average_step" +STAT_AVERAGE_TIMELESS = "average_timeless" +STAT_CHANGE = "change" +STAT_CHANGE_SAMPLE = "change_sample" +STAT_CHANGE_SECOND = "change_second" +STAT_COUNT = "count" +STAT_DATETIME_NEWEST = "datetime_newest" +STAT_DATETIME_OLDEST = "datetime_oldest" +STAT_DISTANCE_95P = "distance_95_percent_of_values" +STAT_DISTANCE_99P = "distance_99_percent_of_values" +STAT_DISTANCE_ABSOLUTE = "distance_absolute" +STAT_MEAN = "mean" +STAT_MEDIAN = "median" +STAT_NOISINESS = "noisiness" +STAT_QUANTILES = "quantiles" +STAT_STANDARD_DEVIATION = "standard_deviation" +STAT_TOTAL = "total" +STAT_VALUE_MAX = "value_max" +STAT_VALUE_MIN = "value_min" +STAT_VARIANCE = "variance" + +STAT_DEFAULT = "default" +DEPRECATION_WARNING = ( + "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/" +) + +STATS_NOT_A_NUMBER = ( + STAT_DATETIME_OLDEST, + STAT_DATETIME_NEWEST, + STAT_QUANTILES, +) + +STATS_BINARY_SUPPORT = ( + STAT_AVERAGE_STEP, + STAT_AVERAGE_TIMELESS, + STAT_COUNT, + STAT_MEAN, + STAT_DEFAULT, +) + +CONF_STATE_CHARACTERISTIC = "state_characteristic" +CONF_SAMPLES_MAX_BUFFER_SIZE = "sampling_size" CONF_MAX_AGE = "max_age" CONF_PRECISION = "precision" CONF_QUANTILE_INTERVALS = "quantile_intervals" CONF_QUANTILE_METHOD = "quantile_method" DEFAULT_NAME = "Stats" -DEFAULT_SIZE = 20 +DEFAULT_BUFFER_SIZE = 20 DEFAULT_PRECISION = 2 DEFAULT_QUANTILE_INTERVALS = 4 DEFAULT_QUANTILE_METHOD = "exclusive" ICON = "mdi:calculator" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + +def valid_binary_characteristic_configuration(config): + """Validate that the characteristic selected is valid for the source sensor type, throw if it isn't.""" + if config.get(CONF_ENTITY_ID).split(".")[0] == "binary_sensor": + if config.get(CONF_STATE_CHARACTERISTIC) not in STATS_BINARY_SUPPORT: + raise ValueError( + "The configured characteristic '" + + config.get(CONF_STATE_CHARACTERISTIC) + + "' is not supported for a binary source sensor." + ) + 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_SAMPLING_SIZE, default=DEFAULT_SIZE): vol.All( - vol.Coerce(int), vol.Range(min=1) + vol.Optional(CONF_STATE_CHARACTERISTIC, default=STAT_DEFAULT): vol.In( + [ + STAT_AVERAGE_LINEAR, + STAT_AVERAGE_STEP, + STAT_AVERAGE_TIMELESS, + STAT_CHANGE_SAMPLE, + STAT_CHANGE_SECOND, + STAT_CHANGE, + STAT_COUNT, + STAT_DATETIME_NEWEST, + STAT_DATETIME_OLDEST, + STAT_DISTANCE_95P, + STAT_DISTANCE_99P, + STAT_DISTANCE_ABSOLUTE, + STAT_MEAN, + STAT_MEDIAN, + STAT_NOISINESS, + STAT_QUANTILES, + STAT_STANDARD_DEVIATION, + STAT_TOTAL, + STAT_VALUE_MAX, + STAT_VALUE_MIN, + STAT_VARIANCE, + STAT_DEFAULT, + ] ), + vol.Optional( + CONF_SAMPLES_MAX_BUFFER_SIZE, default=DEFAULT_BUFFER_SIZE + ): vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional(CONF_MAX_AGE): cv.time_period, vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int), vol.Optional( @@ -75,6 +154,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ), } ) +PLATFORM_SCHEMA = vol.All( + _PLATFORM_SCHEMA_BASE, + valid_binary_characteristic_configuration, +) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -82,29 +165,21 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - entity_id = config.get(CONF_ENTITY_ID) - name = config.get(CONF_NAME) - sampling_size = config.get(CONF_SAMPLING_SIZE) - max_age = config.get(CONF_MAX_AGE) - precision = config.get(CONF_PRECISION) - quantile_intervals = config.get(CONF_QUANTILE_INTERVALS) - quantile_method = config.get(CONF_QUANTILE_METHOD) - async_add_entities( [ StatisticsSensor( - entity_id, - name, - sampling_size, - max_age, - precision, - quantile_intervals, - quantile_method, + source_entity_id=config.get(CONF_ENTITY_ID), + name=config.get(CONF_NAME), + state_characteristic=config.get(CONF_STATE_CHARACTERISTIC), + samples_max_buffer_size=config.get(CONF_SAMPLES_MAX_BUFFER_SIZE), + samples_max_age=config.get(CONF_MAX_AGE), + precision=config.get(CONF_PRECISION), + quantile_intervals=config.get(CONF_QUANTILE_INTERVALS), + quantile_method=config.get(CONF_QUANTILE_METHOD), ) ], True, ) - return True @@ -113,32 +188,48 @@ class StatisticsSensor(SensorEntity): def __init__( self, - entity_id, + source_entity_id, name, - sampling_size, - max_age, + state_characteristic, + samples_max_buffer_size, + samples_max_age, precision, quantile_intervals, quantile_method, ): """Initialize the Statistics sensor.""" - self._entity_id = entity_id - self.is_binary = self._entity_id.split(".")[0] == "binary_sensor" + self._source_entity_id = source_entity_id + self.is_binary = self._source_entity_id.split(".")[0] == "binary_sensor" self._name = name - self._sampling_size = sampling_size - self._max_age = max_age + self._state_characteristic = state_characteristic + if self._state_characteristic == STAT_DEFAULT: + self._state_characteristic = STAT_COUNT if self.is_binary else STAT_MEAN + _LOGGER.warning(DEPRECATION_WARNING, self._state_characteristic, name) + self._samples_max_buffer_size = samples_max_buffer_size + self._samples_max_age = samples_max_age self._precision = precision self._quantile_intervals = quantile_intervals self._quantile_method = quantile_method + self._value = None self._unit_of_measurement = None - self.states = deque(maxlen=self._sampling_size) - self.ages = deque(maxlen=self._sampling_size) + self._available = False + self.states = deque(maxlen=self._samples_max_buffer_size) + self.ages = deque(maxlen=self._samples_max_buffer_size) + self.attributes = { + STAT_AGE_COVERAGE_RATIO: None, + STAT_BUFFER_USAGE_RATIO: None, + STAT_SOURCE_VALUE_VALID: None, + } + + if self.is_binary: + self._state_characteristic_fn = getattr( + self, f"_stat_binary_{self._state_characteristic}" + ) + else: + self._state_characteristic_fn = getattr( + self, f"_stat_{self._state_characteristic}" + ) - self.count = 0 - self.mean = self.median = self.quantiles = self.stdev = self.variance = None - self.total = self.min = self.max = None - self.min_age = self.max_age = None - self.change = self.average_change = self.change_rate = None self._update_listener = None async def async_added_to_hass(self): @@ -149,33 +240,34 @@ class StatisticsSensor(SensorEntity): """Handle the sensor state changes.""" if (new_state := event.data.get("new_state")) is None: return - self._add_state_to_queue(new_state) - self.async_schedule_update_ha_state(True) - @callback - def async_stats_sensor_startup(_): + async def async_stats_sensor_startup(_): """Add listener and get recorded state.""" _LOGGER.debug("Startup for %s", self.entity_id) self.async_on_remove( async_track_state_change_event( - self.hass, [self._entity_id], async_stats_sensor_state_listener + self.hass, + [self._source_entity_id], + async_stats_sensor_state_listener, ) ) if "recorder" in self.hass.config.components: - # Only use the database if it's configured self.hass.async_create_task(self._initialize_from_database()) - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, async_stats_sensor_startup - ) + async_at_start(self.hass, async_stats_sensor_startup) def _add_state_to_queue(self, new_state): """Add the state to the queue.""" - if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): + self._available = new_state.state != STATE_UNAVAILABLE + if new_state.state == STATE_UNAVAILABLE: + self.attributes[STAT_SOURCE_VALUE_VALID] = None + return + if new_state.state in (STATE_UNKNOWN, None): + self.attributes[STAT_SOURCE_VALUE_VALID] = False return try: @@ -183,9 +275,10 @@ class StatisticsSensor(SensorEntity): self.states.append(new_state.state) else: self.states.append(float(new_state.state)) - self.ages.append(new_state.last_updated) + self.attributes[STAT_SOURCE_VALUE_VALID] = True except ValueError: + self.attributes[STAT_SOURCE_VALUE_VALID] = False _LOGGER.error( "%s: parsing error, expected number and received %s", self.entity_id, @@ -193,22 +286,76 @@ class StatisticsSensor(SensorEntity): ) return - self._unit_of_measurement = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + self._unit_of_measurement = self._derive_unit_of_measurement(new_state) + + def _derive_unit_of_measurement(self, new_state): + base_unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if self.is_binary and self._state_characteristic in ( + STAT_AVERAGE_STEP, + STAT_AVERAGE_TIMELESS, + STAT_MEAN, + ): + unit = "%" + elif not base_unit: + unit = None + elif self._state_characteristic in ( + STAT_AVERAGE_LINEAR, + STAT_AVERAGE_STEP, + STAT_AVERAGE_TIMELESS, + STAT_CHANGE, + STAT_DISTANCE_95P, + STAT_DISTANCE_99P, + STAT_DISTANCE_ABSOLUTE, + STAT_MEAN, + STAT_MEDIAN, + STAT_NOISINESS, + STAT_STANDARD_DEVIATION, + STAT_TOTAL, + STAT_VALUE_MAX, + STAT_VALUE_MIN, + ): + unit = base_unit + elif self._state_characteristic in ( + STAT_COUNT, + STAT_DATETIME_NEWEST, + STAT_DATETIME_OLDEST, + STAT_QUANTILES, + ): + unit = None + elif self._state_characteristic == STAT_VARIANCE: + unit = base_unit + "²" + elif self._state_characteristic == STAT_CHANGE_SAMPLE: + unit = base_unit + "/sample" + elif self._state_characteristic == STAT_CHANGE_SECOND: + unit = base_unit + "/s" + return unit @property def name(self): """Return the name of the sensor.""" return self._name + @property + def state_class(self): + """Return the state class of this entity.""" + if self._state_characteristic in STATS_NOT_A_NUMBER: + return None + return STATE_CLASS_MEASUREMENT + @property def native_value(self): """Return the state of the sensor.""" - return self.mean if not self.is_binary else self.count + return self._value @property def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" - return self._unit_of_measurement if not self.is_binary else None + return self._unit_of_measurement + + @property + def available(self): + """Return the availability of the sensor linked to the source sensor.""" + return self._available @property def should_poll(self): @@ -218,24 +365,9 @@ class StatisticsSensor(SensorEntity): @property def extra_state_attributes(self): """Return the state attributes of the sensor.""" - if not self.is_binary: - return { - ATTR_SAMPLING_SIZE: self._sampling_size, - ATTR_COUNT: self.count, - ATTR_MEAN: self.mean, - ATTR_MEDIAN: self.median, - ATTR_QUANTILES: self.quantiles, - ATTR_STANDARD_DEVIATION: self.stdev, - ATTR_VARIANCE: self.variance, - ATTR_TOTAL: self.total, - ATTR_MIN_VALUE: self.min, - ATTR_MAX_VALUE: self.max, - ATTR_MIN_AGE: self.min_age, - ATTR_MAX_AGE: self.max_age, - ATTR_CHANGE: self.change, - ATTR_AVERAGE_CHANGE: self.average_change, - ATTR_CHANGE_RATE: self.change_rate, - } + return { + key: value for key, value in self.attributes.items() if value is not None + } @property def icon(self): @@ -243,17 +375,17 @@ class StatisticsSensor(SensorEntity): return ICON def _purge_old(self): - """Remove states which are older than self._max_age.""" + """Remove states which are older than self._samples_max_age.""" now = dt_util.utcnow() _LOGGER.debug( "%s: purging records older then %s(%s)", self.entity_id, - dt_util.as_local(now - self._max_age), - self._max_age, + dt_util.as_local(now - self._samples_max_age), + self._samples_max_age, ) - while self.ages and (now - self.ages[0]) > self._max_age: + while self.ages and (now - self.ages[0]) > self._samples_max_age: _LOGGER.debug( "%s: purging record with datetime %s(%s)", self.entity_id, @@ -265,73 +397,21 @@ class StatisticsSensor(SensorEntity): def _next_to_purge_timestamp(self): """Find the timestamp when the next purge would occur.""" - if self.ages and self._max_age: + if self.ages and self._samples_max_age: # Take the oldest entry from the ages list and add the configured max_age. # If executed after purging old states, the result is the next timestamp # in the future when the oldest state will expire. - return self.ages[0] + self._max_age + return self.ages[0] + self._samples_max_age return None async def async_update(self): """Get the latest data and updates the states.""" _LOGGER.debug("%s: updating statistics", self.entity_id) - if self._max_age is not None: + if self._samples_max_age is not None: self._purge_old() - self.count = len(self.states) - - if not self.is_binary: - try: # require only one data point - self.mean = round(statistics.mean(self.states), self._precision) - self.median = round(statistics.median(self.states), self._precision) - except statistics.StatisticsError as err: - _LOGGER.debug("%s: %s", self.entity_id, err) - self.mean = self.median = STATE_UNKNOWN - - try: # require at least two data points - self.stdev = round(statistics.stdev(self.states), self._precision) - self.variance = round(statistics.variance(self.states), self._precision) - if self._quantile_intervals < self.count: - self.quantiles = [ - round(quantile, self._precision) - for quantile in statistics.quantiles( - self.states, - n=self._quantile_intervals, - method=self._quantile_method, - ) - ] - except statistics.StatisticsError as err: - _LOGGER.debug("%s: %s", self.entity_id, err) - self.stdev = self.variance = self.quantiles = STATE_UNKNOWN - - if self.states: - self.total = round(sum(self.states), self._precision) - self.min = round(min(self.states), self._precision) - self.max = round(max(self.states), self._precision) - - self.min_age = self.ages[0] - self.max_age = self.ages[-1] - - self.change = self.states[-1] - self.states[0] - self.average_change = self.change - self.change_rate = 0 - - if len(self.states) > 1: - self.average_change /= len(self.states) - 1 - - time_diff = (self.max_age - self.min_age).total_seconds() - if time_diff > 0: - self.change_rate = self.change / time_diff - - self.change = round(self.change, self._precision) - self.average_change = round(self.average_change, self._precision) - self.change_rate = round(self.change_rate, self._precision) - - else: - self.total = self.min = self.max = STATE_UNKNOWN - self.min_age = self.max_age = dt_util.utcnow() - self.change = self.average_change = STATE_UNKNOWN - self.change_rate = STATE_UNKNOWN + self._update_attributes() + self._update_value() # If max_age is set, ensure to update again after the defined interval. next_to_purge_timestamp = self._next_to_purge_timestamp() @@ -369,11 +449,11 @@ class StatisticsSensor(SensorEntity): with session_scope(hass=self.hass) as session: query = session.query(States).filter( - States.entity_id == self._entity_id.lower() + States.entity_id == self._source_entity_id.lower() ) - if self._max_age is not None: - records_older_then = dt_util.utcnow() - self._max_age + if self._samples_max_age is not None: + records_older_then = dt_util.utcnow() - self._samples_max_age _LOGGER.debug( "%s: retrieve records not older then %s", self.entity_id, @@ -384,7 +464,7 @@ class StatisticsSensor(SensorEntity): _LOGGER.debug("%s: retrieving all records", self.entity_id) query = query.order_by(States.last_updated.desc()).limit( - self._sampling_size + self._samples_max_buffer_size ) states = execute(query, to_native=True, validate_entity_ids=False) @@ -394,3 +474,186 @@ class StatisticsSensor(SensorEntity): self.async_schedule_update_ha_state(True) _LOGGER.debug("%s: initializing from database completed", self.entity_id) + + def _update_attributes(self): + """Calculate and update the various attributes.""" + self.attributes[STAT_BUFFER_USAGE_RATIO] = round( + len(self.states) / self._samples_max_buffer_size, 2 + ) + + if len(self.states) >= 1 and self._samples_max_age is not None: + self.attributes[STAT_AGE_COVERAGE_RATIO] = round( + (self.ages[-1] - self.ages[0]).total_seconds() + / self._samples_max_age.total_seconds(), + 2, + ) + else: + self.attributes[STAT_AGE_COVERAGE_RATIO] = None + + def _update_value(self): + """Front to call the right statistical characteristics functions. + + One of the _stat_*() functions is represented by self._state_characteristic_fn(). + """ + + value = self._state_characteristic_fn() + + if self._state_characteristic not in STATS_NOT_A_NUMBER: + with contextlib.suppress(TypeError): + value = round(value, self._precision) + if self._precision == 0: + value = int(value) + self._value = value + + # Statistics for numeric sensor + + def _stat_average_linear(self): + if len(self.states) >= 2: + area = 0 + for i in range(1, len(self.states)): + area += ( + 0.5 + * (self.states[i] + self.states[i - 1]) + * (self.ages[i] - self.ages[i - 1]).total_seconds() + ) + age_range_seconds = (self.ages[-1] - self.ages[0]).total_seconds() + return area / age_range_seconds + return None + + def _stat_average_step(self): + if len(self.states) >= 2: + area = 0 + for i in range(1, len(self.states)): + area += ( + self.states[i - 1] + * (self.ages[i] - self.ages[i - 1]).total_seconds() + ) + age_range_seconds = (self.ages[-1] - self.ages[0]).total_seconds() + return area / age_range_seconds + return None + + def _stat_average_timeless(self): + return self._stat_mean() + + def _stat_change(self): + if len(self.states) > 0: + return self.states[-1] - self.states[0] + return None + + def _stat_change_sample(self): + if len(self.states) > 1: + return (self.states[-1] - self.states[0]) / (len(self.states) - 1) + return None + + def _stat_change_second(self): + if len(self.states) > 1: + age_range_seconds = (self.ages[-1] - self.ages[0]).total_seconds() + if age_range_seconds > 0: + return (self.states[-1] - self.states[0]) / age_range_seconds + return None + + def _stat_count(self): + return len(self.states) + + def _stat_datetime_newest(self): + if len(self.states) > 0: + return self.ages[-1] + return None + + def _stat_datetime_oldest(self): + if len(self.states) > 0: + return self.ages[0] + return None + + def _stat_distance_95_percent_of_values(self): + if len(self.states) >= 2: + return 2 * 1.96 * self._stat_standard_deviation() + return None + + def _stat_distance_99_percent_of_values(self): + if len(self.states) >= 2: + return 2 * 2.58 * self._stat_standard_deviation() + return None + + def _stat_distance_absolute(self): + if len(self.states) > 0: + return max(self.states) - min(self.states) + return None + + def _stat_mean(self): + if len(self.states) > 0: + return statistics.mean(self.states) + return None + + def _stat_median(self): + if len(self.states) > 0: + return statistics.median(self.states) + return None + + def _stat_noisiness(self): + 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 None + + def _stat_quantiles(self): + if len(self.states) > self._quantile_intervals: + return [ + round(quantile, self._precision) + for quantile in statistics.quantiles( + self.states, + n=self._quantile_intervals, + method=self._quantile_method, + ) + ] + return None + + def _stat_standard_deviation(self): + if len(self.states) >= 2: + return statistics.stdev(self.states) + return None + + def _stat_total(self): + if len(self.states) > 0: + return sum(self.states) + return None + + def _stat_value_max(self): + if len(self.states) > 0: + return max(self.states) + return None + + def _stat_value_min(self): + if len(self.states) > 0: + return min(self.states) + return None + + def _stat_variance(self): + if len(self.states) >= 2: + return statistics.variance(self.states) + return None + + # Statistics for binary sensor + + def _stat_binary_average_step(self): + if len(self.states) >= 2: + on_seconds = 0 + for i in range(1, len(self.states)): + if self.states[i - 1] == "on": + on_seconds += (self.ages[i] - self.ages[i - 1]).total_seconds() + age_range_seconds = (self.ages[-1] - self.ages[0]).total_seconds() + return 100 / age_range_seconds * on_seconds + return None + + def _stat_binary_average_timeless(self): + return self._stat_binary_mean() + + def _stat_binary_count(self): + return len(self.states) + + def _stat_binary_mean(self): + if len(self.states) > 0: + return 100.0 / len(self.states) * self.states.count("on") + return None diff --git a/homeassistant/components/stookalert/binary_sensor.py b/homeassistant/components/stookalert/binary_sensor.py index b75790cef8a..b606e3f3d3b 100644 --- a/homeassistant/components/stookalert/binary_sensor.py +++ b/homeassistant/components/stookalert/binary_sensor.py @@ -7,19 +7,20 @@ import stookalert import voluptuous as vol from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_SAFETY, PLATFORM_SCHEMA, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME 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 .const import CONF_PROVINCE, DOMAIN, ENTRY_TYPE_SERVICE, LOGGER, PROVINCES +from .const import CONF_PROVINCE, DOMAIN, LOGGER, PROVINCES DEFAULT_NAME = "Stookalert" ATTRIBUTION = "Data provided by rivm.nl" @@ -71,7 +72,7 @@ class StookalertBinarySensor(BinarySensorEntity): """Defines a Stookalert binary sensor.""" _attr_attribution = ATTRIBUTION - _attr_device_class = DEVICE_CLASS_SAFETY + _attr_device_class = BinarySensorDeviceClass.SAFETY def __init__(self, client: stookalert.stookalert, entry: ConfigEntry) -> None: """Initialize a Stookalert device.""" @@ -83,7 +84,7 @@ class StookalertBinarySensor(BinarySensorEntity): name=entry.data[CONF_PROVINCE], manufacturer="RIVM", model="Stookalert", - entry_type=ENTRY_TYPE_SERVICE, + entry_type=DeviceEntryType.SERVICE, configuration_url="https://www.rivm.nl/stookalert", ) diff --git a/homeassistant/components/stookalert/const.py b/homeassistant/components/stookalert/const.py index 72e39e60048..3d370fb135d 100644 --- a/homeassistant/components/stookalert/const.py +++ b/homeassistant/components/stookalert/const.py @@ -21,5 +21,3 @@ PROVINCES: Final = ( "Zeeland", "Zuid-Holland", ) - -ENTRY_TYPE_SERVICE: Final = "service" diff --git a/homeassistant/components/stookalert/translations/fr.json b/homeassistant/components/stookalert/translations/fr.json new file mode 100644 index 00000000000..9c74e1b5026 --- /dev/null +++ b/homeassistant/components/stookalert/translations/fr.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/stookalert/translations/id.json b/homeassistant/components/stookalert/translations/id.json new file mode 100644 index 00000000000..a5af255739a --- /dev/null +++ b/homeassistant/components/stookalert/translations/id.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "Layanan sudah dikonfigurasi" + }, + "step": { + "user": { + "data": { + "province": "Provinsi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/stookalert/translations/ja.json b/homeassistant/components/stookalert/translations/ja.json index 297ea3baa71..f8318373bb9 100644 --- a/homeassistant/components/stookalert/translations/ja.json +++ b/homeassistant/components/stookalert/translations/ja.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/stookalert/translations/pl.json b/homeassistant/components/stookalert/translations/pl.json index ef80051717e..da1663105e3 100644 --- a/homeassistant/components/stookalert/translations/pl.json +++ b/homeassistant/components/stookalert/translations/pl.json @@ -2,6 +2,13 @@ "config": { "abort": { "already_configured": "Us\u0142uga jest ju\u017c skonfigurowana" + }, + "step": { + "user": { + "data": { + "province": "Prowincja" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/stookalert/translations/sl.json b/homeassistant/components/stookalert/translations/sl.json new file mode 100644 index 00000000000..8cc117856ef --- /dev/null +++ b/homeassistant/components/stookalert/translations/sl.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "Storitev je \u017ee konfigurirana" + }, + "step": { + "user": { + "data": { + "province": "Pokrajina" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/stookalert/translations/tr.json b/homeassistant/components/stookalert/translations/tr.json index 717f6d72b94..7cbf0e58fdc 100644 --- a/homeassistant/components/stookalert/translations/tr.json +++ b/homeassistant/components/stookalert/translations/tr.json @@ -2,6 +2,13 @@ "config": { "abort": { "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "step": { + "user": { + "data": { + "province": "\u0130l" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 77e946770e6..070dd062e42 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -55,12 +55,17 @@ from .hls import HlsStreamOutput, async_setup_hls _LOGGER = logging.getLogger(__name__) -STREAM_SOURCE_RE = re.compile("//.*:.*@") +STREAM_SOURCE_REDACT_PATTERN = [ + (re.compile(r"//.*:.*@"), "//****:****@"), + (re.compile(r"\?auth=.*"), "?auth=****"), +] def redact_credentials(data: str) -> str: """Redact credentials from string data.""" - return STREAM_SOURCE_RE.sub("//****:****@", data) + for (pattern, repl) in STREAM_SOURCE_REDACT_PATTERN: + data = pattern.sub(repl, data) + return data def create_stream( @@ -199,6 +204,7 @@ class Stream: self._thread_quit = threading.Event() self._outputs: dict[str, StreamOutput] = {} self._fast_restart_once = False + self._available = True def endpoint_url(self, fmt: str) -> str: """Start the stream and returns a url for the output format.""" @@ -249,6 +255,11 @@ class Stream: if all(p.idle for p in self._outputs.values()): self.access_token = None + @property + def available(self) -> bool: + """Return False if the stream is started and known to be unavailable.""" + return self._available + def start(self) -> None: """Start a stream.""" if self._thread is None or not self._thread.is_alive(): @@ -275,19 +286,26 @@ class Stream: """Handle consuming streams and restart keepalive streams.""" # Keep import here so that we can import stream integration without installing reqs # pylint: disable=import-outside-toplevel - from .worker import SegmentBuffer, stream_worker + from .worker import StreamState, StreamWorkerError, stream_worker - segment_buffer = SegmentBuffer(self.hass, self.outputs) + stream_state = StreamState(self.hass, self.outputs) wait_timeout = 0 while not self._thread_quit.wait(timeout=wait_timeout): start_time = time.time() - stream_worker( - self.source, - self.options, - segment_buffer, - self._thread_quit, - ) - segment_buffer.discontinuity() + + self._available = True + try: + stream_worker( + self.source, + self.options, + stream_state, + self._thread_quit, + ) + except StreamWorkerError as err: + _LOGGER.error("Error from stream worker: %s", str(err)) + self._available = False + + stream_state.discontinuity() if not self.keepalive or self._thread_quit.is_set(): if self._fast_restart_once: # The stream source is updated, restart without any delay. @@ -295,6 +313,7 @@ class Stream: self._thread_quit.clear() continue break + # To avoid excessive restarts, wait before restarting # As the required recovery time may be different for different setups, start # with trying a short wait_timeout and increase it on each reconnection attempt. diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index a0ab48290f5..b1d79e52800 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections import defaultdict, deque from collections.abc import Callable, Generator, Iterator, Mapping +import contextlib import datetime from io import BytesIO import logging @@ -31,28 +32,85 @@ from .hls import HlsStreamOutput _LOGGER = logging.getLogger(__name__) -class SegmentBuffer: - """Buffer for writing a sequence of packets to the output as a segment.""" +class StreamWorkerError(Exception): + """An exception thrown while processing a stream.""" + + +class StreamEndedError(StreamWorkerError): + """Raised when the stream is complete, exposed for facilitating testing.""" + + +class StreamState: + """Responsible for trakcing output and playback state for a stream. + + Holds state used for playback to interpret a decoded stream. A source stream + may be reset (e.g. reconnecting to an rtsp stream) and this object tracks + the state to inform the player. + """ def __init__( self, hass: HomeAssistant, outputs_callback: Callable[[], Mapping[str, StreamOutput]], ) -> None: - """Initialize SegmentBuffer.""" + """Initialize StreamState.""" self._stream_id: int = 0 - self._hass = hass + self.hass = hass self._outputs_callback: Callable[ [], Mapping[str, StreamOutput] ] = outputs_callback # sequence gets incremented before the first segment so the first segment # has a sequence number of 0. self._sequence = -1 + + @property + def sequence(self) -> int: + """Return the current sequence for the latest segment.""" + return self._sequence + + def next_sequence(self) -> int: + """Increment the sequence number.""" + self._sequence += 1 + return self._sequence + + @property + def stream_id(self) -> int: + """Return the readonly stream_id attribute.""" + return self._stream_id + + def discontinuity(self) -> None: + """Mark the stream as having been restarted.""" + # Preserving sequence and stream_id here keep the HLS playlist logic + # simple to check for discontinuity at output time, and to determine + # the discontinuity sequence number. + self._stream_id += 1 + # Call discontinuity to remove incomplete segment from the HLS output + if hls_output := self._outputs_callback().get(HLS_PROVIDER): + cast(HlsStreamOutput, hls_output).discontinuity() + + @property + def outputs(self) -> list[StreamOutput]: + """Return the active stream outputs.""" + return list(self._outputs_callback().values()) + + +class StreamMuxer: + """StreamMuxer re-packages video/audio packets for output.""" + + def __init__( + self, + hass: HomeAssistant, + video_stream: av.video.VideoStream, + audio_stream: av.audio.stream.AudioStream | None, + stream_state: StreamState, + ) -> None: + """Initialize StreamMuxer.""" + self._hass = hass self._segment_start_dts: int = cast(int, None) self._memory_file: BytesIO = cast(BytesIO, None) self._av_output: av.container.OutputContainer = None - self._input_video_stream: av.video.VideoStream = None - self._input_audio_stream: av.audio.stream.AudioStream | None = None + self._input_video_stream: av.video.VideoStream = video_stream + self._input_audio_stream: av.audio.stream.AudioStream | None = audio_stream self._output_video_stream: av.video.VideoStream = None self._output_audio_stream: av.audio.stream.AudioStream | None = None self._segment: Segment | None = None @@ -61,6 +119,7 @@ class SegmentBuffer: self._part_start_dts: int = cast(int, None) self._part_has_keyframe = False self._stream_settings: StreamSettings = hass.data[DOMAIN][ATTR_SETTINGS] + self._stream_state = stream_state self._start_time = datetime.datetime.utcnow() def make_new_av( @@ -68,14 +127,13 @@ class SegmentBuffer: memory_file: BytesIO, sequence: int, input_vstream: av.video.VideoStream, - input_astream: av.audio.stream.AudioStream, + input_astream: av.audio.stream.AudioStream | None, ) -> tuple[ av.container.OutputContainer, av.video.VideoStream, av.audio.stream.AudioStream | None, ]: """Make a new av OutputContainer and add output streams.""" - add_audio = input_astream and input_astream.name in AUDIO_CODECS container = av.open( memory_file, mode="w", @@ -126,24 +184,12 @@ class SegmentBuffer: output_vstream = container.add_stream(template=input_vstream) # Check if audio is requested output_astream = None - if add_audio: + if input_astream: output_astream = container.add_stream(template=input_astream) return container, output_vstream, output_astream - def set_streams( - self, - video_stream: av.video.VideoStream, - audio_stream: Any, - # no type hint for audio_stream until https://github.com/PyAV-Org/PyAV/pull/775 is merged - ) -> None: - """Initialize output buffer with streams from container.""" - self._input_video_stream = video_stream - self._input_audio_stream = audio_stream - def reset(self, video_dts: int) -> None: """Initialize a new stream segment.""" - # Keep track of the number of segments we've processed - self._sequence += 1 self._part_start_dts = self._segment_start_dts = video_dts self._segment = None self._memory_file = BytesIO() @@ -154,7 +200,7 @@ class SegmentBuffer: self._output_audio_stream, ) = self.make_new_av( memory_file=self._memory_file, - sequence=self._sequence, + sequence=self._stream_state.next_sequence(), input_vstream=self._input_video_stream, input_astream=self._input_audio_stream, ) @@ -192,12 +238,12 @@ class SegmentBuffer: # We have our first non-zero byte position. This means the init has just # been written. Create a Segment and put it to the queue of each output. self._segment = Segment( - sequence=self._sequence, - stream_id=self._stream_id, + sequence=self._stream_state.sequence, + stream_id=self._stream_state.stream_id, init=self._memory_file.getvalue(), # Fetch the latest StreamOutputs, which may have changed since the # worker started. - stream_outputs=self._outputs_callback().values(), + stream_outputs=self._stream_state.outputs, start_time=self._start_time, ) self._memory_file_pos = self._memory_file.tell() @@ -274,17 +320,6 @@ class SegmentBuffer: self._part_start_dts = adjusted_dts self._part_has_keyframe = False - def discontinuity(self) -> None: - """Mark the stream as having been restarted.""" - # Preserving sequence and stream_id here keep the HLS playlist logic - # simple to check for discontinuity at output time, and to determine - # the discontinuity sequence number. - self._stream_id += 1 - self._start_time = datetime.datetime.utcnow() - # Call discontinuity to remove incomplete segment from the HLS output - if hls_output := self._outputs_callback().get(HLS_PROVIDER): - cast(HlsStreamOutput, hls_output).discontinuity() - def close(self) -> None: """Close stream buffer.""" self._av_output.close() @@ -356,7 +391,7 @@ class TimestampValidator: # Discard packets missing DTS. Terminate if too many are missing. if packet.dts is None: if self._missing_dts >= MAX_MISSING_DTS: - raise StopIteration( + raise StreamWorkerError( f"No dts in {MAX_MISSING_DTS+1} consecutive packets" ) self._missing_dts += 1 @@ -367,7 +402,7 @@ class TimestampValidator: if packet.dts <= prev_dts: gap = packet.time_base * (prev_dts - packet.dts) if gap > MAX_TIMESTAMP_GAP: - raise StopIteration( + raise StreamWorkerError( f"Timestamp overflow detected: last dts = {prev_dts}, dts = {packet.dts}" ) return False @@ -403,26 +438,27 @@ def unsupported_audio(packets: Iterator[av.Packet], audio_stream: Any) -> bool: def stream_worker( source: str, options: dict[str, str], - segment_buffer: SegmentBuffer, + stream_state: StreamState, quit_event: Event, ) -> None: """Handle consuming streams.""" try: container = av.open(source, options=options, timeout=SOURCE_TIMEOUT) - except av.AVError: - _LOGGER.error("Error opening stream %s", redact_credentials(str(source))) - return + except av.AVError as err: + raise StreamWorkerError( + "Error opening stream %s" % redact_credentials(str(source)) + ) from err try: video_stream = container.streams.video[0] - except (KeyError, IndexError): - _LOGGER.error("Stream has no video") - container.close() - return + except (KeyError, IndexError) as ex: + raise StreamWorkerError("Stream has no video") from ex try: audio_stream = container.streams.audio[0] except (KeyError, IndexError): audio_stream = None + if audio_stream and audio_stream.name not in AUDIO_CODECS: + audio_stream = None # These formats need aac_adtstoasc bitstream filter, but auto_bsf not # compatible with empty_moov and manual bitstream filters not in PyAV if container.format.name in {"hls", "mpegts"}: @@ -469,25 +505,33 @@ def stream_worker( # dts. Use "or 1" to deal with this. start_dts = next_video_packet.dts - (next_video_packet.duration or 1) first_keyframe.dts = first_keyframe.pts = start_dts - except (av.AVError, StopIteration) as ex: - _LOGGER.error("Error demuxing stream while finding first packet: %s", str(ex)) + except StreamWorkerError as ex: container.close() - return + raise ex + except StopIteration as ex: + container.close() + raise StreamEndedError("Stream ended; no additional packets") from ex + except av.AVError as ex: + container.close() + raise StreamWorkerError( + "Error demuxing stream while finding first packet: %s" % str(ex) + ) from ex - segment_buffer.set_streams(video_stream, audio_stream) - segment_buffer.reset(start_dts) + muxer = StreamMuxer(stream_state.hass, video_stream, audio_stream, stream_state) + muxer.reset(start_dts) # Mux the first keyframe, then proceed through the rest of the packets - segment_buffer.mux_packet(first_keyframe) + muxer.mux_packet(first_keyframe) - while not quit_event.is_set(): - try: - packet = next(container_packets) - except (av.AVError, StopIteration) as ex: - _LOGGER.error("Error demuxing stream: %s", str(ex)) - break - segment_buffer.mux_packet(packet) + with contextlib.closing(container), contextlib.closing(muxer): + while not quit_event.is_set(): + try: + packet = next(container_packets) + except StreamWorkerError as ex: + raise ex + except StopIteration as ex: + raise StreamEndedError("Stream ended; no additional packets") from ex + except av.AVError as ex: + raise StreamWorkerError("Error demuxing stream: %s" % str(ex)) from ex - # Close stream - segment_buffer.close() - container.close() + muxer.mux_packet(packet) diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py index ce5ac6a95f5..220478da8bd 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -125,8 +125,7 @@ async def refresh_subaru_data(config_entry, vehicle_info, controller): await controller.fetch(vin, force=True) # Update our local data that will go to entity states - received_data = await controller.get_data(vin) - if received_data: + if received_data := await controller.get_data(vin): data[vin] = received_data return data diff --git a/homeassistant/components/subaru/entity.py b/homeassistant/components/subaru/entity.py index 559feeea303..2bdb1425b2d 100644 --- a/homeassistant/components/subaru/entity.py +++ b/homeassistant/components/subaru/entity.py @@ -1,4 +1,5 @@ """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 @@ -25,10 +26,10 @@ class SubaruEntity(CoordinatorEntity): return f"{self.vin}_{self.entity_type}" @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self.vin)}, - "name": self.car_name, - "manufacturer": MANUFACTURER, - } + return DeviceInfo( + identifiers={(DOMAIN, self.vin)}, + manufacturer=MANUFACTURER, + name=self.car_name, + ) diff --git a/homeassistant/components/subaru/translations/ja.json b/homeassistant/components/subaru/translations/ja.json new file mode 100644 index 00000000000..c557f3419bc --- /dev/null +++ b/homeassistant/components/subaru/translations/ja.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "error": { + "bad_pin_format": "PIN\u306f4\u6841\u3067\u3042\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "incorrect_pin": "PIN\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "pin": { + "data": { + "pin": "PIN" + }, + "description": "MySubaru PIN\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\n\u6ce8: \u30a2\u30ab\u30a6\u30f3\u30c8\u5185\u306e\u3059\u3079\u3066\u306e\u8eca\u4e21\u306f\u3001\u540c\u3058PIN\u3092\u6301\u3063\u3066\u3044\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059", + "title": "Subaru Starlink\u306e\u8a2d\u5b9a" + }, + "user": { + "data": { + "country": "\u56fd\u3092\u9078\u629e", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "MySubaru\u306e\u8cc7\u683c\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n\u6ce8: \u521d\u671f\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u306b\u306f\u6700\u5927 30\u79d2\u304b\u304b\u308b\u3053\u3068\u304c\u3042\u308a\u307e\u3059", + "title": "Subaru Starlink\u306e\u8a2d\u5b9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "update_enabled": "\u8eca\u4e21\u30dd\u30fc\u30ea\u30f3\u30b0\u3092\u6709\u52b9\u306b\u3059\u308b" + }, + "description": "\u6709\u52b9\u306b\u3059\u308b\u3068\u3001\u8eca\u4e21\u30dd\u30fc\u30ea\u30f3\u30b0\u306f2\u6642\u9593\u3054\u3068\u306b\u8eca\u4e21\u306b\u30ea\u30e2\u30fc\u30c8\u30b3\u30de\u30f3\u30c9\u3092\u9001\u4fe1\u3057\u3066\u3001\u65b0\u3057\u3044\u30bb\u30f3\u30b5\u30fc\u30c7\u30fc\u30bf\u3092\u53d6\u5f97\u3057\u307e\u3059\u3002\u8eca\u4e21\u30dd\u30fc\u30ea\u30f3\u30b0\u3092\u3057\u306a\u3044\u5834\u5408\u306f\u3001\u8eca\u4e21\u304c\u81ea\u52d5\u7684\u306b\u30c7\u30fc\u30bf\u3092\u30d7\u30c3\u30b7\u30e5\u3057\u305f\u3068\u304d(\u901a\u5e38\u306f\u30a8\u30f3\u30b8\u30f3\u306e\u505c\u6b62\u5f8c)\u306b\u306e\u307f\u3001\u65b0\u3057\u3044\u30bb\u30f3\u30b5\u30fc\u30c7\u30fc\u30bf\u3092\u53d7\u4fe1\u3057\u307e\u3059\u3002", + "title": "Subaru Starlink\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/subaru/translations/tr.json b/homeassistant/components/subaru/translations/tr.json new file mode 100644 index 00000000000..5724bd4f2c3 --- /dev/null +++ b/homeassistant/components/subaru/translations/tr.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "error": { + "bad_pin_format": "PIN 4 haneli olmal\u0131d\u0131r", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "incorrect_pin": "Yanl\u0131\u015f PIN", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + }, + "step": { + "pin": { + "data": { + "pin": "PIN" + }, + "description": "L\u00fctfen MySubaru PIN'inizi girin\n NOT: Hesaptaki t\u00fcm ara\u00e7lar ayn\u0131 PIN'e sahip olmal\u0131d\u0131r", + "title": "Subaru Starlink Yap\u0131land\u0131rmas\u0131" + }, + "user": { + "data": { + "country": "\u00dclkeyi se\u00e7", + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "L\u00fctfen MySubaru kimlik bilgilerinizi girin\n NOT: \u0130lk kurulum 30 saniye kadar s\u00fcrebilir", + "title": "Subaru Starlink Yap\u0131land\u0131rmas\u0131" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "update_enabled": "Ara\u00e7 yoklamay\u0131 etkinle\u015ftir" + }, + "description": "Etkinle\u015ftirildi\u011finde, ara\u00e7 yoklama, yeni sens\u00f6r verileri elde etmek i\u00e7in her 2 saatte bir arac\u0131n\u0131za bir uzaktan komut g\u00f6nderir. Ara\u00e7 sorgulamas\u0131 olmadan, yeni sens\u00f6r verileri yaln\u0131zca ara\u00e7 otomatik olarak veri g\u00f6nderdi\u011finde al\u0131n\u0131r (normalde motor kapat\u0131ld\u0131ktan sonra).", + "title": "Subaru Starlink Se\u00e7enekleri" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index eecf82abc52..d53252fae9b 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -8,8 +8,7 @@ from surepy.entities.pet import Pet as SurepyPet from surepy.enums import EntityType, Location from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_PRESENCE, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -67,7 +66,7 @@ class SurePetcareBinarySensor(SurePetcareEntity, BinarySensorEntity): class Hub(SurePetcareBinarySensor): """Sure Petcare Hub.""" - _attr_device_class = DEVICE_CLASS_CONNECTIVITY + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC @property @@ -94,7 +93,7 @@ class Hub(SurePetcareBinarySensor): class Pet(SurePetcareBinarySensor): """Sure Petcare Pet.""" - _attr_device_class = DEVICE_CLASS_PRESENCE + _attr_device_class = BinarySensorDeviceClass.PRESENCE @callback def _update_attr(self, surepy_entity: SurepyEntity) -> None: @@ -117,7 +116,7 @@ class Pet(SurePetcareBinarySensor): class DeviceConnectivity(SurePetcareBinarySensor): """Sure Petcare Device.""" - _attr_device_class = DEVICE_CLASS_CONNECTIVITY + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC def __init__( diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index cc4fe01fffa..e53f319bdc5 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -7,11 +7,10 @@ from surepy.entities import SurepyEntity from surepy.entities.devices import Felaqua as SurepyFelaqua from surepy.enums import EntityType -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_VOLTAGE, - DEVICE_CLASS_BATTERY, ENTITY_CATEGORY_DIAGNOSTIC, PERCENTAGE, VOLUME_MILLILITERS, @@ -52,7 +51,7 @@ async def async_setup_entry( class SureBattery(SurePetcareEntity, SensorEntity): """A sensor implementation for Sure Petcare batteries.""" - _attr_device_class = DEVICE_CLASS_BATTERY + _attr_device_class = SensorDeviceClass.BATTERY _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC _attr_native_unit_of_measurement = PERCENTAGE diff --git a/homeassistant/components/surepetcare/services.yaml b/homeassistant/components/surepetcare/services.yaml index fc352aeb6ab..57b1ef22008 100644 --- a/homeassistant/components/surepetcare/services.yaml +++ b/homeassistant/components/surepetcare/services.yaml @@ -33,7 +33,7 @@ set_pet_location: text: location: description: Pet location (Inside or Outside) - example: inside + example: Inside required: true selector: select: diff --git a/homeassistant/components/surepetcare/translations/ja.json b/homeassistant/components/surepetcare/translations/ja.json new file mode 100644 index 00000000000..b4c39a6b251 --- /dev/null +++ b/homeassistant/components/surepetcare/translations/ja.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/tr.json b/homeassistant/components/surepetcare/translations/tr.json new file mode 100644 index 00000000000..a83e1936fb4 --- /dev/null +++ b/homeassistant/components/surepetcare/translations/tr.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap 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": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index b50f0b31083..228f69d4c6c 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -65,8 +65,7 @@ class SwisscomDeviceScanner(DeviceScanner): return False _LOGGER.info("Loading data from Swisscom Internet Box") - data = self.get_swisscom_data() - if not data: + if not (data := self.get_swisscom_data()): return False active_clients = [client for client in data.values() if client["status"]] diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 0abbc6d9f97..ac29e99bb73 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -8,6 +8,7 @@ from typing import Any, final import voluptuous as vol +from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SERVICE_TOGGLE, @@ -40,16 +41,25 @@ PROP_TO_ATTR = { "today_energy_kwh": ATTR_TODAY_ENERGY_KWH, } -DEVICE_CLASS_OUTLET = "outlet" -DEVICE_CLASS_SWITCH = "switch" - -DEVICE_CLASSES = [DEVICE_CLASS_OUTLET, DEVICE_CLASS_SWITCH] - -DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) - _LOGGER = logging.getLogger(__name__) +class SwitchDeviceClass(StrEnum): + """Device class for switches.""" + + OUTLET = "outlet" + SWITCH = "switch" + + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(SwitchDeviceClass)) + +# DEVICE_CLASS* below are deprecated as of 2021.12 +# use the SwitchDeviceClass enum instead. +DEVICE_CLASSES = [cls.value for cls in SwitchDeviceClass] +DEVICE_CLASS_OUTLET = SwitchDeviceClass.OUTLET.value +DEVICE_CLASS_SWITCH = SwitchDeviceClass.SWITCH.value + + @bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return if the switch is on based on the statemachine. @@ -89,12 +99,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class SwitchEntityDescription(ToggleEntityDescription): """A class that describes switch entities.""" + device_class: SwitchDeviceClass | str | None = None + class SwitchEntity(ToggleEntity): """Base class for switch entities.""" entity_description: SwitchEntityDescription _attr_current_power_w: float | None = None + _attr_device_class: SwitchDeviceClass | str | None _attr_today_energy_kwh: float | None = None @property @@ -102,6 +115,15 @@ class SwitchEntity(ToggleEntity): """Return the current power usage in W.""" return self._attr_current_power_w + @property + def device_class(self) -> SwitchDeviceClass | str | None: + """Return the class of this entity.""" + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class + return None + @property def today_energy_kwh(self) -> float | None: """Return the today total energy usage in kWh.""" @@ -114,8 +136,7 @@ class SwitchEntity(ToggleEntity): data = {} for prop, attr in PROP_TO_ATTR.items(): - value = getattr(self, prop) - if value is not None: + if (value := getattr(self, prop)) is not None: data[attr] = value return data diff --git a/homeassistant/components/switch/device_condition.py b/homeassistant/components/switch/device_condition.py index b59e533375c..7f47983ba67 100644 --- a/homeassistant/components/switch/device_condition.py +++ b/homeassistant/components/switch/device_condition.py @@ -20,12 +20,10 @@ CONDITION_SCHEMA = toggle_entity.CONDITION_SCHEMA.extend( @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> ConditionCheckerType: """Evaluate state based on configuration.""" - if config_validation: - config = CONDITION_SCHEMA(config) - return toggle_entity.async_condition_from_config(config) + return toggle_entity.async_condition_from_config(hass, config) async def async_get_conditions( diff --git a/homeassistant/components/switch/translations/ca.json b/homeassistant/components/switch/translations/ca.json index 999373799f0..e39386a680f 100644 --- a/homeassistant/components/switch/translations/ca.json +++ b/homeassistant/components/switch/translations/ca.json @@ -16,8 +16,8 @@ }, "state": { "_": { - "off": "off", - "on": "on" + "off": "OFF", + "on": "ON" } }, "title": "Interruptor" diff --git a/homeassistant/components/switch/translations/ja.json b/homeassistant/components/switch/translations/ja.json index 42b7ddc7d06..bc55f9c8d2f 100644 --- a/homeassistant/components/switch/translations/ja.json +++ b/homeassistant/components/switch/translations/ja.json @@ -1,4 +1,19 @@ { + "device_automation": { + "action_type": { + "toggle": "\u30c8\u30b0\u30eb {entity_name}", + "turn_off": "\u30aa\u30d5\u306b\u3059\u308b {entity_name}", + "turn_on": "\u30aa\u30f3\u306b\u3059\u308b {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u306f\u30aa\u30d5\u3067\u3059", + "is_on": "{entity_name} \u304c\u30aa\u30f3\u3067\u3059" + }, + "trigger_type": { + "turned_off": "{entity_name} \u30aa\u30d5\u306b\u306a\u308a\u307e\u3057\u305f", + "turned_on": "{entity_name} \u30aa\u30f3\u306b\u306a\u3063\u3066\u3044\u307e\u3059" + } + }, "state": { "_": { "off": "\u30aa\u30d5", diff --git a/homeassistant/components/switch/translations/tr.json b/homeassistant/components/switch/translations/tr.json index 0bbe4d5abf6..824b01c3b02 100644 --- a/homeassistant/components/switch/translations/tr.json +++ b/homeassistant/components/switch/translations/tr.json @@ -1,4 +1,19 @@ { + "device_automation": { + "action_type": { + "toggle": "{entity_name} de\u011fi\u015ftir", + "turn_off": "{entity_name} kapat", + "turn_on": "{entity_name} a\u00e7\u0131n" + }, + "condition_type": { + "is_off": "{entity_name} kapal\u0131", + "is_on": "{entity_name} a\u00e7\u0131k" + }, + "trigger_type": { + "turned_off": "{entity_name} kapat\u0131ld\u0131", + "turned_on": "{entity_name} a\u00e7\u0131ld\u0131" + } + }, "state": { "_": { "off": "Kapal\u0131", diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index d58e244d57c..899066ff502 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MAC, CONF_NAME +from homeassistant.const import CONF_MAC, CONF_NAME, ENTITY_CATEGORY_DIAGNOSTIC from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -19,6 +19,7 @@ PARALLEL_UPDATES = 1 BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { "calibration": BinarySensorEntityDescription( key="calibration", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), } diff --git a/homeassistant/components/switchbot/coordinator.py b/homeassistant/components/switchbot/coordinator.py index 4976af18809..e901cc539ea 100644 --- a/homeassistant/components/switchbot/coordinator.py +++ b/homeassistant/components/switchbot/coordinator.py @@ -30,6 +30,7 @@ class SwitchbotDataUpdateCoordinator(DataUpdateCoordinator): ) -> None: """Initialize global switchbot data updater.""" self.switchbot_api = api + self.switchbot_data = self.switchbot_api.GetSwitchbotDevices() self.retry_count = retry_count self.scan_timeout = scan_timeout self.update_interval = timedelta(seconds=update_interval) @@ -43,7 +44,7 @@ class SwitchbotDataUpdateCoordinator(DataUpdateCoordinator): def _update_data(self) -> dict | None: """Fetch device states from switchbot api.""" - return self.switchbot_api.GetSwitchbotDevices().discover( + return self.switchbot_data.discover( retry=self.retry_count, scan_timeout=self.scan_timeout ) diff --git a/homeassistant/components/switchbot/entity.py b/homeassistant/components/switchbot/entity.py index d6e88174d79..688cfea6a86 100644 --- a/homeassistant/components/switchbot/entity.py +++ b/homeassistant/components/switchbot/entity.py @@ -28,12 +28,12 @@ class SwitchbotEntity(CoordinatorEntity, Entity): self._idx = idx self._mac = mac self._attr_name = name - self._attr_device_info: DeviceInfo = { - "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac)}, - "name": name, - "model": self.data["modelName"], - "manufacturer": MANUFACTURER, - } + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, self._mac)}, + manufacturer=MANUFACTURER, + model=self.data["modelName"], + name=name, + ) @property def data(self) -> dict[str, Any]: diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 6dd23e2eec5..69f9eddc6cd 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.12.0"], + "requirements": ["PySwitchbot==0.13.0"], "config_flow": true, "codeowners": ["@danielhiversen", "@RenierM26"], "iot_class": "local_polling" diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index 22e4bb33f1a..a09255d0392 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -117,8 +117,7 @@ class SwitchBotBotEntity(SwitchbotEntity, SwitchEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" await super().async_added_to_hass() - last_state = await self.async_get_last_state() - if not last_state: + if not (last_state := await self.async_get_last_state()): return self._attr_is_on = last_state.state == STATE_ON self._last_run_success = last_state.attributes["last_run_success"] @@ -131,6 +130,8 @@ class SwitchBotBotEntity(SwitchbotEntity, SwitchEntity, RestoreEntity): self._last_run_success = bool( await self.hass.async_add_executor_job(self._device.turn_on) ) + if self._last_run_success: + self._attr_is_on = True async def async_turn_off(self, **kwargs: Any) -> None: """Turn device off.""" @@ -140,6 +141,8 @@ class SwitchBotBotEntity(SwitchbotEntity, SwitchEntity, RestoreEntity): self._last_run_success = bool( await self.hass.async_add_executor_job(self._device.turn_off) ) + if self._last_run_success: + self._attr_is_on = False @property def assumed_state(self) -> bool: @@ -151,6 +154,8 @@ class SwitchBotBotEntity(SwitchbotEntity, SwitchEntity, RestoreEntity): @property def is_on(self) -> bool: """Return true if device is on.""" + if not self.data["data"]["switchMode"]: + return self._attr_is_on return self.data["data"]["isOn"] @property diff --git a/homeassistant/components/switchbot/translations/fr.json b/homeassistant/components/switchbot/translations/fr.json new file mode 100644 index 00000000000..d155457d4b6 --- /dev/null +++ b/homeassistant/components/switchbot/translations/fr.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u00c9chec de connexion" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/id.json b/homeassistant/components/switchbot/translations/id.json index af61966afa5..4619f421811 100644 --- a/homeassistant/components/switchbot/translations/id.json +++ b/homeassistant/components/switchbot/translations/id.json @@ -2,6 +2,8 @@ "config": { "abort": { "already_configured_device": "Perangkat sudah dikonfigurasi", + "cannot_connect": "Gagal terhubung", + "no_unconfigured_devices": "Tidak ditemukan perangkat yang tidak dikonfigurasi.", "switchbot_unsupported_type": "Jenis Switchbot yang tidak didukung.", "unknown": "Kesalahan yang tidak diharapkan" }, diff --git a/homeassistant/components/switchbot/translations/ja.json b/homeassistant/components/switchbot/translations/ja.json new file mode 100644 index 00000000000..41fb320428f --- /dev/null +++ b/homeassistant/components/switchbot/translations/ja.json @@ -0,0 +1,37 @@ +{ + "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", + "switchbot_unsupported_type": "\u30b5\u30dd\u30fc\u30c8\u3057\u3066\u3044\u306a\u3044\u7a2e\u985e\u306eSwitchbot", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "\u30c7\u30d0\u30a4\u30b9\u306eMAC\u30a2\u30c9\u30ec\u30b9", + "name": "\u540d\u524d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "title": "Switchbot\u30c7\u30d0\u30a4\u30b9\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "\u518d\u8a66\u884c\u56de\u6570", + "retry_timeout": "\u518d\u8a66\u884c\u306e\u9593\u306e\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8", + "scan_timeout": "\u5e83\u544a\u30c7\u30fc\u30bf\u3092\u30b9\u30ad\u30e3\u30f3\u3059\u308b\u6642\u9593", + "update_time": "\u66f4\u65b0\u9593\u9694(\u79d2)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/translations/pl.json b/homeassistant/components/switchbot/translations/pl.json index 2fdc90d59cf..15e64b86e5a 100644 --- a/homeassistant/components/switchbot/translations/pl.json +++ b/homeassistant/components/switchbot/translations/pl.json @@ -3,10 +3,16 @@ "abort": { "already_configured_device": "Urz\u0105dzenie jest ju\u017c skonfigurowane", "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "no_unconfigured_devices": "Nie znaleziono nieskonfigurowanych urz\u0105dze\u0144.", + "switchbot_unsupported_type": "Nieobs\u0142ugiwany typ Switchbota.", "unknown": "Nieoczekiwany b\u0142\u0105d" }, "error": { - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "few": "Puste", + "many": "Pustych", + "one": "Pusty", + "other": "" }, "flow_title": "{name}", "step": { @@ -15,7 +21,8 @@ "mac": "Adres MAC urz\u0105dzenia", "name": "Nazwa", "password": "Has\u0142o" - } + }, + "title": "Konfiguracja urz\u0105dzenia Switchbot" } } }, @@ -25,6 +32,7 @@ "data": { "retry_count": "Liczba ponownych pr\u00f3b", "retry_timeout": "Limit czasu mi\u0119dzy kolejnymi pr\u00f3bami", + "scan_timeout": "Jak d\u0142ugo skanowa\u0107 w poszukiwaniu danych reklamowych", "update_time": "Czas mi\u0119dzy aktualizacjami (sekundy)" } } diff --git a/homeassistant/components/switchbot/translations/tr.json b/homeassistant/components/switchbot/translations/tr.json new file mode 100644 index 00000000000..77a2921fa38 --- /dev/null +++ b/homeassistant/components/switchbot/translations/tr.json @@ -0,0 +1,39 @@ +{ + "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.", + "switchbot_unsupported_type": "Desteklenmeyen Switchbot T\u00fcr\u00fc.", + "unknown": "Beklenmeyen hata" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "one": "Bo\u015f", + "other": "Bo\u015f" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "mac": "Cihaz MAC adresi", + "name": "Ad", + "password": "Parola" + }, + "title": "Switchbot cihaz\u0131n\u0131 kurun" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "retry_count": "Yeniden deneme say\u0131s\u0131", + "retry_timeout": "Yeniden denemeler aras\u0131ndaki zaman a\u015f\u0131m\u0131", + "scan_timeout": "Reklam verilerinin taranmas\u0131 ne kadar s\u00fcrer?", + "update_time": "G\u00fcncellemeler aras\u0131ndaki s\u00fcre (saniye)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 8b93e422e2a..620a742f4e3 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -23,6 +23,7 @@ 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 homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -92,11 +93,11 @@ class SwitcherBaseSwitchEntity(CoordinatorEntity, SwitchEntity): # Entity class attributes self._attr_name = coordinator.name self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" - self._attr_device_info = { - "connections": { + self._attr_device_info = DeviceInfo( + connections={ (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) } - } + ) @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/switcher_kis/translations/ja.json b/homeassistant/components/switcher_kis/translations/ja.json new file mode 100644 index 00000000000..d1234b69652 --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/ja.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "confirm": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/tr.json b/homeassistant/components/switcher_kis/translations/tr.json new file mode 100644 index 00000000000..3df15466f03 --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/tr.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", + "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + }, + "step": { + "confirm": { + "description": "Kuruluma ba\u015flamak ister misiniz?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/syncthing/sensor.py b/homeassistant/components/syncthing/sensor.py index e88636b814b..b57315b43b9 100644 --- a/homeassistant/components/syncthing/sensor.py +++ b/homeassistant/components/syncthing/sensor.py @@ -5,7 +5,9 @@ import aiosyncthing from homeassistant.components.sensor import SensorEntity from homeassistant.core import callback from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.event import async_track_time_interval from .const import ( @@ -130,15 +132,15 @@ class FolderSensor(SensorEntity): return False @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device information.""" - return { - "identifiers": {(DOMAIN, self._server_id)}, - "name": f"Syncthing ({self._syncthing.url})", - "manufacturer": "Syncthing Team", - "sw_version": self._version, - "entry_type": "service", - } + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._server_id)}, + manufacturer="Syncthing Team", + name=f"Syncthing ({self._syncthing.url})", + sw_version=self._version, + ) async def async_update_status(self): """Request folder status and update state.""" diff --git a/homeassistant/components/syncthing/translations/bg.json b/homeassistant/components/syncthing/translations/bg.json new file mode 100644 index 00000000000..5a326ca17cb --- /dev/null +++ b/homeassistant/components/syncthing/translations/bg.json @@ -0,0 +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" + }, + "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" + }, + "step": { + "user": { + "data": { + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/syncthing/translations/id.json b/homeassistant/components/syncthing/translations/id.json index 2aa4f701c95..d8f2a76c41d 100644 --- a/homeassistant/components/syncthing/translations/id.json +++ b/homeassistant/components/syncthing/translations/id.json @@ -10,6 +10,7 @@ "step": { "user": { "data": { + "title": "Siapkan integrasi Syncthing", "token": "Token", "url": "URL", "verify_ssl": "Verifikasi sertifikat SSL" diff --git a/homeassistant/components/syncthing/translations/ja.json b/homeassistant/components/syncthing/translations/ja.json new file mode 100644 index 00000000000..482087ed730 --- /dev/null +++ b/homeassistant/components/syncthing/translations/ja.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "user": { + "data": { + "title": "Syncthing\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7", + "token": "\u30c8\u30fc\u30af\u30f3", + "url": "URL", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + } + } + } + }, + "title": "Syncthing" +} \ No newline at end of file diff --git a/homeassistant/components/syncthing/translations/tr.json b/homeassistant/components/syncthing/translations/tr.json new file mode 100644 index 00000000000..eeeb7e02c3c --- /dev/null +++ b/homeassistant/components/syncthing/translations/tr.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + }, + "step": { + "user": { + "data": { + "title": "Senkronizasyon entegrasyonunu kurun", + "token": "Anahtar", + "url": "URL", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" + } + } + } + }, + "title": "E\u015fitleme" +} \ No newline at end of file diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index ef3e8c4419d..da45350836c 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -66,7 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # and the config should simply be discarded return False - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, connections=device_connections(printer), diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index 1c402fbf836..18780e9225e 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -1,4 +1,5 @@ """Support for Samsung Printers with SyncThru web interface.""" +from __future__ import annotations from pysyncthru import SyncThru, SyncthruState @@ -8,6 +9,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.const import CONF_NAME +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -63,9 +65,13 @@ class SyncThruBinarySensor(CoordinatorEntity, BinarySensorEntity): return self._name @property - def device_info(self): + def device_info(self) -> DeviceInfo | None: """Return device information.""" - return {"identifiers": device_identifiers(self.syncthru)} + if (identifiers := device_identifiers(self.syncthru)) is None: + return None + return DeviceInfo( + identifiers=identifiers, + ) class SyncThruOnlineSensor(SyncThruBinarySensor): diff --git a/homeassistant/components/syncthru/config_flow.py b/homeassistant/components/syncthru/config_flow.py index db822f650dc..ac7b317faf8 100644 --- a/homeassistant/components/syncthru/config_flow.py +++ b/homeassistant/components/syncthru/config_flow.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.const import CONF_NAME, CONF_URL +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import DEFAULT_MODEL, DEFAULT_NAME_TEMPLATE, DOMAIN @@ -33,15 +34,15 @@ class SyncThruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle import initiated flow.""" return await self.async_step_user(user_input=user_input) - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle SSDP initiated flow.""" - await self.async_set_unique_id(discovery_info[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) self._abort_if_unique_id_configured() self.url = url_normalize( - discovery_info.get( + discovery_info.upnp.get( ssdp.ATTR_UPNP_PRESENTATION_URL, - f"http://{urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname}/", + f"http://{urlparse(discovery_info.ssdp_location or '').hostname}/", ) ) @@ -50,12 +51,12 @@ class SyncThruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ): # Update unique id of entry with the same URL if not existing_entry.unique_id: - await self.hass.config_entries.async_update_entry( - existing_entry, unique_id=discovery_info[ssdp.ATTR_UPNP_UDN] + self.hass.config_entries.async_update_entry( + existing_entry, unique_id=discovery_info.upnp[ssdp.ATTR_UPNP_UDN] ) return self.async_abort(reason="already_configured") - self.name = discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + self.name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "") if self.name: # Remove trailing " (ip)" if present for consistency with user driven config self.name = re.sub(r"\s+\([\d.]+\)\s*$", "", self.name) diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index cc832f77f0a..a3e24f9ffd8 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -1,4 +1,5 @@ """Support for Samsung Printers with SyncThru web interface.""" +from __future__ import annotations import logging @@ -9,6 +10,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_NAME, CONF_RESOURCE, CONF_URL, PERCENTAGE import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -129,9 +131,13 @@ class SyncThruSensor(CoordinatorEntity, SensorEntity): return self._unit_of_measurement @property - def device_info(self): + def device_info(self) -> DeviceInfo | None: """Return device information.""" - return {"identifiers": device_identifiers(self.syncthru)} + if (identifiers := device_identifiers(self.syncthru)) is None: + return None + return DeviceInfo( + identifiers=identifiers, + ) class SyncThruMainSensor(SyncThruSensor): diff --git a/homeassistant/components/syncthru/translations/bg.json b/homeassistant/components/syncthru/translations/bg.json new file mode 100644 index 00000000000..bd8d6d4bf88 --- /dev/null +++ b/homeassistant/components/syncthru/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": { + "invalid_url": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d URL" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "data": { + "name": "\u0418\u043c\u0435" + } + }, + "user": { + "data": { + "name": "\u0418\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/syncthru/translations/ja.json b/homeassistant/components/syncthru/translations/ja.json new file mode 100644 index 00000000000..076a2a99a70 --- /dev/null +++ b/homeassistant/components/syncthru/translations/ja.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "invalid_url": "\u7121\u52b9\u306aURL", + "syncthru_not_supported": "\u30c7\u30d0\u30a4\u30b9\u306fSyncThru\u3092\u30b5\u30dd\u30fc\u30c8\u3057\u3066\u3044\u307e\u305b\u3093", + "unknown_state": "\u30d7\u30ea\u30f3\u30bf\u306e\u72b6\u614b\u304c\u4e0d\u660e\u3067\u3059\u3002URL\u3068\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u63a5\u7d9a\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "data": { + "name": "\u540d\u524d", + "url": "Web\u30a4\u30f3\u30bf\u30fc\u30d5\u30a7\u30a4\u30b9\u306eURL" + } + }, + "user": { + "data": { + "name": "\u540d\u524d", + "url": "Web\u30a4\u30f3\u30bf\u30fc\u30d5\u30a7\u30a4\u30b9\u306eURL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/syncthru/translations/tr.json b/homeassistant/components/syncthru/translations/tr.json index 942457958f8..f1c9117e684 100644 --- a/homeassistant/components/syncthru/translations/tr.json +++ b/homeassistant/components/syncthru/translations/tr.json @@ -3,9 +3,16 @@ "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, + "error": { + "invalid_url": "Ge\u00e7ersiz URL", + "syncthru_not_supported": "Cihaz SyncThru'yu desteklemiyor", + "unknown_state": "Yaz\u0131c\u0131 durumu bilinmiyor, URL'yi ve a\u011f ba\u011flant\u0131s\u0131n\u0131 do\u011frulay\u0131n" + }, + "flow_title": "{name}", "step": { "confirm": { "data": { + "name": "Ad", "url": "Web aray\u00fcz\u00fc URL'si" } }, diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 1801e96d2bd..8212bd67eda 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -42,7 +42,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import ( DeviceEntry, @@ -70,13 +70,9 @@ from .const import ( SERVICE_REBOOT, SERVICE_SHUTDOWN, SERVICES, - STORAGE_DISK_BINARY_SENSORS, - STORAGE_DISK_SENSORS, - STORAGE_VOL_SENSORS, SYNO_API, SYSTEM_LOADED, UNDO_UPDATE_LISTENER, - UTILISATION_SENSORS, SynologyDSMEntityDescription, ) @@ -89,75 +85,10 @@ ATTRIBUTION = "Data provided by Synology" _LOGGER = logging.getLogger(__name__) -async def async_setup_entry( # noqa: C901 - hass: HomeAssistant, entry: ConfigEntry -) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Synology DSM sensors.""" - # Migrate old unique_id - @callback - def _async_migrator( - entity_entry: entity_registry.RegistryEntry, - ) -> dict[str, str] | None: - """Migrate away from ID using label.""" - # Reject if new unique_id - if "SYNO." in entity_entry.unique_id: - return None - - entries = ( - *STORAGE_DISK_BINARY_SENSORS, - *STORAGE_DISK_SENSORS, - *STORAGE_VOL_SENSORS, - *UTILISATION_SENSORS, - ) - infos = entity_entry.unique_id.split("_") - serial = infos.pop(0) - label = infos.pop(0) - device_id = "_".join(infos) - - # Removed entity - if ( - "Type" in entity_entry.unique_id - or "Device" in entity_entry.unique_id - or "Name" in entity_entry.unique_id - ): - return None - - entity_type: str | None = None - for description in entries: - if ( - device_id - and description.name == "Status" - and "Status" in entity_entry.unique_id - and "(Smart)" not in entity_entry.unique_id - ): - if "sd" in device_id and "disk" in description.key: - entity_type = description.key - continue - if "volume" in device_id and "volume" in description.key: - entity_type = description.key - continue - - if description.name == label: - entity_type = description.key - - if entity_type is None: - return None - - new_unique_id = "_".join([serial, entity_type]) - if device_id: - new_unique_id += f"_{device_id}" - - _LOGGER.info( - "Migrating unique_id from [%s] to [%s]", - entity_entry.unique_id, - new_unique_id, - ) - return {"new_unique_id": new_unique_id} - - await entity_registry.async_migrate_entries(hass, entry.entry_id, _async_migrator) - - # migrate device indetifiers + # Migrate device indentifiers dev_reg = await get_dev_reg(hass) devices: list[DeviceEntry] = device_registry.async_entries_for_config_entry( dev_reg, entry.entry_id @@ -367,6 +298,7 @@ class SynoApi: else: self.config_url = f"http://{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}" + self.initialized = False # DSM APIs self.dsm: SynologyDSM = None self.information: SynoDSMInformation = None @@ -416,6 +348,7 @@ class SynoApi: await self._hass.async_add_executor_job(self._fetch_device_configuration) await self.async_update() + self.initialized = True @callback def subscribe(self, api_key: str, unique_id: str) -> Callable[[], None]: @@ -575,6 +508,9 @@ class SynoApi: self.dsm.update, self._with_information ) except (SynologyDSMLoginFailedException, SynologyDSMRequestException) as err: + if not self.initialized: + raise err + _LOGGER.warning( "Connection error during update, fallback by reloading the entry" ) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index a40dfd87744..8b3a566d854 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -237,14 +237,14 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return self._show_form(step) return await self.async_validate_input_create_entry(user_input, step_id=step) - async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered synology_dsm.""" - parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) + parsed_url = urlparse(discovery_info.ssdp_location) friendly_name = ( - discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME].split("(", 1)[0].strip() + discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME].split("(", 1)[0].strip() ) - discovered_mac = discovery_info[ssdp.ATTR_UPNP_SERIAL].upper() + discovered_mac = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL].upper() # Synology NAS can broadcast on multiple IP addresses, since they can be connected to multiple ethernets. # The serial of the NAS is actually its MAC address. diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index 5bae7426769..b1afe37b765 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -108,7 +108,7 @@ UPGRADE_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ...] = ( SynologyDSMBinarySensorEntityDescription( api_key=SynoCoreUpgrade.API_KEY, key="update_available", - name="Update available", + name="Update Available", device_class=DEVICE_CLASS_UPDATE, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), @@ -118,7 +118,7 @@ SECURITY_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ...] = SynologyDSMBinarySensorEntityDescription( api_key=SynoCoreSecurity.API_KEY, key="status", - name="Security status", + name="Security Status", device_class=DEVICE_CLASS_SAFETY, ), ) @@ -353,7 +353,7 @@ INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoDSMInformation.API_KEY, key="temperature", - name="temperature", + name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, @@ -362,7 +362,7 @@ INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( api_key=SynoDSMInformation.API_KEY, key="uptime", - name="last boot", + name="Last Boot", device_class=DEVICE_CLASS_TIMESTAMP, entity_registry_enabled_default=False, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, @@ -374,7 +374,7 @@ SURVEILLANCE_SWITCH: tuple[SynologyDSMSwitchEntityDescription, ...] = ( SynologyDSMSwitchEntityDescription( api_key=SynoSurveillanceStation.HOME_MODE_API_KEY, key="home_mode", - name="home mode", + name="Home Mode", icon="mdi:home-account", entity_category=ENTITY_CATEGORY_CONFIG, ), diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 1aa7e35d992..305e6dda4e7 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -1,7 +1,7 @@ """Support for Synology DSM sensors.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta from typing import Any from homeassistant.components.sensor import SensorEntity @@ -164,7 +164,7 @@ class SynoDSMInfoSensor(SynoDSMSensor): """Initialize the Synology SynoDSMInfoSensor entity.""" super().__init__(api, coordinator, description) self._previous_uptime: str | None = None - self._last_boot: str | None = None + self._last_boot: datetime | None = None @property def native_value(self) -> Any | None: @@ -176,8 +176,7 @@ class SynoDSMInfoSensor(SynoDSMSensor): if self.entity_description.key == "uptime": # reboot happened or entity creation if self._previous_uptime is None or self._previous_uptime > attr: - last_boot = utcnow() - timedelta(seconds=attr) - self._last_boot = last_boot.replace(microsecond=0).isoformat() + self._last_boot = utcnow() - timedelta(seconds=attr) self._previous_uptime = attr return self._last_boot diff --git a/homeassistant/components/synology_dsm/translations/bg.json b/homeassistant/components/synology_dsm/translations/bg.json index 4b857bf5cf0..f77c82130a4 100644 --- a/homeassistant/components/synology_dsm/translations/bg.json +++ b/homeassistant/components/synology_dsm/translations/bg.json @@ -48,5 +48,14 @@ "title": "Synology DSM" } } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u041c\u0438\u043d\u0443\u0442\u0438 \u043c\u0435\u0436\u0434\u0443 \u0441\u043a\u0430\u043d\u0438\u0440\u0430\u043d\u0438\u044f\u0442\u0430" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/synology_dsm/translations/id.json b/homeassistant/components/synology_dsm/translations/id.json index ca322fc518e..8169a6be4bf 100644 --- a/homeassistant/components/synology_dsm/translations/id.json +++ b/homeassistant/components/synology_dsm/translations/id.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Perangkat sudah dikonfigurasi", - "reauth_successful": "Autentikasi ulang berhasil" + "reauth_successful": "Autentikasi ulang berhasil", + "reconfigure_successful": "Konfigurasi ulang berhasil" }, "error": { "cannot_connect": "Gagal terhubung", @@ -32,15 +33,18 @@ }, "reauth": { "data": { + "password": "Kata Sandi", "username": "Nama Pengguna" }, + "description": "Alasan: {details}", "title": "Autentikasi Ulang Integrasi Synology DSM" }, "reauth_confirm": { "data": { "password": "Kata Sandi", "username": "Nama Pengguna" - } + }, + "title": "Autentikasi Ulang Integrasi Synology DSM" }, "user": { "data": { diff --git a/homeassistant/components/synology_dsm/translations/ja.json b/homeassistant/components/synology_dsm/translations/ja.json new file mode 100644 index 00000000000..c526869805d --- /dev/null +++ b/homeassistant/components/synology_dsm/translations/ja.json @@ -0,0 +1,72 @@ +{ + "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", + "reconfigure_successful": "\u518d\u8a2d\u5b9a\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", + "missing_data": "\u30c7\u30fc\u30bf\u6b20\u843d: \u5f8c\u3067\u518d\u8a66\u884c\u3059\u308b\u304b\u3001\u5225\u306e\u8a2d\u5b9a\u306b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "otp_failed": "2\u6bb5\u968e\u8a8d\u8a3c\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u65b0\u3057\u3044\u30d1\u30b9\u30b3\u30fc\u30c9\u3067\u518d\u8a66\u884c\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name} ({host})", + "step": { + "2sa": { + "data": { + "otp_code": "\u30b3\u30fc\u30c9" + }, + "title": "Synology DSM: 2\u8981\u7d20\u8a8d\u8a3c" + }, + "link": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "ssl": "SSL\u8a3c\u660e\u66f8\u3092\u4f7f\u7528\u3059\u308b", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + }, + "description": "{name} ({host})\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b?", + "title": "Synology DSM" + }, + "reauth": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u7406\u7531: {details}", + "title": "Synology DSM \u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Synology DSM \u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "ssl": "SSL\u8a3c\u660e\u66f8\u3092\u4f7f\u7528\u3059\u308b", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + }, + "title": "Synology DSM" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u30b9\u30ad\u30e3\u30f3\u306e\u9593\u306e\u5206\u6570", + "timeout": "\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8(\u79d2)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/synology_dsm/translations/tr.json b/homeassistant/components/synology_dsm/translations/tr.json index f2b93648da0..2941e260c93 100644 --- a/homeassistant/components/synology_dsm/translations/tr.json +++ b/homeassistant/components/synology_dsm/translations/tr.json @@ -1,38 +1,72 @@ { "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", + "reconfigure_successful": "Yeniden yap\u0131land\u0131rma ba\u015far\u0131l\u0131 oldu" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "missing_data": "Eksik veriler: l\u00fctfen daha sonra veya ba\u015fka bir yap\u0131land\u0131rmay\u0131 yeniden deneyin", + "otp_failed": "\u0130ki ad\u0131ml\u0131 kimlik do\u011frulama ba\u015far\u0131s\u0131z oldu, yeni bir parolayla yeniden deneyin", "unknown": "Beklenmeyen hata" }, + "flow_title": "{name} ({host})", "step": { + "2sa": { + "data": { + "otp_code": "Kod" + }, + "title": "Synology DSM: iki ad\u0131ml\u0131 kimlik do\u011frulama" + }, "link": { "data": { "password": "Parola", "port": "Port", + "ssl": "SSL sertifikas\u0131 kullan\u0131r", "username": "Kullan\u0131c\u0131 Ad\u0131", "verify_ssl": "SSL sertifikalar\u0131n\u0131 do\u011frula" - } + }, + "description": "{name} ( {host} ) kurulumu yapmak istiyor musunuz?", + "title": "Synology DSM" + }, + "reauth": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "Sebep: {details}", + "title": "Synology DSM Entegrasyonu Yeniden Do\u011frula" }, "reauth_confirm": { "data": { "password": "\u015eifre", "username": "Kullan\u0131c\u0131 ad\u0131" - } + }, + "title": "Synology DSM Entegrasyonu Yeniden Do\u011frula" }, "user": { "data": { "host": "Ana Bilgisayar", "password": "Parola", "port": "Port", + "ssl": "SSL sertifikas\u0131 kullan\u0131r", "username": "Kullan\u0131c\u0131 Ad\u0131", "verify_ssl": "SSL sertifikalar\u0131n\u0131 do\u011frula" }, "title": "Synology DSM" } } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Taramalar aras\u0131ndaki dakika", + "timeout": "Zaman a\u015f\u0131m\u0131 (saniye)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index d1da463816f..cf50368c34c 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -10,6 +10,7 @@ from systembridge import Bridge from systembridge.client import BridgeClient from systembridge.exceptions import BridgeAuthenticationException from systembridge.objects.command.response import CommandResponse +from systembridge.objects.keyboard.payload import KeyboardPayload import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -21,7 +22,11 @@ from homeassistant.const import ( CONF_PORT, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers import ( aiohttp_client, config_validation as cv, @@ -39,20 +44,15 @@ PLATFORMS = ["binary_sensor", "sensor"] CONF_ARGUMENTS = "arguments" CONF_BRIDGE = "bridge" +CONF_KEY = "key" +CONF_MODIFIERS = "modifiers" +CONF_TEXT = "text" CONF_WAIT = "wait" SERVICE_SEND_COMMAND = "send_command" -SERVICE_SEND_COMMAND_SCHEMA = vol.Schema( - { - vol.Required(CONF_BRIDGE): cv.string, - vol.Required(CONF_COMMAND): cv.string, - vol.Optional(CONF_ARGUMENTS, []): cv.string, - } -) SERVICE_OPEN = "open" -SERVICE_OPEN_SCHEMA = vol.Schema( - {vol.Required(CONF_BRIDGE): cv.string, vol.Required(CONF_PATH): cv.string} -) +SERVICE_SEND_KEYPRESS = "send_keypress" +SERVICE_SEND_TEXT = "send_text" async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -113,25 +113,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if hass.services.has_service(DOMAIN, SERVICE_SEND_COMMAND): return True + def valid_device(device: str): + """Check device is valid.""" + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device) + if device_entry is not None: + try: + return next( + entry.entry_id + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.entry_id in device_entry.config_entries + ) + except StopIteration as exception: + raise vol.Invalid from exception + raise vol.Invalid(f"Device {device} does not exist") + async def handle_send_command(call): """Handle the send_command service call.""" - device_registry = dr.async_get(hass) - device_id = call.data[CONF_BRIDGE] - device_entry = device_registry.async_get(device_id) - if device_entry is None: - _LOGGER.warning("Missing device: %s", device_id) - return + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ + call.data[CONF_BRIDGE] + ] + bridge: Bridge = coordinator.bridge command = call.data[CONF_COMMAND] - arguments = shlex.split(call.data.get(CONF_ARGUMENTS, "")) - - entry_id = next( - entry.entry_id - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.entry_id in device_entry.config_entries - ) - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry_id] - bridge: Bridge = coordinator.bridge + arguments = shlex.split(call.data[CONF_ARGUMENTS]) _LOGGER.debug( "Command payload: %s", @@ -141,55 +146,113 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: response: CommandResponse = await bridge.async_send_command( {CONF_COMMAND: command, CONF_ARGUMENTS: arguments, CONF_WAIT: False} ) - if response.success: - _LOGGER.debug( - "Sent command. Response message was: %s", response.message - ) - else: - _LOGGER.warning( - "Error sending command. Response message was: %s", response.message + if not response.success: + raise HomeAssistantError( + f"Error sending command. Response message was: {response.message}" ) except (BridgeAuthenticationException, *BRIDGE_CONNECTION_ERRORS) as exception: - _LOGGER.warning("Error sending command. Error was: %s", exception) + raise HomeAssistantError("Error sending command") from exception + _LOGGER.debug("Sent command. Response message was: %s", response.message) async def handle_open(call): """Handle the open service call.""" - device_registry = dr.async_get(hass) - device_id = call.data[CONF_BRIDGE] - device_entry = device_registry.async_get(device_id) - if device_entry is None: - _LOGGER.warning("Missing device: %s", device_id) - return + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ + call.data[CONF_BRIDGE] + ] + bridge: Bridge = coordinator.bridge path = call.data[CONF_PATH] - entry_id = next( - entry.entry_id - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.entry_id in device_entry.config_entries - ) - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry_id] - bridge: Bridge = coordinator.bridge - _LOGGER.debug("Open payload: %s", {CONF_PATH: path}) try: await bridge.async_open({CONF_PATH: path}) - _LOGGER.debug("Sent open request") except (BridgeAuthenticationException, *BRIDGE_CONNECTION_ERRORS) as exception: - _LOGGER.warning("Error sending. Error was: %s", exception) + raise HomeAssistantError("Error sending") from exception + _LOGGER.debug("Sent open request") + + async def handle_send_keypress(call): + """Handle the send_keypress service call.""" + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ + call.data[CONF_BRIDGE] + ] + bridge: Bridge = coordinator.data + + keyboard_payload: KeyboardPayload = { + CONF_KEY: call.data[CONF_KEY], + CONF_MODIFIERS: shlex.split(call.data.get(CONF_MODIFIERS, "")), + } + + _LOGGER.debug("Keypress payload: %s", keyboard_payload) + try: + await bridge.async_send_keypress(keyboard_payload) + except (BridgeAuthenticationException, *BRIDGE_CONNECTION_ERRORS) as exception: + raise HomeAssistantError("Error sending") from exception + _LOGGER.debug("Sent keypress request") + + async def handle_send_text(call): + """Handle the send_keypress service call.""" + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ + call.data[CONF_BRIDGE] + ] + bridge: Bridge = coordinator.data + + keyboard_payload: KeyboardPayload = {CONF_TEXT: call.data[CONF_TEXT]} + + _LOGGER.debug("Text payload: %s", keyboard_payload) + try: + await bridge.async_send_keypress(keyboard_payload) + except (BridgeAuthenticationException, *BRIDGE_CONNECTION_ERRORS) as exception: + raise HomeAssistantError("Error sending") from exception + _LOGGER.debug("Sent text request") hass.services.async_register( DOMAIN, SERVICE_SEND_COMMAND, handle_send_command, - schema=SERVICE_SEND_COMMAND_SCHEMA, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): valid_device, + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_ARGUMENTS, ""): cv.string, + }, + ), ) hass.services.async_register( DOMAIN, SERVICE_OPEN, handle_open, - schema=SERVICE_OPEN_SCHEMA, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): valid_device, + vol.Required(CONF_PATH): cv.string, + }, + ), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_SEND_KEYPRESS, + handle_send_keypress, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): valid_device, + vol.Required(CONF_KEY): cv.string, + vol.Optional(CONF_MODIFIERS): cv.string, + }, + ), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_SEND_TEXT, + handle_send_text, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): valid_device, + vol.Required(CONF_TEXT): cv.string, + }, + ), ) # Reload entry when its updated. @@ -239,6 +302,7 @@ class SystemBridgeEntity(CoordinatorEntity): bridge: Bridge = coordinator.data self._key = f"{bridge.information.host}_{key}" self._name = f"{bridge.information.host} {name}" + self._configuration_url = bridge.get_configuration_url() self._hostname = bridge.information.host self._mac = bridge.information.mac self._manufacturer = bridge.system.system.manufacturer @@ -262,10 +326,11 @@ class SystemBridgeDeviceEntity(SystemBridgeEntity): @property def device_info(self) -> DeviceInfo: """Return device information about this System Bridge instance.""" - return { - "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac)}, - "manufacturer": self._manufacturer, - "model": self._model, - "name": self._hostname, - "sw_version": self._version, - } + return DeviceInfo( + configuration_url=self._configuration_url, + connections={(dr.CONNECTION_NETWORK_MAC, self._mac)}, + manufacturer=self._manufacturer, + model=self._model, + name=self._hostname, + sw_version=self._version, + ) diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index 4ff887c6389..26ccf83c345 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -11,11 +11,11 @@ from systembridge.exceptions import BridgeAuthenticationException import voluptuous as vol from homeassistant import config_entries, exceptions +from homeassistant.components import zeroconf from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv -from homeassistant.helpers.typing import DiscoveryInfoType from .const import BRIDGE_CONNECTION_ERRORS, DOMAIN @@ -148,11 +148,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" - host = discovery_info["properties"].get("ip") - uuid = discovery_info["properties"].get("uuid") + properties = discovery_info.properties + host = properties.get("ip") + uuid = properties.get("uuid") if host is None or uuid is None: return self.async_abort(reason="unknown") @@ -164,7 +165,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._name = host self._input = { CONF_HOST: host, - CONF_PORT: discovery_info["properties"].get("port"), + CONF_PORT: properties.get("port"), } return await self.async_step_authenticate() diff --git a/homeassistant/components/system_bridge/const.py b/homeassistant/components/system_bridge/const.py index 5560d79a769..f2e83ceb186 100644 --- a/homeassistant/components/system_bridge/const.py +++ b/homeassistant/components/system_bridge/const.py @@ -16,4 +16,5 @@ BRIDGE_CONNECTION_ERRORS = ( ClientConnectionError, ClientConnectorError, ClientResponseError, + OSError, ) diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 368262a767b..cd4ee5a51a1 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -3,7 +3,7 @@ "name": "System Bridge", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/system_bridge", - "requirements": ["systembridge==2.1.3"], + "requirements": ["systembridge==2.2.3"], "codeowners": ["@timmo001"], "zeroconf": ["_system-bridge._udp.local."], "after_dependencies": ["zeroconf"], diff --git a/homeassistant/components/system_bridge/services.yaml b/homeassistant/components/system_bridge/services.yaml index 3f79f441415..aff0094501e 100644 --- a/homeassistant/components/system_bridge/services.yaml +++ b/homeassistant/components/system_bridge/services.yaml @@ -42,3 +42,49 @@ open: example: "https://www.home-assistant.io" selector: text: +send_keypress: + name: Send Keyboard Keypress + description: Sends a keyboard keypress. + fields: + bridge: + name: Bridge + description: The server to send the command to. + required: true + selector: + device: + integration: system_bridge + key: + name: Key + description: "Key to press. List available here: http://robotjs.io/docs/syntax#keys" + required: true + example: "audio_play" + selector: + text: + modifiers: + name: Modifiers + description: "List of modifier(s). Accepts alt, command/win, control, and shift." + required: false + default: "" + example: + - "control" + - "shift" + selector: + text: +send_text: + name: Send Keyboard Text + description: Sends text for the server to type. + fields: + bridge: + name: Bridge + description: The server to send the command to. + required: true + selector: + device: + integration: system_bridge + text: + name: Text + description: "Text to type." + required: true + example: "Hello world" + selector: + text: diff --git a/homeassistant/components/system_bridge/translations/bg.json b/homeassistant/components/system_bridge/translations/bg.json index 4983c9a14b2..ccf68c66d57 100644 --- a/homeassistant/components/system_bridge/translations/bg.json +++ b/homeassistant/components/system_bridge/translations/bg.json @@ -1,8 +1,21 @@ { "config": { + "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": "{name}", "step": { + "authenticate": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447" + } + }, "user": { "data": { + "api_key": "API \u043a\u043b\u044e\u0447", + "host": "\u0425\u043e\u0441\u0442", "port": "\u041f\u043e\u0440\u0442" } } diff --git a/homeassistant/components/system_bridge/translations/ja.json b/homeassistant/components/system_bridge/translations/ja.json new file mode 100644 index 00000000000..48ee7fc2378 --- /dev/null +++ b/homeassistant/components/system_bridge/translations/ja.json @@ -0,0 +1,32 @@ +{ + "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", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "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" + }, + "flow_title": "{name}", + "step": { + "authenticate": { + "data": { + "api_key": "API\u30ad\u30fc" + }, + "description": "{name} \u306e\u8a2d\u5b9a\u3067\u8a2d\u5b9a\u3057\u305fAPI\u30ad\u30fc\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + }, + "description": "\u63a5\u7d9a\u306e\u8a73\u7d30\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + }, + "title": "\u30b7\u30b9\u30c6\u30e0\u30d6\u30ea\u30c3\u30b8" +} \ No newline at end of file diff --git a/homeassistant/components/system_bridge/translations/tr.json b/homeassistant/components/system_bridge/translations/tr.json new file mode 100644 index 00000000000..9c269e6e5fb --- /dev/null +++ b/homeassistant/components/system_bridge/translations/tr.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu", + "unknown": "Beklenmeyen hata" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "{name}", + "step": { + "authenticate": { + "data": { + "api_key": "API Anahtar\u0131" + }, + "description": "{name} i\u00e7in yap\u0131land\u0131rman\u0131zda belirledi\u011finiz API Anahtar\u0131n\u0131 girin." + }, + "user": { + "data": { + "api_key": "API Anahtar\u0131", + "host": "Ana bilgisayar", + "port": "Port" + }, + "description": "L\u00fctfen ba\u011flant\u0131 bilgilerinizi giriniz." + } + } + }, + "title": "Sistem K\u00f6pr\u00fcs\u00fc" +} \ No newline at end of file diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 2683f6a2f3a..1d4adbfca64 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -64,7 +64,7 @@ async def get_integration_info( ): """Get integration system health.""" try: - with async_timeout.timeout(INFO_CALLBACK_TIMEOUT): + async with async_timeout.timeout(INFO_CALLBACK_TIMEOUT): data = await registration.info_callback(hass) except asyncio.TimeoutError: data = {"error": {"type": "failed", "error": "timeout"}} diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 8a88eef7bfc..fa8df5af870 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -81,8 +81,7 @@ def _figure_out_source(record, call_stack, hass): for pathname in reversed(stack): # Try to match with a file within Home Assistant - match = re.match(paths_re, pathname[0]) - if match: + if match := re.match(paths_re, pathname[0]): return [match.group(1), pathname[1]] # Ok, we don't know what this is return (record.pathname, record.lineno) diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index f630e4215f7..8bbdae820e3 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from dataclasses import dataclass -import datetime +from datetime import datetime, timedelta from functools import lru_cache import logging import os @@ -315,9 +315,9 @@ class SensorData: """Data for a sensor.""" argument: Any - state: str | None + state: str | datetime | None value: Any | None - update_time: datetime.datetime | None + update_time: datetime | None last_exception: BaseException | None @@ -367,7 +367,7 @@ async def async_setup_platform( async def async_setup_sensor_registry_updates( hass: HomeAssistant, sensor_registry: dict[tuple[str, str], SensorData], - scan_interval: datetime.timedelta, + scan_interval: timedelta, ) -> None: """Update the registry and create polling.""" @@ -439,7 +439,7 @@ class SystemMonitorSensor(SensorEntity): self._argument: str = argument @property - def native_value(self) -> str | None: + def native_value(self) -> str | datetime | None: """Return the state of the device.""" return self.data.state @@ -465,7 +465,7 @@ class SystemMonitorSensor(SensorEntity): def _update( # noqa: C901 type_: str, data: SensorData -) -> tuple[str | None, str | None, datetime.datetime | None]: +) -> tuple[str | datetime | None, str | None, datetime | None]: """Get the latest system information.""" state = None value = None @@ -549,7 +549,7 @@ def _update( # noqa: C901 elif type_ == "last_boot": # Only update on initial setup if data.state is None: - state = dt_util.utc_from_timestamp(psutil.boot_time()).isoformat() + state = dt_util.utc_from_timestamp(psutil.boot_time()) else: state = data.state elif type_ == "load_1m": diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index aa9852f643f..5c0c4debf80 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -398,8 +398,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): def set_temperature(self, **kwargs): """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return if self._current_tado_hvac_mode not in ( diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index d762329d658..e2a67c21b5d 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -6,8 +6,10 @@ import requests.exceptions import voluptuous as vol from homeassistant import config_entries, core, exceptions +from homeassistant.components import zeroconf from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import CONF_FALLBACK, DOMAIN, UNIQUE_ID @@ -80,13 +82,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_homekit(self, discovery_info): + async def async_step_homekit( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle HomeKit discovery.""" self._async_abort_entries_match() properties = { - key.lower(): value for (key, value) in discovery_info["properties"].items() + key.lower(): value for (key, value) in discovery_info.properties.items() } - await self.async_set_unique_id(properties["id"]) + await self.async_set_unique_id(properties[zeroconf.ATTR_PROPERTIES_ID]) + self._abort_if_unique_id_configured() return await self.async_step_user() def _username_already_configured(self, user_input): diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index e49a5be5a71..977be7d226a 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -106,7 +106,7 @@ class TadoDeviceScanner(DeviceScanner): last_results = [] try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): # Format the URL here, so we can log the template URL if # anything goes wrong without exposing username and password. url = self.tadoapiurl.format( diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py index 95c0643191b..7827564afa5 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -42,14 +42,14 @@ class TadoHomeEntity(Entity): self.home_id = tado.home_id @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self.home_id)}, - "name": self.home_name, - "manufacturer": DEFAULT_NAME, - "model": TADO_HOME, - } + return DeviceInfo( + identifiers={(DOMAIN, self.home_id)}, + manufacturer=DEFAULT_NAME, + model=TADO_HOME, + name=self.home_name, + ) class TadoZoneEntity(Entity): diff --git a/homeassistant/components/tado/translations/id.json b/homeassistant/components/tado/translations/id.json index bdbfa19cb6d..e6a00f9ee07 100644 --- a/homeassistant/components/tado/translations/id.json +++ b/homeassistant/components/tado/translations/id.json @@ -25,6 +25,7 @@ "data": { "fallback": "Aktifkan mode alternatif." }, + "description": "Mode fallback akan beralih ke Smart Schedule pada pergantian jadwal berikutnya setelah menyesuaikan zona secara manual.", "title": "Sesuaikan opsi Tado." } } diff --git a/homeassistant/components/tado/translations/ja.json b/homeassistant/components/tado/translations/ja.json new file mode 100644 index 00000000000..99ef3691ba6 --- /dev/null +++ b/homeassistant/components/tado/translations/ja.json @@ -0,0 +1,33 @@ +{ + "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "no_homes": "\u3053\u306etado\u30a2\u30ab\u30a6\u30f3\u30c8\u306b\u30ea\u30f3\u30af\u3055\u308c\u3066\u3044\u308b\u5bb6\u306f\u3042\u308a\u307e\u305b\u3093\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Tado\u30a2\u30ab\u30a6\u30f3\u30c8\u306b\u63a5\u7d9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "fallback": "\u30d5\u30a9\u30fc\u30eb\u30d0\u30c3\u30af\u30e2\u30fc\u30c9\u3092\u6709\u52b9\u306b\u3057\u307e\u3059\u3002" + }, + "description": "\u30d5\u30a9\u30fc\u30eb\u30d0\u30c3\u30af\u30e2\u30fc\u30c9\u306f\u3001\u624b\u52d5\u3067\u30be\u30fc\u30f3\u3092\u8abf\u6574\u3057\u305f\u5f8c\u3001\u6b21\u306e\u30b9\u30b1\u30b8\u30e5\u30fc\u30eb\u5207\u308a\u66ff\u3048\u6642\u306bSmart Schedule\u306b\u5207\u308a\u66ff\u308f\u308a\u307e\u3059\u3002", + "title": "Tado\u30aa\u30d7\u30b7\u30e7\u30f3\u306e\u8abf\u6574" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tado/translations/tr.json b/homeassistant/components/tado/translations/tr.json index 09ffbf8a7d1..30fc86b8e6a 100644 --- a/homeassistant/components/tado/translations/tr.json +++ b/homeassistant/components/tado/translations/tr.json @@ -6,6 +6,7 @@ "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "no_homes": "Bu Tado hesab\u0131na ba\u011fl\u0131 ev yok.", "unknown": "Beklenmeyen hata" }, "step": { @@ -13,7 +14,8 @@ "data": { "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "Tado hesab\u0131n\u0131za ba\u011flan\u0131n" } } }, @@ -22,7 +24,9 @@ "init": { "data": { "fallback": "Geri d\u00f6n\u00fc\u015f modunu etkinle\u015ftirin." - } + }, + "description": "Geri d\u00f6n\u00fc\u015f modu, bir b\u00f6lgeyi manuel olarak ayarlad\u0131ktan sonra bir sonraki program anahtar\u0131nda Ak\u0131ll\u0131 Programa ge\u00e7ecektir.", + "title": "Tado se\u00e7eneklerini ayarlay\u0131n." } } } diff --git a/homeassistant/components/tag/translations/ja.json b/homeassistant/components/tag/translations/ja.json new file mode 100644 index 00000000000..4639c5a2989 --- /dev/null +++ b/homeassistant/components/tag/translations/ja.json @@ -0,0 +1,3 @@ +{ + "title": "\u30bf\u30b0" +} \ No newline at end of file diff --git a/homeassistant/components/tag/translations/tr.json b/homeassistant/components/tag/translations/tr.json new file mode 100644 index 00000000000..52000386163 --- /dev/null +++ b/homeassistant/components/tag/translations/tr.json @@ -0,0 +1,3 @@ +{ + "title": "Etiket" +} \ No newline at end of file diff --git a/homeassistant/components/tailscale/__init__.py b/homeassistant/components/tailscale/__init__.py new file mode 100644 index 00000000000..72a0ef49fc0 --- /dev/null +++ b/homeassistant/components/tailscale/__init__.py @@ -0,0 +1,76 @@ +"""The Tailscale integration.""" +from __future__ import annotations + +from tailscale import Device as TailscaleDevice + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN +from .coordinator import TailscaleDataUpdateCoordinator + +PLATFORMS = (BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Tailscale from a config entry.""" + coordinator = TailscaleDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Tailscale config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + return unload_ok + + +class TailscaleEntity(CoordinatorEntity): + """Defines a Tailscale base entity.""" + + def __init__( + self, + *, + coordinator: DataUpdateCoordinator, + device: TailscaleDevice, + description: EntityDescription, + ) -> None: + """Initialize a Tailscale sensor.""" + super().__init__(coordinator=coordinator) + self.entity_description = description + self.device_id = device.device_id + self._attr_name = f"{device.hostname} {description.name}" + self._attr_unique_id = f"{device.device_id}_{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + device: TailscaleDevice = self.coordinator.data[self.device_id] + + configuration_url = "https://login.tailscale.com/admin/machines/" + if device.addresses: + configuration_url += device.addresses[0] + + return DeviceInfo( + configuration_url=configuration_url, + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, device.device_id)}, + manufacturer="Tailscale Inc.", + model=device.os, + name=device.hostname, + sw_version=device.client_version, + ) diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py new file mode 100644 index 00000000000..fff4cfbf908 --- /dev/null +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -0,0 +1,118 @@ +"""Support for Tailscale binary sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from tailscale import Device as TailscaleDevice + +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 TailscaleEntity +from .const import DOMAIN + + +@dataclass +class TailscaleBinarySensorEntityDescriptionMixin: + """Mixin for required keys.""" + + is_on_fn: Callable[[TailscaleDevice], bool | None] + + +@dataclass +class TailscaleBinarySensorEntityDescription( + BinarySensorEntityDescription, TailscaleBinarySensorEntityDescriptionMixin +): + """Describes a Tailscale binary sensor entity.""" + + +BINARY_SENSORS: tuple[TailscaleBinarySensorEntityDescription, ...] = ( + TailscaleBinarySensorEntityDescription( + key="update_available", + name="Client", + device_class=BinarySensorDeviceClass.UPDATE, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda device: device.update_available, + ), + TailscaleBinarySensorEntityDescription( + key="client_supports_hair_pinning", + name="Supports Hairpinning", + icon="mdi:wan", + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda device: device.client_connectivity.client_supports.hair_pinning, + ), + TailscaleBinarySensorEntityDescription( + key="client_supports_ipv6", + name="Supports IPv6", + icon="mdi:wan", + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda device: device.client_connectivity.client_supports.ipv6, + ), + TailscaleBinarySensorEntityDescription( + key="client_supports_pcp", + name="Supports PCP", + icon="mdi:wan", + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda device: device.client_connectivity.client_supports.pcp, + ), + TailscaleBinarySensorEntityDescription( + key="client_supports_pmp", + name="Supports NAT-PMP", + icon="mdi:wan", + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda device: device.client_connectivity.client_supports.pmp, + ), + TailscaleBinarySensorEntityDescription( + key="client_supports_udp", + name="Supports UDP", + icon="mdi:wan", + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda device: device.client_connectivity.client_supports.udp, + ), + TailscaleBinarySensorEntityDescription( + key="client_supports_upnp", + name="Supports UPnP", + icon="mdi:wan", + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda device: device.client_connectivity.client_supports.upnp, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Tailscale binary sensors based on a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailscaleBinarySensorEntity( + coordinator=coordinator, + device=device, + description=description, + ) + for device in coordinator.data.values() + for description in BINARY_SENSORS + ) + + +class TailscaleBinarySensorEntity(TailscaleEntity, BinarySensorEntity): + """Defines a Tailscale binary sensor.""" + + entity_description: TailscaleBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return the state of the sensor.""" + return bool( + self.entity_description.is_on_fn(self.coordinator.data[self.device_id]) + ) diff --git a/homeassistant/components/tailscale/config_flow.py b/homeassistant/components/tailscale/config_flow.py new file mode 100644 index 00000000000..cda4020a290 --- /dev/null +++ b/homeassistant/components/tailscale/config_flow.py @@ -0,0 +1,122 @@ +"""Config flow to configure the Tailscale integration.""" +from __future__ import annotations + +from typing import Any + +from tailscale import Tailscale, TailscaleAuthenticationError, TailscaleError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_TAILNET, DOMAIN + + +async def validate_input(hass: HomeAssistant, *, tailnet: str, api_key: str) -> None: + """Try using the give tailnet & api key against the Tailscale API.""" + session = async_get_clientsession(hass) + tailscale = Tailscale( + session=session, + api_key=api_key, + tailnet=tailnet, + ) + await tailscale.devices() + + +class TailscaleFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for Tailscale.""" + + VERSION = 1 + + reauth_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + try: + await validate_input( + self.hass, + tailnet=user_input[CONF_TAILNET], + api_key=user_input[CONF_API_KEY], + ) + except TailscaleAuthenticationError: + errors["base"] = "invalid_auth" + except TailscaleError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(user_input[CONF_TAILNET]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_TAILNET], + data={ + CONF_TAILNET: user_input[CONF_TAILNET], + CONF_API_KEY: user_input[CONF_API_KEY], + }, + ) + else: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_TAILNET, default=user_input.get(CONF_TAILNET, "") + ): str, + vol.Required( + CONF_API_KEY, default=user_input.get(CONF_API_KEY, "") + ): str, + } + ), + errors=errors, + ) + + async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with Tailscale.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle re-authentication with Tailscale.""" + errors = {} + + if user_input is not None and self.reauth_entry: + try: + await validate_input( + self.hass, + tailnet=self.reauth_entry.data[CONF_TAILNET], + api_key=user_input[CONF_API_KEY], + ) + except TailscaleAuthenticationError: + errors["base"] = "invalid_auth" + except TailscaleError: + errors["base"] = "cannot_connect" + else: + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data={ + **self.reauth_entry.data, + CONF_API_KEY: user_input[CONF_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_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) diff --git a/homeassistant/components/tailscale/const.py b/homeassistant/components/tailscale/const.py new file mode 100644 index 00000000000..7cdf0cddf71 --- /dev/null +++ b/homeassistant/components/tailscale/const.py @@ -0,0 +1,13 @@ +"""Constants for the Tailscale integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "tailscale" + +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(minutes=1) + +CONF_TAILNET: Final = "tailnet" diff --git a/homeassistant/components/tailscale/coordinator.py b/homeassistant/components/tailscale/coordinator.py new file mode 100644 index 00000000000..daebfe807c1 --- /dev/null +++ b/homeassistant/components/tailscale/coordinator.py @@ -0,0 +1,39 @@ +"""DataUpdateCoordinator for the Tailscale integration.""" +from __future__ import annotations + +from tailscale import Device, Tailscale, TailscaleAuthenticationError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +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 + +from .const import CONF_TAILNET, DOMAIN, LOGGER, SCAN_INTERVAL + + +class TailscaleDataUpdateCoordinator(DataUpdateCoordinator): + """The Tailscale Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the Tailscale coordinator.""" + self.config_entry = entry + + session = async_get_clientsession(hass) + self.tailscale = Tailscale( + session=session, + api_key=entry.data[CONF_API_KEY], + tailnet=entry.data[CONF_TAILNET], + ) + + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + + async def _async_update_data(self) -> dict[str, Device]: + """Fetch devices from Tailscale.""" + try: + return await self.tailscale.devices() + except TailscaleAuthenticationError as err: + raise ConfigEntryAuthFailed from err diff --git a/homeassistant/components/tailscale/manifest.json b/homeassistant/components/tailscale/manifest.json new file mode 100644 index 00000000000..4d47e397b76 --- /dev/null +++ b/homeassistant/components/tailscale/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tailscale", + "name": "Tailscale", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tailscale", + "requirements": ["tailscale==0.1.4"], + "codeowners": ["@frenck"], + "quality_scale": "platinum", + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/tailscale/sensor.py b/homeassistant/components/tailscale/sensor.py new file mode 100644 index 00000000000..07f7dbe91cc --- /dev/null +++ b/homeassistant/components/tailscale/sensor.py @@ -0,0 +1,88 @@ +"""Support for Tailscale sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from tailscale import Device as TailscaleDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +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 TailscaleEntity +from .const import DOMAIN + + +@dataclass +class TailscaleSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[TailscaleDevice], datetime | str | None] + + +@dataclass +class TailscaleSensorEntityDescription( + SensorEntityDescription, TailscaleSensorEntityDescriptionMixin +): + """Describes a Tailscale sensor entity.""" + + +SENSORS: tuple[TailscaleSensorEntityDescription, ...] = ( + TailscaleSensorEntityDescription( + key="expires", + name="Expires", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.expires, + ), + TailscaleSensorEntityDescription( + key="ip", + name="IP Address", + icon="mdi:ip-network", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.addresses[0] if device.addresses else None, + ), + TailscaleSensorEntityDescription( + key="last_seen", + name="Last Seen", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda device: device.last_seen, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Tailscale sensors based on a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailscaleSensorEntity( + coordinator=coordinator, + device=device, + description=description, + ) + for device in coordinator.data.values() + for description in SENSORS + ) + + +class TailscaleSensorEntity(TailscaleEntity, SensorEntity): + """Defines a Tailscale sensor.""" + + entity_description: TailscaleSensorEntityDescription + + @property + def native_value(self) -> datetime | str | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data[self.device_id]) diff --git a/homeassistant/components/tailscale/strings.json b/homeassistant/components/tailscale/strings.json new file mode 100644 index 00000000000..247d6032c03 --- /dev/null +++ b/homeassistant/components/tailscale/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "user": { + "description": "To authenticate with Tailscale you'll need to create an API key at https://login.tailscale.com/admin/settings/authkeys.\n\nA Tailnet is the name of your Tailscale network. You can find it in the top left corner in the Tailscale Admin Panel (beside the Tailscale logo).", + "data": { + "tailnet": "Tailnet", + "api_key": "[%key:common::config_flow::data::api_key%]" + } + }, + "reauth_confirm": { + "description":"Tailscale API tokens are valid for 90-days. You can create a fresh Tailscale API key at https://login.tailscale.com/admin/settings/authkeys.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/tailscale/translations/ca.json b/homeassistant/components/tailscale/translations/ca.json new file mode 100644 index 00000000000..d111da767f2 --- /dev/null +++ b/homeassistant/components/tailscale/translations/ca.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "Clau API" + }, + "description": "Els tokens API de Tailscale s\u00f3n v\u00e0lids durant 90 dies. Pots crear una nova clau API de Tailscale a https://login.tailscale.com/admin/settings/authkeys." + }, + "user": { + "data": { + "api_key": "Clau API", + "tailnet": "Tailnet" + }, + "description": "Per autenticar-te amb Tailscale, has de crear una clau API a https://login.tailscale.com/admin/settings/authkeys. \n\nLa Tailnet \u00e9s el nom de la teva xarxa Tailscale. La pots trobar a l'extrem superior esquerre del tauler d'administraci\u00f3 de Tailscale (al costat del logotip de Tailscale)." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tailscale/translations/de.json b/homeassistant/components/tailscale/translations/de.json new file mode 100644 index 00000000000..9fbcceaa674 --- /dev/null +++ b/homeassistant/components/tailscale/translations/de.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-Schl\u00fcssel" + }, + "description": "Tailscale-API-Token sind 90 Tage lang g\u00fcltig. Du kannst unter https://login.tailscale.com/admin/settings/authkeys einen neuen Tailscale-API-Schl\u00fcssel erstellen." + }, + "user": { + "data": { + "api_key": "API-Schl\u00fcssel", + "tailnet": "Tailnet" + }, + "description": "Um sich bei Tailscale zu authentifizieren, musst du einen API-Schl\u00fcssel unter https://login.tailscale.com/admin/settings/authkeys erstellen.\n\nEin Tailnet ist der Name Ihres Tailscale-Netzwerks. Sie finden ihn in der linken oberen Ecke des Tailscale Admin Panels (neben dem Tailscale Logo)." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tailscale/translations/en.json b/homeassistant/components/tailscale/translations/en.json new file mode 100644 index 00000000000..f1e79785cbf --- /dev/null +++ b/homeassistant/components/tailscale/translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API Key" + }, + "description": "Tailscale API tokens are valid for 90-days. You can create a fresh Tailscale API key at https://login.tailscale.com/admin/settings/authkeys." + }, + "user": { + "data": { + "api_key": "API Key", + "tailnet": "Tailnet" + }, + "description": "To authenticate with Tailscale you'll need to create an API key at https://login.tailscale.com/admin/settings/authkeys.\n\nA Tailnet is the name of your Tailscale network. You can find it in the top left corner in the Tailscale Admin Panel (beside the Tailscale logo)." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tailscale/translations/et.json b/homeassistant/components/tailscale/translations/et.json new file mode 100644 index 00000000000..d542c930ecd --- /dev/null +++ b/homeassistant/components/tailscale/translations/et.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "reauth_successful": "Taastuvastamine \u00f5nnestus" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API v\u00f5ti" + }, + "description": "Tailscale API v\u00f5tmed kehtivad 90 p\u00e4eva. Saad luua v\u00e4rske Tailscale API v\u00f5tme aadressil https://login.tailscale.com/admin/settings/authkeys." + }, + "user": { + "data": { + "api_key": "API v\u00f5ti", + "tailnet": "Tailnet" + }, + "description": "Tailscale'iga autentimiseks pead looma API v\u00f5tme aadressil https://login.tailscale.com/admin/settings/authkeys. \n\n Tailnet on Tailscale v\u00f5rgu nimi. Leiad selle Tailscale halduspaneeli vasakus \u00fclanurgas (Tailscale logo k\u00f5rval)." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tailscale/translations/hu.json b/homeassistant/components/tailscale/translations/hu.json new file mode 100644 index 00000000000..ec727cbc00f --- /dev/null +++ b/homeassistant/components/tailscale/translations/hu.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API kulcs" + }, + "description": "A Tailscale API kulcsok 90 napig \u00e9rv\u00e9nyesek. \u00daj Tailscale API kulcsot a https://login.tailscale.com/admin/settings/authkeys oldalon hozhat l\u00e9tre." + }, + "user": { + "data": { + "api_key": "API kulcs", + "tailnet": "Tailnet" + }, + "description": "A Tailscale-rel val\u00f3 hiteles\u00edt\u00e9shez l\u00e9tre kell hoznia egy API-kulcsot a https://login.tailscale.com/admin/settings/authkeys oldalon.\n\nTailnet az \u00f6n tailscale h\u00e1l\u00f3zat\u00e1nak neve. Megtal\u00e1lhat\u00f3 a bal fels\u0151 sarokban a Tailscale Admin panelen (a Tailscale log\u00f3 mellett)." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tailscale/translations/id.json b/homeassistant/components/tailscale/translations/id.json new file mode 100644 index 00000000000..d88a47fa82e --- /dev/null +++ b/homeassistant/components/tailscale/translations/id.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "Kunci API" + }, + "description": "Token API Tailscale berlaku selama 90 hari. Anda dapat membuat kunci API Tailscale baru di https://login.tailscale.com/admin/settings/authkeys." + }, + "user": { + "data": { + "api_key": "Kunci API", + "tailnet": "Tailnet" + }, + "description": "Untuk mengautentikasi dengan Tailscale, Anda harus membuat kunci API di https://login.tailscale.com/admin/settings/authkeys. \n\nTailnet adalah nama jaringan Tailscale Anda. Anda dapat menemukannya di pojok kiri atas di Panel Admin Tailscale (di samping logo Tailscale)." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tailscale/translations/ja.json b/homeassistant/components/tailscale/translations/ja.json new file mode 100644 index 00000000000..bed4a13e8d3 --- /dev/null +++ b/homeassistant/components/tailscale/translations/ja.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "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" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API\u30ad\u30fc" + } + }, + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "tailnet": "Tailnet" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tailscale/translations/nl.json b/homeassistant/components/tailscale/translations/nl.json new file mode 100644 index 00000000000..5e46f4f0511 --- /dev/null +++ b/homeassistant/components/tailscale/translations/nl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "reauth_successful": "Herauthenticatie was succesvol" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-sleutel" + }, + "description": "Tailscale API tokens zijn 90 dagen geldig. U kunt een nieuwe Tailscale API sleutel aanmaken op https://login.tailscale.com/admin/settings/authkeys." + }, + "user": { + "data": { + "api_key": "API-sleutel", + "tailnet": "Tailnet" + }, + "description": "Om te authenticeren met Tailscale moet je een API-sleutel maken op https://login.tailscale.com/admin/settings/authkeys. \n\n Een Tailnet is de naam van uw Tailscale-netwerk. Je vindt het in de linkerbovenhoek in het Tailscale Admin Panel (naast het Tailscale-logo)." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tailscale/translations/no.json b/homeassistant/components/tailscale/translations/no.json new file mode 100644 index 00000000000..627facd8f66 --- /dev/null +++ b/homeassistant/components/tailscale/translations/no.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-n\u00f8kkel" + }, + "description": "Tailscale API-tokens er gyldige i 90 dager. Du kan opprette en ny Tailscale API-n\u00f8kkel p\u00e5 https://login.tailscale.com/admin/settings/authkeys." + }, + "user": { + "data": { + "api_key": "API-n\u00f8kkel", + "tailnet": "Tailnet" + }, + "description": "For \u00e5 autentisere med Tailscale m\u00e5 du opprette en API-n\u00f8kkel p\u00e5 https://login.tailscale.com/admin/settings/authkeys. \n\n Et Tailnet er navnet p\u00e5 Tailscale-nettverket ditt. Du finner den i \u00f8verste venstre hj\u00f8rne i Tailscale Admin Panel (ved siden av Tailscale-logoen)." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tailscale/translations/ru.json b/homeassistant/components/tailscale/translations/ru.json new file mode 100644 index 00000000000..1b97b0998e7 --- /dev/null +++ b/homeassistant/components/tailscale/translations/ru.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "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.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API" + }, + "description": "\u0422\u043e\u043a\u0435\u043d\u044b API Tailscale \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u044b \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0435 90 \u0434\u043d\u0435\u0439. \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u0441\u043e\u0437\u0434\u0430\u0442\u044c \u043d\u043e\u0432\u044b\u0439 \u043a\u043b\u044e\u0447 API Tailscale \u043f\u043e \u0430\u0434\u0440\u0435\u0441\u0443 https://login.tailscale.com/admin/settings/authkeys." + }, + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "tailnet": "Tailnet" + }, + "description": "\u0414\u043b\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0441\u043e\u0437\u0434\u0430\u0442\u044c \u043a\u043b\u044e\u0447 API \u043f\u043e \u0430\u0434\u0440\u0435\u0441\u0443 https://login.tailscale.com/admin/settings/authkeys. \n\nTailnet \u2014 \u044d\u0442\u043e \u0438\u043c\u044f \u0412\u0430\u0448\u0435\u0439 \u0441\u0435\u0442\u0438 Tailscale. \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043d\u0430\u0439\u0442\u0438 \u0435\u0433\u043e \u0432 \u0432\u0435\u0440\u0445\u043d\u0435\u043c \u043b\u0435\u0432\u043e\u043c \u0443\u0433\u043b\u0443 \u043f\u0430\u043d\u0435\u043b\u0438 \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u0430 Tailscale (\u0440\u044f\u0434\u043e\u043c \u0441 \u043b\u043e\u0433\u043e\u0442\u0438\u043f\u043e\u043c Tailscale)." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tailscale/translations/tr.json b/homeassistant/components/tailscale/translations/tr.json new file mode 100644 index 00000000000..680acd65d70 --- /dev/null +++ b/homeassistant/components/tailscale/translations/tr.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "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" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API Anahtar\u0131" + }, + "description": "Tailnet API belirte\u00e7leri 90 g\u00fcn boyunca ge\u00e7erlidir. https://login.tailscale.com/admin/settings/authkeys adresinde yeni bir Tailscale API anahtar\u0131 olu\u015fturabilirsiniz." + }, + "user": { + "data": { + "api_key": "API Anahtar\u0131", + "tailnet": "Tailnet" + }, + "description": "Tailscale ile kimlik do\u011frulamas\u0131 yapmak i\u00e7in https://login.tailscale.com/admin/settings/authkeys adresinde bir API anahtar\u0131 olu\u015fturman\u0131z gerekir. \n\n Kuyruk a\u011f\u0131, Kuyruk \u00f6l\u00e7e\u011fi a\u011f\u0131n\u0131z\u0131n ad\u0131d\u0131r. Bunu, Tailscale Y\u00f6netici Panelinin sol \u00fcst k\u00f6\u015fesinde (Tailscale logosunun yan\u0131nda) bulabilirsiniz." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tailscale/translations/zh-Hant.json b/homeassistant/components/tailscale/translations/zh-Hant.json new file mode 100644 index 00000000000..b47dd0cc57b --- /dev/null +++ b/homeassistant/components/tailscale/translations/zh-Hant.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API \u5bc6\u9470" + }, + "description": "Tailscale API \u6b0a\u6756\u6709\u6548\u671f\u70ba 90 \u5929\u3002\u53ef\u4ee5\u65bc https://login.tailscale.com/admin/settings/authkeys \u53d6\u5f97\u66f4\u65b0 Tailscale API \u5bc6\u9470\u3002" + }, + "user": { + "data": { + "api_key": "API \u5bc6\u9470", + "tailnet": "Tailnet" + }, + "description": "\u6b32\u4f7f\u7528 Tailscale \u8a8d\u8b49\u3001\u5c07\u9700\u8981\u65bc https://login.tailscale.com/admin/settings/authkeys \u65b0\u589e\u4e00\u7d44 API \u5bc6\u9470 \n\nTailnet \u70ba Tailscale \u7db2\u8def\u7684\u540d\u7a31\uff0c\u53ef\u4ee5\u65bc Tailscale \u7ba1\u7406\u9762\u677f\uff08Tailscale Logo \u65c1\uff09\u7684\u5de6\u4e0a\u65b9\u627e\u5230\u6b64\u8cc7\u8a0a\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tasmota/__init__.py b/homeassistant/components/tasmota/__init__.py index fd156e20c3c..f8dcd4035df 100644 --- a/homeassistant/components/tasmota/__init__.py +++ b/homeassistant/components/tasmota/__init__.py @@ -49,13 +49,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: websocket_api.async_register_command(hass, websocket_remove_device) hass.data[DATA_UNSUB] = [] - def _publish( + async def _publish( topic: str, payload: mqtt.PublishPayloadType, - qos: int | None = None, - retain: bool | None = None, + qos: int | None, + retain: bool | None, ) -> None: - mqtt.async_publish(hass, topic, payload, qos, retain) + await mqtt.async_publish(hass, topic, payload, qos, retain) async def _subscribe_topics(sub_state: dict | None, topics: dict) -> dict: # Optionally mark message handlers as callback @@ -71,9 +71,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_registry = await hass.helpers.device_registry.async_get_registry() - def async_discover_device(config: TasmotaDeviceConfig, mac: str) -> None: + async def async_discover_device(config: TasmotaDeviceConfig, mac: str) -> None: """Discover and add a Tasmota device.""" - async_setup_device(hass, mac, config, entry, tasmota_mqtt, device_registry) + await async_setup_device( + hass, mac, config, entry, tasmota_mqtt, device_registry + ) async def async_device_removed(event: Event) -> None: """Handle the removal of a device.""" @@ -88,7 +90,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: macs = [c[1] for c in device.connections if c[0] == CONNECTION_NETWORK_MAC] for mac in macs: - clear_discovery_topic(mac, entry.data[CONF_DISCOVERY_PREFIX], tasmota_mqtt) + await clear_discovery_topic( + mac, entry.data[CONF_DISCOVERY_PREFIX], tasmota_mqtt + ) hass.data[DATA_UNSUB].append( hass.bus.async_listen(EVENT_DEVICE_REGISTRY_UPDATED, async_device_removed) @@ -139,7 +143,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def _remove_device( +async def _remove_device( hass: HomeAssistant, config_entry: ConfigEntry, mac: str, @@ -154,7 +158,9 @@ def _remove_device( _LOGGER.debug("Removing tasmota device %s", mac) device_registry.async_remove_device(device.id) - clear_discovery_topic(mac, config_entry.data[CONF_DISCOVERY_PREFIX], tasmota_mqtt) + await clear_discovery_topic( + mac, config_entry.data[CONF_DISCOVERY_PREFIX], tasmota_mqtt + ) def _update_device( @@ -176,7 +182,7 @@ def _update_device( ) -def async_setup_device( +async def async_setup_device( hass: HomeAssistant, mac: str, config: TasmotaDeviceConfig, @@ -186,7 +192,7 @@ def async_setup_device( ) -> None: """Set up the Tasmota device.""" if not config: - _remove_device(hass, config_entry, mac, tasmota_mqtt, device_registry) + await _remove_device(hass, config_entry, mac, tasmota_mqtt, device_registry) else: _update_device(hass, config_entry, config, device_registry) diff --git a/homeassistant/components/tasmota/config_flow.py b/homeassistant/components/tasmota/config_flow.py index 435604b4bdd..e2109a16afe 100644 --- a/homeassistant/components/tasmota/config_flow.py +++ b/homeassistant/components/tasmota/config_flow.py @@ -6,9 +6,8 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.mqtt import valid_subscribe_topic +from homeassistant.components.mqtt import MqttServiceInfo, valid_subscribe_topic from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.typing import DiscoveryInfoType from .const import CONF_DISCOVERY_PREFIX, DEFAULT_PREFIX, DOMAIN @@ -22,7 +21,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Initialize flow.""" self._prefix = DEFAULT_PREFIX - async def async_step_mqtt(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_mqtt(self, discovery_info: MqttServiceInfo) -> FlowResult: """Handle a flow initialized by MQTT discovery.""" if self._async_in_progress() or self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -30,15 +29,15 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(DOMAIN) # Validate the message, abort if it fails - if not discovery_info["topic"].endswith("/config"): + if not discovery_info.topic.endswith("/config"): # Not a Tasmota discovery message return self.async_abort(reason="invalid_discovery_info") - if not discovery_info["payload"]: + if not discovery_info.payload: # Empty payload, the Tasmota is not configured for native discovery return self.async_abort(reason="invalid_discovery_info") # "tasmota/discovery/#" is hardcoded in Tasmota's manifest - assert discovery_info["subscribed_topic"] == "tasmota/discovery/#" + assert discovery_info.subscribed_topic == "tasmota/discovery/#" self._prefix = "tasmota/discovery" return await self.async_step_confirm() diff --git a/homeassistant/components/tasmota/cover.py b/homeassistant/components/tasmota/cover.py index 458c712ae3d..0b67e469929 100644 --- a/homeassistant/components/tasmota/cover.py +++ b/homeassistant/components/tasmota/cover.py @@ -111,17 +111,17 @@ class TasmotaCover( async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - self._tasmota_entity.open() + await self._tasmota_entity.open() async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - self._tasmota_entity.close() + await self._tasmota_entity.close() async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[cover.ATTR_POSITION] - self._tasmota_entity.set_position(position) + await self._tasmota_entity.set_position(position) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - self._tasmota_entity.stop() + await self._tasmota_entity.stop() diff --git a/homeassistant/components/tasmota/discovery.py b/homeassistant/components/tasmota/discovery.py index 37b373d30a1..5ba4a4032f8 100644 --- a/homeassistant/components/tasmota/discovery.py +++ b/homeassistant/components/tasmota/discovery.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Callable +from typing import Awaitable, Callable from hatasmota.discovery import ( TasmotaDiscovery, @@ -34,7 +34,7 @@ TASMOTA_DISCOVERY_ENTITY_NEW = "tasmota_discovery_entity_new_{}" TASMOTA_DISCOVERY_ENTITY_UPDATED = "tasmota_discovery_entity_updated_{}_{}_{}_{}" TASMOTA_DISCOVERY_INSTANCE = "tasmota_discovery_instance" -SetupDeviceCallback = Callable[[TasmotaDeviceConfig, str], None] +SetupDeviceCallback = Callable[[TasmotaDeviceConfig, str], Awaitable[None]] def clear_discovery_hash( @@ -119,7 +119,7 @@ async def async_start( _LOGGER.debug("Received discovery data for tasmota device: %s", mac) tasmota_device_config = tasmota_get_device_config(payload) - setup_device(tasmota_device_config, mac) + await setup_device(tasmota_device_config, mac) if not payload: return diff --git a/homeassistant/components/tasmota/fan.py b/homeassistant/components/tasmota/fan.py index 92399fa1bbc..6aabfb05091 100644 --- a/homeassistant/components/tasmota/fan.py +++ b/homeassistant/components/tasmota/fan.py @@ -109,7 +109,7 @@ class TasmotaFan( tasmota_speed = percentage_to_ordered_list_item( ORDERED_NAMED_FAN_SPEEDS, percentage ) - self._tasmota_entity.set_speed(tasmota_speed) + await self._tasmota_entity.set_speed(tasmota_speed) async def async_turn_on( self, @@ -129,4 +129,4 @@ class TasmotaFan( async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" - self._tasmota_entity.set_speed(tasmota_const.FAN_SPEED_OFF) + await self._tasmota_entity.set_speed(tasmota_const.FAN_SPEED_OFF) diff --git a/homeassistant/components/tasmota/light.py b/homeassistant/components/tasmota/light.py index c09b4c71948..e739967b48b 100644 --- a/homeassistant/components/tasmota/light.py +++ b/homeassistant/components/tasmota/light.py @@ -275,7 +275,7 @@ class TasmotaLight( if ATTR_EFFECT in kwargs: attributes["effect"] = kwargs[ATTR_EFFECT] - self._tasmota_entity.set_state(True, attributes) + await self._tasmota_entity.set_state(True, attributes) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" @@ -284,4 +284,4 @@ class TasmotaLight( if ATTR_TRANSITION in kwargs: attributes["transition"] = kwargs[ATTR_TRANSITION] - self._tasmota_entity.set_state(False, attributes) + await self._tasmota_entity.set_state(False, attributes) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 592f833fd12..bd30231396f 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -3,7 +3,7 @@ "name": "Tasmota", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tasmota", - "requirements": ["hatasmota==0.2.21"], + "requirements": ["hatasmota==0.3.1"], "dependencies": ["mqtt"], "mqtt": ["tasmota/discovery/#"], "codeowners": ["@emontnemery"], diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index a07e48b53a7..1cac7fc2d4b 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -62,7 +62,9 @@ class TasmotaEntity(Entity): @property def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" - return {"connections": {(CONNECTION_NETWORK_MAC, self._tasmota_entity.mac)}} + return DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self._tasmota_entity.mac)} + ) @property def name(self) -> str | None: @@ -123,10 +125,9 @@ class TasmotaAvailability(TasmotaEntity): ) await super().async_added_to_hass() - @callback - def availability_updated(self, available: bool) -> None: + async def availability_updated(self, available: bool) -> None: """Handle updated availability.""" - self._tasmota_entity.poll_status() + await self._tasmota_entity.poll_status() self._available = available self.async_write_ha_state() diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index 348ff741c9b..961a89cfb31 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -10,25 +10,15 @@ from hatasmota.models import DiscoveryHashType from homeassistant.components import sensor from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, SensorEntity, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CO2, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_POWER, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, ELECTRIC_CURRENT_AMPERE, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, @@ -63,28 +53,55 @@ STATE_CLASS = "state_class" ICON = "icon" # A Tasmota sensor type may be mapped to either a device class or an icon, not both -SENSOR_DEVICE_CLASS_ICON_MAP = { - hc.SENSOR_AMBIENT: {DEVICE_CLASS: DEVICE_CLASS_ILLUMINANCE}, - hc.SENSOR_APPARENT_POWERUSAGE: {DEVICE_CLASS: DEVICE_CLASS_POWER}, - hc.SENSOR_BATTERY: { - DEVICE_CLASS: DEVICE_CLASS_BATTERY, - STATE_CLASS: STATE_CLASS_MEASUREMENT, +SENSOR_DEVICE_CLASS_ICON_MAP: dict[str, dict[str, Any]] = { + hc.SENSOR_AMBIENT: { + DEVICE_CLASS: SensorDeviceClass.ILLUMINANCE, + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + hc.SENSOR_APPARENT_POWERUSAGE: { + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + hc.SENSOR_BATTERY: { + DEVICE_CLASS: SensorDeviceClass.BATTERY, + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + hc.SENSOR_CCT: { + ICON: "mdi:temperature-kelvin", + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + hc.SENSOR_CO2: { + DEVICE_CLASS: SensorDeviceClass.CO2, + STATE_CLASS: SensorStateClass.MEASUREMENT, }, - hc.SENSOR_CCT: {ICON: "mdi:temperature-kelvin"}, - hc.SENSOR_CO2: {DEVICE_CLASS: DEVICE_CLASS_CO2}, hc.SENSOR_COLOR_BLUE: {ICON: "mdi:palette"}, hc.SENSOR_COLOR_GREEN: {ICON: "mdi:palette"}, hc.SENSOR_COLOR_RED: {ICON: "mdi:palette"}, - hc.SENSOR_CURRENT: {ICON: "mdi:alpha-a-circle-outline"}, - hc.SENSOR_DEWPOINT: {ICON: "mdi:weather-rainy"}, - hc.SENSOR_DISTANCE: {ICON: "mdi:leak"}, - hc.SENSOR_ECO2: {ICON: "mdi:molecule-co2"}, - hc.SENSOR_FREQUENCY: {ICON: "mdi:current-ac"}, - hc.SENSOR_HUMIDITY: { - DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - STATE_CLASS: STATE_CLASS_MEASUREMENT, + hc.SENSOR_CURRENT: { + ICON: "mdi:alpha-a-circle-outline", + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + hc.SENSOR_DEWPOINT: { + DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ICON: "mdi:weather-rainy", + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + hc.SENSOR_DISTANCE: { + ICON: "mdi:leak", + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + hc.SENSOR_ECO2: {ICON: "mdi:molecule-co2"}, + hc.SENSOR_FREQUENCY: { + DEVICE_CLASS: SensorDeviceClass.FREQUENCY, + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + hc.SENSOR_HUMIDITY: { + DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + hc.SENSOR_ILLUMINANCE: { + DEVICE_CLASS: SensorDeviceClass.ILLUMINANCE, + STATE_CLASS: SensorStateClass.MEASUREMENT, }, - hc.SENSOR_ILLUMINANCE: {DEVICE_CLASS: DEVICE_CLASS_ILLUMINANCE}, hc.SENSOR_STATUS_IP: {ICON: "mdi:ip-network"}, hc.SENSOR_STATUS_LINK_COUNT: {ICON: "mdi:counter"}, hc.SENSOR_MOISTURE: {ICON: "mdi:cup-water"}, @@ -95,40 +112,66 @@ SENSOR_DEVICE_CLASS_ICON_MAP = { hc.SENSOR_PB1: {ICON: "mdi:flask"}, hc.SENSOR_PB2_5: {ICON: "mdi:flask"}, hc.SENSOR_PB5: {ICON: "mdi:flask"}, - hc.SENSOR_PM10: {ICON: "mdi:air-filter"}, - hc.SENSOR_PM1: {ICON: "mdi:air-filter"}, - hc.SENSOR_PM2_5: {ICON: "mdi:air-filter"}, - hc.SENSOR_POWERFACTOR: {ICON: "mdi:alpha-f-circle-outline"}, - hc.SENSOR_POWERUSAGE: {DEVICE_CLASS: DEVICE_CLASS_POWER}, + hc.SENSOR_PM10: { + DEVICE_CLASS: SensorDeviceClass.PM10, + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + hc.SENSOR_PM1: { + DEVICE_CLASS: SensorDeviceClass.PM1, + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + hc.SENSOR_PM2_5: { + DEVICE_CLASS: SensorDeviceClass.PM25, + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + hc.SENSOR_POWERFACTOR: { + ICON: "mdi:alpha-f-circle-outline", + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + hc.SENSOR_POWERUSAGE: { + DEVICE_CLASS: SensorDeviceClass.POWER, + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, hc.SENSOR_PRESSURE: { - DEVICE_CLASS: DEVICE_CLASS_PRESSURE, - STATE_CLASS: STATE_CLASS_MEASUREMENT, + DEVICE_CLASS: SensorDeviceClass.PRESSURE, + STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_PRESSUREATSEALEVEL: { - DEVICE_CLASS: DEVICE_CLASS_PRESSURE, - STATE_CLASS: STATE_CLASS_MEASUREMENT, + DEVICE_CLASS: SensorDeviceClass.PRESSURE, + STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_PROXIMITY: {ICON: "mdi:ruler"}, - hc.SENSOR_REACTIVE_POWERUSAGE: {DEVICE_CLASS: DEVICE_CLASS_POWER}, - hc.SENSOR_STATUS_LAST_RESTART_TIME: {DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP}, + hc.SENSOR_REACTIVE_POWERUSAGE: { + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + hc.SENSOR_STATUS_LAST_RESTART_TIME: {DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, hc.SENSOR_STATUS_RESTART_REASON: {ICON: "mdi:information-outline"}, - hc.SENSOR_STATUS_SIGNAL: {DEVICE_CLASS: DEVICE_CLASS_SIGNAL_STRENGTH}, - hc.SENSOR_STATUS_RSSI: {ICON: "mdi:access-point"}, + hc.SENSOR_STATUS_SIGNAL: { + DEVICE_CLASS: SensorDeviceClass.SIGNAL_STRENGTH, + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + hc.SENSOR_STATUS_RSSI: { + ICON: "mdi:access-point", + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, hc.SENSOR_STATUS_SSID: {ICON: "mdi:access-point-network"}, hc.SENSOR_TEMPERATURE: { - DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - STATE_CLASS: STATE_CLASS_MEASUREMENT, + DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + STATE_CLASS: SensorStateClass.MEASUREMENT, }, - hc.SENSOR_TODAY: {DEVICE_CLASS: DEVICE_CLASS_ENERGY}, + hc.SENSOR_TODAY: {DEVICE_CLASS: SensorDeviceClass.ENERGY}, hc.SENSOR_TOTAL: { - DEVICE_CLASS: DEVICE_CLASS_ENERGY, - STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + DEVICE_CLASS: SensorDeviceClass.ENERGY, + STATE_CLASS: SensorStateClass.TOTAL_INCREASING, }, hc.SENSOR_TOTAL_START_TIME: {ICON: "mdi:progress-clock"}, hc.SENSOR_TVOC: {ICON: "mdi:air-filter"}, - hc.SENSOR_VOLTAGE: {ICON: "mdi:alpha-v-circle-outline"}, - hc.SENSOR_WEIGHT: {ICON: "mdi:scale"}, - hc.SENSOR_YESTERDAY: {DEVICE_CLASS: DEVICE_CLASS_ENERGY}, + hc.SENSOR_VOLTAGE: { + ICON: "mdi:alpha-v-circle-outline", + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + hc.SENSOR_WEIGHT: {ICON: "mdi:scale", STATE_CLASS: SensorStateClass.MEASUREMENT}, + hc.SENSOR_YESTERDAY: {DEVICE_CLASS: SensorDeviceClass.ENERGY}, } SENSOR_UNIT_MAP = { @@ -208,7 +251,7 @@ class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): @callback def sensor_state_updated(self, state: Any, **kwargs: Any) -> None: """Handle state updates.""" - if self.device_class == DEVICE_CLASS_TIMESTAMP: + if self.device_class == SensorDeviceClass.TIMESTAMP: self._state_timestamp = state else: self._state = state @@ -259,10 +302,10 @@ class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): return class_or_icon.get(ICON) @property - def native_value(self) -> str | None: + def native_value(self) -> datetime | str | None: """Return the state of the entity.""" - if self._state_timestamp and self.device_class == DEVICE_CLASS_TIMESTAMP: - return self._state_timestamp.isoformat() + if self._state_timestamp and self.device_class == SensorDeviceClass.TIMESTAMP: + return self._state_timestamp return self._state @property diff --git a/homeassistant/components/tasmota/switch.py b/homeassistant/components/tasmota/switch.py index 50319abac56..8ee4d2f47ee 100644 --- a/homeassistant/components/tasmota/switch.py +++ b/homeassistant/components/tasmota/switch.py @@ -58,8 +58,8 @@ class TasmotaSwitch( async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - self._tasmota_entity.set_state(True) + await self._tasmota_entity.set_state(True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - self._tasmota_entity.set_state(False) + await self._tasmota_entity.set_state(False) diff --git a/homeassistant/components/tasmota/translations/ja.json b/homeassistant/components/tasmota/translations/ja.json new file mode 100644 index 00000000000..353b3020e8c --- /dev/null +++ b/homeassistant/components/tasmota/translations/ja.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "invalid_discovery_topic": "(\u4e0d\u6b63\u306a)Invalid discovery topic prefix." + }, + "step": { + "config": { + "data": { + "discovery_prefix": "(\u691c\u51fa)Discovery topic prefix" + }, + "description": "Tasmota\u306e\u8a2d\u5b9a\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Tasmota" + }, + "confirm": { + "description": "Tasmota\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tasmota/translations/ru.json b/homeassistant/components/tasmota/translations/ru.json index 3f4b8996f2f..4f01d164030 100644 --- a/homeassistant/components/tasmota/translations/ru.json +++ b/homeassistant/components/tasmota/translations/ru.json @@ -11,7 +11,7 @@ "data": { "discovery_prefix": "\u041f\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" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Tasmota.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Tasmota.", "title": "Tasmota" }, "confirm": { diff --git a/homeassistant/components/tasmota/translations/tr.json b/homeassistant/components/tasmota/translations/tr.json index a559d0911ee..95e4e41b11e 100644 --- a/homeassistant/components/tasmota/translations/tr.json +++ b/homeassistant/components/tasmota/translations/tr.json @@ -3,8 +3,14 @@ "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, + "error": { + "invalid_discovery_topic": "Ge\u00e7ersiz ba\u015fl\u0131k ke\u015ffi" + }, "step": { "config": { + "data": { + "discovery_prefix": "Ba\u015fl\u0131k Ke\u015ffet" + }, "description": "L\u00fctfen Tasmota yap\u0131land\u0131rmas\u0131n\u0131 girin.", "title": "Tasmota" }, diff --git a/homeassistant/components/tautulli/manifest.json b/homeassistant/components/tautulli/manifest.json index cad31683c73..68edea99838 100644 --- a/homeassistant/components/tautulli/manifest.json +++ b/homeassistant/components/tautulli/manifest.json @@ -2,7 +2,7 @@ "domain": "tautulli", "name": "Tautulli", "documentation": "https://www.home-assistant.io/integrations/tautulli", - "requirements": ["pytautulli==21.10.0"], + "requirements": ["pytautulli==21.11.0"], "codeowners": ["@ludeeus"], "iot_class": "local_polling" } diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 7fd83141b7d..c79b8c5a033 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -575,7 +575,7 @@ class TelegramNotificationService: } if message_tag is not None: event_data[ATTR_MESSAGE_TAG] = message_tag - self.hass.bus.async_fire(EVENT_TELEGRAM_SENT, event_data) + self.hass.bus.fire(EVENT_TELEGRAM_SENT, event_data) elif not isinstance(out, bool): _LOGGER.warning( "Update last message: out_type:%s, out=%s", type(out), out diff --git a/homeassistant/components/tellduslive/config_flow.py b/homeassistant/components/tellduslive/config_flow.py index 712f25560cd..18ae324a572 100644 --- a/homeassistant/components/tellduslive/config_flow.py +++ b/homeassistant/components/tellduslive/config_flow.py @@ -92,7 +92,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): auth_url = await self.hass.async_add_executor_job(self._get_auth_url) if not auth_url: return self.async_abort(reason="unknown_authorize_url_generation") diff --git a/homeassistant/components/tellduslive/translations/bg.json b/homeassistant/components/tellduslive/translations/bg.json index 6e770c58b30..6848917b501 100644 --- a/homeassistant/components/tellduslive/translations/bg.json +++ b/homeassistant/components/tellduslive/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", "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.", "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, diff --git a/homeassistant/components/tellduslive/translations/ja.json b/homeassistant/components/tellduslive/translations/ja.json new file mode 100644 index 00000000000..cbeefb97eb2 --- /dev/null +++ b/homeassistant/components/tellduslive/translations/ja.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "authorize_url_timeout": "\u8a8d\u8a3cURL\u306e\u751f\u6210\u304c\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc", + "unknown_authorize_url_generation": "\u8a8d\u8a3cURL\u306e\u751f\u6210\u4e2d\u306b\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "auth": { + "description": "TelldusLive\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u30ea\u30f3\u30af\u3059\u308b\u306b\u306f:\n1. \u4ee5\u4e0b\u306e\u30ea\u30f3\u30af\u3092\u30af\u30ea\u30c3\u30af\u3057\u307e\u3059\n2. Telldus Live\u306b\u30ed\u30b0\u30a4\u30f3\u3057\u307e\u3059\n3. **{app_name}** \u3092\u627f\u8a8d\u3057\u307e\u3059(**Yes(\u306f\u3044)**\u3092\u30af\u30ea\u30c3\u30af\u3057\u307e\u3059)\n4. \u3053\u3053\u306b\u623b\u3063\u3066\u304d\u3066\u3001**\u9001\u4fe1(submit)** \u3092\u30af\u30ea\u30c3\u30af\u3057\u307e\u3059\u3002\n\n[TelldusLive\u30a2\u30ab\u30a6\u30f3\u30c8\u306e\u30ea\u30f3\u30af]({auth_url})", + "title": "TelldusLive\u306b\u5bfe\u3057\u3066\u8a8d\u8a3c\u3059\u308b" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "\u7a7a", + "title": "\u30a8\u30f3\u30c9\u30dd\u30a4\u30f3\u30c8\u3092\u9078\u3076\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tellduslive/translations/pl.json b/homeassistant/components/tellduslive/translations/pl.json index 4f42a4d3810..c0b386fe5ae 100644 --- a/homeassistant/components/tellduslive/translations/pl.json +++ b/homeassistant/components/tellduslive/translations/pl.json @@ -11,7 +11,7 @@ }, "step": { "auth": { - "description": "Aby po\u0142\u0105czy\u0107 konto TelldusLive: \n 1. Kliknij poni\u017cszy link \n 2. Zaloguj si\u0119 do Telldus Live \n 3. Autoryzuj **{app_name}** (kliknij **Tak**). \n 4. Wr\u00f3\u0107 tutaj i kliknij **SUBMIT**. \n\n [Link do konta TelldusLive]({auth_url})", + "description": "Aby po\u0142\u0105czy\u0107 konto TelldusLive: \n 1. Kliknij poni\u017cszy link \n 2. Zaloguj si\u0119 do Telldus Live \n 3. Autoryzuj **{app_name}** (kliknij **Tak**). \n 4. Wr\u00f3\u0107 tutaj i kliknij **Zatwierd\u017a**. \n\n [Link do konta TelldusLive]({auth_url})", "title": "Uwierzytelnienie na TelldusLive" }, "user": { diff --git a/homeassistant/components/tellduslive/translations/tr.json b/homeassistant/components/tellduslive/translations/tr.json index 300fad68391..7b462a4b61d 100644 --- a/homeassistant/components/tellduslive/translations/tr.json +++ b/homeassistant/components/tellduslive/translations/tr.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "authorize_url_timeout": "Yetkilendirme URL'si olu\u015ftururken zaman a\u015f\u0131m\u0131.", "unknown": "Beklenmeyen hata", "unknown_authorize_url_generation": "Yetkilendirme url'si olu\u015fturulurken bilinmeyen hata." }, @@ -9,10 +10,16 @@ "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" }, "step": { + "auth": { + "description": "TelldusLive hesab\u0131n\u0131z\u0131 ba\u011flamak i\u00e7in:\n 1. A\u015fa\u011f\u0131daki ba\u011flant\u0131ya t\u0131klay\u0131n\n 2. Telldus Live'a giri\u015f yap\u0131n\n 3. ** {app_name} **'yi yetkilendirin (**Evet**'i t\u0131klay\u0131n).\n 4. Buraya geri d\u00f6n\u00fcn ve **G\u00d6NDER**'e t\u0131klay\u0131n. \n\n [TelldusLive hesab\u0131n\u0131 ba\u011fla]( {auth_url} )", + "title": "TelldusLive'a kar\u015f\u0131 kimlik do\u011frulamas\u0131" + }, "user": { "data": { "host": "Ana Bilgisayar" - } + }, + "description": "Bo\u015f", + "title": "Biti\u015f noktas\u0131n\u0131 se\u00e7in." } } } diff --git a/homeassistant/components/telnet/switch.py b/homeassistant/components/telnet/switch.py index e4d93d9f685..5298713d5a1 100644 --- a/homeassistant/components/telnet/switch.py +++ b/homeassistant/components/telnet/switch.py @@ -157,9 +157,11 @@ class TelnetSwitch(SwitchEntity): self._telnet_command(self._command_on) if self.assumed_state: self._state = True + self.schedule_update_ha_state() def turn_off(self, **kwargs): """Turn the device off.""" self._telnet_command(self._command_off) if self.assumed_state: self._state = False + self.schedule_update_ha_state() diff --git a/homeassistant/components/temper/manifest.json b/homeassistant/components/temper/manifest.json index d80c44f8a87..0443987a87b 100644 --- a/homeassistant/components/temper/manifest.json +++ b/homeassistant/components/temper/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://www.home-assistant.io/integrations/temper", "requirements": ["temperusb==1.5.3"], "codeowners": [], - "iot_class": "local_push" + "iot_class": "local_polling" } diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 2cb830e54c2..05006049b02 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -36,6 +36,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.script import Script +from .const import DOMAIN from .template_entity import TemplateEntity _LOGGER = logging.getLogger(__name__) @@ -158,18 +159,17 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): self._disarm_script = None self._code_arm_required = code_arm_required self._code_format = code_format - domain = __name__.split(".")[-2] if disarm_action is not None: - self._disarm_script = Script(hass, disarm_action, name, domain) + self._disarm_script = Script(hass, disarm_action, name, DOMAIN) self._arm_away_script = None if arm_away_action is not None: - self._arm_away_script = Script(hass, arm_away_action, name, domain) + self._arm_away_script = Script(hass, arm_away_action, name, DOMAIN) self._arm_home_script = None if arm_home_action is not None: - self._arm_home_script = Script(hass, arm_home_action, name, domain) + self._arm_home_script = Script(hass, arm_home_action, name, DOMAIN) self._arm_night_script = None if arm_night_action is not None: - self._arm_night_script = Script(hass, arm_night_action, name, domain) + self._arm_night_script = Script(hass, arm_night_action, name, DOMAIN) self._state = None self._unique_id = unique_id diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 9a4c3f93f63..06af6dca925 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import timedelta from functools import partial import logging +from typing import Any import voluptuous as vol @@ -45,7 +46,11 @@ from .const import ( CONF_OBJECT_ID, CONF_PICTURE, ) -from .template_entity import TemplateEntity +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_SCHEMA, + TemplateEntity, + rewrite_common_legacy_to_modern_conf, +) from .trigger_entity import TriggerEntity CONF_DELAY_ON = "delay_on" @@ -65,20 +70,16 @@ LEGACY_FIELDS = { BINARY_SENSOR_SCHEMA = vol.Schema( { + vol.Optional(CONF_AUTO_OFF): vol.Any(cv.positive_time_period, cv.template), + vol.Optional(CONF_DELAY_OFF): vol.Any(cv.positive_time_period, cv.template), + vol.Optional(CONF_DELAY_ON): vol.Any(cv.positive_time_period, cv.template), + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_NAME): cv.template, vol.Required(CONF_STATE): cv.template, - vol.Optional(CONF_ICON): cv.template, - vol.Optional(CONF_PICTURE): cv.template, - vol.Optional(CONF_AVAILABILITY): cv.template, - vol.Optional(CONF_ATTRIBUTES): vol.Schema({cv.string: cv.template}), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_DELAY_ON): vol.Any(cv.positive_time_period, cv.template), - vol.Optional(CONF_DELAY_OFF): vol.Any(cv.positive_time_period, cv.template), - vol.Optional(CONF_AUTO_OFF): vol.Any(cv.positive_time_period, cv.template), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, } -) +).extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema) LEGACY_BINARY_SENSOR_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), @@ -109,14 +110,7 @@ def rewrite_legacy_to_modern_conf(cfg: dict[str, dict]) -> list[dict]: for object_id, entity_cfg in cfg.items(): entity_cfg = {**entity_cfg, CONF_OBJECT_ID: object_id} - for from_key, to_key in LEGACY_FIELDS.items(): - if from_key not in entity_cfg or to_key in entity_cfg: - continue - - val = entity_cfg.pop(from_key) - if isinstance(val, str): - val = template.Template(val) - entity_cfg[to_key] = val + entity_cfg = rewrite_common_legacy_to_modern_conf(entity_cfg, LEGACY_FIELDS) if CONF_NAME not in entity_cfg: entity_cfg[CONF_NAME] = template.Template(object_id) @@ -143,18 +137,6 @@ def _async_create_template_tracking_entities( sensors = [] for entity_conf in definitions: - # Still available on legacy - object_id = entity_conf.get(CONF_OBJECT_ID) - - value = entity_conf[CONF_STATE] - icon = entity_conf.get(CONF_ICON) - entity_picture = entity_conf.get(CONF_PICTURE) - availability = entity_conf.get(CONF_AVAILABILITY) - attributes = entity_conf.get(CONF_ATTRIBUTES, {}) - friendly_name = entity_conf.get(CONF_NAME) - device_class = entity_conf.get(CONF_DEVICE_CLASS) - delay_on_raw = entity_conf.get(CONF_DELAY_ON) - delay_off_raw = entity_conf.get(CONF_DELAY_OFF) unique_id = entity_conf.get(CONF_UNIQUE_ID) if unique_id and unique_id_prefix: @@ -163,16 +145,7 @@ def _async_create_template_tracking_entities( sensors.append( BinarySensorTemplate( hass, - object_id, - friendly_name, - device_class, - value, - icon, - entity_picture, - availability, - delay_on_raw, - delay_off_raw, - attributes, + entity_conf, unique_id, ) ) @@ -212,49 +185,37 @@ class BinarySensorTemplate(TemplateEntity, BinarySensorEntity): def __init__( self, hass: HomeAssistant, - object_id: str | None, - friendly_name: template.Template | None, - device_class: str, - value_template: template.Template, - icon_template: template.Template | None, - entity_picture_template: template.Template | None, - availability_template: template.Template | None, - delay_on_raw, - delay_off_raw, - attribute_templates: dict[str, template.Template], + config: dict[str, Any], unique_id: str | None, - ): + ) -> None: """Initialize the Template binary sensor.""" - super().__init__( - attribute_templates=attribute_templates, - availability_template=availability_template, - icon_template=icon_template, - entity_picture_template=entity_picture_template, - ) - if object_id is not None: + super().__init__(config=config) + if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) self._name: str | None = None - self._friendly_name_template: template.Template | None = friendly_name + self._friendly_name_template = config.get(CONF_NAME) # Try to render the name as it can influence the entity ID - if friendly_name: - friendly_name.hass = hass + if self._friendly_name_template: + self._friendly_name_template.hass = hass try: - self._name = friendly_name.async_render(parse_result=False) + self._name = self._friendly_name_template.async_render( + parse_result=False + ) except template.TemplateError: pass - self._device_class = device_class - self._template = value_template + self._device_class = config.get(CONF_DEVICE_CLASS) + self._template = config[CONF_STATE] self._state = None self._delay_cancel = None self._delay_on = None - self._delay_on_raw = delay_on_raw + self._delay_on_raw = config.get(CONF_DELAY_ON) self._delay_off = None - self._delay_off_raw = delay_off_raw + self._delay_off_raw = config.get(CONF_DELAY_OFF) self._unique_id = unique_id async def async_added_to_hass(self): diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 54d213be0b1..9dfbe4a11d9 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -25,5 +25,6 @@ PLATFORMS = [ CONF_AVAILABILITY = "availability" CONF_ATTRIBUTES = "attributes" +CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_PICTURE = "picture" CONF_OBJECT_ID = "object_id" diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index a9f28d56669..dcfd8b41521 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -23,9 +23,7 @@ from homeassistant.const import ( CONF_COVERS, CONF_DEVICE_CLASS, CONF_ENTITY_ID, - CONF_ENTITY_PICTURE_TEMPLATE, CONF_FRIENDLY_NAME, - CONF_ICON_TEMPLATE, CONF_OPTIMISTIC, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -40,8 +38,12 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.script import Script -from .const import CONF_AVAILABILITY_TEMPLATE -from .template_entity import TemplateEntity +from .const import DOMAIN +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TemplateEntity, + rewrite_common_legacy_to_modern_conf, +) _LOGGER = logging.getLogger(__name__) _VALID_STATES = [ @@ -79,11 +81,8 @@ COVER_SCHEMA = vol.All( vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template, vol.Optional(CONF_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_TILT_TEMPLATE): cv.template, - vol.Optional(CONF_ICON_TEMPLATE): cv.template, - vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_OPTIMISTIC): cv.boolean, vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, @@ -93,7 +92,7 @@ COVER_SCHEMA = vol.All( vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_UNIQUE_ID): cv.string, } - ), + ).extend(TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY.schema), cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) @@ -106,44 +105,17 @@ async def _async_create_entities(hass, config): """Create the Template cover.""" covers = [] - for device, device_config in config[CONF_COVERS].items(): - state_template = device_config.get(CONF_VALUE_TEMPLATE) - position_template = device_config.get(CONF_POSITION_TEMPLATE) - tilt_template = device_config.get(CONF_TILT_TEMPLATE) - icon_template = device_config.get(CONF_ICON_TEMPLATE) - availability_template = device_config.get(CONF_AVAILABILITY_TEMPLATE) - entity_picture_template = device_config.get(CONF_ENTITY_PICTURE_TEMPLATE) + for object_id, entity_config in config[CONF_COVERS].items(): - friendly_name = device_config.get(CONF_FRIENDLY_NAME, device) - device_class = device_config.get(CONF_DEVICE_CLASS) - open_action = device_config.get(OPEN_ACTION) - close_action = device_config.get(CLOSE_ACTION) - stop_action = device_config.get(STOP_ACTION) - position_action = device_config.get(POSITION_ACTION) - tilt_action = device_config.get(TILT_ACTION) - optimistic = device_config.get(CONF_OPTIMISTIC) - tilt_optimistic = device_config.get(CONF_TILT_OPTIMISTIC) - unique_id = device_config.get(CONF_UNIQUE_ID) + entity_config = rewrite_common_legacy_to_modern_conf(entity_config) + + unique_id = entity_config.get(CONF_UNIQUE_ID) covers.append( CoverTemplate( hass, - device, - friendly_name, - device_class, - state_template, - position_template, - tilt_template, - icon_template, - entity_picture_template, - availability_template, - open_action, - close_action, - stop_action, - position_action, - tilt_action, - optimistic, - tilt_optimistic, + object_id, + entity_config, unique_id, ) ) @@ -162,56 +134,41 @@ class CoverTemplate(TemplateEntity, CoverEntity): def __init__( self, hass, - device_id, - friendly_name, - device_class, - state_template, - position_template, - tilt_template, - icon_template, - entity_picture_template, - availability_template, - open_action, - close_action, - stop_action, - position_action, - tilt_action, - optimistic, - tilt_optimistic, + object_id, + config, unique_id, ): """Initialize the Template cover.""" - super().__init__( - availability_template=availability_template, - icon_template=icon_template, - entity_picture_template=entity_picture_template, - ) + super().__init__(config=config) self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, device_id, hass=hass + ENTITY_ID_FORMAT, object_id, hass=hass ) - self._name = friendly_name - self._template = state_template - self._position_template = position_template - self._tilt_template = tilt_template - self._device_class = device_class + self._name = friendly_name = config.get(CONF_FRIENDLY_NAME, object_id) + self._template = config.get(CONF_VALUE_TEMPLATE) + self._position_template = config.get(CONF_POSITION_TEMPLATE) + self._tilt_template = config.get(CONF_TILT_TEMPLATE) + self._device_class = config.get(CONF_DEVICE_CLASS) self._open_script = None - domain = __name__.split(".")[-2] - if open_action is not None: - self._open_script = Script(hass, open_action, friendly_name, domain) + if (open_action := config.get(OPEN_ACTION)) is not None: + self._open_script = Script(hass, open_action, friendly_name, DOMAIN) self._close_script = None - if close_action is not None: - self._close_script = Script(hass, close_action, friendly_name, domain) + if (close_action := config.get(CLOSE_ACTION)) is not None: + self._close_script = Script(hass, close_action, friendly_name, DOMAIN) self._stop_script = None - if stop_action is not None: - self._stop_script = Script(hass, stop_action, friendly_name, domain) + if (stop_action := config.get(STOP_ACTION)) is not None: + self._stop_script = Script(hass, stop_action, friendly_name, DOMAIN) self._position_script = None - if position_action is not None: - self._position_script = Script(hass, position_action, friendly_name, domain) + if (position_action := config.get(POSITION_ACTION)) is not None: + self._position_script = Script(hass, position_action, friendly_name, DOMAIN) self._tilt_script = None - if tilt_action is not None: - self._tilt_script = Script(hass, tilt_action, friendly_name, domain) - self._optimistic = optimistic or (not state_template and not position_template) - self._tilt_optimistic = tilt_optimistic or not tilt_template + if (tilt_action := config.get(TILT_ACTION)) is not None: + self._tilt_script = Script(hass, tilt_action, friendly_name, DOMAIN) + optimistic = config.get(CONF_OPTIMISTIC) + self._optimistic = optimistic or ( + not self._template and not self._position_template + ) + tilt_optimistic = config.get(CONF_TILT_OPTIMISTIC) + self._tilt_optimistic = tilt_optimistic or not self._tilt_template self._position = None self._is_opening = False self._is_closing = False diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index f39d49fa9fd..4a872d51dbc 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -39,7 +39,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.script import Script -from .const import CONF_AVAILABILITY_TEMPLATE +from .const import CONF_AVAILABILITY_TEMPLATE, DOMAIN from .template_entity import TemplateEntity _LOGGER = logging.getLogger(__name__) @@ -209,39 +209,37 @@ class TemplateFan(TemplateEntity, FanEntity): self._direction_template = direction_template self._supported_features = 0 - domain = __name__.split(".")[-2] - - self._on_script = Script(hass, on_action, friendly_name, domain) - self._off_script = Script(hass, off_action, friendly_name, domain) + self._on_script = Script(hass, on_action, friendly_name, DOMAIN) + self._off_script = Script(hass, off_action, friendly_name, DOMAIN) self._set_speed_script = None if set_speed_action: self._set_speed_script = Script( - hass, set_speed_action, friendly_name, domain + hass, set_speed_action, friendly_name, DOMAIN ) self._set_percentage_script = None if set_percentage_action: self._set_percentage_script = Script( - hass, set_percentage_action, friendly_name, domain + hass, set_percentage_action, friendly_name, DOMAIN ) self._set_preset_mode_script = None if set_preset_mode_action: self._set_preset_mode_script = Script( - hass, set_preset_mode_action, friendly_name, domain + hass, set_preset_mode_action, friendly_name, DOMAIN ) self._set_oscillating_script = None if set_oscillating_action: self._set_oscillating_script = Script( - hass, set_oscillating_action, friendly_name, domain + hass, set_oscillating_action, friendly_name, DOMAIN ) self._set_direction_script = None if set_direction_action: self._set_direction_script = Script( - hass, set_direction_action, friendly_name, domain + hass, set_direction_action, friendly_name, DOMAIN ) self._state = STATE_OFF diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index b8ebe03ceba..5d172489840 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -37,7 +37,7 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.script import Script -from .const import CONF_AVAILABILITY_TEMPLATE +from .const import CONF_AVAILABILITY_TEMPLATE, DOMAIN from .template_entity import TemplateEntity _LOGGER = logging.getLogger(__name__) @@ -211,32 +211,31 @@ class LightTemplate(TemplateEntity, LightEntity): ) self._name = friendly_name self._template = state_template - domain = __name__.split(".")[-2] - self._on_script = Script(hass, on_action, friendly_name, domain) - self._off_script = Script(hass, off_action, friendly_name, domain) + self._on_script = Script(hass, on_action, friendly_name, DOMAIN) + self._off_script = Script(hass, off_action, friendly_name, DOMAIN) self._level_script = None if level_action is not None: - self._level_script = Script(hass, level_action, friendly_name, domain) + self._level_script = Script(hass, level_action, friendly_name, DOMAIN) self._level_template = level_template self._temperature_script = None if temperature_action is not None: self._temperature_script = Script( - hass, temperature_action, friendly_name, domain + hass, temperature_action, friendly_name, DOMAIN ) self._temperature_template = temperature_template self._color_script = None if color_action is not None: - self._color_script = Script(hass, color_action, friendly_name, domain) + self._color_script = Script(hass, color_action, friendly_name, DOMAIN) self._color_template = color_template self._white_value_script = None if white_value_action is not None: self._white_value_script = Script( - hass, white_value_action, friendly_name, domain + hass, white_value_action, friendly_name, DOMAIN ) self._white_value_template = white_value_template self._effect_script = None if effect_action is not None: - self._effect_script = Script(hass, effect_action, friendly_name, domain) + self._effect_script = Script(hass, effect_action, friendly_name, DOMAIN) self._effect_list_template = effect_list_template self._effect_template = effect_template self._max_mireds_template = max_mireds_template @@ -511,7 +510,7 @@ class LightTemplate(TemplateEntity, LightEntity): def _update_brightness(self, brightness): """Update the brightness from the template.""" try: - if brightness in ("None", ""): + if brightness in (None, "None", ""): self._brightness = None return if 0 <= int(brightness) <= 255: @@ -532,7 +531,7 @@ class LightTemplate(TemplateEntity, LightEntity): def _update_white_value(self, white_value): """Update the white value from the template.""" try: - if white_value in ("None", ""): + if white_value in (None, "None", ""): self._white_value = None return if 0 <= int(white_value) <= 255: @@ -552,7 +551,7 @@ class LightTemplate(TemplateEntity, LightEntity): @callback def _update_effect_list(self, effect_list): """Update the effect list from the template.""" - if effect_list in ("None", ""): + if effect_list in (None, "None", ""): self._effect_list = None return @@ -573,7 +572,7 @@ class LightTemplate(TemplateEntity, LightEntity): @callback def _update_effect(self, effect): """Update the effect from the template.""" - if effect in ("None", ""): + if effect in (None, "None", ""): self._effect = None return @@ -618,7 +617,7 @@ class LightTemplate(TemplateEntity, LightEntity): def _update_temperature(self, render): """Update the temperature from the template.""" try: - if render in ("None", ""): + if render in (None, "None", ""): self._temperature = None return temperature = int(render) @@ -644,7 +643,7 @@ class LightTemplate(TemplateEntity, LightEntity): """Update the hs_color from the template.""" h_str = s_str = None if isinstance(render, str): - if render in ("None", ""): + if render in (None, "None", ""): self._color = None return h_str, s_str = map( @@ -676,7 +675,7 @@ class LightTemplate(TemplateEntity, LightEntity): """Update the max mireds from the template.""" try: - if render in ("None", ""): + if render in (None, "None", ""): self._max_mireds = None return self._max_mireds = int(render) @@ -691,7 +690,7 @@ class LightTemplate(TemplateEntity, LightEntity): def _update_min_mireds(self, render): """Update the min mireds from the template.""" try: - if render in ("None", ""): + if render in (None, "None", ""): self._min_mireds = None return self._min_mireds = int(render) @@ -705,7 +704,7 @@ class LightTemplate(TemplateEntity, LightEntity): @callback def _update_supports_transition(self, render): """Update the supports transition from the template.""" - if render in ("None", ""): + if render in (None, "None", ""): self._supports_transition = False return self._supports_transition = bool(render) diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 51431d133f7..a078ce778b6 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -22,7 +22,7 @@ from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.script import Script -from .const import CONF_AVAILABILITY_TEMPLATE +from .const import CONF_AVAILABILITY_TEMPLATE, DOMAIN from .template_entity import TemplateEntity CONF_LOCK = "lock" @@ -88,9 +88,8 @@ class TemplateLock(TemplateEntity, LockEntity): self._state = None self._name = name self._state_template = value_template - domain = __name__.split(".")[-2] - self._command_lock = Script(hass, command_lock, name, domain) - self._command_unlock = Script(hass, command_unlock, name, domain) + self._command_lock = Script(hass, command_lock, name, DOMAIN) + self._command_unlock = Script(hass, command_unlock, name, DOMAIN) self._optimistic = optimistic self._unique_id = unique_id diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 86cc4886430..0f5e8e4bee8 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -31,7 +31,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script from homeassistant.helpers.template import Template, TemplateError -from .const import CONF_AVAILABILITY +from .const import CONF_AVAILABILITY, DOMAIN from .template_entity import TemplateEntity from .trigger_entity import TriggerEntity @@ -139,9 +139,8 @@ class TemplateNumber(TemplateEntity, NumberEntity): with contextlib.suppress(TemplateError): self._attr_name = name_template.async_render(parse_result=False) self._value_template = value_template - domain = __name__.split(".")[-2] self._command_set_value = Script( - hass, command_set_value, self._attr_name, domain + hass, command_set_value, self._attr_name, DOMAIN ) self._step_template = step_template self._min_value_template = minimum_template @@ -212,12 +211,11 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity): ) -> None: """Initialize the entity.""" super().__init__(hass, coordinator, config) - domain = __name__.split(".")[-2] self._command_set_value = Script( hass, config[CONF_SET_VALUE], self._rendered.get(CONF_NAME, DEFAULT_NAME), - domain, + DOMAIN, ) @property diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 03136c0193d..ee43ba906c0 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -27,7 +27,7 @@ from homeassistant.helpers.script import Script from homeassistant.helpers.template import Template, TemplateError from . import TriggerUpdateCoordinator -from .const import CONF_AVAILABILITY +from .const import CONF_AVAILABILITY, DOMAIN from .template_entity import TemplateEntity from .trigger_entity import TriggerEntity @@ -129,9 +129,8 @@ class TemplateSelect(TemplateEntity, SelectEntity): self._attr_name = name_template.async_render(parse_result=False) self._name_template = name_template self._value_template = value_template - domain = __name__.split(".")[-2] self._command_select_option = Script( - hass, command_select_option, self._attr_name, domain + hass, command_select_option, self._attr_name, DOMAIN ) self._options_template = options_template self._attr_assumed_state = self._optimistic = optimistic @@ -182,12 +181,11 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): ) -> None: """Initialize the entity.""" super().__init__(hass, coordinator, config) - domain = __name__.split(".")[-2] self._command_select_option = Script( hass, config[CONF_SELECT_OPTION], self._rendered.get(CONF_NAME, DEFAULT_NAME), - domain, + DOMAIN, ) @property diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index ea203bdd879..18d0be616d4 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -1,6 +1,9 @@ """Allows the creation of a sensor that breaks out state_attributes.""" from __future__ import annotations +from datetime import date, datetime +from typing import Any + import voluptuous as vol from homeassistant.components.sensor import ( @@ -10,15 +13,16 @@ from homeassistant.components.sensor import ( ENTITY_ID_FORMAT, PLATFORM_SCHEMA, STATE_CLASSES_SCHEMA, + SensorDeviceClass, SensorEntity, ) +from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_CLASS, CONF_ENTITY_PICTURE_TEMPLATE, CONF_FRIENDLY_NAME, CONF_FRIENDLY_NAME_TEMPLATE, - CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, CONF_SENSORS, @@ -34,21 +38,18 @@ from homeassistant.helpers.entity import async_generate_entity_id from .const import ( CONF_ATTRIBUTE_TEMPLATES, - CONF_ATTRIBUTES, - CONF_AVAILABILITY, CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID, - CONF_PICTURE, CONF_TRIGGER, ) -from .template_entity import TemplateEntity +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_SCHEMA, + TemplateEntity, + rewrite_common_legacy_to_modern_conf, +) from .trigger_entity import TriggerEntity LEGACY_FIELDS = { - CONF_ICON_TEMPLATE: CONF_ICON, - CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, - CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, - CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES, CONF_FRIENDLY_NAME_TEMPLATE: CONF_NAME, CONF_FRIENDLY_NAME: CONF_NAME, CONF_VALUE_TEMPLATE: CONF_STATE, @@ -57,18 +58,14 @@ LEGACY_FIELDS = { SENSOR_SCHEMA = vol.Schema( { - vol.Optional(CONF_NAME): cv.template, - vol.Required(CONF_STATE): cv.template, - vol.Optional(CONF_ICON): cv.template, - vol.Optional(CONF_PICTURE): cv.template, - vol.Optional(CONF_AVAILABILITY): cv.template, - vol.Optional(CONF_ATTRIBUTES): vol.Schema({cv.string: cv.template}), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_NAME): cv.template, vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Required(CONF_STATE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, } -) +).extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema) LEGACY_SENSOR_SCHEMA = vol.All( @@ -108,20 +105,13 @@ def extra_validation_checks(val): def rewrite_legacy_to_modern_conf(cfg: dict[str, dict]) -> list[dict]: - """Rewrite a legacy sensor definitions to modern ones.""" + """Rewrite legacy sensor definitions to modern ones.""" sensors = [] for object_id, entity_cfg in cfg.items(): entity_cfg = {**entity_cfg, CONF_OBJECT_ID: object_id} - for from_key, to_key in LEGACY_FIELDS.items(): - if from_key not in entity_cfg or to_key in entity_cfg: - continue - - val = entity_cfg.pop(from_key) - if isinstance(val, str): - val = template.Template(val) - entity_cfg[to_key] = val + entity_cfg = rewrite_common_legacy_to_modern_conf(entity_cfg, LEGACY_FIELDS) if CONF_NAME not in entity_cfg: entity_cfg[CONF_NAME] = template.Template(object_id) @@ -150,19 +140,7 @@ def _async_create_template_tracking_entities( sensors = [] for entity_conf in definitions: - # Still available on legacy - object_id = entity_conf.get(CONF_OBJECT_ID) - - state_template = entity_conf[CONF_STATE] - icon_template = entity_conf.get(CONF_ICON) - entity_picture_template = entity_conf.get(CONF_PICTURE) - availability_template = entity_conf.get(CONF_AVAILABILITY) - friendly_name_template = entity_conf.get(CONF_NAME) - unit_of_measurement = entity_conf.get(CONF_UNIT_OF_MEASUREMENT) - device_class = entity_conf.get(CONF_DEVICE_CLASS) - attribute_templates = entity_conf.get(CONF_ATTRIBUTES, {}) unique_id = entity_conf.get(CONF_UNIQUE_ID) - state_class = entity_conf.get(CONF_STATE_CLASS) if unique_id and unique_id_prefix: unique_id = f"{unique_id_prefix}-{unique_id}" @@ -170,17 +148,8 @@ def _async_create_template_tracking_entities( sensors.append( SensorTemplate( hass, - object_id, - friendly_name_template, - unit_of_measurement, - state_template, - icon_template, - entity_picture_template, - availability_template, - device_class, - attribute_templates, + entity_conf, unique_id, - state_class, ) ) @@ -219,47 +188,33 @@ class SensorTemplate(TemplateEntity, SensorEntity): def __init__( self, hass: HomeAssistant, - object_id: str | None, - friendly_name_template: template.Template | None, - unit_of_measurement: str | None, - state_template: template.Template, - icon_template: template.Template | None, - entity_picture_template: template.Template | None, - availability_template: template.Template | None, - device_class: str | None, - attribute_templates: dict[str, template.Template], + config: dict[str, Any], unique_id: str | None, - state_class: str | None, ) -> None: """Initialize the sensor.""" - super().__init__( - attribute_templates=attribute_templates, - availability_template=availability_template, - icon_template=icon_template, - entity_picture_template=entity_picture_template, - ) - if object_id is not None: + super().__init__(config=config) + if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) - self._friendly_name_template = friendly_name_template + self._friendly_name_template = config.get(CONF_NAME) self._attr_name = None # Try to render the name as it can influence the entity ID - if friendly_name_template: - friendly_name_template.hass = hass + if self._friendly_name_template: + self._friendly_name_template.hass = hass try: - self._attr_name = friendly_name_template.async_render( + self._attr_name = self._friendly_name_template.async_render( parse_result=False ) except template.TemplateError: pass - self._attr_native_unit_of_measurement = unit_of_measurement - self._template = state_template - self._attr_device_class = device_class - self._attr_state_class = state_class + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + self._template = config.get(CONF_STATE) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_state_class = config.get(CONF_STATE_CLASS) self._attr_unique_id = unique_id async def async_added_to_hass(self): @@ -275,7 +230,20 @@ class SensorTemplate(TemplateEntity, SensorEntity): @callback def _update_state(self, result): super()._update_state(result) - self._attr_native_value = None if isinstance(result, TemplateError) else result + if isinstance(result, TemplateError): + self._attr_native_value = None + return + + if result is None or self.device_class not in ( + SensorDeviceClass.DATE, + SensorDeviceClass.TIMESTAMP, + ): + self._attr_native_value = result + return + + self._attr_native_value = async_parse_date_datetime( + result, self.entity_id, self.device_class + ) class TriggerSensorEntity(TriggerEntity, SensorEntity): @@ -285,7 +253,7 @@ class TriggerSensorEntity(TriggerEntity, SensorEntity): extra_template_keys = (CONF_STATE,) @property - def native_value(self) -> str | None: + def native_value(self) -> str | datetime | date | None: """Return state of the sensor.""" return self._rendered.get(CONF_STATE) @@ -293,3 +261,20 @@ class TriggerSensorEntity(TriggerEntity, SensorEntity): def state_class(self) -> str | None: """Sensor state class.""" return self._config.get(CONF_STATE_CLASS) + + @callback + def _process_data(self) -> None: + """Process new data.""" + super()._process_data() + + if ( + state := self._rendered.get(CONF_STATE) + ) is None or self.device_class not in ( + SensorDeviceClass.DATE, + SensorDeviceClass.TIMESTAMP, + ): + return + + self._rendered[CONF_STATE] = async_parse_date_datetime( + state, self.entity_id, self.device_class + ) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 0e083df13f4..a5b85e4b408 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -25,7 +25,7 @@ from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import Script -from .const import CONF_AVAILABILITY_TEMPLATE +from .const import CONF_AVAILABILITY_TEMPLATE, DOMAIN from .template_entity import TemplateEntity _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] @@ -119,9 +119,8 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): ) self._name = friendly_name self._template = state_template - domain = __name__.split(".")[-2] - self._on_script = Script(hass, on_action, friendly_name, domain) - self._off_script = Script(hass, off_action, friendly_name, domain) + self._on_script = Script(hass, on_action, friendly_name, DOMAIN) + self._off_script = Script(hass, off_action, friendly_name, DOMAIN) self._state = False self._unique_id = unique_id @@ -148,8 +147,7 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): # restore state after startup await super().async_added_to_hass() - state = await self.async_get_last_state() - if state: + if state := await self.async_get_last_state(): self._state = state.state == STATE_ON # no need to listen for events diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 70d7b4af7dd..55c9dbcf45b 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -2,12 +2,18 @@ from __future__ import annotations from collections.abc import Callable +import itertools import logging from typing import Any import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_ENTITY_PICTURE_TEMPLATE, + CONF_ICON, + CONF_ICON_TEMPLATE, +) from homeassistant.core import EVENT_HOMEASSISTANT_START, CoreState, callback from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv @@ -20,9 +26,70 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.template import Template, result_as_boolean +from .const import ( + CONF_ATTRIBUTE_TEMPLATES, + CONF_ATTRIBUTES, + CONF_AVAILABILITY, + CONF_AVAILABILITY_TEMPLATE, + CONF_PICTURE, +) + _LOGGER = logging.getLogger(__name__) +TEMPLATE_ENTITY_COMMON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ATTRIBUTES): vol.Schema({cv.string: cv.template}), + vol.Optional(CONF_AVAILABILITY): cv.template, + vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + } +) + +TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY = vol.Schema( + { + vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template, + } +) + +TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY = vol.Schema( + { + vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, + vol.Optional(CONF_ICON_TEMPLATE): cv.template, + } +).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema) + + +LEGACY_FIELDS = { + CONF_ICON_TEMPLATE: CONF_ICON, + CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, + CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, + CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES, +} + + +def rewrite_common_legacy_to_modern_conf( + entity_cfg: dict[str, Any], extra_legacy_fields: dict[str, str] = None +) -> list[dict]: + """Rewrite legacy config.""" + entity_cfg = {**entity_cfg} + if extra_legacy_fields is None: + extra_legacy_fields = {} + + for from_key, to_key in itertools.chain( + LEGACY_FIELDS.items(), extra_legacy_fields.items() + ): + if from_key not in entity_cfg or to_key in entity_cfg: + continue + + val = entity_cfg.pop(from_key) + if isinstance(val, str): + val = Template(val) + entity_cfg[to_key] = val + + return entity_cfg + + class _TemplateAttribute: """Attribute value linked to template result.""" @@ -125,16 +192,23 @@ class TemplateEntity(Entity): icon_template=None, entity_picture_template=None, attribute_templates=None, + config=None, ): """Template Entity.""" self._template_attrs = {} self._async_update = None - self._attribute_templates = attribute_templates self._attr_extra_state_attributes = {} - self._availability_template = availability_template - self._icon_template = icon_template - self._entity_picture_template = entity_picture_template self._self_ref_update_count = 0 + if config is None: + self._attribute_templates = attribute_templates + self._availability_template = availability_template + self._icon_template = icon_template + self._entity_picture_template = entity_picture_template + else: + self._attribute_templates = config.get(CONF_ATTRIBUTES) + self._availability_template = config.get(CONF_AVAILABILITY) + self._icon_template = config.get(CONF_ICON) + self._entity_picture_template = config.get(CONF_PICTURE) @callback def _update_available(self, result): diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index c80620b0453..d4bc96e43bf 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -21,7 +21,7 @@ from .const import CONF_ATTRIBUTES, CONF_AVAILABILITY, CONF_PICTURE class TriggerEntity(update_coordinator.CoordinatorEntity): """Template entity based on trigger data.""" - domain = "" + domain: str extra_template_keys: tuple | None = None extra_template_keys_complex: tuple | None = None diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 78c51d2009c..a6b8bf0f379 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -43,7 +43,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.script import Script -from .const import CONF_AVAILABILITY_TEMPLATE +from .const import CONF_AVAILABILITY_TEMPLATE, DOMAIN from .template_entity import TemplateEntity _LOGGER = logging.getLogger(__name__) @@ -187,43 +187,41 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): self._fan_speed_template = fan_speed_template self._supported_features = SUPPORT_START - domain = __name__.split(".")[-2] - - self._start_script = Script(hass, start_action, friendly_name, domain) + self._start_script = Script(hass, start_action, friendly_name, DOMAIN) self._pause_script = None if pause_action: - self._pause_script = Script(hass, pause_action, friendly_name, domain) + self._pause_script = Script(hass, pause_action, friendly_name, DOMAIN) self._supported_features |= SUPPORT_PAUSE self._stop_script = None if stop_action: - self._stop_script = Script(hass, stop_action, friendly_name, domain) + self._stop_script = Script(hass, stop_action, friendly_name, DOMAIN) self._supported_features |= SUPPORT_STOP self._return_to_base_script = None if return_to_base_action: self._return_to_base_script = Script( - hass, return_to_base_action, friendly_name, domain + hass, return_to_base_action, friendly_name, DOMAIN ) self._supported_features |= SUPPORT_RETURN_HOME self._clean_spot_script = None if clean_spot_action: self._clean_spot_script = Script( - hass, clean_spot_action, friendly_name, domain + hass, clean_spot_action, friendly_name, DOMAIN ) self._supported_features |= SUPPORT_CLEAN_SPOT self._locate_script = None if locate_action: - self._locate_script = Script(hass, locate_action, friendly_name, domain) + self._locate_script = Script(hass, locate_action, friendly_name, DOMAIN) self._supported_features |= SUPPORT_LOCATE self._set_fan_speed_script = None if set_fan_speed_action: self._set_fan_speed_script = Script( - hass, set_fan_speed_action, friendly_name, domain + hass, set_fan_speed_action, friendly_name, DOMAIN ) self._supported_features |= SUPPORT_FAN_SPEED diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 391eeb1cf87..8d5ea0acaa2 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -6,7 +6,7 @@ "tensorflow==2.3.0", "tf-models-official==2.3.0", "pycocotools==2.0.1", - "numpy==1.21.2", + "numpy==1.21.4", "pillow==8.2.0" ], "codeowners": [], diff --git a/homeassistant/components/tesla_wall_connector/__init__.py b/homeassistant/components/tesla_wall_connector/__init__.py new file mode 100644 index 00000000000..78ae232d493 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/__init__.py @@ -0,0 +1,173 @@ +"""The Tesla Wall Connector integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +from tesla_wall_connector import WallConnector +from tesla_wall_connector.exceptions import ( + WallConnectorConnectionError, + WallConnectorConnectionTimeoutError, + WallConnectorError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL +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 +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import ( + DEFAULT_SCAN_INTERVAL, + DOMAIN, + WALLCONNECTOR_DATA_LIFETIME, + WALLCONNECTOR_DATA_VITALS, + WALLCONNECTOR_DEVICE_NAME, +) + +PLATFORMS: list[str] = ["binary_sensor", "sensor"] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Tesla Wall Connector from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + hostname = entry.data[CONF_HOST] + + wall_connector = WallConnector(host=hostname, session=async_get_clientsession(hass)) + + try: + version_data = await wall_connector.async_get_version() + except WallConnectorError as ex: + raise ConfigEntryNotReady from ex + + async def async_update_data(): + """Fetch new data from the Wall Connector.""" + try: + vitals = await wall_connector.async_get_vitals() + lifetime = await wall_connector.async_get_lifetime() + except WallConnectorConnectionTimeoutError as ex: + raise UpdateFailed( + f"Could not fetch data from Tesla WallConnector at {hostname}: Timeout" + ) from ex + except WallConnectorConnectionError as ex: + raise UpdateFailed( + f"Could not fetch data from Tesla WallConnector at {hostname}: Cannot connect" + ) from ex + except WallConnectorError as ex: + raise UpdateFailed( + f"Could not fetch data from Tesla WallConnector at {hostname}: {ex}" + ) from ex + + return { + WALLCONNECTOR_DATA_VITALS: vitals, + WALLCONNECTOR_DATA_LIFETIME: lifetime, + } + + coordinator: DataUpdateCoordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="tesla-wallconnector", + update_interval=get_poll_interval(entry), + update_method=async_update_data, + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = WallConnectorData( + wall_connector_client=wall_connector, + hostname=hostname, + part_number=version_data.part_number, + firmware_version=version_data.firmware_version, + serial_number=version_data.serial_number, + update_coordinator=coordinator, + ) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +def get_poll_interval(entry: ConfigEntry) -> timedelta: + """Get the poll interval from config.""" + return timedelta( + seconds=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ) + + +async def update_listener(hass, entry): + """Handle options update.""" + wall_connector_data: WallConnectorData = hass.data[DOMAIN][entry.entry_id] + wall_connector_data.update_coordinator.update_interval = get_poll_interval(entry) + + +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 + + +def prefix_entity_name(name: str) -> str: + """Prefixes entity name.""" + return f"{WALLCONNECTOR_DEVICE_NAME} {name}" + + +def get_unique_id(serial_number: str, key: str) -> str: + """Get a unique entity name.""" + return f"{serial_number}-{key}" + + +class WallConnectorEntity(CoordinatorEntity): + """Base class for Wall Connector entities.""" + + def __init__(self, wall_connector_data: WallConnectorData) -> None: + """Initialize WallConnector Entity.""" + self.wall_connector_data = wall_connector_data + self._attr_unique_id = get_unique_id( + wall_connector_data.serial_number, self.entity_description.key + ) + super().__init__(wall_connector_data.update_coordinator) + + @property + def device_info(self) -> DeviceInfo: + """Return information about the device.""" + return DeviceInfo( + identifiers={(DOMAIN, self.wall_connector_data.serial_number)}, + default_name=WALLCONNECTOR_DEVICE_NAME, + model=self.wall_connector_data.part_number, + sw_version=self.wall_connector_data.firmware_version, + default_manufacturer="Tesla", + ) + + +@dataclass() +class WallConnectorLambdaValueGetterMixin: + """Mixin with a function pointer for getting sensor value.""" + + value_fn: Callable[[dict], Any] + + +@dataclass +class WallConnectorData: + """Data for the Tesla Wall Connector integration.""" + + wall_connector_client: WallConnector + update_coordinator: DataUpdateCoordinator + hostname: str + part_number: str + firmware_version: str + serial_number: str diff --git a/homeassistant/components/tesla_wall_connector/binary_sensor.py b/homeassistant/components/tesla_wall_connector/binary_sensor.py new file mode 100644 index 00000000000..8aef4f86478 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/binary_sensor.py @@ -0,0 +1,77 @@ +"""Binary Sensors for Tesla Wall Connector.""" +from dataclasses import dataclass +import logging + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_PLUG, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC + +from . import ( + WallConnectorData, + WallConnectorEntity, + WallConnectorLambdaValueGetterMixin, + prefix_entity_name, +) +from .const import DOMAIN, WALLCONNECTOR_DATA_VITALS + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class WallConnectorBinarySensorDescription( + BinarySensorEntityDescription, WallConnectorLambdaValueGetterMixin +): + """Binary Sensor entity description.""" + + +WALL_CONNECTOR_SENSORS = [ + WallConnectorBinarySensorDescription( + key="vehicle_connected", + name=prefix_entity_name("Vehicle connected"), + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].vehicle_connected, + device_class=DEVICE_CLASS_PLUG, + ), + WallConnectorBinarySensorDescription( + key="contactor_closed", + name=prefix_entity_name("Contactor closed"), + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].contactor_closed, + device_class=DEVICE_CLASS_BATTERY_CHARGING, + ), +] + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Create the Wall Connector sensor devices.""" + wall_connector_data = hass.data[DOMAIN][config_entry.entry_id] + + all_entities = [ + WallConnectorBinarySensorEntity(wall_connector_data, description) + for description in WALL_CONNECTOR_SENSORS + ] + + async_add_devices(all_entities) + + +class WallConnectorBinarySensorEntity(WallConnectorEntity, BinarySensorEntity): + """Wall Connector Sensor Entity.""" + + def __init__( + self, + wall_connectord_data: WallConnectorData, + description: WallConnectorBinarySensorDescription, + ) -> None: + """Initialize WallConnectorBinarySensorEntity.""" + self.entity_description = description + super().__init__(wall_connectord_data) + + @property + def is_on(self): + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/tesla_wall_connector/config_flow.py b/homeassistant/components/tesla_wall_connector/config_flow.py new file mode 100644 index 00000000000..5b3cf9bd835 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/config_flow.py @@ -0,0 +1,121 @@ +"""Config flow for Tesla Wall Connector integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from tesla_wall_connector import WallConnector +from tesla_wall_connector.exceptions import WallConnectorError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import dhcp +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, WALLCONNECTOR_DEVICE_NAME, WALLCONNECTOR_SERIAL_NUMBER + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + wall_connector = WallConnector( + host=data[CONF_HOST], session=async_get_clientsession(hass) + ) + + version = await wall_connector.async_get_version() + + return { + "title": WALLCONNECTOR_DEVICE_NAME, + WALLCONNECTOR_SERIAL_NUMBER: version.serial_number, + } + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Tesla Wall Connector.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize config flow.""" + super().__init__() + self.ip_address: str | None = None + self.serial_number = None + + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Handle dhcp discovery.""" + self.ip_address = discovery_info.ip + _LOGGER.debug("Discovered Tesla Wall Connector at [%s]", self.ip_address) + + self._async_abort_entries_match({CONF_HOST: self.ip_address}) + + try: + wall_connector = WallConnector( + host=self.ip_address, session=async_get_clientsession(self.hass) + ) + version = await wall_connector.async_get_version() + except WallConnectorError as ex: + _LOGGER.debug( + "Could not read serial number from Tesla WallConnector at [%s]: [%s]", + self.ip_address, + ex, + ) + return self.async_abort(reason="cannot_connect") + + self.serial_number = version.serial_number + + await self.async_set_unique_id(self.serial_number) + self._abort_if_unique_id_configured(updates={CONF_HOST: self.ip_address}) + + _LOGGER.debug( + "No entry found for wall connector with IP %s. Serial nr: %s", + self.ip_address, + self.serial_number, + ) + + placeholders = { + CONF_HOST: self.ip_address, + WALLCONNECTOR_SERIAL_NUMBER: self.serial_number, + } + + self.context["title_placeholders"] = placeholders + return await self.async_step_user() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + data_schema = vol.Schema( + {vol.Required(CONF_HOST, default=self.ip_address): str} + ) + if user_input is None: + return self.async_show_form(step_id="user", data_schema=data_schema) + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except WallConnectorError: + errors["base"] = "cannot_connect" + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception: %s", ex) + errors["base"] = "unknown" + + if not errors: + await self.async_set_unique_id( + unique_id=info[WALLCONNECTOR_SERIAL_NUMBER], raise_on_progress=True + ) + self._abort_if_unique_id_configured( + updates=user_input, reload_on_update=True + ) + + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) diff --git a/homeassistant/components/tesla_wall_connector/const.py b/homeassistant/components/tesla_wall_connector/const.py new file mode 100644 index 00000000000..2a660ee1aae --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/const.py @@ -0,0 +1,11 @@ +"""Constants for the Tesla Wall Connector integration.""" + +DOMAIN = "tesla_wall_connector" +DEFAULT_SCAN_INTERVAL = 30 + +WALLCONNECTOR_SERIAL_NUMBER = "serial_number" + +WALLCONNECTOR_DATA_VITALS = "vitals" +WALLCONNECTOR_DATA_LIFETIME = "lifetime" + +WALLCONNECTOR_DEVICE_NAME = "Tesla Wall Connector" diff --git a/homeassistant/components/tesla_wall_connector/manifest.json b/homeassistant/components/tesla_wall_connector/manifest.json new file mode 100644 index 00000000000..8e86fa3d2f8 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/manifest.json @@ -0,0 +1,25 @@ +{ + "domain": "tesla_wall_connector", + "name": "Tesla Wall Connector", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tesla_wall_connector", + "requirements": ["tesla-wall-connector==1.0.1"], + "dhcp": [ + { + "hostname": "teslawallconnector_*", + "macaddress": "DC44271*" + }, + { + "hostname": "teslawallconnector_*", + "macaddress": "98ED5C*" + }, + { + "hostname": "teslawallconnector_*", + "macaddress": "4CFCAA*" + } + ], + "codeowners": [ + "@einarhauks" + ], + "iot_class": "local_polling" +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py new file mode 100644 index 00000000000..8219d121ae3 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -0,0 +1,163 @@ +"""Sensors for Tesla Wall Connector.""" +from dataclasses import dataclass +import logging + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_WATT_HOUR, + ENTITY_CATEGORY_DIAGNOSTIC, + FREQUENCY_HERTZ, + TEMP_CELSIUS, +) + +from . import ( + WallConnectorData, + WallConnectorEntity, + WallConnectorLambdaValueGetterMixin, + prefix_entity_name, +) +from .const import DOMAIN, WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class WallConnectorSensorDescription( + SensorEntityDescription, WallConnectorLambdaValueGetterMixin +): + """Sensor entity description with a function pointer for getting sensor value.""" + + +WALL_CONNECTOR_SENSORS = [ + WallConnectorSensorDescription( + key="evse_state", + name=prefix_entity_name("State"), + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].evse_state, + ), + WallConnectorSensorDescription( + key="handle_temp_c", + name=prefix_entity_name("Handle Temperature"), + native_unit_of_measurement=TEMP_CELSIUS, + value_fn=lambda data: round(data[WALLCONNECTOR_DATA_VITALS].handle_temp_c, 1), + device_class=DEVICE_CLASS_TEMPERATURE, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=STATE_CLASS_MEASUREMENT, + ), + WallConnectorSensorDescription( + key="grid_v", + name=prefix_entity_name("Grid Voltage"), + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + value_fn=lambda data: round(data[WALLCONNECTOR_DATA_VITALS].grid_v, 1), + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + WallConnectorSensorDescription( + key="grid_hz", + name=prefix_entity_name("Grid Frequency"), + native_unit_of_measurement=FREQUENCY_HERTZ, + value_fn=lambda data: round(data[WALLCONNECTOR_DATA_VITALS].grid_hz, 3), + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + WallConnectorSensorDescription( + key="current_a_a", + name=prefix_entity_name("Phase A Current"), + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].currentA_a, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + WallConnectorSensorDescription( + key="current_b_a", + name=prefix_entity_name("Phase B Current"), + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].currentB_a, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + WallConnectorSensorDescription( + key="current_c_a", + name=prefix_entity_name("Phase C Current"), + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].currentC_a, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + WallConnectorSensorDescription( + key="voltage_a_v", + name=prefix_entity_name("Phase A Voltage"), + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].voltageA_v, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + WallConnectorSensorDescription( + key="voltage_b_v", + name=prefix_entity_name("Phase B Voltage"), + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].voltageB_v, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + WallConnectorSensorDescription( + key="voltage_c_v", + name=prefix_entity_name("Phase C Voltage"), + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].voltageC_v, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + WallConnectorSensorDescription( + key="energy_kWh", + name=prefix_entity_name("Energy"), + native_unit_of_measurement=ENERGY_WATT_HOUR, + value_fn=lambda data: data[WALLCONNECTOR_DATA_LIFETIME].energy_wh, + state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=DEVICE_CLASS_ENERGY, + ), +] + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Create the Wall Connector sensor devices.""" + wall_connector_data = hass.data[DOMAIN][config_entry.entry_id] + + all_entities = [ + WallConnectorSensorEntity(wall_connector_data, description) + for description in WALL_CONNECTOR_SENSORS + ] + + async_add_devices(all_entities) + + +class WallConnectorSensorEntity(WallConnectorEntity, SensorEntity): + """Wall Connector Sensor Entity.""" + + entity_description: WallConnectorSensorDescription + + def __init__( + self, + wall_connector_data: WallConnectorData, + description: WallConnectorSensorDescription, + ) -> None: + """Initialize WallConnectorSensorEntity.""" + self.entity_description = description + super().__init__(wall_connector_data) + + @property + def native_value(self): + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json new file mode 100644 index 00000000000..6fd43f52f30 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "flow_title": "{serial_number} ({host})", + "step": { + "user": { + "title": "Configure Tesla Wall Connector", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/translations/bg.json b/homeassistant/components/tesla_wall_connector/translations/bg.json new file mode 100644 index 00000000000..678f40de649 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/translations/bg.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "flow_title": "{serial_number} ({host})", + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/translations/ca.json b/homeassistant/components/tesla_wall_connector/translations/ca.json new file mode 100644 index 00000000000..7f0056f3413 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/translations/ca.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "flow_title": "{serial_number} ({host})", + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3" + }, + "title": "Configuraci\u00f3 de Tesla Wall Connector" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Freq\u00fc\u00e8ncia d'actualitzaci\u00f3" + }, + "title": "Configuraci\u00f3 d'opcions de Tesla Wall Connector" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/translations/de.json b/homeassistant/components/tesla_wall_connector/translations/de.json new file mode 100644 index 00000000000..3500fc1aa7b --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/translations/de.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "flow_title": "{serial_number} ({host})", + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Tesla Wall Connector konfigurieren" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Aktualisierungsfrequenz" + }, + "title": "Optionen f\u00fcr Tesla Wall Connector konfigurieren" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/translations/en.json b/homeassistant/components/tesla_wall_connector/translations/en.json new file mode 100644 index 00000000000..79a3005299f --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "flow_title": "{serial_number} ({host})", + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Configure Tesla Wall Connector" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Update frequency" + }, + "title": "Configure options for Tesla Wall Connector" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/translations/et.json b/homeassistant/components/tesla_wall_connector/translations/et.json new file mode 100644 index 00000000000..a13b447d542 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/translations/et.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "flow_title": "{serial_number} ({host})", + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Tesla Wall Connector'i seadistamine" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "V\u00e4rskendussagedus" + }, + "title": "Tesla Wall Connector'i seadistamise valikud" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/translations/hu.json b/homeassistant/components/tesla_wall_connector/translations/hu.json new file mode 100644 index 00000000000..951d4de5a86 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/translations/hu.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "flow_title": "{serial_number} ({host})", + "step": { + "user": { + "data": { + "host": "C\u00edm" + }, + "title": "Tesla Wall Connector konfigur\u00e1l\u00e1sa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Friss\u00edt\u00e9si gyakoris\u00e1g" + }, + "title": "Tesla Wall Connector konfigur\u00e1l\u00e1sa" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/translations/id.json b/homeassistant/components/tesla_wall_connector/translations/id.json new file mode 100644 index 00000000000..6214e3d153f --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/translations/id.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "flow_title": "{serial_number} ({host})", + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Konfigurasikan Konektor Dinding Tesla" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Frekuensi pembaruan" + }, + "title": "Konfigurasikan opsi untuk Konektor Dinding Tesla" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/translations/ja.json b/homeassistant/components/tesla_wall_connector/translations/ja.json new file mode 100644 index 00000000000..5370b0b161a --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/translations/ja.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{serial_number} ({host})", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "title": "Tesla Wall Connector\u306e\u8a2d\u5b9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u983b\u5ea6" + }, + "title": "Tesla Wall Connector\u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/translations/nl.json b/homeassistant/components/tesla_wall_connector/translations/nl.json new file mode 100644 index 00000000000..b6182cdcf5a --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/translations/nl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "flow_title": "{serial_number} ({host})", + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Configureer Tesla Wall Connector" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Update frequentie" + }, + "title": "Configureer opties voor Tesla Wall Connector" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/translations/no.json b/homeassistant/components/tesla_wall_connector/translations/no.json new file mode 100644 index 00000000000..ed6b4c30cfd --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/translations/no.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + }, + "flow_title": "", + "step": { + "user": { + "data": { + "host": "Vert" + }, + "title": "Konfigurer Tesla Wall Connector" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Oppdateringsfrekvens" + }, + "title": "Konfigurer alternativer for Tesla Wall Connector" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/translations/ru.json b/homeassistant/components/tesla_wall_connector/translations/ru.json new file mode 100644 index 00000000000..0fc1ef88790 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/translations/ru.json @@ -0,0 +1,30 @@ +{ + "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." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "flow_title": "{serial_number} ({host})", + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Tesla Wall Connector" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u0427\u0430\u0441\u0442\u043e\u0442\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Tesla Wall Connector" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/translations/sl.json b/homeassistant/components/tesla_wall_connector/translations/sl.json new file mode 100644 index 00000000000..0eec93b817d --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "host": "Gostitelj" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/translations/tr.json b/homeassistant/components/tesla_wall_connector/translations/tr.json new file mode 100644 index 00000000000..5eaeba841e2 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/translations/tr.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "{serial_number} ({host})", + "step": { + "user": { + "data": { + "host": "Ana bilgisayar" + }, + "title": "Tesla Duvar Ba\u011flant\u0131s\u0131n\u0131 Yap\u0131land\u0131r\u0131n" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "G\u00fcncelleme s\u0131kl\u0131\u011f\u0131" + }, + "title": "Tesla Duvar Konekt\u00f6r\u00fc i\u00e7in se\u00e7enekleri yap\u0131land\u0131r\u0131n" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/translations/zh-Hans.json b/homeassistant/components/tesla_wall_connector/translations/zh-Hans.json new file mode 100644 index 00000000000..21ee02edbf0 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/translations/zh-Hans.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "unknown": "\u975e\u9884\u671f\u7684\u9519\u8bef" + }, + "flow_title": "{serial_number} ({host})", + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a" + }, + "title": "\u914d\u7f6e Tesla \u58c1\u6302\u5f0f\u5145\u7535\u8fde\u63a5\u5668" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u9891\u7387" + }, + "title": "Tesla \u58c1\u6302\u5f0f\u5145\u7535\u8fde\u63a5\u5668\u914d\u7f6e\u9009\u9879" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/translations/zh-Hant.json b/homeassistant/components/tesla_wall_connector/translations/zh-Hant.json new file mode 100644 index 00000000000..69de7dd2eb3 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/translations/zh-Hant.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "flow_title": "{serial_number} ({host})", + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "title": "\u8a2d\u5b9a\u7279\u65af\u62c9\u58c1\u639b\u5f0f\u5145\u96fb\u5ea7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u983b\u7387" + }, + "title": "\u7279\u65af\u62c9\u58c1\u639b\u5f0f\u5145\u96fb\u5ea7\u8a2d\u5b9a\u9078\u9805" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index 792eaa0170c..2431d1e2022 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -124,7 +124,7 @@ class TtnDataStorage: """Get the current state from The Things Network Data Storage.""" try: session = async_get_clientsession(self._hass) - with async_timeout.timeout(DEFAULT_TIMEOUT): + async with async_timeout.timeout(DEFAULT_TIMEOUT): response = await session.get(self._url, headers=self._headers) except (asyncio.TimeoutError, aiohttp.ClientError): diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py index 4922117b64c..8c52a6669dc 100644 --- a/homeassistant/components/thomson/device_tracker.py +++ b/homeassistant/components/thomson/device_tracker.py @@ -78,8 +78,7 @@ class ThomsonDeviceScanner(DeviceScanner): return False _LOGGER.info("Checking ARP") - data = self.get_thomson_data() - if not data: + if not (data := self.get_thomson_data()): return False # Flag C stands for CONNECTED @@ -110,8 +109,7 @@ class ThomsonDeviceScanner(DeviceScanner): devices = {} for device in devices_result: - match = _DEVICES_REGEX.search(device.decode("utf-8")) - if match: + if match := _DEVICES_REGEX.search(device.decode("utf-8")): devices[match.group("ip")] = { "ip": match.group("ip"), "mac": match.group("mac").upper(), diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index bbc90f7218c..a653e91b991 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -2,7 +2,7 @@ "domain": "tibber", "name": "Tibber", "documentation": "https://www.home-assistant.io/integrations/tibber", - "requirements": ["pyTibber==0.19.1"], + "requirements": ["pyTibber==0.21.0"], "codeowners": ["@danielhiversen"], "quality_scale": "silver", "config_flow": true, diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 9a19e5a7602..cd4d4994f97 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -9,17 +9,10 @@ from random import randrange import aiohttp from homeassistant.components.sensor import ( - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_MONETARY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_POWER_FACTOR, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_VOLTAGE, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( ELECTRIC_CURRENT_AMPERE, @@ -47,152 +40,151 @@ ICON = "mdi:currency-usd" SCAN_INTERVAL = timedelta(minutes=1) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) PARALLEL_UPDATES = 0 -SIGNAL_UPDATE_ENTITY = "tibber_rt_update_{}" RT_SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="averagePower", name="average power", - device_class=DEVICE_CLASS_POWER, + device_class=SensorDeviceClass.POWER, native_unit_of_measurement=POWER_WATT, ), SensorEntityDescription( key="power", name="power", - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=POWER_WATT, ), SensorEntityDescription( key="powerProduction", name="power production", - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=POWER_WATT, ), SensorEntityDescription( key="minPower", name="min power", - device_class=DEVICE_CLASS_POWER, + device_class=SensorDeviceClass.POWER, native_unit_of_measurement=POWER_WATT, ), SensorEntityDescription( key="maxPower", name="max power", - device_class=DEVICE_CLASS_POWER, + device_class=SensorDeviceClass.POWER, native_unit_of_measurement=POWER_WATT, ), SensorEntityDescription( key="accumulatedConsumption", name="accumulated consumption", - device_class=DEVICE_CLASS_ENERGY, + device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="accumulatedConsumptionLastHour", name="accumulated consumption current hour", - device_class=DEVICE_CLASS_ENERGY, + device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="accumulatedProduction", name="accumulated production", - device_class=DEVICE_CLASS_ENERGY, + device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="accumulatedProductionLastHour", name="accumulated production current hour", - device_class=DEVICE_CLASS_ENERGY, + device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="lastMeterConsumption", name="last meter consumption", - device_class=DEVICE_CLASS_ENERGY, + device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="lastMeterProduction", name="last meter production", - device_class=DEVICE_CLASS_ENERGY, + device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="voltagePhase1", name="voltage phase1", - device_class=DEVICE_CLASS_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="voltagePhase2", name="voltage phase2", - device_class=DEVICE_CLASS_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="voltagePhase3", name="voltage phase3", - device_class=DEVICE_CLASS_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="currentL1", name="current L1", - device_class=DEVICE_CLASS_CURRENT, + device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="currentL2", name="current L2", - device_class=DEVICE_CLASS_CURRENT, + device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="currentL3", name="current L3", - device_class=DEVICE_CLASS_CURRENT, + device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="signalStrength", name="signal strength", - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), SensorEntityDescription( key="accumulatedReward", name="accumulated reward", - device_class=DEVICE_CLASS_MONETARY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="accumulatedCost", name="accumulated cost", - device_class=DEVICE_CLASS_MONETARY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="powerFactor", name="power factor", - device_class=DEVICE_CLASS_POWER_FACTOR, + device_class=SensorDeviceClass.POWER_FACTOR, native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), ) @@ -301,7 +293,7 @@ class TibberSensorElPrice(TibberSensor): } self._attr_icon = ICON self._attr_name = f"Electricity price {self._home_name}" - self._attr_unique_id = f"{self._tibber_home.home_id}" + self._attr_unique_id = self._tibber_home.home_id self._model = "Price Sensor" self._device_name = self._attr_name diff --git a/homeassistant/components/tibber/translations/ja.json b/homeassistant/components/tibber/translations/ja.json new file mode 100644 index 00000000000..ed3c2f71357 --- /dev/null +++ b/homeassistant/components/tibber/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\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", + "invalid_access_token": "\u7121\u52b9\u306a\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", + "timeout": "Tibber\u3078\u306e\u63a5\u7d9a\u306e\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8" + }, + "step": { + "user": { + "data": { + "access_token": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3" + }, + "description": "https://developer.tibber.com/settings/accesstoken \u304b\u3089\u306e\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3\u3092\u5165\u529b\u3057\u307e\u3059", + "title": "Tibber" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tibber/translations/tr.json b/homeassistant/components/tibber/translations/tr.json index 5f8e72986b2..a34aab70d6c 100644 --- a/homeassistant/components/tibber/translations/tr.json +++ b/homeassistant/components/tibber/translations/tr.json @@ -5,13 +5,16 @@ }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", - "invalid_access_token": "Ge\u00e7ersiz eri\u015fim belirteci" + "invalid_access_token": "Ge\u00e7ersiz eri\u015fim belirteci", + "timeout": "Tibber'a ba\u011flan\u0131rken zaman a\u015f\u0131m\u0131" }, "step": { "user": { "data": { "access_token": "Eri\u015fim Belirteci" - } + }, + "description": "https://developer.tibber.com/settings/accesstoken adresinden eri\u015fim anahtar\u0131n\u0131z\u0131 girin", + "title": "Tibber" } } } diff --git a/homeassistant/components/tile/__init__.py b/homeassistant/components/tile/__init__.py index 6d16ea79b68..75bf306a98b 100644 --- a/homeassistant/components/tile/__init__.py +++ b/homeassistant/components/tile/__init__.py @@ -30,8 +30,6 @@ CONF_SHOW_INACTIVE = "show_inactive" async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Tile as config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} @callback def async_migrate_callback(entity_entry: RegistryEntry) -> dict | None: @@ -100,8 +98,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator_init_tasks.append(coordinator.async_refresh()) await gather_with_concurrency(DEFAULT_INIT_TASK_LIMIT, *coordinator_init_tasks) - hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] = coordinators - hass.data[DOMAIN][entry.entry_id][DATA_TILE] = tiles + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + DATA_COORDINATOR: coordinators, + DATA_TILE: tiles, + } hass.config_entries.async_setup_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py index 1276dc3f5fd..9b8e2a94257 100644 --- a/homeassistant/components/tile/device_tracker.py +++ b/homeassistant/components/tile/device_tracker.py @@ -10,7 +10,7 @@ from pytile.tile import Tile from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.components.device_tracker.const import SOURCE_TYPE_GPS from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -32,7 +32,6 @@ ATTR_RING_STATE = "ring_state" ATTR_TILE_NAME = "tile_name" ATTR_VOIP_STATE = "voip_state" -DEFAULT_ATTRIBUTION = "Data provided by Tile" DEFAULT_ICON = "mdi:view-grid" @@ -89,7 +88,7 @@ class TileDeviceTracker(CoordinatorEntity, TrackerEntity): """Initialize.""" super().__init__(coordinator) - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._attr_extra_state_attributes = {} self._attr_name = tile.name self._attr_unique_id = f"{entry.data[CONF_USERNAME]}_{tile.uuid}" self._entry = entry diff --git a/homeassistant/components/tile/translations/bg.json b/homeassistant/components/tile/translations/bg.json index 946b62a8690..debdbdaaf87 100644 --- a/homeassistant/components/tile/translations/bg.json +++ b/homeassistant/components/tile/translations/bg.json @@ -1,7 +1,18 @@ { "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" + }, "error": { "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "Email" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/tile/translations/ja.json b/homeassistant/components/tile/translations/ja.json new file mode 100644 index 00000000000..9dd693379bd --- /dev/null +++ b/homeassistant/components/tile/translations/ja.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "E\u30e1\u30fc\u30eb" + }, + "title": "\u30bf\u30a4\u30eb\u306e\u8a2d\u5b9a" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_inactive": "\u975e\u30a2\u30af\u30c6\u30a3\u30d6\u306a\u30bf\u30a4\u30eb\u3092\u8868\u793a" + }, + "title": "\u30bf\u30a4\u30eb\u306e\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tile/translations/tr.json b/homeassistant/components/tile/translations/tr.json index 8a04e2f4bbf..793f7fd5fd7 100644 --- a/homeassistant/components/tile/translations/tr.json +++ b/homeassistant/components/tile/translations/tr.json @@ -21,7 +21,8 @@ "init": { "data": { "show_inactive": "Etkin Olmayan Karolar\u0131 G\u00f6ster" - } + }, + "title": "Tile'yi Yap\u0131land\u0131r\u0131n" } } } diff --git a/homeassistant/components/timer/translations/ja.json b/homeassistant/components/timer/translations/ja.json new file mode 100644 index 00000000000..d560f4da835 --- /dev/null +++ b/homeassistant/components/timer/translations/ja.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "active": "\u30a2\u30af\u30c6\u30a3\u30d6", + "idle": "\u30a2\u30a4\u30c9\u30eb", + "paused": "\u4e00\u6642\u505c\u6b62" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/timer/translations/tr.json b/homeassistant/components/timer/translations/tr.json index 0711eb71f7a..8a581e3069a 100644 --- a/homeassistant/components/timer/translations/tr.json +++ b/homeassistant/components/timer/translations/tr.json @@ -1,7 +1,7 @@ { "state": { "_": { - "active": "Aktif", + "active": "Etkin", "idle": "Bo\u015fta", "paused": "Durduruldu" } diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 51f4e859a1f..4caef136f3f 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -233,8 +233,7 @@ def _parse_due_date(data: dict, gmt_string) -> datetime | None: # Add time information to date only strings. if len(data["date"]) == 10: return datetime.fromisoformat(data["date"]).replace(tzinfo=dt.UTC) - nowtime = dt.parse_datetime(data["date"]) - if not nowtime: + if not (nowtime := dt.parse_datetime(data["date"])): return None if nowtime.tzinfo is None: data["date"] += gmt_string @@ -422,7 +421,7 @@ class TodoistProjectData: # it shouldn't be counted. return None - task[DUE_TODAY] = task[END].date() == datetime.today().date() + task[DUE_TODAY] = task[END].date() == dt.utcnow().date() # Special case: Task is overdue. if task[END] <= task[START]: diff --git a/homeassistant/components/tolo/__init__.py b/homeassistant/components/tolo/__init__.py new file mode 100644 index 00000000000..4f41303c8f8 --- /dev/null +++ b/homeassistant/components/tolo/__init__.py @@ -0,0 +1,109 @@ +"""Component to control TOLO Sauna/Steam Bath.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import NamedTuple + +from tololib import ToloClient +from tololib.errors import ResponseTimedOutError +from tololib.message_info import SettingsInfo, StatusInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import DEFAULT_RETRY_COUNT, DEFAULT_RETRY_TIMEOUT, DOMAIN + +PLATFORMS = [ + "binary_sensor", + "button", + "climate", + "fan", + "light", + "select", + "sensor", +] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up tolo from a config entry.""" + coordinator = ToloSaunaUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class ToloSaunaData(NamedTuple): + """Compound class for reflecting full state (status and info) of a TOLO Sauna.""" + + status: StatusInfo + settings: SettingsInfo + + +class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): + """DataUpdateCoordinator for TOLO Sauna.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize ToloSaunaUpdateCoordinator.""" + self.client = ToloClient(entry.data[CONF_HOST]) + super().__init__( + hass=hass, + logger=_LOGGER, + name=f"{entry.title} ({entry.data[CONF_HOST]}) Data Update Coordinator", + update_interval=timedelta(seconds=5), + ) + + async def _async_update_data(self) -> ToloSaunaData: + return await self.hass.async_add_executor_job(self._get_tolo_sauna_data) + + def _get_tolo_sauna_data(self) -> ToloSaunaData: + try: + status = self.client.get_status_info( + resend_timeout=DEFAULT_RETRY_TIMEOUT, retries=DEFAULT_RETRY_COUNT + ) + settings = self.client.get_settings_info( + resend_timeout=DEFAULT_RETRY_TIMEOUT, retries=DEFAULT_RETRY_COUNT + ) + except ResponseTimedOutError as error: + raise UpdateFailed("communication timeout") from error + return ToloSaunaData(status, settings) + + +class ToloSaunaCoordinatorEntity(CoordinatorEntity): + """CoordinatorEntity for TOLO Sauna.""" + + coordinator: ToloSaunaUpdateCoordinator + + def __init__( + self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry + ) -> None: + """Initialize ToloSaunaCoordinatorEntity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + name="TOLO Sauna", + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="SteamTec", + model=self.coordinator.data.status.model.name.capitalize(), + ) diff --git a/homeassistant/components/tolo/binary_sensor.py b/homeassistant/components/tolo/binary_sensor.py new file mode 100644 index 00000000000..777e2f16942 --- /dev/null +++ b/homeassistant/components/tolo/binary_sensor.py @@ -0,0 +1,72 @@ +"""TOLO Sauna binary sensors.""" + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_OPENING, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensors for TOLO Sauna.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + ToloFlowInBinarySensor(coordinator, entry), + ToloFlowOutBinarySensor(coordinator, entry), + ] + ) + + +class ToloFlowInBinarySensor(ToloSaunaCoordinatorEntity, BinarySensorEntity): + """Water In Valve Sensor.""" + + _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + _attr_name = "Water In Valve" + _attr_device_class = DEVICE_CLASS_OPENING + _attr_icon = "mdi:water-plus-outline" + + def __init__( + self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry + ) -> None: + """Initialize TOLO Water In Valve entity.""" + super().__init__(coordinator, entry) + + self._attr_unique_id = f"{entry.entry_id}_flow_in" + + @property + def is_on(self) -> bool: + """Return if flow in valve is open.""" + return self.coordinator.data.status.flow_in + + +class ToloFlowOutBinarySensor(ToloSaunaCoordinatorEntity, BinarySensorEntity): + """Water Out Valve Sensor.""" + + _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + _attr_name = "Water Out Valve" + _attr_device_class = DEVICE_CLASS_OPENING + _attr_icon = "mdi:water-minus-outline" + + def __init__( + self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry + ) -> None: + """Initialize TOLO Water Out Valve entity.""" + super().__init__(coordinator, entry) + + self._attr_unique_id = f"{entry.entry_id}_flow_out" + + @property + def is_on(self) -> bool: + """Return if flow out valve is open.""" + return self.coordinator.data.status.flow_out diff --git a/homeassistant/components/tolo/button.py b/homeassistant/components/tolo/button.py new file mode 100644 index 00000000000..9dd7752b0c9 --- /dev/null +++ b/homeassistant/components/tolo/button.py @@ -0,0 +1,54 @@ +"""TOLO Sauna Button controls.""" + +from tololib.const import LampMode + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_CONFIG +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up buttons for TOLO Sauna.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + ToloLampNextColorButton(coordinator, entry), + ] + ) + + +class ToloLampNextColorButton(ToloSaunaCoordinatorEntity, ButtonEntity): + """Button for switching to the next lamp color.""" + + _attr_entity_category = ENTITY_CATEGORY_CONFIG + _attr_icon = "mdi:palette" + _attr_name = "Next Color" + + def __init__( + self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry + ) -> None: + """Initialize lamp next color button entity.""" + super().__init__(coordinator, entry) + + self._attr_unique_id = f"{entry.entry_id}_lamp_next_color" + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + self.coordinator.data.status.lamp_on + and self.coordinator.data.settings.lamp_mode == LampMode.MANUAL + ) + + def press(self) -> None: + """Execute action when lamp change color button was pressed.""" + self.coordinator.client.lamp_change_color() diff --git a/homeassistant/components/tolo/climate.py b/homeassistant/components/tolo/climate.py new file mode 100644 index 00000000000..659dfcbda16 --- /dev/null +++ b/homeassistant/components/tolo/climate.py @@ -0,0 +1,156 @@ +"""TOLO Sauna climate controls (main sauna control).""" + +from __future__ import annotations + +from typing import Any + +from tololib.const import Calefaction + +from homeassistant.components.climate import ( + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + ClimateEntity, +) +from homeassistant.components.climate.const import ( + CURRENT_HVAC_DRY, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + FAN_OFF, + FAN_ON, + HVAC_MODE_DRY, + SUPPORT_FAN_MODE, + SUPPORT_TARGET_HUMIDITY, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, TEMP_CELSIUS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator +from .const import ( + DEFAULT_MAX_HUMIDITY, + DEFAULT_MAX_TEMP, + DEFAULT_MIN_HUMIDITY, + DEFAULT_MIN_TEMP, + DOMAIN, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up climate controls for TOLO Sauna.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SaunaClimate(coordinator, entry)]) + + +class SaunaClimate(ToloSaunaCoordinatorEntity, ClimateEntity): + """Sauna climate control.""" + + _attr_fan_modes = [FAN_ON, FAN_OFF] + _attr_hvac_modes = [HVAC_MODE_OFF, HVAC_MODE_HEAT, HVAC_MODE_DRY] + _attr_max_humidity = DEFAULT_MAX_HUMIDITY + _attr_max_temp = DEFAULT_MAX_TEMP + _attr_min_humidity = DEFAULT_MIN_HUMIDITY + _attr_min_temp = DEFAULT_MIN_TEMP + _attr_name = "Sauna Climate" + _attr_precision = PRECISION_WHOLE + _attr_supported_features = ( + SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_HUMIDITY | SUPPORT_FAN_MODE + ) + _attr_target_temperature_step = 1 + _attr_temperature_unit = TEMP_CELSIUS + + def __init__( + self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry + ) -> None: + """Initialize TOLO Sauna Climate entity.""" + super().__init__(coordinator, entry) + + self._attr_unique_id = f"{entry.entry_id}_climate" + + @property + def current_temperature(self) -> int: + """Return current temperature.""" + return self.coordinator.data.status.current_temperature + + @property + def current_humidity(self) -> int: + """Return current humidity.""" + return self.coordinator.data.status.current_humidity + + @property + def target_temperature(self) -> int: + """Return target temperature.""" + return self.coordinator.data.settings.target_temperature + + @property + def target_humidity(self) -> int: + """Return target humidity.""" + return self.coordinator.data.settings.target_humidity + + @property + def hvac_mode(self) -> str: + """Get current HVAC mode.""" + if self.coordinator.data.status.power_on: + return HVAC_MODE_HEAT + if ( + not self.coordinator.data.status.power_on + and self.coordinator.data.status.fan_on + ): + return HVAC_MODE_DRY + return HVAC_MODE_OFF + + @property + def hvac_action(self) -> str | None: + """Execute HVAC action.""" + if self.coordinator.data.status.calefaction == Calefaction.HEAT: + return CURRENT_HVAC_HEAT + if self.coordinator.data.status.calefaction == Calefaction.KEEP: + return CURRENT_HVAC_IDLE + if self.coordinator.data.status.calefaction == Calefaction.INACTIVE: + if self.coordinator.data.status.fan_on: + return CURRENT_HVAC_DRY + return CURRENT_HVAC_OFF + return None + + @property + def fan_mode(self) -> str: + """Return current fan mode.""" + if self.coordinator.data.status.fan_on: + return FAN_ON + return FAN_OFF + + def set_hvac_mode(self, hvac_mode: str) -> None: + """Set HVAC mode.""" + if hvac_mode == HVAC_MODE_OFF: + self._set_power_and_fan(False, False) + if hvac_mode == HVAC_MODE_HEAT: + self._set_power_and_fan(True, False) + if hvac_mode == HVAC_MODE_DRY: + self._set_power_and_fan(False, True) + + def set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + self.coordinator.client.set_fan_on(fan_mode == FAN_ON) + + def set_humidity(self, humidity: float) -> None: + """Set desired target humidity.""" + self.coordinator.client.set_target_humidity(round(humidity)) + + def set_temperature(self, **kwargs: Any) -> None: + """Set desired target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + + self.coordinator.client.set_target_temperature(round(temperature)) + + def _set_power_and_fan(self, power_on: bool, fan_on: bool) -> None: + """Shortcut for setting power and fan of TOLO device on one method.""" + self.coordinator.client.set_power_on(power_on) + self.coordinator.client.set_fan_on(fan_on) diff --git a/homeassistant/components/tolo/config_flow.py b/homeassistant/components/tolo/config_flow.py new file mode 100644 index 00000000000..14304f6653e --- /dev/null +++ b/homeassistant/components/tolo/config_flow.py @@ -0,0 +1,95 @@ +"""Config flow for tolo.""" + +from __future__ import annotations + +import logging +from typing import Any + +from tololib import ToloClient +from tololib.errors import ResponseTimedOutError +import voluptuous as vol + +from homeassistant.components import dhcp +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.device_registry import format_mac + +from .const import DEFAULT_NAME, DEFAULT_RETRY_COUNT, DEFAULT_RETRY_TIMEOUT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN): + """ConfigFlow for TOLO Sauna.""" + + VERSION = 1 + + _discovered_host: str | None = None + + @staticmethod + def _check_device_availability(host: str) -> bool: + client = ToloClient(host) + try: + result = client.get_status_info( + resend_timeout=DEFAULT_RETRY_TIMEOUT, retries=DEFAULT_RETRY_COUNT + ) + except ResponseTimedOutError: + return False + return result is not None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + + device_available = await self.hass.async_add_executor_job( + self._check_device_availability, user_input[CONF_HOST] + ) + + if not device_available: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=DEFAULT_NAME, data={CONF_HOST: user_input[CONF_HOST]} + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, + ) + + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Handle a flow initialized by discovery.""" + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured({CONF_HOST: discovery_info.ip}) + self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) + + device_available = await self.hass.async_add_executor_job( + self._check_device_availability, discovery_info.ip + ) + + if device_available: + self._discovered_host = discovery_info.ip + return await self.async_step_confirm() + return self.async_abort(reason="not_tolo_device") + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user-confirmation of discovered node.""" + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: self._discovered_host}) + return self.async_create_entry( + title=DEFAULT_NAME, data={CONF_HOST: self._discovered_host} + ) + + return self.async_show_form( + step_id="confirm", + description_placeholders={CONF_HOST: self._discovered_host}, + ) diff --git a/homeassistant/components/tolo/const.py b/homeassistant/components/tolo/const.py new file mode 100644 index 00000000000..bfd700bb955 --- /dev/null +++ b/homeassistant/components/tolo/const.py @@ -0,0 +1,13 @@ +"""Constants for the tolo integration.""" + +DOMAIN = "tolo" +DEFAULT_NAME = "TOLO Sauna" + +DEFAULT_RETRY_TIMEOUT = 1 +DEFAULT_RETRY_COUNT = 3 + +DEFAULT_MAX_TEMP = 60 +DEFAULT_MIN_TEMP = 20 + +DEFAULT_MAX_HUMIDITY = 99 +DEFAULT_MIN_HUMIDITY = 60 diff --git a/homeassistant/components/tolo/fan.py b/homeassistant/components/tolo/fan.py new file mode 100644 index 00000000000..499a348bd0b --- /dev/null +++ b/homeassistant/components/tolo/fan.py @@ -0,0 +1,56 @@ +"""TOLO Sauna fan controls.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.fan import FanEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up fan controls for TOLO Sauna.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([ToloFan(coordinator, entry)]) + + +class ToloFan(ToloSaunaCoordinatorEntity, FanEntity): + """Sauna fan control.""" + + _attr_name = "Fan" + + def __init__( + self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry + ) -> None: + """Initialize TOLO fan entity.""" + super().__init__(coordinator, entry) + + self._attr_unique_id = f"{entry.entry_id}_fan" + + @property + def is_on(self) -> bool: + """Return if sauna fan is running.""" + return self.coordinator.data.status.fan_on + + def turn_on( + self, + speed: str | None = None, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on sauna fan.""" + self.coordinator.client.set_fan_on(True) + + def turn_off(self, **kwargs: Any) -> None: + """Turn off sauna fan.""" + self.coordinator.client.set_fan_on(False) diff --git a/homeassistant/components/tolo/light.py b/homeassistant/components/tolo/light.py new file mode 100644 index 00000000000..f58f7e7b8c9 --- /dev/null +++ b/homeassistant/components/tolo/light.py @@ -0,0 +1,51 @@ +"""TOLO Sauna light controls.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.light import COLOR_MODE_ONOFF, LightEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up light controls for TOLO Sauna.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([ToloLight(coordinator, entry)]) + + +class ToloLight(ToloSaunaCoordinatorEntity, LightEntity): + """Sauna light control.""" + + _attr_name = "Sauna Light" + _attr_supported_color_modes = {COLOR_MODE_ONOFF} + + def __init__( + self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry + ) -> None: + """Initialize TOLO Sauna Light entity.""" + super().__init__(coordinator, entry) + + self._attr_unique_id = f"{entry.entry_id}_light" + + @property + def is_on(self) -> bool: + """Return current lamp status.""" + return self.coordinator.data.status.lamp_on + + def turn_on(self, **kwargs: Any) -> None: + """Turn on TOLO Sauna lamp.""" + self.coordinator.client.set_lamp_on(True) + + def turn_off(self, **kwargs: Any) -> None: + """Turn off TOLO Sauna lamp.""" + self.coordinator.client.set_lamp_on(False) diff --git a/homeassistant/components/tolo/manifest.json b/homeassistant/components/tolo/manifest.json new file mode 100644 index 00000000000..63e87ebf876 --- /dev/null +++ b/homeassistant/components/tolo/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "tolo", + "name": "TOLO Sauna", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tolo", + "requirements": [ + "tololib==0.1.0b3" + ], + "codeowners": [ + "@MatthiasLohr" + ], + "iot_class": "local_polling", + "dhcp": [{"hostname": "usr-tcp232-ed2"}] +} \ No newline at end of file diff --git a/homeassistant/components/tolo/select.py b/homeassistant/components/tolo/select.py new file mode 100644 index 00000000000..1dc1f4f6163 --- /dev/null +++ b/homeassistant/components/tolo/select.py @@ -0,0 +1,51 @@ +"""TOLO Sauna Select controls.""" + +from __future__ import annotations + +from tololib.const import LampMode + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_CONFIG +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up select entities for TOLO Sauna.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([ToloLampModeSelect(coordinator, entry)]) + + +class ToloLampModeSelect(ToloSaunaCoordinatorEntity, SelectEntity): + """TOLO Sauna lamp mode select.""" + + _attr_device_class = "tolo__lamp_mode" + _attr_entity_category = ENTITY_CATEGORY_CONFIG + _attr_icon = "mdi:lightbulb-multiple-outline" + _attr_name = "Lamp Mode" + _attr_options = [lamp_mode.name.lower() for lamp_mode in LampMode] + + def __init__( + self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry + ) -> None: + """Initialize lamp mode select entity.""" + super().__init__(coordinator, entry) + + self._attr_unique_id = f"{entry.entry_id}_lamp_mode" + + @property + def current_option(self) -> str: + """Return current lamp mode.""" + return self.coordinator.data.settings.lamp_mode.name.lower() + + def select_option(self, option: str) -> None: + """Select lamp mode.""" + self.coordinator.client.set_lamp_mode(LampMode[option.upper()]) diff --git a/homeassistant/components/tolo/sensor.py b/homeassistant/components/tolo/sensor.py new file mode 100644 index 00000000000..23533f68784 --- /dev/null +++ b/homeassistant/components/tolo/sensor.py @@ -0,0 +1,76 @@ +"""TOLO Sauna (non-binary, general) sensors.""" + +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + PERCENTAGE, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up (non-binary, general) sensors for TOLO Sauna.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + ToloWaterLevelSensor(coordinator, entry), + ToloTankTemperatureSensor(coordinator, entry), + ] + ) + + +class ToloWaterLevelSensor(ToloSaunaCoordinatorEntity, SensorEntity): + """Sensor for tank water level.""" + + _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + _attr_name = "Water Level" + _attr_icon = "mdi:waves-arrow-up" + _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_native_unit_of_measurement = PERCENTAGE + + def __init__( + self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry + ) -> None: + """Initialize TOLO Sauna tank water level sensor entity.""" + super().__init__(coordinator, entry) + + self._attr_unique_id = f"{entry.entry_id}_water_level" + + @property + def native_value(self) -> int: + """Return current tank water level.""" + return self.coordinator.data.status.water_level_percent + + +class ToloTankTemperatureSensor(ToloSaunaCoordinatorEntity, SensorEntity): + """Sensor for tank temperature.""" + + _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + _attr_name = "Tank Temperature" + _attr_device_class = DEVICE_CLASS_TEMPERATURE + _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_native_unit_of_measurement = TEMP_CELSIUS + + def __init__( + self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry + ) -> None: + """Initialize TOLO Sauna tank temperature sensor entity.""" + super().__init__(coordinator, entry) + + self._attr_unique_id = f"{entry.entry_id}_tank_temperature" + + @property + def native_value(self) -> int: + """Return current tank temperature.""" + return self.coordinator.data.status.tank_temperature diff --git a/homeassistant/components/tolo/strings.json b/homeassistant/components/tolo/strings.json new file mode 100644 index 00000000000..0a81dad73f6 --- /dev/null +++ b/homeassistant/components/tolo/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "Enter the hostname or IP address of your TOLO Sauna device.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/strings.select.json b/homeassistant/components/tolo/strings.select.json new file mode 100644 index 00000000000..c65caaf5d2d --- /dev/null +++ b/homeassistant/components/tolo/strings.select.json @@ -0,0 +1,8 @@ +{ + "state": { + "tolo__lamp_mode": { + "automatic": "automatic", + "manual": "manual" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/bg.json b/homeassistant/components/tolo/translations/bg.json new file mode 100644 index 00000000000..f1c33573305 --- /dev/null +++ b/homeassistant/components/tolo/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", + "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" + }, + "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": { + "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\u0432\u0430\u043d\u0435\u0442\u043e?" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/ca.json b/homeassistant/components/tolo/translations/ca.json new file mode 100644 index 00000000000..06d201141a5 --- /dev/null +++ b/homeassistant/components/tolo/translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "no_devices_found": "No s'han trobat dispositius a la xarxa" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Vols comen\u00e7ar la configuraci\u00f3?" + }, + "user": { + "data": { + "host": "Amfitri\u00f3" + }, + "description": "Introdueix el nom d'amfitri\u00f3 o l'adre\u00e7a IP del dispositiu TOLO Sauna." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/de.json b/homeassistant/components/tolo/translations/de.json new file mode 100644 index 00000000000..6002d2ada8b --- /dev/null +++ b/homeassistant/components/tolo/translations/de.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Gib den Hostnamen oder die IP-Adresse deines TOLO Sauna-Ger\u00e4ts ein." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/en.json b/homeassistant/components/tolo/translations/en.json new file mode 100644 index 00000000000..488c2f7ae69 --- /dev/null +++ b/homeassistant/components/tolo/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "no_devices_found": "No devices found on the network" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Do you want to start set up?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Enter the hostname or IP address of your TOLO Sauna device." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/et.json b/homeassistant/components/tolo/translations/et.json new file mode 100644 index 00000000000..57d59b85713 --- /dev/null +++ b/homeassistant/components/tolo/translations/et.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "no_devices_found": "V\u00f5rgust ei leitud \u00fchtegi seadet" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Kas soovid alustada seadistamist?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Sisesta oma TOLO Sauna seadme hostinimi v\u00f5i IP-aadress." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/he.json b/homeassistant/components/tolo/translations/he.json new file mode 100644 index 00000000000..9da8a69a4fe --- /dev/null +++ b/homeassistant/components/tolo/translations/he.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "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" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" + }, + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/hu.json b/homeassistant/components/tolo/translations/hu.json new file mode 100644 index 00000000000..55239599c16 --- /dev/null +++ b/homeassistant/components/tolo/translations/hu.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" + }, + "user": { + "data": { + "host": "C\u00edm" + }, + "description": "Adja meg a TOLO Sauna eszk\u00f6z\u00e9nek hostnev\u00e9t vagy IP-c\u00edm\u00e9t." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/id.json b/homeassistant/components/tolo/translations/id.json new file mode 100644 index 00000000000..53ea0e46cb1 --- /dev/null +++ b/homeassistant/components/tolo/translations/id.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan" + }, + "error": { + "cannot_connect": "Gagal terhubung" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Ingin memulai penyiapan?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Masukkan nama host atau alamat IP perangkat TOLO Sauna." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/ja.json b/homeassistant/components/tolo/translations/ja.json new file mode 100644 index 00000000000..f8d4a1646ae --- /dev/null +++ b/homeassistant/components/tolo/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", + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "TOLO Sauna device\u306e\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9\u3092\u5165\u529b\u3057\u307e\u3059\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/nl.json b/homeassistant/components/tolo/translations/nl.json new file mode 100644 index 00000000000..f65f6bae7c1 --- /dev/null +++ b/homeassistant/components/tolo/translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "no_devices_found": "Geen apparaten gevonden op het netwerk" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Wilt u beginnen met instellen?" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Voer de hostnaam of het IP-adres van uw TOLO Sauna-apparaat in." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/no.json b/homeassistant/components/tolo/translations/no.json new file mode 100644 index 00000000000..20311dd8f69 --- /dev/null +++ b/homeassistant/components/tolo/translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Vil du starte oppsettet?" + }, + "user": { + "data": { + "host": "Vert" + }, + "description": "Skriv inn vertsnavnet eller IP-adressen til TOLO Sauna-enheten." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/pl.json b/homeassistant/components/tolo/translations/pl.json new file mode 100644 index 00000000000..4809e00e29f --- /dev/null +++ b/homeassistant/components/tolo/translations/pl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "no_devices_found": "Nie znaleziono urz\u0105dze\u0144 w sieci" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Czy chcesz rozpocz\u0105\u0107 konfiguracj\u0119?" + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + }, + "description": "Wprowad\u017a nazw\u0119 hosta lub adres IP urz\u0105dzenia TOLO Sauna." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/ru.json b/homeassistant/components/tolo/translations/ru.json new file mode 100644 index 00000000000..82fdffdb8b1 --- /dev/null +++ b/homeassistant/components/tolo/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.", + "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." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0447\u0430\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443?" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/select.bg.json b/homeassistant/components/tolo/translations/select.bg.json new file mode 100644 index 00000000000..e66365b2339 --- /dev/null +++ b/homeassistant/components/tolo/translations/select.bg.json @@ -0,0 +1,8 @@ +{ + "state": { + "tolo__lamp_mode": { + "automatic": "\u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u043d\u043e", + "manual": "\u0440\u044a\u0447\u043d\u043e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/select.ca.json b/homeassistant/components/tolo/translations/select.ca.json new file mode 100644 index 00000000000..91b7948786f --- /dev/null +++ b/homeassistant/components/tolo/translations/select.ca.json @@ -0,0 +1,8 @@ +{ + "state": { + "tolo__lamp_mode": { + "automatic": "autom\u00e0tic", + "manual": "manual" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/select.de.json b/homeassistant/components/tolo/translations/select.de.json new file mode 100644 index 00000000000..70c28bbd4d9 --- /dev/null +++ b/homeassistant/components/tolo/translations/select.de.json @@ -0,0 +1,8 @@ +{ + "state": { + "tolo__lamp_mode": { + "automatic": "Automatisch", + "manual": "Manuell" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/select.en.json b/homeassistant/components/tolo/translations/select.en.json new file mode 100644 index 00000000000..ba4b0d20dbc --- /dev/null +++ b/homeassistant/components/tolo/translations/select.en.json @@ -0,0 +1,8 @@ +{ + "state": { + "tolo__lamp_mode": { + "automatic": "automatic", + "manual": "manual" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/select.et.json b/homeassistant/components/tolo/translations/select.et.json new file mode 100644 index 00000000000..17014b9b867 --- /dev/null +++ b/homeassistant/components/tolo/translations/select.et.json @@ -0,0 +1,8 @@ +{ + "state": { + "tolo__lamp_mode": { + "automatic": "automaatne", + "manual": "k\u00e4sitsi" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/select.he.json b/homeassistant/components/tolo/translations/select.he.json new file mode 100644 index 00000000000..f02588f3be5 --- /dev/null +++ b/homeassistant/components/tolo/translations/select.he.json @@ -0,0 +1,8 @@ +{ + "state": { + "tolo__lamp_mode": { + "automatic": "\u05d0\u05d5\u05d8\u05d5\u05de\u05d8\u05d9", + "manual": "\u05d9\u05d3\u05e0\u05d9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/select.hu.json b/homeassistant/components/tolo/translations/select.hu.json new file mode 100644 index 00000000000..1768eeaa0a8 --- /dev/null +++ b/homeassistant/components/tolo/translations/select.hu.json @@ -0,0 +1,8 @@ +{ + "state": { + "tolo__lamp_mode": { + "automatic": "automatikus", + "manual": "k\u00e9zi" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/select.id.json b/homeassistant/components/tolo/translations/select.id.json new file mode 100644 index 00000000000..262d67bb310 --- /dev/null +++ b/homeassistant/components/tolo/translations/select.id.json @@ -0,0 +1,8 @@ +{ + "state": { + "tolo__lamp_mode": { + "automatic": "otomatis", + "manual": "manual" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/select.ja.json b/homeassistant/components/tolo/translations/select.ja.json new file mode 100644 index 00000000000..dda0d8e99b3 --- /dev/null +++ b/homeassistant/components/tolo/translations/select.ja.json @@ -0,0 +1,8 @@ +{ + "state": { + "tolo__lamp_mode": { + "automatic": "\u81ea\u52d5", + "manual": "\u624b\u52d5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/select.nl.json b/homeassistant/components/tolo/translations/select.nl.json new file mode 100644 index 00000000000..ea981d36e54 --- /dev/null +++ b/homeassistant/components/tolo/translations/select.nl.json @@ -0,0 +1,8 @@ +{ + "state": { + "tolo__lamp_mode": { + "automatic": "Automatisch", + "manual": "Handmatig" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/select.no.json b/homeassistant/components/tolo/translations/select.no.json new file mode 100644 index 00000000000..880a8dc6f70 --- /dev/null +++ b/homeassistant/components/tolo/translations/select.no.json @@ -0,0 +1,8 @@ +{ + "state": { + "tolo__lamp_mode": { + "automatic": "automatisk", + "manual": "manuell" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/select.pl.json b/homeassistant/components/tolo/translations/select.pl.json new file mode 100644 index 00000000000..58a0192a7b8 --- /dev/null +++ b/homeassistant/components/tolo/translations/select.pl.json @@ -0,0 +1,8 @@ +{ + "state": { + "tolo__lamp_mode": { + "automatic": "automatyczny", + "manual": "r\u0119czny" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/select.ru.json b/homeassistant/components/tolo/translations/select.ru.json new file mode 100644 index 00000000000..d736bd8680c --- /dev/null +++ b/homeassistant/components/tolo/translations/select.ru.json @@ -0,0 +1,8 @@ +{ + "state": { + "tolo__lamp_mode": { + "automatic": "\u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438\u0439", + "manual": "\u0440\u0443\u0447\u043d\u043e\u0439" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/select.sl.json b/homeassistant/components/tolo/translations/select.sl.json new file mode 100644 index 00000000000..71dbd7a4ffb --- /dev/null +++ b/homeassistant/components/tolo/translations/select.sl.json @@ -0,0 +1,7 @@ +{ + "state": { + "tolo__lamp_mode": { + "automatic": "avtomatsko" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/select.tr.json b/homeassistant/components/tolo/translations/select.tr.json new file mode 100644 index 00000000000..fda5a726da0 --- /dev/null +++ b/homeassistant/components/tolo/translations/select.tr.json @@ -0,0 +1,8 @@ +{ + "state": { + "tolo__lamp_mode": { + "automatic": "otomatik", + "manual": "manuel" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/select.zh-Hant.json b/homeassistant/components/tolo/translations/select.zh-Hant.json new file mode 100644 index 00000000000..dda0d8e99b3 --- /dev/null +++ b/homeassistant/components/tolo/translations/select.zh-Hant.json @@ -0,0 +1,8 @@ +{ + "state": { + "tolo__lamp_mode": { + "automatic": "\u81ea\u52d5", + "manual": "\u624b\u52d5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/sl.json b/homeassistant/components/tolo/translations/sl.json new file mode 100644 index 00000000000..e32b3eb95ca --- /dev/null +++ b/homeassistant/components/tolo/translations/sl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana", + "no_devices_found": "V omre\u017eju ni mogo\u010de najti nobene naprave" + }, + "error": { + "cannot_connect": "Povezava ni uspela" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Ali \u017eelite za\u010deti z nastavitvijo?" + }, + "user": { + "data": { + "host": "Gostitelj" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/tr.json b/homeassistant/components/tolo/translations/tr.json new file mode 100644 index 00000000000..2d5c8117cd6 --- /dev/null +++ b/homeassistant/components/tolo/translations/tr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "no_devices_found": "A\u011fda cihaz bulunamad\u0131" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Kuruluma ba\u015flamak ister misiniz?" + }, + "user": { + "data": { + "host": "Ana bilgisayar" + }, + "description": "TOLO Sauna cihaz\u0131n\u0131z\u0131n ana bilgisayar ad\u0131n\u0131 veya IP adresini girin." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tolo/translations/zh-Hant.json b/homeassistant/components/tolo/translations/zh-Hant.json new file mode 100644 index 00000000000..d887eb212a1 --- /dev/null +++ b/homeassistant/components/tolo/translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u88dd\u7f6e" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u958b\u59cb\u8a2d\u5b9a\uff1f" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "description": "\u8f38\u5165 TOLO Sauna \u8a2d\u5099\u4e4b\u4e3b\u6a5f\u540d\u7a31\u6216 IP \u4f4d\u5740\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index f05c480aede..372eeb47096 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -102,7 +102,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = coordinator # Register device for the Meter Adapter, since it will have no entities. - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={ diff --git a/homeassistant/components/toon/models.py b/homeassistant/components/toon/models.py index 116613640ad..57db44beb6b 100644 --- a/homeassistant/components/toon/models.py +++ b/homeassistant/components/toon/models.py @@ -23,15 +23,13 @@ class ToonDisplayDeviceEntity(ToonEntity): def device_info(self) -> DeviceInfo: """Return device information about this thermostat.""" agreement = self.coordinator.data.agreement - model = agreement.display_hardware_version.rpartition("/")[0] - sw_version = agreement.display_software_version.rpartition("/")[-1] - return { - "identifiers": {(DOMAIN, agreement.agreement_id)}, - "name": "Toon Display", - "manufacturer": "Eneco", - "model": model, - "sw_version": sw_version, - } + return DeviceInfo( + identifiers={(DOMAIN, agreement.agreement_id)}, + manufacturer="Eneco", + model=agreement.display_hardware_version.rpartition("/")[0], + name="Toon Display", + sw_version=agreement.display_software_version.rpartition("/")[-1], + ) class ToonElectricityMeterDeviceEntity(ToonEntity): diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 35f317a2638..30cde4632a9 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -13,6 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_GAS, + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, ENERGY_KILO_WATT_HOUR, @@ -141,6 +142,17 @@ SENSOR_ENTITIES: tuple[ToonSensorEntityDescription, ...] = ( state_class=STATE_CLASS_MEASUREMENT, cls=ToonDisplayDeviceSensor, ), + ToonSensorEntityDescription( + key="current_humidity", + name="Humidity", + section="thermostat", + measurement="current_humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + cls=ToonDisplayDeviceSensor, + ), ToonSensorEntityDescription( key="gas_average", name="Average Gas Usage", diff --git a/homeassistant/components/toon/translations/bg.json b/homeassistant/components/toon/translations/bg.json index 11793c5bb5d..fabff55d680 100644 --- a/homeassistant/components/toon/translations/bg.json +++ b/homeassistant/components/toon/translations/bg.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "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.", "no_agreements": "\u0422\u043e\u0437\u0438 \u043f\u0440\u043e\u0444\u0438\u043b \u043d\u044f\u043c\u0430 Toon \u0434\u0438\u0441\u043f\u043b\u0435\u0438." } } diff --git a/homeassistant/components/toon/translations/ja.json b/homeassistant/components/toon/translations/ja.json new file mode 100644 index 00000000000..26889353f92 --- /dev/null +++ b/homeassistant/components/toon/translations/ja.json @@ -0,0 +1,23 @@ +{ + "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_agreements": "\u3053\u306e\u30a2\u30ab\u30a6\u30f3\u30c8\u306b\u306f\u3001Toon displays\u304c\u3042\u308a\u307e\u305b\u3093\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})", + "unknown_authorize_url_generation": "\u8a8d\u8a3cURL\u306e\u751f\u6210\u4e2d\u306b\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002" + }, + "step": { + "agreement": { + "data": { + "agreement": "\u5408\u610f(Agreement)" + }, + "description": "\u8ffd\u52a0\u3057\u305f\u3044\u5951\u7d04(Agreement)\u30a2\u30c9\u30ec\u30b9\u3092\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "\u5951\u7d04(Agreement)\u306e\u9078\u629e" + }, + "pick_implementation": { + "title": "\u8a8d\u8a3c\u3059\u308b\u30c6\u30ca\u30f3\u30c8\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/translations/tr.json b/homeassistant/components/toon/translations/tr.json index 97765a99a7f..5964dea6ba5 100644 --- a/homeassistant/components/toon/translations/tr.json +++ b/homeassistant/components/toon/translations/tr.json @@ -2,6 +2,10 @@ "config": { "abort": { "already_configured": "Se\u00e7ilen anla\u015fma zaten yap\u0131land\u0131r\u0131lm\u0131\u015f.", + "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_agreements": "Bu hesapta Toon ekran\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})", "unknown_authorize_url_generation": "Yetkilendirme url'si olu\u015fturulurken bilinmeyen hata." }, "step": { diff --git a/homeassistant/components/totalconnect/__init__.py b/homeassistant/components/totalconnect/__init__.py index 8acc7801de8..dcbc1592814 100644 --- a/homeassistant/components/totalconnect/__init__.py +++ b/homeassistant/components/totalconnect/__init__.py @@ -34,12 +34,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: temp_codes = conf[CONF_USERCODES] usercodes = {int(code): temp_codes[code] for code in temp_codes} - client = await hass.async_add_executor_job( - TotalConnectClient, username, password, usercodes - ) - if not client.is_logged_in(): - raise ConfigEntryAuthFailed("TotalConnect authentication failed") + try: + client = await hass.async_add_executor_job( + TotalConnectClient, username, password, usercodes + ) + except AuthenticationError as exception: + raise ConfigEntryAuthFailed("TotalConnect authentication failed") from exception coordinator = TotalConnectDataUpdateCoordinator(hass, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/totalconnect/config_flow.py b/homeassistant/components/totalconnect/config_flow.py index f3550722de5..b529bdd80fd 100644 --- a/homeassistant/components/totalconnect/config_flow.py +++ b/homeassistant/components/totalconnect/config_flow.py @@ -1,5 +1,6 @@ """Config flow for the Total Connect component.""" from total_connect_client.client import TotalConnectClient +from total_connect_client.exceptions import AuthenticationError import voluptuous as vol from homeassistant import config_entries @@ -36,18 +37,18 @@ class TotalConnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(username) self._abort_if_unique_id_configured() - client = await self.hass.async_add_executor_job( - TotalConnectClient, username, password, None - ) - - if client.is_logged_in(): + try: + client = await self.hass.async_add_executor_job( + TotalConnectClient, username, password, None + ) + except AuthenticationError: + errors["base"] = "invalid_auth" + else: # username/password valid so show user locations self.username = username self.password = password self.client = client return await self.async_step_locations() - # authentication failed / invalid - errors["base"] = "invalid_auth" data_schema = vol.Schema( {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} @@ -88,6 +89,12 @@ class TotalConnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): }, ) else: + # Force the loading of locations using I/O + number_locations = await self.hass.async_add_executor_job( + self.client.get_number_locations, + ) + if number_locations < 1: + return self.async_abort(reason="no_locations") for location_id in self.client.locations: self.usercodes[location_id] = None @@ -129,14 +136,14 @@ class TotalConnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=PASSWORD_DATA_SCHEMA, ) - client = await self.hass.async_add_executor_job( - TotalConnectClient, - self.username, - user_input[CONF_PASSWORD], - self.usercodes, - ) - - if not client.is_logged_in(): + try: + await self.hass.async_add_executor_job( + TotalConnectClient, + self.username, + user_input[CONF_PASSWORD], + self.usercodes, + ) + except AuthenticationError: errors["base"] = "invalid_auth" return self.async_show_form( step_id="reauth_confirm", diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index c70eafd9c31..0eec41968cc 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -2,7 +2,7 @@ "domain": "totalconnect", "name": "Total Connect", "documentation": "https://www.home-assistant.io/integrations/totalconnect", - "requirements": ["total_connect_client==2021.11.2"], + "requirements": ["total_connect_client==2021.11.4"], "dependencies": [], "codeowners": ["@austinmroczek"], "config_flow": true, diff --git a/homeassistant/components/totalconnect/strings.json b/homeassistant/components/totalconnect/strings.json index 5c32d19b348..63505c2446c 100644 --- a/homeassistant/components/totalconnect/strings.json +++ b/homeassistant/components/totalconnect/strings.json @@ -26,7 +26,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "no_locations": "No locations are available for this user, check TotalConnect settings" } } } diff --git a/homeassistant/components/totalconnect/translations/ca.json b/homeassistant/components/totalconnect/translations/ca.json index fa42c81e1be..0edad920cdf 100644 --- a/homeassistant/components/totalconnect/translations/ca.json +++ b/homeassistant/components/totalconnect/translations/ca.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "El compte ja est\u00e0 configurat", + "no_locations": "No hi ha ubicacions disponibles per a aquest usuari, comprova la configuraci\u00f3 de TotalConnect", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { diff --git a/homeassistant/components/totalconnect/translations/de.json b/homeassistant/components/totalconnect/translations/de.json index c435169c804..b89b99fc2d4 100644 --- a/homeassistant/components/totalconnect/translations/de.json +++ b/homeassistant/components/totalconnect/translations/de.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Konto wurde bereits konfiguriert", + "no_locations": "F\u00fcr diesen Benutzer sind keine Standorte verf\u00fcgbar, \u00fcberpr\u00fcfe die TotalConnect-Einstellungen", "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { diff --git a/homeassistant/components/totalconnect/translations/en.json b/homeassistant/components/totalconnect/translations/en.json index 05f394fbb31..f3a96550cba 100644 --- a/homeassistant/components/totalconnect/translations/en.json +++ b/homeassistant/components/totalconnect/translations/en.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Account is already configured", + "no_locations": "No locations are available for this user, check TotalConnect settings", "reauth_successful": "Re-authentication was successful" }, "error": { diff --git a/homeassistant/components/totalconnect/translations/et.json b/homeassistant/components/totalconnect/translations/et.json index a4110f9bf0f..e2df8dd51e8 100644 --- a/homeassistant/components/totalconnect/translations/et.json +++ b/homeassistant/components/totalconnect/translations/et.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Konto on juba seadistatud", + "no_locations": "Kasutaja jaoks ei ole asukohti saadaval, kontrolli TotalConnecti seadeid.", "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { diff --git a/homeassistant/components/totalconnect/translations/hu.json b/homeassistant/components/totalconnect/translations/hu.json index 18cbad6bc50..3d40f84d262 100644 --- a/homeassistant/components/totalconnect/translations/hu.json +++ b/homeassistant/components/totalconnect/translations/hu.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "no_locations": "Nincs el\u00e9rhet\u0151 helyzet a felhaszn\u00e1l\u00f3 sz\u00e1m\u00e1ra, ellen\u0151rizze a TotalConnect be\u00e1ll\u00edt\u00e1sait.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." }, "error": { diff --git a/homeassistant/components/totalconnect/translations/id.json b/homeassistant/components/totalconnect/translations/id.json index b1bc5573021..0c2cbbfc6e2 100644 --- a/homeassistant/components/totalconnect/translations/id.json +++ b/homeassistant/components/totalconnect/translations/id.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Akun sudah dikonfigurasi", + "no_locations": "Tidak ada lokasi yang tersedia untuk pengguna ini, periksa pengaturan TotalConnect", "reauth_successful": "Autentikasi ulang berhasil" }, "error": { @@ -11,7 +12,8 @@ "step": { "locations": { "data": { - "location": "Lokasi" + "location": "Lokasi", + "usercode": "Kode pengguna" }, "description": "Masukkan kode pengguna untuk pengguna ini di lokasi {location_id}", "title": "Lokasi Kode Pengguna" diff --git a/homeassistant/components/totalconnect/translations/it.json b/homeassistant/components/totalconnect/translations/it.json index dfc480ab961..437edd55a44 100644 --- a/homeassistant/components/totalconnect/translations/it.json +++ b/homeassistant/components/totalconnect/translations/it.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "no_locations": "Nessuna posizione disponibile per questo utente, controlla le impostazioni di TotalConnect", "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { diff --git a/homeassistant/components/totalconnect/translations/ja.json b/homeassistant/components/totalconnect/translations/ja.json new file mode 100644 index 00000000000..d6bffce6dae --- /dev/null +++ b/homeassistant/components/totalconnect/translations/ja.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "no_locations": "\u3053\u306e\u30e6\u30fc\u30b6\u30fc\u304c\u5229\u7528\u3067\u304d\u308b\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u304c\u3042\u308a\u307e\u305b\u3093\u3002TotalConnect\u306e\u8a2d\u5b9a\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "usercode": "\u3053\u306e\u30e6\u30fc\u30b6\u30fc\u304c\u3053\u306e\u5834\u6240\u306b\u5bfe\u3059\u308b\u306b\u306f\u30e6\u30fc\u30b6\u30fc\u30b3\u30fc\u30c9\u304c\u7121\u52b9\u3067\u3059" + }, + "step": { + "locations": { + "data": { + "location": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3", + "usercode": "\u30e6\u30fc\u30b6\u30fc\u30b3\u30fc\u30c9" + }, + "description": "\u3053\u306e\u30e6\u30fc\u30b6\u30fc\u306e\u30e6\u30fc\u30b6\u30fc\u30b3\u30fc\u30c9\u3092\u5834\u6240 {location_id} \u306b\u5165\u529b\u3057\u307e\u3059", + "title": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u30e6\u30fc\u30b6\u30fc\u30b3\u30fc\u30c9" + }, + "reauth_confirm": { + "description": "Total Connect\u306f\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u518d\u8a8d\u8a3c\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "\u30c8\u30fc\u30bf\u30eb\u30b3\u30cd\u30af\u30c8" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/nl.json b/homeassistant/components/totalconnect/translations/nl.json index 0ec7bb52d88..674818d8428 100644 --- a/homeassistant/components/totalconnect/translations/nl.json +++ b/homeassistant/components/totalconnect/translations/nl.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Account is al geconfigureerd", + "no_locations": "Er zijn geen locaties beschikbaar voor deze gebruiker, controleer de instellingen van TotalConnect", "reauth_successful": "Herauthenticatie was succesvol" }, "error": { diff --git a/homeassistant/components/totalconnect/translations/no.json b/homeassistant/components/totalconnect/translations/no.json index 839d901047b..c1624f08259 100644 --- a/homeassistant/components/totalconnect/translations/no.json +++ b/homeassistant/components/totalconnect/translations/no.json @@ -2,6 +2,7 @@ "config": { "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" }, "error": { diff --git a/homeassistant/components/totalconnect/translations/pl.json b/homeassistant/components/totalconnect/translations/pl.json index 03452569c28..5174d717c26 100644 --- a/homeassistant/components/totalconnect/translations/pl.json +++ b/homeassistant/components/totalconnect/translations/pl.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Konto jest ju\u017c skonfigurowane", + "no_locations": "Brak dost\u0119pnych lokalizacji dla tego u\u017cytkownika, sprawd\u017a ustawienia TotalConnect.", "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" }, "error": { diff --git a/homeassistant/components/totalconnect/translations/ru.json b/homeassistant/components/totalconnect/translations/ru.json index 268f620c238..a46c37032a1 100644 --- a/homeassistant/components/totalconnect/translations/ru.json +++ b/homeassistant/components/totalconnect/translations/ru.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "\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.", + "no_locations": "\u0414\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043d\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0439, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 TotalConnect.", "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": { diff --git a/homeassistant/components/totalconnect/translations/tr.json b/homeassistant/components/totalconnect/translations/tr.json index f941db5ab89..925353f05a4 100644 --- a/homeassistant/components/totalconnect/translations/tr.json +++ b/homeassistant/components/totalconnect/translations/tr.json @@ -1,17 +1,33 @@ { "config": { "abort": { - "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "no_locations": "Bu kullan\u0131c\u0131 i\u00e7in uygun konum yok, TotalConnect ayarlar\u0131n\u0131 kontrol edin", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { - "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "usercode": "Kullan\u0131c\u0131 kodu bu konumdaki kullan\u0131c\u0131 i\u00e7in ge\u00e7erli de\u011fil" }, "step": { + "locations": { + "data": { + "location": "Konum", + "usercode": "Kullan\u0131c\u0131 kodu" + }, + "description": "Bu kullan\u0131c\u0131n\u0131n kullan\u0131c\u0131 kodunu {location_id} konumuna girin", + "title": "Konum Kullan\u0131c\u0131 Kodlar\u0131" + }, + "reauth_confirm": { + "description": "Total Connect'in hesab\u0131n\u0131z\u0131 yeniden do\u011frulamas\u0131 gerekiyor", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, "user": { "data": { "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "Toplam Ba\u011flant\u0131" } } } diff --git a/homeassistant/components/totalconnect/translations/zh-Hant.json b/homeassistant/components/totalconnect/translations/zh-Hant.json index eb739cb5e38..beaeaa5d9bf 100644 --- a/homeassistant/components/totalconnect/translations/zh-Hant.json +++ b/homeassistant/components/totalconnect/translations/zh-Hant.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "no_locations": "\u8a72\u4f7f\u7528\u8005\u7121\u53ef\u7528\u7684\u4f4d\u5740\uff0c\u8acb\u6aa2\u67e5 TotalConnect \u8a2d\u5b9a", "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 8abc00d7fdb..e2c03dd43f2 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -9,7 +9,7 @@ from kasa.discover import Discover import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -32,10 +32,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._discovered_devices: dict[str, SmartDevice] = {} self._discovered_device: SmartDevice | None = None - async def async_step_dhcp(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle discovery via dhcp.""" return await self._async_handle_discovery( - discovery_info[IP_ADDRESS], discovery_info[MAC_ADDRESS] + discovery_info.ip, discovery_info.macaddress ) async def async_step_discovery( diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index b331f70c5bb..3038e93ac25 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -38,24 +38,20 @@ class CoordinatedTPLinkEntity(CoordinatorEntity): """Initialize the switch.""" super().__init__(coordinator) self.device: SmartDevice = device + self._attr_name = self.device.alias self._attr_unique_id = self.device.device_id - @property - def name(self) -> str: - """Return the name of the Smart Plug.""" - return cast(str, self.device.alias) - @property def device_info(self) -> DeviceInfo: """Return information about the device.""" - return { - "name": self.device.alias, - "model": self.device.model, - "manufacturer": "TP-Link", - "identifiers": {(DOMAIN, str(self.device.device_id))}, - "connections": {(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, - "sw_version": self.device.hw_info["sw_ver"], - } + return DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, + identifiers={(DOMAIN, str(self.device.device_id))}, + manufacturer="TP-Link", + model=self.device.model, + name=self.device.alias, + sw_version=self.device.hw_info["sw_ver"], + ) @property def is_on(self) -> bool: diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 4db98d680d3..1531f96c545 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -9,6 +9,10 @@ "quality_scale": "platinum", "iot_class": "local_polling", "dhcp": [ + { + "hostname": "k[lp]*", + "macaddress": "60A4B7*" + }, { "hostname": "k[lp]*", "macaddress": "005F67*" diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 9bd4a056d33..0ffe375e6ff 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -100,8 +100,7 @@ def async_emeter_from_device( ) -> float | None: """Map a sensor key to the device attribute.""" if attr := description.emeter_attr: - val = getattr(device.emeter_realtime, attr) - if val is None: + if (val := getattr(device.emeter_realtime, attr)) is None: return None return round(cast(float, val), description.precision) diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index f0d299e21c8..927765d15f7 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -8,6 +8,7 @@ from kasa import SmartDevice from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_CONFIG from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -29,7 +30,7 @@ async def async_setup_entry( device = coordinator.device if not device.is_plug and not device.is_strip: return - entities = [] + entities: list = [] if device.is_strip: # Historically we only add the children if the device is a strip _LOGGER.debug("Initializing strip with %s sockets", len(device.children)) @@ -38,9 +39,48 @@ async def async_setup_entry( else: entities.append(SmartPlugSwitch(device, coordinator)) + entities.append(SmartPlugLedSwitch(device, coordinator)) + async_add_entities(entities) +class SmartPlugLedSwitch(CoordinatedTPLinkEntity, SwitchEntity): + """Representation of switch for the LED of a TPLink Smart Plug.""" + + coordinator: TPLinkDataUpdateCoordinator + + _attr_entity_category = ENTITY_CATEGORY_CONFIG + + def __init__( + self, device: SmartDevice, coordinator: TPLinkDataUpdateCoordinator + ) -> None: + """Initialize the LED switch.""" + super().__init__(device, coordinator) + + self._attr_name = f"{device.alias} LED" + self._attr_unique_id = f"{self.device.mac}_led" + + @property + def icon(self) -> str: + """Return the icon for the LED.""" + return "mdi:led-on" if self.is_on else "mdi:led-off" + + @async_refresh_after + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the LED switch on.""" + await self.device.set_led(True) + + @async_refresh_after + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the LED switch off.""" + await self.device.set_led(False) + + @property + def is_on(self) -> bool: + """Return true if LED switch is on.""" + return bool(self.device.led) + + class SmartPlugSwitch(CoordinatedTPLinkEntity, SwitchEntity): """Representation of a TPLink Smart Plug switch.""" diff --git a/homeassistant/components/tplink/translations/fr.json b/homeassistant/components/tplink/translations/fr.json index f36b3865e55..7d1efcafeb8 100644 --- a/homeassistant/components/tplink/translations/fr.json +++ b/homeassistant/components/tplink/translations/fr.json @@ -1,12 +1,21 @@ { "config": { "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, + "error": { + "cannot_connect": "\u00c9chec de connexion" + }, "step": { "confirm": { "description": "Voulez-vous configurer TP-Link smart devices?" + }, + "user": { + "data": { + "host": "H\u00f4te" + } } } } diff --git a/homeassistant/components/tplink/translations/id.json b/homeassistant/components/tplink/translations/id.json index 66d510de4ed..2a435ac1ac1 100644 --- a/homeassistant/components/tplink/translations/id.json +++ b/homeassistant/components/tplink/translations/id.json @@ -1,12 +1,31 @@ { "config": { "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan", "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." }, + "error": { + "cannot_connect": "Gagal terhubung" + }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { "description": "Ingin menyiapkan perangkat cerdas TP-Link?" + }, + "discovery_confirm": { + "description": "Ingin menyiapkan {name} {model} ({host})?" + }, + "pick_device": { + "data": { + "device": "Perangkat" + } + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Jika host dibiarkan kosong, proses penemuan akan digunakan untuk menemukan perangkat." } } } diff --git a/homeassistant/components/tplink/translations/ja.json b/homeassistant/components/tplink/translations/ja.json new file mode 100644 index 00000000000..f78e4adec9c --- /dev/null +++ b/homeassistant/components/tplink/translations/ja.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "TP-Link\u30b9\u30de\u30fc\u30c8\u30c7\u30d0\u30a4\u30b9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "discovery_confirm": { + "description": "{name} {model} ({host}) \u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u884c\u3044\u307e\u3059\u304b\uff1f" + }, + "pick_device": { + "data": { + "device": "\u30c7\u30d0\u30a4\u30b9" + } + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "\u30db\u30b9\u30c8\u3092\u7a7a\u306b\u3057\u3066\u304a\u304f\u3068\u3001\u30c7\u30a3\u30b9\u30ab\u30d0\u30ea\u30fc(discovery)\u3092\u4f7f\u3063\u3066\u30c7\u30d0\u30a4\u30b9\u3092\u691c\u7d22\u3057\u307e\u3059\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tplink/translations/pl.json b/homeassistant/components/tplink/translations/pl.json index da91b12ea7c..35e1e7f5354 100644 --- a/homeassistant/components/tplink/translations/pl.json +++ b/homeassistant/components/tplink/translations/pl.json @@ -8,10 +8,14 @@ "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { "description": "Czy chcesz rozpocz\u0105\u0107 konfiguracj\u0119?" }, + "discovery_confirm": { + "description": "Czy chcesz skonfigurowa\u0107 {name} {model} ({host})?" + }, "pick_device": { "data": { "device": "Urz\u0105dzenie" @@ -20,7 +24,8 @@ "user": { "data": { "host": "Nazwa hosta lub adres IP" - } + }, + "description": "Je\u015bli nie podasz IP lub nazwy hosta, zostanie u\u017cyte wykrywanie do odnalezienia urz\u0105dze\u0144." } } } diff --git a/homeassistant/components/tplink/translations/tr.json b/homeassistant/components/tplink/translations/tr.json index e8f7a5aaf6d..3a1710c39d2 100644 --- a/homeassistant/components/tplink/translations/tr.json +++ b/homeassistant/components/tplink/translations/tr.json @@ -1,11 +1,31 @@ { "config": { "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "flow_title": "{name} {model} ({host})", "step": { "confirm": { "description": "TP-Link ak\u0131ll\u0131 cihazlar\u0131 kurmak istiyor musunuz?" + }, + "discovery_confirm": { + "description": "{name} {model} ( {host} ) kurulumu yapmak istiyor musunuz?" + }, + "pick_device": { + "data": { + "device": "Cihaz" + } + }, + "user": { + "data": { + "host": "Ana bilgisayar" + }, + "description": "Ana bilgisayar\u0131 bo\u015f b\u0131rak\u0131rsan\u0131z, cihazlar\u0131 bulmak i\u00e7in ke\u015fif kullan\u0131lacakt\u0131r." } } } diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 16cd9ba94e5..d800123e3fa 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -402,8 +402,7 @@ class TraccarEntity(TrackerEntity, RestoreEntity): if self._latitude is not None or self._longitude is not None: return - state = await self.async_get_last_state() - if state is None: + if (state := await self.async_get_last_state()) is None: self._latitude = None self._longitude = None self._accuracy = None diff --git a/homeassistant/components/traccar/manifest.json b/homeassistant/components/traccar/manifest.json index fd8908a3264..77a8511a671 100644 --- a/homeassistant/components/traccar/manifest.json +++ b/homeassistant/components/traccar/manifest.json @@ -3,7 +3,7 @@ "name": "Traccar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/traccar", - "requirements": ["pytraccar==0.9.0", "stringcase==1.2.0"], + "requirements": ["pytraccar==0.10.0", "stringcase==1.2.0"], "dependencies": ["webhook"], "codeowners": ["@ludeeus"], "iot_class": "local_polling" diff --git a/homeassistant/components/traccar/translations/ja.json b/homeassistant/components/traccar/translations/ja.json new file mode 100644 index 00000000000..4338175fda6 --- /dev/null +++ b/homeassistant/components/traccar/translations/ja.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "webhook_not_internet_accessible": "Webhook\u30e1\u30c3\u30bb\u30fc\u30b8\u3092\u53d7\u4fe1\u3059\u308b\u306b\u306f\u3001Home Assistant\u306e\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306b\u3001\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u304b\u3089\u30a2\u30af\u30bb\u30b9\u3067\u304d\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "create_entry": { + "default": "Home Assistant\u306b\u30a4\u30d9\u30f3\u30c8\u3092\u9001\u4fe1\u3059\u308b\u306b\u306f\u3001Traccar\u3067Webhook\u6a5f\u80fd\u3092\u8a2d\u5b9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\n\n\u6b21\u306eURL\u3092\u4f7f\u7528\u3057\u3066\u304f\u3060\u3055\u3044: `{webhook_url}`\n\n\u8a73\u7d30\u306f[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8]({docs_url}) \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "user": { + "description": "Traccar\u3092\u8a2d\u5b9a\u3057\u3066\u3082\u3088\u308d\u3057\u3044\u3067\u3059\u304b\uff1f", + "title": "Traccar\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/traccar/translations/tr.json b/homeassistant/components/traccar/translations/tr.json index 9a2b1a119cd..c16dc8c09d2 100644 --- a/homeassistant/components/traccar/translations/tr.json +++ b/homeassistant/components/traccar/translations/tr.json @@ -4,8 +4,12 @@ "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr.", "webhook_not_internet_accessible": "Webhook mesajlar\u0131n\u0131 alabilmek i\u00e7in Home Assistant \u00f6rne\u011finize internetten eri\u015filebilmelidir." }, + "create_entry": { + "default": "Olaylar\u0131 Home Assistant'a g\u00f6ndermek i\u00e7in Traccar'da webhook \u00f6zelli\u011fini kurman\u0131z gerekir. \n\n \u015eu URL'yi kullan\u0131n: ` {webhook_url} ` \n\n Daha fazla ayr\u0131nt\u0131 i\u00e7in [belgelere]( {docs_url}" + }, "step": { "user": { + "description": "Traccar'\u0131 kurmak istedi\u011finizden emin misiniz?", "title": "Traccar'\u0131 kur" } } diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 1d51ab66585..66a2afccb43 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -27,6 +27,7 @@ from .const import ( ATTR_LED, ATTR_LIVE_TRACKING, ATTR_MINUTES_ACTIVE, + ATTR_TRACKER_STATE, CLIENT, DOMAIN, RECONNECT_INTERVAL, @@ -143,6 +144,8 @@ class TractiveClient: self._hass = hass self._client = client self._user_id = user_id + self._last_hw_time = 0 + self._last_pos_time = 0 self._listen_task: asyncio.Task | None = None @property @@ -181,20 +184,29 @@ class TractiveClient: if server_was_unavailable: _LOGGER.debug("Tractive is back online") server_was_unavailable = False - if event["message"] == "activity_update": self._send_activity_update(event) - else: - if "hardware" in event: - self._send_hardware_update(event) + continue + if ( + "hardware" in event + and self._last_hw_time != event["hardware"]["time"] + ): + self._last_hw_time = event["hardware"]["time"] + self._send_hardware_update(event) - if "position" in event: - self._send_position_update(event) + if ( + "position" in event + and self._last_pos_time != event["position"]["time"] + ): + self._last_pos_time = event["position"]["time"] + self._send_position_update(event) except aiotractive.exceptions.TractiveError: _LOGGER.debug( "Tractive is not available. Internet connection is down? Sleeping %i seconds and retrying", RECONNECT_INTERVAL.total_seconds(), ) + self._last_hw_time = 0 + self._last_pos_time = 0 async_dispatcher_send( self._hass, f"{SERVER_UNAVAILABLE}-{self._user_id}" ) @@ -206,6 +218,7 @@ class TractiveClient: # Sometimes hardware event doesn't contain complete data. payload = { ATTR_BATTERY_LEVEL: event["hardware"]["battery_level"], + ATTR_TRACKER_STATE: event["tracker_state"].lower(), ATTR_BATTERY_CHARGING: event["charging_state"] == "CHARGING", ATTR_LIVE_TRACKING: event.get("live_tracking", {}).get("active"), ATTR_BUZZER: event.get("buzzer_control", {}).get("active"), @@ -229,6 +242,7 @@ class TractiveClient: "latitude": event["position"]["latlong"][0], "longitude": event["position"]["latlong"][1], "accuracy": event["position"]["accuracy"], + "sensor_used": event["position"]["sensor_used"], } self._dispatch_tracker_event( TRACKER_POSITION_UPDATED, event["tracker_id"], payload diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py index dfd28eed98d..453b5cf5b0c 100644 --- a/homeassistant/components/tractive/binary_sensor.py +++ b/homeassistant/components/tractive/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_BATTERY_CHARGING, + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -76,7 +76,7 @@ class TractiveBinarySensor(TractiveEntity, BinarySensorEntity): SENSOR_TYPE = BinarySensorEntityDescription( key=ATTR_BATTERY_CHARGING, name="Battery Charging", - device_class=DEVICE_CLASS_BATTERY_CHARGING, + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ) diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py index 6a61024cd51..0d7d62ccae7 100644 --- a/homeassistant/components/tractive/const.py +++ b/homeassistant/components/tractive/const.py @@ -11,6 +11,7 @@ ATTR_BUZZER = "buzzer" ATTR_LED = "led" ATTR_LIVE_TRACKING = "live_tracking" ATTR_MINUTES_ACTIVE = "minutes_active" +ATTR_TRACKER_STATE = "tracker_state" CLIENT = "client" TRACKABLES = "trackables" diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index a4109eee71c..218151ae769 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -3,7 +3,10 @@ from __future__ import annotations from typing import Any -from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker import ( + SOURCE_TYPE_BLUETOOTH, + SOURCE_TYPE_GPS, +) from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -47,6 +50,7 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): self._latitude: float = item.pos_report["latlong"][0] self._longitude: float = item.pos_report["latlong"][1] self._accuracy: int = item.pos_report["pos_uncertainty"] + self._source_type: str = item.pos_report["sensor_used"] self._attr_name = f"{self._tracker_id} {item.trackable['details']['name']}" self._attr_unique_id = item.trackable["_id"] @@ -54,6 +58,8 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): @property def source_type(self) -> str: """Return the source type, eg gps or router, of the device.""" + if self._source_type == "PHONE": + return SOURCE_TYPE_BLUETOOTH return SOURCE_TYPE_GPS @property @@ -87,6 +93,7 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): self._latitude = event["latitude"] self._longitude = event["longitude"] self._accuracy = event["accuracy"] + self._source_type = event["sensor_used"] self._attr_available = True self.async_write_ha_state() diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index b9afbeba757..3f6c18fa07f 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -4,11 +4,14 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, - DEVICE_CLASS_BATTERY, ENTITY_CATEGORY_DIAGNOSTIC, PERCENTAGE, TIME_MINUTES, @@ -21,6 +24,7 @@ from . import Trackables from .const import ( ATTR_DAILY_GOAL, ATTR_MINUTES_ACTIVE, + ATTR_TRACKER_STATE, CLIENT, DOMAIN, SERVER_UNAVAILABLE, @@ -74,7 +78,9 @@ class TractiveHardwareSensor(TractiveSensor): @callback def handle_hardware_status_update(self, event: dict[str, Any]) -> None: """Handle hardware status update.""" - self._attr_native_value = event[self.entity_description.key] + if (_state := event[self.entity_description.key]) is None: + return + self._attr_native_value = _state self._attr_available = True self.async_write_ha_state() @@ -133,10 +139,18 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( key=ATTR_BATTERY_LEVEL, name="Battery Level", native_unit_of_measurement=PERCENTAGE, - device_class=DEVICE_CLASS_BATTERY, + device_class=SensorDeviceClass.BATTERY, entity_class=TractiveHardwareSensor, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), + TractiveSensorEntityDescription( + # Currently, only state operational and not_reporting are used + # More states are available by polling the data + key=ATTR_TRACKER_STATE, + name="Tracker state", + device_class="tractive__tracker_state", + entity_class=TractiveHardwareSensor, + ), TractiveSensorEntityDescription( key=ATTR_MINUTES_ACTIVE, name="Minutes Active", diff --git a/homeassistant/components/tractive/strings.sensor.json b/homeassistant/components/tractive/strings.sensor.json new file mode 100644 index 00000000000..b9c2cd603da --- /dev/null +++ b/homeassistant/components/tractive/strings.sensor.json @@ -0,0 +1,10 @@ +{ + "state": { + "tractive__tracker_state": { + "not_reporting": "Not reporting", + "operational": "Operational", + "system_shutdown_user": "System shutdown user", + "system_startup": "System startup" + } + } +} diff --git a/homeassistant/components/tractive/translations/id.json b/homeassistant/components/tractive/translations/id.json new file mode 100644 index 00000000000..91843f1c37f --- /dev/null +++ b/homeassistant/components/tractive/translations/id.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "reauth_failed_existing": "Tidak dapat memperbarui entri konfigurasi, hapus integrasi dan siapkan kembali.", + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Kata Sandi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/ja.json b/homeassistant/components/tractive/translations/ja.json new file mode 100644 index 00000000000..7f97d4c23f4 --- /dev/null +++ b/homeassistant/components/tractive/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", + "reauth_failed_existing": "\u69cb\u6210\u30a8\u30f3\u30c8\u30ea\u30fc\u3092\u66f4\u65b0\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u524a\u9664\u3057\u3066\u518d\u5ea6\u8a2d\u5b9a\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/sensor.ca.json b/homeassistant/components/tractive/translations/sensor.ca.json new file mode 100644 index 00000000000..c463819dc19 --- /dev/null +++ b/homeassistant/components/tractive/translations/sensor.ca.json @@ -0,0 +1,10 @@ +{ + "state": { + "tractive__tracker_state": { + "not_reporting": "Sense informaci\u00f3", + "operational": "Operatiu", + "system_shutdown_user": "Aturada del sistema d'usuari", + "system_startup": "Engegada del sistema" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/sensor.de.json b/homeassistant/components/tractive/translations/sensor.de.json new file mode 100644 index 00000000000..53577399924 --- /dev/null +++ b/homeassistant/components/tractive/translations/sensor.de.json @@ -0,0 +1,10 @@ +{ + "state": { + "tractive__tracker_state": { + "not_reporting": "Keine Meldung", + "operational": "Betriebsbereit", + "system_shutdown_user": "Benutzer zum Herunterfahren des Systems", + "system_startup": "Systemstart" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/sensor.en.json b/homeassistant/components/tractive/translations/sensor.en.json new file mode 100644 index 00000000000..13232951cd9 --- /dev/null +++ b/homeassistant/components/tractive/translations/sensor.en.json @@ -0,0 +1,10 @@ +{ + "state": { + "tractive__tracker_state": { + "not_reporting": "Not reporting", + "operational": "Operational", + "system_shutdown_user": "System shutdown user", + "system_startup": "System startup" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/sensor.et.json b/homeassistant/components/tractive/translations/sensor.et.json new file mode 100644 index 00000000000..24f647a0619 --- /dev/null +++ b/homeassistant/components/tractive/translations/sensor.et.json @@ -0,0 +1,10 @@ +{ + "state": { + "tractive__tracker_state": { + "not_reporting": "Ei vasta", + "operational": "Tegevuses", + "system_shutdown_user": "S\u00fcsteemi sulgemise kasutaja", + "system_startup": "S\u00fcsteemi k\u00e4ivitamine" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/sensor.id.json b/homeassistant/components/tractive/translations/sensor.id.json new file mode 100644 index 00000000000..408fdf83b67 --- /dev/null +++ b/homeassistant/components/tractive/translations/sensor.id.json @@ -0,0 +1,10 @@ +{ + "state": { + "tractive__tracker_state": { + "not_reporting": "Tidak melaporkan", + "operational": "Operasional", + "system_shutdown_user": "Pengguna mematikan sistem", + "system_startup": "Sistem mulai" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/sensor.ja.json b/homeassistant/components/tractive/translations/sensor.ja.json new file mode 100644 index 00000000000..163376fe070 --- /dev/null +++ b/homeassistant/components/tractive/translations/sensor.ja.json @@ -0,0 +1,10 @@ +{ + "state": { + "tractive__tracker_state": { + "not_reporting": "\u5831\u544a\u3057\u306a\u3044", + "operational": "\u904b\u7528", + "system_shutdown_user": "System shutdown user", + "system_startup": "\u30b7\u30b9\u30c6\u30e0\u306e\u8d77\u52d5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/sensor.nl.json b/homeassistant/components/tractive/translations/sensor.nl.json new file mode 100644 index 00000000000..c5e32bdacd1 --- /dev/null +++ b/homeassistant/components/tractive/translations/sensor.nl.json @@ -0,0 +1,10 @@ +{ + "state": { + "tractive__tracker_state": { + "not_reporting": "Niet rapporteren", + "operational": "Operationeel", + "system_shutdown_user": "Systeem afsluiten gebruiker", + "system_startup": "Systeem opstarten" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/sensor.no.json b/homeassistant/components/tractive/translations/sensor.no.json new file mode 100644 index 00000000000..a7369b11036 --- /dev/null +++ b/homeassistant/components/tractive/translations/sensor.no.json @@ -0,0 +1,10 @@ +{ + "state": { + "tractive__tracker_state": { + "not_reporting": "Rapporterer ikke", + "operational": "Operativ", + "system_shutdown_user": "Bruker av systemavslutning", + "system_startup": "Oppstart av systemet" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/sensor.ru.json b/homeassistant/components/tractive/translations/sensor.ru.json new file mode 100644 index 00000000000..2e4c645e8a3 --- /dev/null +++ b/homeassistant/components/tractive/translations/sensor.ru.json @@ -0,0 +1,10 @@ +{ + "state": { + "tractive__tracker_state": { + "not_reporting": "\u041d\u0435 \u0441\u043e\u043e\u0431\u0449\u0430\u0435\u0442\u0441\u044f", + "operational": "\u042d\u043a\u0441\u043f\u043b\u0443\u0430\u0442\u0438\u0440\u0443\u0435\u0442\u0441\u044f", + "system_shutdown_user": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c \u0432\u044b\u043a\u043b\u044e\u0447\u0438\u043b \u0441\u0438\u0441\u0442\u0435\u043c\u0443", + "system_startup": "\u0417\u0430\u043f\u0443\u0441\u043a \u0441\u0438\u0441\u0442\u0435\u043c\u044b" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/sensor.tr.json b/homeassistant/components/tractive/translations/sensor.tr.json new file mode 100644 index 00000000000..6a76cc304e2 --- /dev/null +++ b/homeassistant/components/tractive/translations/sensor.tr.json @@ -0,0 +1,10 @@ +{ + "state": { + "tractive__tracker_state": { + "not_reporting": "Rapor edilmiyor", + "operational": "Operasyonel", + "system_shutdown_user": "Sistem kapatma kullan\u0131c\u0131s\u0131", + "system_startup": "Sistem ba\u015flatma" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/sensor.zh-Hant.json b/homeassistant/components/tractive/translations/sensor.zh-Hant.json new file mode 100644 index 00000000000..fdd46f0cefa --- /dev/null +++ b/homeassistant/components/tractive/translations/sensor.zh-Hant.json @@ -0,0 +1,10 @@ +{ + "state": { + "tractive__tracker_state": { + "not_reporting": "\u672a\u56de\u5831", + "operational": "\u53ef\u64cd\u4f5c", + "system_shutdown_user": "\u7cfb\u7d71\u95dc\u6a5f\u7528\u6236", + "system_startup": "\u7cfb\u7d71\u555f\u52d5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/tr.json b/homeassistant/components/tractive/translations/tr.json new file mode 100644 index 00000000000..270c80ca0a4 --- /dev/null +++ b/homeassistant/components/tractive/translations/tr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_failed_existing": "Yap\u0131land\u0131rma giri\u015fi g\u00fcncellenemedi, l\u00fctfen entegrasyonu kald\u0131r\u0131n ve yeniden kurun.", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "email": "E-posta", + "password": "Parola" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 3988775ad2b..2acd79a3e49 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta import logging from typing import Any -from pytradfri import Gateway, RequestError +from pytradfri import Gateway, PytradfriError, RequestError from pytradfri.api.aiocoap_api import APIFactory import voluptuous as vol @@ -15,6 +15,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import Event, async_track_time_interval from homeassistant.helpers.typing import ConfigType @@ -34,6 +35,8 @@ from .const import ( GROUPS, KEY_API, PLATFORMS, + SIGNAL_GW, + TIMEOUT_API, ) _LOGGER = logging.getLogger(__name__) @@ -105,14 +108,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: gateway = Gateway() try: - gateway_info = await api(gateway.get_gateway_info()) - devices_commands = await api(gateway.get_devices()) - devices = await api(devices_commands) - groups_commands = await api(gateway.get_groups()) - groups = await api(groups_commands) - except RequestError as err: + gateway_info = await api(gateway.get_gateway_info(), timeout=TIMEOUT_API) + devices_commands = await api(gateway.get_devices(), timeout=TIMEOUT_API) + devices = await api(devices_commands, timeout=TIMEOUT_API) + groups_commands = await api(gateway.get_groups(), timeout=TIMEOUT_API) + groups = await api(groups_commands, timeout=TIMEOUT_API) + except PytradfriError as exc: await factory.shutdown() - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady from exc tradfri_data[KEY_API] = api tradfri_data[FACTORY] = factory @@ -137,10 +140,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if hass.is_stopping: return + gw_status = True try: await api(gateway.get_gateway_info()) except RequestError: _LOGGER.error("Keep-alive failed") + gw_status = False + + async_dispatcher_send(hass, SIGNAL_GW, gw_status) listeners.append( async_track_time_interval(hass, async_keep_alive, timedelta(seconds=60)) diff --git a/homeassistant/components/tradfri/base_class.py b/homeassistant/components/tradfri/base_class.py index 8a7cc6a2f4a..34ad7b792b9 100644 --- a/homeassistant/components/tradfri/base_class.py +++ b/homeassistant/components/tradfri/base_class.py @@ -20,9 +20,10 @@ from pytradfri.device.socket_control import SocketControl from pytradfri.error import PytradfriError from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_GW _LOGGER = logging.getLogger(__name__) @@ -122,8 +123,24 @@ class TradfriBaseDevice(TradfriBaseClass): ) -> None: """Initialize a device.""" self._attr_available = device.reachable + self._hub_available = True super().__init__(device, api, gateway_id) + async def async_added_to_hass(self) -> None: + """Start thread when added to hass.""" + # Only devices shall receive SIGNAL_GW + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_GW, self.set_hub_available) + ) + await super().async_added_to_hass() + + @callback + def set_hub_available(self, available: bool) -> None: + """Set status of hub.""" + if available != self._hub_available: + self._hub_available = available + self._refresh(self._device) + @property def device_info(self) -> DeviceInfo: """Return the device info.""" @@ -142,5 +159,5 @@ class TradfriBaseDevice(TradfriBaseClass): # The base class _refresh cannot be used, because # there are devices (group) that do not have .reachable # so set _attr_available here and let the base class do the rest. - self._attr_available = device.reachable + self._attr_available = device.reachable and self._hub_available super()._refresh(device, write_ha) diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 11a56200eda..a0b63f94f4f 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -11,9 +11,9 @@ from pytradfri.api.aiocoap_api import APIFactory import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.typing import DiscoveryInfoType from .const import ( CONF_GATEWAY_ID, @@ -42,7 +42,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize flow.""" - self._host = None + self._host: str | None = None self._import_groups = False async def async_step_user( @@ -92,12 +92,16 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="auth", data_schema=vol.Schema(fields), errors=errors ) - async def async_step_homekit(self, discovery_info: DiscoveryInfoType) -> FlowResult: + async def async_step_homekit( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle homekit discovery.""" - await self.async_set_unique_id(discovery_info["properties"]["id"]) - self._abort_if_unique_id_configured({CONF_HOST: discovery_info["host"]}) + await self.async_set_unique_id( + discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] + ) + self._abort_if_unique_id_configured({CONF_HOST: discovery_info.host}) - host = discovery_info["host"] + host = discovery_info.host for entry in self._async_current_entries(): if entry.data.get(CONF_HOST) != host: @@ -106,7 +110,8 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Backwards compat, we update old entries if not entry.unique_id: self.hass.config_entries.async_update_entry( - entry, unique_id=discovery_info["properties"]["id"] + entry, + unique_id=discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID], ) return self.async_abort(reason="already_configured") @@ -174,7 +179,7 @@ async def authenticate( api_factory = await APIFactory.init(host, psk_id=identity) try: - with async_timeout.timeout(5): + async with async_timeout.timeout(5): key = await api_factory.generate_psk(security_code) except RequestError as err: raise AuthError("invalid_security_code") from err diff --git a/homeassistant/components/tradfri/const.py b/homeassistant/components/tradfri/const.py index 8efb1837ae4..abd3e8aff4a 100644 --- a/homeassistant/components/tradfri/const.py +++ b/homeassistant/components/tradfri/const.py @@ -21,7 +21,9 @@ DOMAIN = "tradfri" KEY_API = "tradfri_api" DEVICES = "tradfri_devices" GROUPS = "tradfri_groups" +SIGNAL_GW = "tradfri.gw_status" KEY_SECURITY_CODE = "security_code" SUPPORTED_GROUP_FEATURES = SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION SUPPORTED_LIGHT_FEATURES = SUPPORT_TRANSITION PLATFORMS = ["cover", "fan", "light", "sensor", "switch"] +TIMEOUT_API = 30 diff --git a/homeassistant/components/tradfri/cover.py b/homeassistant/components/tradfri/cover.py index 074b6ddd726..554650f9005 100644 --- a/homeassistant/components/tradfri/cover.py +++ b/homeassistant/components/tradfri/cover.py @@ -26,9 +26,9 @@ async def async_setup_entry( api = tradfri_data[KEY_API] devices = tradfri_data[DEVICES] - covers = [dev for dev in devices if dev.has_blind_control] - if covers: - async_add_entities(TradfriCover(cover, api, gateway_id) for cover in covers) + async_add_entities( + TradfriCover(dev, api, gateway_id) for dev in devices if dev.has_blind_control + ) class TradfriCover(TradfriBaseDevice, CoverEntity): diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index d1b5de8b7cc..845d5e6d9c3 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -31,11 +31,11 @@ async def async_setup_entry( api = tradfri_data[KEY_API] devices = tradfri_data[DEVICES] - purifiers = [dev for dev in devices if dev.has_air_purifier_control] - if purifiers: - async_add_entities( - TradfriAirPurifierFan(purifier, api, gateway_id) for purifier in purifiers - ) + async_add_entities( + TradfriAirPurifierFan(dev, api, gateway_id) + for dev in devices + if dev.has_air_purifier_control + ) def _from_percentage(percentage: int) -> int: diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index 5a2da96152f..ca078a37e81 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -49,12 +49,12 @@ async def async_setup_entry( api = tradfri_data[KEY_API] devices = tradfri_data[DEVICES] - lights = [dev for dev in devices if dev.has_light_control] - if lights: - async_add_entities(TradfriLight(light, api, gateway_id) for light in lights) - + entities: list[TradfriBaseClass] = [ + TradfriLight(dev, api, gateway_id) for dev in devices if dev.has_light_control + ] if config_entry.data[CONF_IMPORT_GROUPS] and (groups := tradfri_data[GROUPS]): - async_add_entities(TradfriGroup(group, api, gateway_id) for group in groups) + entities.extend([TradfriGroup(group, api, gateway_id) for group in groups]) + async_add_entities(entities) class TradfriGroup(TradfriBaseClass, LightEntity): diff --git a/homeassistant/components/tradfri/manifest.json b/homeassistant/components/tradfri/manifest.json index 3579d9a9bd8..b13abda25b0 100644 --- a/homeassistant/components/tradfri/manifest.json +++ b/homeassistant/components/tradfri/manifest.json @@ -3,10 +3,10 @@ "name": "IKEA TR\u00c5DFRI", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tradfri", - "requirements": ["pytradfri[async]==7.1.1"], + "requirements": ["pytradfri[async]==7.2.1"], "homekit": { "models": ["TRADFRI"] }, - "codeowners": ["@janiversen"], + "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index c6214773b97..27d2690ce34 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -27,17 +27,17 @@ async def async_setup_entry( api = tradfri_data[KEY_API] devices = tradfri_data[DEVICES] - sensors = ( - dev + async_add_entities( + TradfriSensor(dev, api, gateway_id) for dev in devices - if not dev.has_light_control - and not dev.has_socket_control - and not dev.has_blind_control - and not dev.has_signal_repeater_control - and not dev.has_air_purifier_control + if ( + not dev.has_light_control + and not dev.has_socket_control + and not dev.has_blind_control + and not dev.has_signal_repeater_control + and not dev.has_air_purifier_control + ) ) - if sensors: - async_add_entities(TradfriSensor(sensor, api, gateway_id) for sensor in sensors) class TradfriSensor(TradfriBaseDevice, SensorEntity): diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py index d308e78c3d1..72d311fe555 100644 --- a/homeassistant/components/tradfri/switch.py +++ b/homeassistant/components/tradfri/switch.py @@ -26,11 +26,9 @@ async def async_setup_entry( api = tradfri_data[KEY_API] devices = tradfri_data[DEVICES] - switches = [dev for dev in devices if dev.has_socket_control] - if switches: - async_add_entities( - TradfriSwitch(switch, api, gateway_id) for switch in switches - ) + async_add_entities( + TradfriSwitch(dev, api, gateway_id) for dev in devices if dev.has_socket_control + ) class TradfriSwitch(TradfriBaseDevice, SwitchEntity): diff --git a/homeassistant/components/tradfri/translations/hu.json b/homeassistant/components/tradfri/translations/hu.json index 5e9c281fdd6..01bb51b0af3 100644 --- a/homeassistant/components/tradfri/translations/hu.json +++ b/homeassistant/components/tradfri/translations/hu.json @@ -5,7 +5,7 @@ "already_in_progress": "A konfigur\u00e1l\u00e1s m\u00e1r folyamatban van" }, "error": { - "cannot_authenticate": "Sikertelen azonos\u00edt\u00e1s. A Gateway egy m\u00e1sik eszk\u00f6zzel van p\u00e1ros\u00edtva, mint p\u00e9ld\u00e1ul a Homekittel?", + "cannot_authenticate": "Sikertelen hiteles\u00edt\u00e9s. A Gateway egy m\u00e1sik eszk\u00f6zzel van p\u00e1ros\u00edtva, mint p\u00e9ld\u00e1ul a Homekittel?", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_key": "Nem siker\u00fclt regisztr\u00e1lni a megadott kulcs seg\u00edts\u00e9g\u00e9vel. Ha ez t\u00f6bbsz\u00f6r megt\u00f6rt\u00e9nik, pr\u00f3b\u00e1lja meg \u00fajraind\u00edtani a gatewayt.", "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a k\u00f3d \u00e9rv\u00e9nyes\u00edt\u00e9se sor\u00e1n." diff --git a/homeassistant/components/tradfri/translations/id.json b/homeassistant/components/tradfri/translations/id.json index 8ff5fe257eb..f24fdac1980 100644 --- a/homeassistant/components/tradfri/translations/id.json +++ b/homeassistant/components/tradfri/translations/id.json @@ -5,6 +5,7 @@ "already_in_progress": "Alur konfigurasi sedang berlangsung" }, "error": { + "cannot_authenticate": "Tidak dapat mengautentikasi, apakah Gateway dipasangkan dengan server lain seperti misalnya Homekit?", "cannot_connect": "Gagal terhubung", "invalid_key": "Gagal mendaftar dengan kunci yang disediakan. Jika ini terus terjadi, coba mulai ulang gateway.", "timeout": "Waktu tunggu memvalidasi kode telah habis." diff --git a/homeassistant/components/tradfri/translations/ja.json b/homeassistant/components/tradfri/translations/ja.json new file mode 100644 index 00000000000..e8a2682af3b --- /dev/null +++ b/homeassistant/components/tradfri/translations/ja.json @@ -0,0 +1,24 @@ +{ + "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" + }, + "error": { + "cannot_authenticate": "\u8a8d\u8a3c\u3067\u304d\u307e\u305b\u3093\u3002\u30b2\u30fc\u30c8\u30a6\u30a7\u30a4\u306f\u3001Homekit\u306a\u3069\u306e\u4ed6\u306e\u30b5\u30fc\u30d0\u30fc\u3068\u30da\u30a2\u30ea\u30f3\u30b0\u3055\u308c\u3066\u3044\u307e\u3059\u304b\uff1f", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_key": "\u63d0\u4f9b\u3055\u308c\u305f\u30ad\u30fc\u3067\u306e\u767b\u9332\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u3053\u306e\u554f\u984c\u304c\u5f15\u304d\u7d9a\u304d\u767a\u751f\u3059\u308b\u5834\u5408\u306f\u3001\u30b2\u30fc\u30c8\u30a6\u30a7\u30a4\u3092\u518d\u8d77\u52d5\u3057\u3066\u307f\u3066\u304f\u3060\u3055\u3044\u3002", + "timeout": "\u30b3\u30fc\u30c9\u306e\u691c\u8a3c\u3067\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002" + }, + "step": { + "auth": { + "data": { + "host": "\u30db\u30b9\u30c8", + "security_code": "\u30bb\u30ad\u30e5\u30ea\u30c6\u30a3\u30b3\u30fc\u30c9" + }, + "description": "\u30bb\u30ad\u30e5\u30ea\u30c6\u30a3\u30b3\u30fc\u30c9\u306f\u3001\u30b2\u30fc\u30c8\u30a6\u30a7\u30a4\u306e\u80cc\u9762\u306b\u3042\u308a\u307e\u3059\u3002", + "title": "\u30bb\u30ad\u30e5\u30ea\u30c6\u30a3\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/translations/pl.json b/homeassistant/components/tradfri/translations/pl.json index e2fcb45f51c..bdd88ec1143 100644 --- a/homeassistant/components/tradfri/translations/pl.json +++ b/homeassistant/components/tradfri/translations/pl.json @@ -5,6 +5,7 @@ "already_in_progress": "Konfiguracja jest ju\u017c w toku" }, "error": { + "cannot_authenticate": "Nie mo\u017cna uwierzytelni\u0107, czy bramka jest sparowana z innym serwerem, np. Homekit?", "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "invalid_key": "Rejestracja si\u0119 nie powiod\u0142a z podanym kluczem. Je\u015bli tak si\u0119 stanie, spr\u00f3buj ponownie uruchomi\u0107 bramk\u0119.", "timeout": "Przekroczono limit czasu sprawdzania poprawno\u015bci kodu" diff --git a/homeassistant/components/tradfri/translations/tr.json b/homeassistant/components/tradfri/translations/tr.json index e4483536b12..26db68abc5c 100644 --- a/homeassistant/components/tradfri/translations/tr.json +++ b/homeassistant/components/tradfri/translations/tr.json @@ -5,13 +5,19 @@ "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor" }, "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_authenticate": "Kimlik do\u011frulamas\u0131 yap\u0131lam\u0131yor, A\u011f Ge\u00e7idi, \u00f6rne\u011fin Homekit gibi ba\u015fka bir sunucuyla e\u015fle\u015ftirilmi\u015f mi?", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_key": "Sa\u011flanan anahtarla kay\u0131t olunamad\u0131. Bu durum devam ederse, a\u011f ge\u00e7idini yeniden ba\u015flatmay\u0131 deneyin.", + "timeout": "Kodun do\u011frulanmas\u0131 zaman a\u015f\u0131m\u0131na u\u011frad\u0131." }, "step": { "auth": { "data": { - "host": "Ana Bilgisayar" - } + "host": "Ana Bilgisayar", + "security_code": "G\u00fcvenlik Kodu" + }, + "description": "G\u00fcvenlik kodunu a\u011f ge\u00e7idinizin arkas\u0131nda bulabilirsiniz.", + "title": "G\u00fcvenlik kodunu gir" } } } diff --git a/homeassistant/components/trafikverket_weatherstation/__init__.py b/homeassistant/components/trafikverket_weatherstation/__init__.py index 7feac4aad27..854b5d54d70 100644 --- a/homeassistant/components/trafikverket_weatherstation/__init__.py +++ b/homeassistant/components/trafikverket_weatherstation/__init__.py @@ -1 +1,33 @@ """The trafikverket_weatherstation component.""" +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Trafikverket Weatherstation from a config entry.""" + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + _LOGGER.debug("Loaded entry for %s", entry.title) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Trafikverket Weatherstation config entry.""" + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + _LOGGER.debug("Unloaded entry for %s", entry.title) + return unload_ok + + return False diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py new file mode 100644 index 00000000000..103af1c7eb4 --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py @@ -0,0 +1,80 @@ +"""Adds config flow for Trafikverket Weather integration.""" +from __future__ import annotations + +from pytrafikverket.trafikverket_weather import TrafikverketWeather +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import CONF_STATION, DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_STATION): cv.string, + } +) + + +class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Trafikverket Weatherstation integration.""" + + VERSION = 1 + + entry: config_entries.ConfigEntry + + async def validate_input(self, sensor_api: str, station: str) -> str: + """Validate input from user input.""" + web_session = async_get_clientsession(self.hass) + weather_api = TrafikverketWeather(web_session, sensor_api) + try: + await weather_api.async_get_weather(station) + except ValueError as err: + return str(err) + return "connected" + + async def async_step_import(self, config: dict): + """Import a configuration from config.yaml.""" + + self.context.update( + {"title_placeholders": {CONF_STATION: f"YAML import {DOMAIN}"}} + ) + + self._async_abort_entries_match({CONF_STATION: config[CONF_STATION]}) + return await self.async_step_user(user_input=config) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + name = user_input[CONF_STATION] + api_key = user_input[CONF_API_KEY] + station = user_input[CONF_STATION] + + validate = await self.validate_input(api_key, station) + if validate == "connected": + return self.async_create_entry( + title=name, + data={ + CONF_API_KEY: api_key, + CONF_STATION: station, + }, + ) + if validate == "Source: Security, message: Invalid authentication": + errors["base"] = "invalid_auth" + elif validate == "Could not find a weather station with the specified name": + errors["base"] = "invalid_station" + elif validate == "Found multiple weather stations with the specified name": + errors["base"] = "more_stations" + else: + errors["base"] = "cannot_connect" + + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/trafikverket_weatherstation/const.py b/homeassistant/components/trafikverket_weatherstation/const.py new file mode 100644 index 00000000000..bfda5782084 --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/const.py @@ -0,0 +1,8 @@ +"""Adds constants for Trafikverket Weather integration.""" + +DOMAIN = "trafikverket_weatherstation" +CONF_STATION = "station" +PLATFORMS = ["sensor"] +ATTRIBUTION = "Data provided by Trafikverket" +ATTR_MEASURE_TIME = "measure_time" +ATTR_ACTIVE = "active" diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index 6e123983e8b..202d2683d2d 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", "requirements": ["pytrafikverket==0.1.6.2"], "codeowners": ["@endor-force"], + "config_flow": true, "iot_class": "cloud_polling" } diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index 5fe3c462a56..01b70d5d3c7 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -5,16 +5,19 @@ import asyncio from dataclasses import dataclass from datetime import timedelta import logging +from typing import Any import aiohttp -from pytrafikverket.trafikverket_weather import TrafikverketWeather +from pytrafikverket.trafikverket_weather import TrafikverketWeather, WeatherStationInfo import voluptuous as vol from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, SensorEntity, SensorEntityDescription, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_API_KEY, @@ -28,18 +31,20 @@ from homeassistant.const import ( SPEED_METERS_PER_SECOND, TEMP_CELSIUS, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + ConfigType, + DiscoveryInfoType, +) from homeassistant.util import Throttle +from .const import ATTR_ACTIVE, ATTR_MEASURE_TIME, ATTRIBUTION, CONF_STATION, DOMAIN + _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by Trafikverket" -ATTR_MEASURE_TIME = "measure_time" -ATTR_ACTIVE = "active" - -CONF_STATION = "station" - MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) SCAN_INTERVAL = timedelta(seconds=300) @@ -67,6 +72,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( native_unit_of_measurement=TEMP_CELSIUS, icon="mdi:thermometer", device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), TrafikverketSensorEntityDescription( key="road_temp", @@ -75,12 +81,14 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( native_unit_of_measurement=TEMP_CELSIUS, icon="mdi:thermometer", device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), TrafikverketSensorEntityDescription( key="precipitation", api_key="precipitationtype", name="Precipitation type", icon="mdi:weather-snowy-rainy", + entity_registry_enabled_default=False, ), TrafikverketSensorEntityDescription( key="wind_direction", @@ -88,6 +96,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( name="Wind direction", native_unit_of_measurement=DEGREE, icon="mdi:flag-triangle", + state_class=STATE_CLASS_MEASUREMENT, ), TrafikverketSensorEntityDescription( key="wind_direction_text", @@ -101,6 +110,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( name="Wind speed", native_unit_of_measurement=SPEED_METERS_PER_SECOND, icon="mdi:weather-windy", + state_class=STATE_CLASS_MEASUREMENT, ), TrafikverketSensorEntityDescription( key="wind_speed_max", @@ -108,6 +118,8 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( name="Wind speed max", native_unit_of_measurement=SPEED_METERS_PER_SECOND, icon="mdi:weather-windy-variant", + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, ), TrafikverketSensorEntityDescription( key="humidity", @@ -116,6 +128,8 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, icon="mdi:water-percent", device_class=DEVICE_CLASS_HUMIDITY, + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, ), TrafikverketSensorEntityDescription( key="precipitation_amount", @@ -123,18 +137,20 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( name="Precipitation amount", native_unit_of_measurement=LENGTH_MILLIMETERS, icon="mdi:cup-water", + state_class=STATE_CLASS_MEASUREMENT, ), TrafikverketSensorEntityDescription( key="precipitation_amountname", api_key="precipitation_amountname", name="Precipitation name", icon="mdi:weather-pouring", + entity_registry_enabled_default=False, ), ) SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_NAME): cv.string, vol.Required(CONF_API_KEY): cv.string, @@ -144,24 +160,39 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Trafikverket sensor platform.""" +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType = None, +) -> None: + """Import Trafikverket Weather configuration from YAML.""" + _LOGGER.warning( + # Config flow added in Home Assistant Core 2021.12, remove import flow in 2022.4 + "Loading Trafikverket Weather via platform setup is deprecated; Please remove it from your configuration" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) - sensor_name = config[CONF_NAME] - sensor_api = config[CONF_API_KEY] - sensor_station = config[CONF_STATION] + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Trafikverket sensor entry.""" web_session = async_get_clientsession(hass) + weather_api = TrafikverketWeather(web_session, entry.data[CONF_API_KEY]) - weather_api = TrafikverketWeather(web_session, sensor_api) - - monitored_conditions = config[CONF_MONITORED_CONDITIONS] entities = [ TrafikverketWeatherStation( - weather_api, sensor_name, sensor_station, description + weather_api, entry.entry_id, entry.data[CONF_STATION], description ) for description in SENSOR_TYPES - if description.key in monitored_conditions ] async_add_entities(entities, True) @@ -174,29 +205,36 @@ class TrafikverketWeatherStation(SensorEntity): def __init__( self, - weather_api, - name, - sensor_station, + weather_api: TrafikverketWeather, + entry_id: str, + sensor_station: str, description: TrafikverketSensorEntityDescription, - ): + ) -> None: """Initialize the sensor.""" self.entity_description = description - self._attr_name = f"{name} {description.name}" + self._attr_name = f"{sensor_station} {description.name}" + self._attr_unique_id = f"{entry_id}_{description.key}" self._station = sensor_station self._weather_api = weather_api - self._weather = None + self._weather: WeatherStationInfo | None = None + self._active: bool | None = None + self._measure_time: str | None = None @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of Trafikverket Weatherstation.""" - return { + _additional_attributes: dict[str, Any] = { ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_ACTIVE: self._weather.active, - ATTR_MEASURE_TIME: self._weather.measure_time, } + if self._active: + _additional_attributes[ATTR_ACTIVE] = self._active + if self._measure_time: + _additional_attributes[ATTR_MEASURE_TIME] = self._measure_time + + return _additional_attributes @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self): + async def async_update(self) -> None: """Get the latest data from Trafikverket and updates the states.""" try: self._weather = await self._weather_api.async_get_weather(self._station) @@ -205,3 +243,6 @@ class TrafikverketWeatherStation(SensorEntity): ) except (asyncio.TimeoutError, aiohttp.ClientError, ValueError) as error: _LOGGER.error("Could not fetch weather data: %s", error) + return + self._active = self._weather.active + self._measure_time = self._weather.measure_time diff --git a/homeassistant/components/trafikverket_weatherstation/strings.json b/homeassistant/components/trafikverket_weatherstation/strings.json new file mode 100644 index 00000000000..d4a1eb69f1d --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_station": "Could not find a weather station with the specified name", + "more_stations": "Found multiple weather stations with the specified name" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "station": "Station" + } + } + } + } + } \ No newline at end of file diff --git a/homeassistant/components/trafikverket_weatherstation/translations/ca.json b/homeassistant/components/trafikverket_weatherstation/translations/ca.json new file mode 100644 index 00000000000..80fe04ba83f --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "invalid_station": "No s'ha pogut trobar una estaci\u00f3 meteorol\u00f2gica amb el nom especificat", + "more_stations": "S'han trobat diverses estacions meteorol\u00f2giques amb el nom especificat" + }, + "step": { + "user": { + "data": { + "api_key": "Clau API", + "conditions": "Condicions monitoritzades", + "name": "Nom d'usuari", + "station": "Estaci\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_weatherstation/translations/de.json b/homeassistant/components/trafikverket_weatherstation/translations/de.json new file mode 100644 index 00000000000..47d3dfc2e9b --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/translations/de.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "invalid_station": "Eine Wetterstation mit dem angegebenen Namen konnte nicht gefunden werden", + "more_stations": "Mehrere Wetterstationen mit dem angegebenen Namen gefunden" + }, + "step": { + "user": { + "data": { + "api_key": "API-Schl\u00fcssel", + "conditions": "\u00dcberwachte Bedingungen", + "name": "Benutzername", + "station": "Station" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_weatherstation/translations/en.json b/homeassistant/components/trafikverket_weatherstation/translations/en.json new file mode 100644 index 00000000000..0c0c15f5bb9 --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "invalid_station": "Could not find a weather station with the specified name", + "more_stations": "Found multiple weather stations with the specified name" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "conditions": "Monitored conditions", + "name": "Username", + "station": "Station" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_weatherstation/translations/et.json b/homeassistant/components/trafikverket_weatherstation/translations/et.json new file mode 100644 index 00000000000..7350031bd36 --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/translations/et.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "invalid_station": "M\u00e4\u00e4ratud nimega ilmajaama ei leitud", + "more_stations": "Leiti mitu m\u00e4\u00e4ratud nimega ilmajaama" + }, + "step": { + "user": { + "data": { + "api_key": "API v\u00f5ti", + "conditions": "J\u00e4lgitavad elemendid", + "name": "Kasutajanimi", + "station": "Seirejaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_weatherstation/translations/hu.json b/homeassistant/components/trafikverket_weatherstation/translations/hu.json new file mode 100644 index 00000000000..27c8d290af4 --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/translations/hu.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + }, + "step": { + "user": { + "data": { + "api_key": "API kulcs", + "conditions": "Megfigyelt k\u00f6r\u00fclm\u00e9nyek", + "name": "Felhaszn\u00e1l\u00f3n\u00e9v", + "station": "\u00c1llom\u00e1s" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_weatherstation/translations/id.json b/homeassistant/components/trafikverket_weatherstation/translations/id.json new file mode 100644 index 00000000000..0db961235d4 --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/translations/id.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "invalid_station": "Tidak dapat menemukan stasiun cuaca dengan nama yang ditentukan", + "more_stations": "Ditemukan beberapa stasiun cuaca dengan nama yang ditentukan" + }, + "step": { + "user": { + "data": { + "api_key": "Kunci API", + "conditions": "Kondisi yang dipantau", + "name": "Nama Pengguna", + "station": "Stasiun" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_weatherstation/translations/ja.json b/homeassistant/components/trafikverket_weatherstation/translations/ja.json new file mode 100644 index 00000000000..d6e2cf28221 --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "conditions": "\u30e2\u30cb\u30bf\u30fc\u306e\u72b6\u614b", + "name": "\u30e6\u30fc\u30b6\u30fc\u540d", + "station": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_weatherstation/translations/nl.json b/homeassistant/components/trafikverket_weatherstation/translations/nl.json new file mode 100644 index 00000000000..1e8d8ae9f3a --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "invalid_station": "Kon geen weerstation vinden met de opgegeven naam", + "more_stations": "Meerdere weerstations gevonden met de opgegeven naam" + }, + "step": { + "user": { + "data": { + "api_key": "API-sleutel", + "conditions": "Gemonitorde condities", + "name": "Gebruikersnaam", + "station": "Station" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_weatherstation/translations/no.json b/homeassistant/components/trafikverket_weatherstation/translations/no.json new file mode 100644 index 00000000000..fea917f9c38 --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "invalid_station": "Kunne ikke finne en v\u00e6rstasjon med det angitte navnet", + "more_stations": "Fant flere v\u00e6rstasjoner med det angitte navnet" + }, + "step": { + "user": { + "data": { + "api_key": "API-n\u00f8kkel", + "conditions": "Overv\u00e5kede forhold", + "name": "Brukernavn", + "station": "Stasjon" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_weatherstation/translations/ru.json b/homeassistant/components/trafikverket_weatherstation/translations/ru.json new file mode 100644 index 00000000000..d7658cda81b --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\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." + }, + "error": { + "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.", + "invalid_station": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043d\u0430\u0439\u0442\u0438 \u043c\u0435\u0442\u0435\u043e\u0441\u0442\u0430\u043d\u0446\u0438\u044e \u0441 \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u043c \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043c.", + "more_stations": "\u041d\u0430\u0439\u0434\u0435\u043d\u043e \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u043c\u0435\u0442\u0435\u043e\u0441\u0442\u0430\u043d\u0446\u0438\u0439 \u0441 \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u043c \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043c." + }, + "step": { + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "conditions": "\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u0438\u0440\u0443\u0435\u043c\u044b\u0435 \u0443\u0441\u043b\u043e\u0432\u0438\u044f", + "name": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f", + "station": "\u0421\u0442\u0430\u043d\u0446\u0438\u044f" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_weatherstation/translations/tr.json b/homeassistant/components/trafikverket_weatherstation/translations/tr.json new file mode 100644 index 00000000000..e999f7b50d1 --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/translations/tr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "invalid_station": "Belirtilen ada sahip bir hava durumu istasyonu bulunamad\u0131", + "more_stations": "Belirtilen ada sahip birden fazla hava durumu istasyonu bulundu" + }, + "step": { + "user": { + "data": { + "api_key": "API Anahtar\u0131", + "conditions": "\u0130zlenen ko\u015fullar", + "name": "Kullan\u0131c\u0131 Ad\u0131", + "station": "\u0130stasyon" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_weatherstation/translations/zh-Hant.json b/homeassistant/components/trafikverket_weatherstation/translations/zh-Hant.json new file mode 100644 index 00000000000..4323ed42b7a --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "invalid_station": "\u8a72\u6307\u5b9a\u540d\u7a31\u7121\u6cd5\u627e\u5230\u5929\u6c23\u89c0\u6e2c\u7ad9", + "more_stations": "\u8a72\u6307\u5b9a\u540d\u7a31\u627e\u5230\u591a\u500b\u5929\u6c23\u89c0\u6e2c\u7ad9" + }, + "step": { + "user": { + "data": { + "api_key": "API \u5bc6\u9470", + "conditions": "\u5df2\u76e3\u63a7\u72c0\u614b", + "name": "\u4f7f\u7528\u8005\u540d\u7a31", + "station": "\u76e3\u63a7\u7ad9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 40edc8aeab9..f40f0bd7d82 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -441,13 +441,13 @@ class TransmissionData: def start_torrents(self): """Start all torrents.""" - if len(self._torrents) <= 0: + if not self._torrents: return self._api.start_all() def stop_torrents(self): """Stop all active torrents.""" - if len(self._torrents) == 0: + if not self._torrents: return torrent_ids = [torrent.id for torrent in self._torrents] self._api.stop_torrent(torrent_ids) diff --git a/homeassistant/components/transmission/translations/ja.json b/homeassistant/components/transmission/translations/ja.json new file mode 100644 index 00000000000..5bd5b98a8b3 --- /dev/null +++ b/homeassistant/components/transmission/translations/ja.json @@ -0,0 +1,36 @@ +{ + "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "name_exists": "\u540d\u524d\u306f\u3059\u3067\u306b\u5b58\u5728\u3057\u307e\u3059" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Transmission Client\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "limit": "\u30ea\u30df\u30c3\u30c8", + "order": "\u30aa\u30fc\u30c0\u30fc", + "scan_interval": "\u66f4\u65b0\u983b\u5ea6" + }, + "title": "Transmission\u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/transmission/translations/tr.json b/homeassistant/components/transmission/translations/tr.json index cffcc65151c..94ba7572e03 100644 --- a/homeassistant/components/transmission/translations/tr.json +++ b/homeassistant/components/transmission/translations/tr.json @@ -5,16 +5,31 @@ }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", - "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "name_exists": "Ad zaten var" }, "step": { "user": { "data": { "host": "Ana Bilgisayar", + "name": "Ad", "password": "Parola", "port": "Port", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "\u0130letim \u0130stemcisi Kurulumu" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "limit": "Limit", + "order": "Sipari\u015f", + "scan_interval": "G\u00fcncelleme s\u0131kl\u0131\u011f\u0131" + }, + "title": "\u0130letim se\u00e7eneklerini yap\u0131land\u0131r\u0131n" } } } diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 340edcb6626..831e97aed3d 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -2,7 +2,7 @@ "domain": "trend", "name": "Trend", "documentation": "https://www.home-assistant.io/integrations/trend", - "requirements": ["numpy==1.21.2"], + "requirements": ["numpy==1.21.4"], "codeowners": [], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 00e1dae6a8c..e2fddf3afe5 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -462,8 +462,7 @@ class SpeechManager: This method is a coroutine. """ - record = _RE_VOICE_FILE.match(filename.lower()) - if not record: + if not (record := _RE_VOICE_FILE.match(filename.lower())): raise HomeAssistantError("Wrong tts file format!") key = KEY_PATTERN.format( @@ -571,8 +570,7 @@ def _get_cache_files(cache_dir): folder_data = os.listdir(cache_dir) for file_data in folder_data: - record = _RE_VOICE_FILE.match(file_data) - if record: + if record := _RE_VOICE_FILE.match(file_data): key = KEY_PATTERN.format( record.group(1), record.group(2), record.group(3), record.group(4) ) diff --git a/homeassistant/components/tts/services.yaml b/homeassistant/components/tts/services.yaml index f93a81cf2b2..7dcbe1287cb 100644 --- a/homeassistant/components/tts/services.yaml +++ b/homeassistant/components/tts/services.yaml @@ -6,7 +6,7 @@ say: fields: entity_id: name: Entity - description: Name(s) of media player entities.\ + description: Name(s) of media player entities. required: true selector: entity: diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 4f34d3c31bf..fb5b4d759f3 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from typing import NamedTuple +import requests from tuya_iot import ( AuthType, TuyaDevice, @@ -18,6 +19,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import dispatcher_send @@ -60,18 +62,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data.pop(CONF_PROJECT_TYPE) hass.config_entries.async_update_entry(entry, data=data) - success = await _init_tuya_sdk(hass, entry) - - if not success: - hass.data[DOMAIN].pop(entry.entry_id) - - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - - return bool(success) - - -async def _init_tuya_sdk(hass: HomeAssistant, entry: ConfigEntry) -> bool: auth_type = AuthType(entry.data[CONF_AUTH_TYPE]) api = TuyaOpenAPI( endpoint=entry.data[CONF_ENDPOINT], @@ -82,22 +72,24 @@ async def _init_tuya_sdk(hass: HomeAssistant, entry: ConfigEntry) -> bool: api.set_dev_channel("hass") - if auth_type == AuthType.CUSTOM: - response = await hass.async_add_executor_job( - api.connect, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] - ) - else: - response = await hass.async_add_executor_job( - api.connect, - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_COUNTRY_CODE], - entry.data[CONF_APP_TYPE], - ) + try: + if auth_type == AuthType.CUSTOM: + response = await hass.async_add_executor_job( + api.connect, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] + ) + else: + response = await hass.async_add_executor_job( + api.connect, + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_COUNTRY_CODE], + entry.data[CONF_APP_TYPE], + ) + except requests.exceptions.RequestException as err: + raise ConfigEntryNotReady(err) from err if response.get("success", False) is False: - _LOGGER.error("Tuya login error response: %s", response) - return False + raise ConfigEntryNotReady(response) tuya_mq = TuyaOpenMQ(api) tuya_mq.start() diff --git a/homeassistant/components/tuya/base.py b/homeassistant/components/tuya/base.py index f4bc0dc561f..928899940f4 100644 --- a/homeassistant/components/tuya/base.py +++ b/homeassistant/components/tuya/base.py @@ -26,6 +26,7 @@ class IntegerTypeData: scale: float step: float unit: str | None = None + type: str | None = None @property def max_scaled(self) -> float: @@ -88,6 +89,20 @@ class EnumTypeData: return cls(**json.loads(data)) +@dataclass +class ElectricityTypeData: + """Electricity Type Data.""" + + electriccurrent: str | None = None + power: str | None = None + voltage: str | None = None + + @classmethod + def from_json(cls, data: str) -> ElectricityTypeData: + """Load JSON string and return a ElectricityTypeData object.""" + return cls(**json.loads(data.lower())) + + class TuyaEntity(Entity): """Tuya base device.""" diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 12fbc963942..c9a1ab598c3 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -6,21 +6,14 @@ from dataclasses import dataclass from tuya_iot import TuyaDevice, TuyaDeviceManager from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_DOOR, - DEVICE_CLASS_GAS, - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_SAFETY, - DEVICE_CLASS_SMOKE, - DEVICE_CLASS_TAMPER, - DEVICE_CLASS_VIBRATION, + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantTuyaData @@ -43,8 +36,8 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): TAMPER_BINARY_SENSOR = TuyaBinarySensorEntityDescription( key=DPCode.TEMPER_ALARM, name="Tamper", - device_class=DEVICE_CLASS_TAMPER, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, ) @@ -58,7 +51,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "co2bj": ( TuyaBinarySensorEntityDescription( key=DPCode.CO2_STATE, - device_class=DEVICE_CLASS_SAFETY, + device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TAMPER_BINARY_SENSOR, @@ -68,12 +61,12 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "cobj": ( TuyaBinarySensorEntityDescription( key=DPCode.CO_STATE, - device_class=DEVICE_CLASS_SAFETY, + device_class=BinarySensorDeviceClass.SAFETY, on_value="1", ), TuyaBinarySensorEntityDescription( key=DPCode.CO_STATUS, - device_class=DEVICE_CLASS_SAFETY, + device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TAMPER_BINARY_SENSOR, @@ -83,7 +76,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "hps": ( TuyaBinarySensorEntityDescription( key=DPCode.PRESENCE_STATE, - device_class=DEVICE_CLASS_MOTION, + device_class=BinarySensorDeviceClass.MOTION, on_value="presence", ), ), @@ -92,7 +85,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "jqbj": ( TuyaBinarySensorEntityDescription( key=DPCode.CH2O_STATE, - device_class=DEVICE_CLASS_SAFETY, + device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TAMPER_BINARY_SENSOR, @@ -102,7 +95,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "jwbj": ( TuyaBinarySensorEntityDescription( key=DPCode.CH4_SENSOR_STATE, - device_class=DEVICE_CLASS_GAS, + device_class=BinarySensorDeviceClass.GAS, on_value="alarm", ), TAMPER_BINARY_SENSOR, @@ -112,7 +105,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "mcs": ( TuyaBinarySensorEntityDescription( key=DPCode.DOORCONTACT_STATE, - device_class=DEVICE_CLASS_DOOR, + device_class=BinarySensorDeviceClass.DOOR, ), TAMPER_BINARY_SENSOR, ), @@ -122,8 +115,8 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { TuyaBinarySensorEntityDescription( key=DPCode.TEMPER_ALARM, name="Tamper", - device_class=DEVICE_CLASS_TAMPER, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, ), TAMPER_BINARY_SENSOR, ), @@ -132,7 +125,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "pir": ( TuyaBinarySensorEntityDescription( key=DPCode.PIR, - device_class=DEVICE_CLASS_MOTION, + device_class=BinarySensorDeviceClass.MOTION, on_value="pir", ), TAMPER_BINARY_SENSOR, @@ -142,7 +135,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "pm2.5": ( TuyaBinarySensorEntityDescription( key=DPCode.PM25_STATE, - device_class=DEVICE_CLASS_SAFETY, + device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TAMPER_BINARY_SENSOR, @@ -152,12 +145,12 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "rqbj": ( TuyaBinarySensorEntityDescription( key=DPCode.GAS_SENSOR_STATUS, - device_class=DEVICE_CLASS_GAS, + device_class=BinarySensorDeviceClass.GAS, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.GAS_SENSOR_STATE, - device_class=DEVICE_CLASS_GAS, + device_class=BinarySensorDeviceClass.GAS, on_value="1", ), TAMPER_BINARY_SENSOR, @@ -167,7 +160,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "sj": ( TuyaBinarySensorEntityDescription( key=DPCode.WATERSENSOR_STATE, - device_class=DEVICE_CLASS_MOISTURE, + device_class=BinarySensorDeviceClass.MOISTURE, on_value="alarm", ), TAMPER_BINARY_SENSOR, @@ -177,7 +170,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "sos": ( TuyaBinarySensorEntityDescription( key=DPCode.SOS_STATE, - device_class=DEVICE_CLASS_SAFETY, + device_class=BinarySensorDeviceClass.SAFETY, ), TAMPER_BINARY_SENSOR, ), @@ -186,7 +179,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "voc": ( TuyaBinarySensorEntityDescription( key=DPCode.VOC_STATE, - device_class=DEVICE_CLASS_SAFETY, + device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TAMPER_BINARY_SENSOR, @@ -208,12 +201,12 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "ywbj": ( TuyaBinarySensorEntityDescription( key=DPCode.SMOKE_SENSOR_STATUS, - device_class=DEVICE_CLASS_SMOKE, + device_class=BinarySensorDeviceClass.SMOKE, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.SMOKE_SENSOR_STATE, - device_class=DEVICE_CLASS_SMOKE, + device_class=BinarySensorDeviceClass.SMOKE, on_value="1", ), TAMPER_BINARY_SENSOR, @@ -225,7 +218,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { key=f"{DPCode.SHOCK_STATE}_vibration", dpcode=DPCode.SHOCK_STATE, name="Vibration", - device_class=DEVICE_CLASS_VIBRATION, + device_class=BinarySensorDeviceClass.VIBRATION, on_value="vibration", ), TuyaBinarySensorEntityDescription( diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py new file mode 100644 index 00000000000..cd410db24cc --- /dev/null +++ b/homeassistant/components/tuya/button.py @@ -0,0 +1,105 @@ +"""Support for Tuya buttons.""" +from __future__ import annotations + +from typing import Any + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeAssistantTuyaData +from .base import TuyaEntity +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode + +# All descriptions can be found here. +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = { + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + ButtonEntityDescription( + key=DPCode.RESET_DUSTER_CLOTH, + name="Reset Duster Cloth", + icon="mdi:restart", + entity_category=EntityCategory.CONFIG, + ), + ButtonEntityDescription( + key=DPCode.RESET_EDGE_BRUSH, + name="Reset Edge Brush", + icon="mdi:restart", + entity_category=EntityCategory.CONFIG, + ), + ButtonEntityDescription( + key=DPCode.RESET_FILTER, + name="Reset Filter", + icon="mdi:air-filter", + entity_category=EntityCategory.CONFIG, + ), + ButtonEntityDescription( + key=DPCode.RESET_MAP, + name="Reset Map", + icon="mdi:map-marker-remove", + entity_category=EntityCategory.CONFIG, + ), + ButtonEntityDescription( + key=DPCode.RESET_ROLL_BRUSH, + name="Reset Roll Brush", + icon="mdi:restart", + entity_category=EntityCategory.CONFIG, + ), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Tuya buttons dynamically through Tuya discovery.""" + hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + + @callback + def async_discover_device(device_ids: list[str]) -> None: + """Discover and add a discovered Tuya buttons.""" + entities: list[TuyaButtonEntity] = [] + for device_id in device_ids: + device = hass_data.device_manager.device_map[device_id] + if descriptions := BUTTONS.get(device.category): + for description in descriptions: + if description.key in device.function: + entities.append( + TuyaButtonEntity( + device, hass_data.device_manager, description + ) + ) + + async_add_entities(entities) + + async_discover_device([*hass_data.device_manager.device_map]) + + entry.async_on_unload( + async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) + ) + + +class TuyaButtonEntity(TuyaEntity, ButtonEntity): + """Tuya Button Device.""" + + def __init__( + self, + device: TuyaDevice, + device_manager: TuyaDeviceManager, + description: ButtonEntityDescription, + ) -> None: + """Init Tuya button.""" + super().__init__(device, device_manager) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}{description.key}" + + def press(self, **kwargs: Any) -> None: + """Press the button.""" + self._send_command([{"code": self.entity_description.key, "value": True}]) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index a9f7afb0ec5..fa69b76695c 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -1,44 +1,18 @@ """Constants for the Tuya integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass, field from enum import Enum -from typing import Callable from tuya_iot import TuyaCloudOpenAPIEndpoint +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, - DEVICE_CLASS_AQI, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CO, - DEVICE_CLASS_CO2, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_DATE, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_GAS, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_MONETARY, - DEVICE_CLASS_NITROGEN_DIOXIDE, - DEVICE_CLASS_NITROGEN_MONOXIDE, - DEVICE_CLASS_NITROUS_OXIDE, - DEVICE_CLASS_OZONE, - DEVICE_CLASS_PM1, - DEVICE_CLASS_PM10, - DEVICE_CLASS_PM25, - DEVICE_CLASS_POWER, - DEVICE_CLASS_POWER_FACTOR, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_SULPHUR_DIOXIDE, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, - DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, - DEVICE_CLASS_VOLTAGE, ELECTRIC_CURRENT_AMPERE, ELECTRIC_CURRENT_MILLIAMPERE, ELECTRIC_POTENTIAL_MILLIVOLT, @@ -85,6 +59,10 @@ DEVICE_CLASS_TUYA_MOTION_SENSITIVITY = "tuya__motion_sensitivity" DEVICE_CLASS_TUYA_RECORD_MODE = "tuya__record_mode" DEVICE_CLASS_TUYA_RELAY_STATUS = "tuya__relay_status" DEVICE_CLASS_TUYA_STATUS = "tuya__status" +DEVICE_CLASS_TUYA_FINGERBOT_MODE = "tuya__fingerbot_mode" +DEVICE_CLASS_TUYA_VACUUM_CISTERN = "tuya__vacuum_cistern" +DEVICE_CLASS_TUYA_VACUUM_COLLECTION = "tuya__vacuum_collection" +DEVICE_CLASS_TUYA_VACUUM_MODE = "tuya__vacuum_mode" TUYA_DISCOVERY_NEW = "tuya_discovery_new" TUYA_HA_SIGNAL_UPDATE_ENTITY = "tuya_entry_update" @@ -100,6 +78,7 @@ SMARTLIFE_APP = "smartlife" PLATFORMS = [ "binary_sensor", + "button", "camera", "climate", "cover", @@ -137,6 +116,8 @@ class DPCode(str, Enum): ANGLE_HORIZONTAL = "angle_horizontal" ANGLE_VERTICAL = "angle_vertical" ANION = "anion" # Ionizer unit + ARM_DOWN_PERCENT = "arm_down_percent" + ARM_UP_PERCENT = "arm_up_percent" BASIC_ANTI_FLICKER = "basic_anti_flicker" BASIC_DEVICE_VOLUME = "basic_device_volume" BASIC_FLIP = "basic_flip" @@ -167,14 +148,19 @@ class DPCode(str, Enum): CH4_SENSOR_STATE = "ch4_sensor_state" CH4_SENSOR_VALUE = "ch4_sensor_value" CHILD_LOCK = "child_lock" # Child lock + CISTERN = "cistern" + CLEAN_AREA = "clean_area" + CLEAN_TIME = "clean_time" + CLICK_SUSTAIN_TIME = "click_sustain_time" CO_STATE = "co_state" CO_STATUS = "co_status" CO_VALUE = "co_value" CO2_STATE = "co2_state" CO2_VALUE = "co2_value" # CO2 concentration + COLLECTION_MODE = "collection_mode" COLOR_DATA_V2 = "color_data_v2" - COLOUR_DATA_HSV = "colour_data_hsv" # Colored light mode COLOUR_DATA = "colour_data" # Colored light mode + COLOUR_DATA_HSV = "colour_data_hsv" # Colored light mode COLOUR_DATA_V2 = "colour_data_v2" # Colored light mode CONCENTRATION_SET = "concentration_set" # Concentration setting CONTROL = "control" @@ -190,16 +176,21 @@ class DPCode(str, Enum): DEHUMIDITY_SET_VALUE = "dehumidify_set_value" DO_NOT_DISTURB = "do_not_disturb" DOORCONTACT_STATE = "doorcontact_state" # Status of door window sensor - DOORCONTACT_STATE_2 = "doorcontact_state_3" + DOORCONTACT_STATE_2 = "doorcontact_state_2" DOORCONTACT_STATE_3 = "doorcontact_state_3" + DUSTER_CLOTH = "duster_cloth" + EDGE_BRUSH = "edge_brush" ELECTRICITY_LEFT = "electricity_left" FAN_DIRECTION = "fan_direction" # Fan direction FAN_SPEED_ENUM = "fan_speed_enum" # Speed mode FAN_SPEED_PERCENT = "fan_speed_percent" # Stepless speed FAR_DETECTION = "far_detection" + FAULT = "fault" + FILTER_LIFE = "filter" FILTER_RESET = "filter_reset" # Filter (cartridge) reset FLOODLIGHT_LIGHTNESS = "floodlight_lightness" FLOODLIGHT_SWITCH = "floodlight_switch" + FORWARD_ENERGY_TOTAL = "forward_energy_total" GAS_SENSOR_STATE = "gas_sensor_state" GAS_SENSOR_STATUS = "gas_sensor_status" GAS_SENSOR_VALUE = "gas_sensor_value" @@ -229,12 +220,16 @@ class DPCode(str, Enum): PERCENT_STATE = "percent_state" PERCENT_STATE_2 = "percent_state_2" PERCENT_STATE_3 = "percent_state_3" + PHASE_A = "phase_a" + PHASE_B = "phase_b" + PHASE_C = "phase_c" PIR = "pir" # Motion sensor PM1 = "pm1" PM10 = "pm10" PM25_STATE = "pm25_state" PM25_VALUE = "pm25_value" POWDER_SET = "powder_set" # Powder + POWER = "power" POWER_GO = "power_go" PRESENCE_STATE = "presence_state" PRESSURE_STATE = "pressure_state" @@ -243,6 +238,12 @@ class DPCode(str, Enum): RECORD_MODE = "record_mode" RECORD_SWITCH = "record_switch" # Recording switch RELAY_STATUS = "relay_status" + RESET_DUSTER_CLOTH = "reset_duster_cloth" + RESET_EDGE_BRUSH = "reset_edge_brush" + RESET_FILTER = "reset_filter" + RESET_MAP = "reset_map" + RESET_ROLL_BRUSH = "reset_roll_brush" + ROLL_BRUSH = "roll_brush" SEEK = "seek" SENSITIVITY = "sensitivity" # Sensitivity SENSOR_HUMIDITY = "sensor_humidity" @@ -271,6 +272,7 @@ class DPCode(str, Enum): SWITCH_BACKLIGHT = "switch_backlight" # Backlight switch SWITCH_CHARGE = "switch_charge" SWITCH_CONTROLLER = "switch_controller" + SWITCH_DISTURB = "switch_disturb" SWITCH_HORIZONTAL = "switch_horizontal" # Horizontal swing flap switch SWITCH_LED = "switch_led" # Switch SWITCH_LED_1 = "switch_led_1" @@ -298,12 +300,17 @@ class DPCode(str, Enum): TEMP_VALUE = "temp_value" # Color temperature TEMP_VALUE_V2 = "temp_value_v2" TEMPER_ALARM = "temper_alarm" # Tamper alarm + TOTAL_CLEAN_AREA = "total_clean_area" + TOTAL_CLEAN_COUNT = "total_clean_count" + TOTAL_CLEAN_TIME = "total_clean_time" UV = "uv" # UV sterilization VA_BATTERY = "va_battery" VA_HUMIDITY = "va_humidity" VA_TEMPERATURE = "va_temperature" VOC_STATE = "voc_state" VOC_VALUE = "voc_value" + VOICE_SWITCH = "voice_switch" + VOLUME_SET = "volume_set" WARM = "warm" # Heat preservation WARM_TIME = "warm_time" # Heat preservation time WATER_RESET = "water_reset" # Resetting of water usage days @@ -337,33 +344,33 @@ UNITS = ( unit="", aliases={" "}, device_classes={ - DEVICE_CLASS_AQI, - DEVICE_CLASS_DATE, - DEVICE_CLASS_MONETARY, - DEVICE_CLASS_TIMESTAMP, + SensorDeviceClass.AQI, + SensorDeviceClass.DATE, + SensorDeviceClass.MONETARY, + SensorDeviceClass.TIMESTAMP, }, ), UnitOfMeasurement( unit=PERCENTAGE, aliases={"pct", "percent"}, device_classes={ - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_POWER_FACTOR, + SensorDeviceClass.BATTERY, + SensorDeviceClass.HUMIDITY, + SensorDeviceClass.POWER_FACTOR, }, ), UnitOfMeasurement( unit=CONCENTRATION_PARTS_PER_MILLION, device_classes={ - DEVICE_CLASS_CO, - DEVICE_CLASS_CO2, + SensorDeviceClass.CO, + SensorDeviceClass.CO2, }, ), UnitOfMeasurement( unit=CONCENTRATION_PARTS_PER_BILLION, device_classes={ - DEVICE_CLASS_CO, - DEVICE_CLASS_CO2, + SensorDeviceClass.CO, + SensorDeviceClass.CO2, }, conversion_unit=CONCENTRATION_PARTS_PER_MILLION, conversion_fn=lambda x: x / 1000, @@ -371,73 +378,73 @@ UNITS = ( UnitOfMeasurement( unit=ELECTRIC_CURRENT_AMPERE, aliases={"a", "ampere"}, - device_classes={DEVICE_CLASS_CURRENT}, + device_classes={SensorDeviceClass.CURRENT}, ), UnitOfMeasurement( unit=ELECTRIC_CURRENT_MILLIAMPERE, aliases={"ma", "milliampere"}, - device_classes={DEVICE_CLASS_CURRENT}, + device_classes={SensorDeviceClass.CURRENT}, conversion_unit=ELECTRIC_CURRENT_AMPERE, conversion_fn=lambda x: x / 1000, ), UnitOfMeasurement( unit=ENERGY_WATT_HOUR, aliases={"wh", "watthour"}, - device_classes={DEVICE_CLASS_ENERGY}, + device_classes={SensorDeviceClass.ENERGY}, ), UnitOfMeasurement( unit=ENERGY_KILO_WATT_HOUR, - aliases={"kwh", "kilowatt-hour"}, - device_classes={DEVICE_CLASS_ENERGY}, + aliases={"kwh", "kilowatt-hour", "kW·h"}, + device_classes={SensorDeviceClass.ENERGY}, ), UnitOfMeasurement( unit=VOLUME_CUBIC_FEET, aliases={"ft3"}, - device_classes={DEVICE_CLASS_GAS}, + device_classes={SensorDeviceClass.GAS}, ), UnitOfMeasurement( unit=VOLUME_CUBIC_METERS, aliases={"m3"}, - device_classes={DEVICE_CLASS_GAS}, + device_classes={SensorDeviceClass.GAS}, ), UnitOfMeasurement( unit=LIGHT_LUX, aliases={"lux"}, - device_classes={DEVICE_CLASS_ILLUMINANCE}, + device_classes={SensorDeviceClass.ILLUMINANCE}, ), UnitOfMeasurement( unit="lm", aliases={"lum", "lumen"}, - device_classes={DEVICE_CLASS_ILLUMINANCE}, + device_classes={SensorDeviceClass.ILLUMINANCE}, ), UnitOfMeasurement( unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, aliases={"ug/m3", "µg/m3", "ug/m³"}, device_classes={ - DEVICE_CLASS_NITROGEN_DIOXIDE, - DEVICE_CLASS_NITROGEN_MONOXIDE, - DEVICE_CLASS_NITROUS_OXIDE, - DEVICE_CLASS_OZONE, - DEVICE_CLASS_PM1, - DEVICE_CLASS_PM25, - DEVICE_CLASS_PM10, - DEVICE_CLASS_SULPHUR_DIOXIDE, - DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + SensorDeviceClass.NITROGEN_DIOXIDE, + SensorDeviceClass.NITROGEN_MONOXIDE, + SensorDeviceClass.NITROUS_OXIDE, + SensorDeviceClass.OZONE, + SensorDeviceClass.PM1, + SensorDeviceClass.PM25, + SensorDeviceClass.PM10, + SensorDeviceClass.SULPHUR_DIOXIDE, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, }, ), UnitOfMeasurement( unit=CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, aliases={"mg/m3"}, device_classes={ - DEVICE_CLASS_NITROGEN_DIOXIDE, - DEVICE_CLASS_NITROGEN_MONOXIDE, - DEVICE_CLASS_NITROUS_OXIDE, - DEVICE_CLASS_OZONE, - DEVICE_CLASS_PM1, - DEVICE_CLASS_PM25, - DEVICE_CLASS_PM10, - DEVICE_CLASS_SULPHUR_DIOXIDE, - DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + SensorDeviceClass.NITROGEN_DIOXIDE, + SensorDeviceClass.NITROGEN_MONOXIDE, + SensorDeviceClass.NITROUS_OXIDE, + SensorDeviceClass.OZONE, + SensorDeviceClass.PM1, + SensorDeviceClass.PM25, + SensorDeviceClass.PM10, + SensorDeviceClass.SULPHUR_DIOXIDE, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, }, conversion_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, conversion_fn=lambda x: x * 1000, @@ -445,69 +452,69 @@ UNITS = ( UnitOfMeasurement( unit=POWER_WATT, aliases={"watt"}, - device_classes={DEVICE_CLASS_POWER}, + device_classes={SensorDeviceClass.POWER}, ), UnitOfMeasurement( unit=POWER_KILO_WATT, aliases={"kilowatt"}, - device_classes={DEVICE_CLASS_POWER}, + device_classes={SensorDeviceClass.POWER}, ), UnitOfMeasurement( unit=PRESSURE_BAR, - device_classes={DEVICE_CLASS_PRESSURE}, + device_classes={SensorDeviceClass.PRESSURE}, ), UnitOfMeasurement( unit=PRESSURE_MBAR, aliases={"millibar"}, - device_classes={DEVICE_CLASS_PRESSURE}, + device_classes={SensorDeviceClass.PRESSURE}, ), UnitOfMeasurement( unit=PRESSURE_HPA, aliases={"hpa", "hectopascal"}, - device_classes={DEVICE_CLASS_PRESSURE}, + device_classes={SensorDeviceClass.PRESSURE}, ), UnitOfMeasurement( unit=PRESSURE_INHG, aliases={"inhg"}, - device_classes={DEVICE_CLASS_PRESSURE}, + device_classes={SensorDeviceClass.PRESSURE}, ), UnitOfMeasurement( unit=PRESSURE_PSI, - device_classes={DEVICE_CLASS_PRESSURE}, + device_classes={SensorDeviceClass.PRESSURE}, ), UnitOfMeasurement( unit=PRESSURE_PA, - device_classes={DEVICE_CLASS_PRESSURE}, + device_classes={SensorDeviceClass.PRESSURE}, ), UnitOfMeasurement( unit=SIGNAL_STRENGTH_DECIBELS, aliases={"db"}, - device_classes={DEVICE_CLASS_SIGNAL_STRENGTH}, + device_classes={SensorDeviceClass.SIGNAL_STRENGTH}, ), UnitOfMeasurement( unit=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, aliases={"dbm"}, - device_classes={DEVICE_CLASS_SIGNAL_STRENGTH}, + device_classes={SensorDeviceClass.SIGNAL_STRENGTH}, ), UnitOfMeasurement( unit=TEMP_CELSIUS, aliases={"°c", "c", "celsius"}, - device_classes={DEVICE_CLASS_TEMPERATURE}, + device_classes={SensorDeviceClass.TEMPERATURE}, ), UnitOfMeasurement( unit=TEMP_FAHRENHEIT, aliases={"°f", "f", "fahrenheit"}, - device_classes={DEVICE_CLASS_TEMPERATURE}, + device_classes={SensorDeviceClass.TEMPERATURE}, ), UnitOfMeasurement( unit=ELECTRIC_POTENTIAL_VOLT, aliases={"volt"}, - device_classes={DEVICE_CLASS_VOLTAGE}, + device_classes={SensorDeviceClass.VOLTAGE}, ), UnitOfMeasurement( unit=ELECTRIC_POTENTIAL_MILLIVOLT, aliases={"mv", "millivolt"}, - device_classes={DEVICE_CLASS_VOLTAGE}, + device_classes={SensorDeviceClass.VOLTAGE}, conversion_unit=ELECTRIC_POTENTIAL_VOLT, conversion_fn=lambda x: x / 1000, ), diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 0b8a658fd7c..572d440f937 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -10,13 +10,12 @@ from tuya_iot import TuyaDevice, TuyaDeviceManager from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, - DEVICE_CLASS_CURTAIN, - DEVICE_CLASS_GARAGE, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION, SUPPORT_SET_TILT_POSITION, SUPPORT_STOP, + CoverDeviceClass, CoverEntity, CoverEntityDescription, ) @@ -36,7 +35,8 @@ class TuyaCoverEntityDescription(CoverEntityDescription): """Describe an Tuya cover entity.""" current_state: DPCode | None = None - current_position: DPCode | None = None + current_state_inverse: bool = False + current_position: DPCode | tuple[DPCode, ...] | None = None set_position: DPCode | None = None @@ -49,23 +49,32 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { key=DPCode.CONTROL, name="Curtain", current_state=DPCode.SITUATION_SET, - current_position=DPCode.PERCENT_STATE, + current_position=(DPCode.PERCENT_CONTROL, DPCode.PERCENT_STATE), set_position=DPCode.PERCENT_CONTROL, - device_class=DEVICE_CLASS_CURTAIN, + device_class=CoverDeviceClass.CURTAIN, ), TuyaCoverEntityDescription( key=DPCode.CONTROL_2, name="Curtain 2", current_position=DPCode.PERCENT_STATE_2, set_position=DPCode.PERCENT_CONTROL_2, - device_class=DEVICE_CLASS_CURTAIN, + device_class=CoverDeviceClass.CURTAIN, ), TuyaCoverEntityDescription( key=DPCode.CONTROL_3, name="Curtain 3", current_position=DPCode.PERCENT_STATE_3, set_position=DPCode.PERCENT_CONTROL_3, - device_class=DEVICE_CLASS_CURTAIN, + device_class=CoverDeviceClass.CURTAIN, + ), + # switch_1 is an undocumented code that behaves identically to control + # It is used by the Kogan Smart Blinds Driver + TuyaCoverEntityDescription( + key=DPCode.SWITCH_1, + name="Blind", + current_position=DPCode.PERCENT_CONTROL, + set_position=DPCode.PERCENT_CONTROL, + device_class=CoverDeviceClass.BLIND, ), ), # Garage Door Opener @@ -75,19 +84,22 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { key=DPCode.SWITCH_1, name="Door", current_state=DPCode.DOORCONTACT_STATE, - device_class=DEVICE_CLASS_GARAGE, + current_state_inverse=True, + device_class=CoverDeviceClass.GARAGE, ), TuyaCoverEntityDescription( key=DPCode.SWITCH_2, name="Door 2", current_state=DPCode.DOORCONTACT_STATE_2, - device_class=DEVICE_CLASS_GARAGE, + current_state_inverse=True, + device_class=CoverDeviceClass.GARAGE, ), TuyaCoverEntityDescription( key=DPCode.SWITCH_3, name="Door 3", current_state=DPCode.DOORCONTACT_STATE_3, - device_class=DEVICE_CLASS_GARAGE, + current_state_inverse=True, + device_class=CoverDeviceClass.GARAGE, ), ), # Curtain Switch @@ -98,14 +110,14 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { name="Curtain", current_position=DPCode.PERCENT_CONTROL, set_position=DPCode.PERCENT_CONTROL, - device_class=DEVICE_CLASS_CURTAIN, + device_class=CoverDeviceClass.CURTAIN, ), TuyaCoverEntityDescription( key=DPCode.CONTROL_2, name="Curtain 2", current_position=DPCode.PERCENT_CONTROL_2, set_position=DPCode.PERCENT_CONTROL_2, - device_class=DEVICE_CLASS_CURTAIN, + device_class=CoverDeviceClass.CURTAIN, ), ), # Curtain Robot @@ -115,7 +127,7 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { key=DPCode.CONTROL, current_position=DPCode.PERCENT_STATE, set_position=DPCode.PERCENT_CONTROL, - device_class=DEVICE_CLASS_CURTAIN, + device_class=CoverDeviceClass.CURTAIN, ), ), } @@ -161,6 +173,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): _set_position_type: IntegerTypeData | None = None _tilt_dpcode: DPCode | None = None _tilt_type: IntegerTypeData | None = None + _position_dpcode: DPCode | None = None entity_description: TuyaCoverEntityDescription def __init__( @@ -179,9 +192,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): if device.function[description.key].type == "Boolean": self._attr_supported_features |= SUPPORT_OPEN | SUPPORT_CLOSE elif device.function[description.key].type == "Enum": - data_type = EnumTypeData.from_json( - device.status_range[description.key].values - ) + data_type = EnumTypeData.from_json(device.function[description.key].values) if "open" in data_type.range: self._attr_supported_features |= SUPPORT_OPEN if "close" in data_type.range: @@ -225,21 +236,34 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): device.status_range[tilt_dpcode].values ) + # Determine current_position DPCodes + if ( + self.entity_description.current_position is None + and self.entity_description.set_position is not None + ): + self._position_dpcode = self.entity_description.set_position + elif isinstance(self.entity_description.current_position, DPCode): + self._position_dpcode = self.entity_description.current_position + elif isinstance(self.entity_description.current_position, tuple): + self._position_dpcode = next( + ( + dpcode + for dpcode in self.entity_description.current_position + if self.device.status.get(dpcode) is not None + ), + None, + ) + @property def current_cover_position(self) -> int | None: """Return cover current position.""" if self._current_position_type is None: return None - if not ( - dpcode := ( - self.entity_description.current_position - or self.entity_description.set_position - ) - ): + if not self._position_dpcode: return None - if (position := self.device.status.get(dpcode)) is None: + if (position := self.device.status.get(self._position_dpcode)) is None: return None return round( @@ -262,7 +286,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): @property def is_closed(self) -> bool | None: - """Return is cover is closed.""" + """Return true if cover is closed.""" if ( self.entity_description.current_state is not None and ( @@ -272,7 +296,9 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): ) is not None ): - return current_state in (True, "fully_close") + return self.entity_description.current_state_inverse is not ( + current_state in (True, "fully_close") + ) if (position := self.current_cover_position) is not None: return position == 0 @@ -284,14 +310,54 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): value: bool | str = True if self.device.function[self.entity_description.key].type == "Enum": value = "open" - self._send_command([{"code": self.entity_description.key, "value": value}]) + + commands: list[dict[str, str | int]] = [ + {"code": self.entity_description.key, "value": value} + ] + + if ( + self.entity_description.set_position is not None + and self._set_position_type is not None + ): + commands.append( + { + "code": self.entity_description.set_position, + "value": round( + self._set_position_type.remap_value_from( + 100, 0, 100, reverse=True + ), + ), + } + ) + + self._send_command(commands) def close_cover(self, **kwargs: Any) -> None: """Close cover.""" - value: bool | str = True + value: bool | str = False if self.device.function[self.entity_description.key].type == "Enum": value = "close" - self._send_command([{"code": self.entity_description.key, "value": value}]) + + commands: list[dict[str, str | int]] = [ + {"code": self.entity_description.key, "value": value} + ] + + if ( + self.entity_description.set_position is not None + and self._set_position_type is not None + ): + commands.append( + { + "code": self.entity_description.set_position, + "value": round( + self._set_position_type.remap_value_from( + 0, 0, 100, reverse=True + ), + ), + } + ) + + self._send_command(commands) def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 3169000fcba..c6cddad2759 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -6,9 +6,8 @@ from dataclasses import dataclass from tuya_iot import TuyaDevice, TuyaDeviceManager from homeassistant.components.humidifier import ( - DEVICE_CLASS_DEHUMIDIFIER, - DEVICE_CLASS_HUMIDIFIER, SUPPORT_MODES, + HumidifierDeviceClass, HumidifierEntity, HumidifierEntityDescription, ) @@ -39,7 +38,7 @@ HUMIDIFIERS: dict[str, TuyaHumidifierEntityDescription] = { key=DPCode.SWITCH, dpcode=(DPCode.SWITCH, DPCode.SWITCH_SPRAY), humidity=DPCode.DEHUMIDITY_SET_VALUE, - device_class=DEVICE_CLASS_DEHUMIDIFIER, + device_class=HumidifierDeviceClass.DEHUMIDIFIER, ), # Humidifier # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b @@ -47,7 +46,7 @@ HUMIDIFIERS: dict[str, TuyaHumidifierEntityDescription] = { key=DPCode.SWITCH, dpcode=(DPCode.SWITCH, DPCode.SWITCH_SPRAY), humidity=DPCode.HUMIDITY_SET, - device_class=DEVICE_CLASS_HUMIDIFIER, + device_class=HumidifierDeviceClass.HUMIDIFIER, ), } diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 581cd85647b..16eda1cc324 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -330,6 +330,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): _brightness_type: IntegerTypeData | None = None _color_data_dpcode: DPCode | None = None _color_data_type: ColorTypeData | None = None + _color_mode_dpcode: DPCode | None = None _color_temp_dpcode: DPCode | None = None _color_temp_type: IntegerTypeData | None = None @@ -361,6 +362,13 @@ class TuyaLightEntity(TuyaEntity, LightEntity): None, ) + # Determine color mode DPCode + if ( + description.color_mode is not None + and description.color_mode in device.function + ): + self._color_mode_dpcode = description.color_mode + # Determine DPCodes for color temperature if ( isinstance(description.color_temp, DPCode) @@ -451,10 +459,10 @@ class TuyaLightEntity(TuyaEntity, LightEntity): commands = [{"code": self.entity_description.key, "value": True}] if self._color_temp_type and ATTR_COLOR_TEMP in kwargs: - if color_mode_dpcode := self.entity_description.color_mode: + if self._color_mode_dpcode: commands += [ { - "code": color_mode_dpcode, + "code": self._color_mode_dpcode, "value": WorkMode.WHITE, }, ] @@ -476,10 +484,10 @@ class TuyaLightEntity(TuyaEntity, LightEntity): ATTR_HS_COLOR in kwargs or (ATTR_BRIGHTNESS in kwargs and self.color_mode == COLOR_MODE_HS) ): - if color_mode_dpcode := self.entity_description.color_mode: + if self._color_mode_dpcode: commands += [ { - "code": color_mode_dpcode, + "code": self._color_mode_dpcode, "value": WorkMode.COLOUR, }, ] @@ -651,9 +659,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity): # We consider it to be in HS color mode, when work mode is anything # else than "white". if ( - self.entity_description.color_mode - and self.device.status.get(self.entity_description.color_mode) - != WorkMode.WHITE + self._color_mode_dpcode + and self.device.status.get(self._color_mode_dpcode) != WorkMode.WHITE ): return COLOR_MODE_HS if self._color_temp_dpcode: diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 8db2e4debd5..f75f21dc57f 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -8,9 +8,9 @@ from tuya_iot.device import TuyaDeviceStatusRange from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENTITY_CATEGORY_CONFIG from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantTuyaData @@ -28,31 +28,31 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { key=DPCode.TEMP_SET, name="Temperature", icon="mdi:thermometer", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.TEMP_SET_F, name="Temperature", icon="mdi:thermometer", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.TEMP_BOILING_C, name="Temperature After Boiling", icon="mdi:thermometer", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.TEMP_BOILING_F, name="Temperature After Boiling", icon="mdi:thermometer", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.WARM_TIME, name="Heat Preservation Time", icon="mdi:timer", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Human Presence Sensor @@ -61,19 +61,19 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { NumberEntityDescription( key=DPCode.SENSITIVITY, name="Sensitivity", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.NEAR_DETECTION, name="Near Detection", icon="mdi:signal-distance-variant", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.FAR_DETECTION, name="Far Detection", icon="mdi:signal-distance-variant", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Coffee maker @@ -83,24 +83,34 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { key=DPCode.WATER_SET, name="Water Level", icon="mdi:cup-water", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.TEMP_SET, name="Temperature", icon="mdi:thermometer", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.WARM_TIME, name="Heat Preservation Time", icon="mdi:timer", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.POWDER_SET, name="Powder", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + NumberEntityDescription( + key=DPCode.VOLUME_SET, + name="Volume", + icon="mdi:volume-high", + entity_category=EntityCategory.CONFIG, ), ), # Siren Alarm @@ -109,7 +119,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { NumberEntityDescription( key=DPCode.ALARM_TIME, name="Time", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Smart Camera @@ -119,7 +129,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { key=DPCode.BASIC_DEVICE_VOLUME, name="Volume", icon="mdi:volume-high", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Dimmer Switch @@ -129,37 +139,37 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { key=DPCode.BRIGHTNESS_MIN_1, name="Minimum Brightness", icon="mdi:lightbulb-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_1, name="Maximum Brightness", icon="mdi:lightbulb-on-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_2, name="Minimum Brightness 2", icon="mdi:lightbulb-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_2, name="Maximum Brightness 2", icon="mdi:lightbulb-on-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_3, name="Minimum Brightness 3", icon="mdi:lightbulb-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_3, name="Maximum Brightness 3", icon="mdi:lightbulb-on-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Dimmer Switch @@ -169,25 +179,25 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { key=DPCode.BRIGHTNESS_MIN_1, name="Minimum Brightness", icon="mdi:lightbulb-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_1, name="Maximum Brightness", icon="mdi:lightbulb-on-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_2, name="Minimum Brightness 2", icon="mdi:lightbulb-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_2, name="Maximum Brightness 2", icon="mdi:lightbulb-on-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Vibration Sensor @@ -196,7 +206,28 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { NumberEntityDescription( key=DPCode.SENSITIVITY, name="Sensitivity", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, + ), + ), + # Fingerbot + "szjqr": ( + NumberEntityDescription( + key=DPCode.ARM_DOWN_PERCENT, + name="Move Down %", + icon="mdi:arrow-down-bold", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.ARM_UP_PERCENT, + name="Move Up %", + icon="mdi:arrow-up-bold", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.CLICK_SUSTAIN_TIME, + name="Down Delay", + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, ), ), } @@ -276,7 +307,7 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): value = self.device.status.get(self.entity_description.key) # Scale integer/float value - if value and isinstance(self._type_data, IntegerTypeData): + if value is not None and isinstance(self._type_data, IntegerTypeData): return self._type_data.scale_value(value) return None diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 921dc21eaaa..ca05acbb3e5 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -8,9 +8,9 @@ from tuya_iot.device import TuyaDeviceStatusRange from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENTITY_CATEGORY_CONFIG from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantTuyaData @@ -19,12 +19,16 @@ from .const import ( DEVICE_CLASS_TUYA_BASIC_ANTI_FLICKR, DEVICE_CLASS_TUYA_BASIC_NIGHTVISION, DEVICE_CLASS_TUYA_DECIBEL_SENSITIVITY, + DEVICE_CLASS_TUYA_FINGERBOT_MODE, DEVICE_CLASS_TUYA_IPC_WORK_MODE, DEVICE_CLASS_TUYA_LED_TYPE, DEVICE_CLASS_TUYA_LIGHT_MODE, DEVICE_CLASS_TUYA_MOTION_SENSITIVITY, DEVICE_CLASS_TUYA_RECORD_MODE, DEVICE_CLASS_TUYA_RELAY_STATUS, + DEVICE_CLASS_TUYA_VACUUM_CISTERN, + DEVICE_CLASS_TUYA_VACUUM_COLLECTION, + DEVICE_CLASS_TUYA_VACUUM_MODE, DOMAIN, TUYA_DISCOVERY_NEW, DPCode, @@ -46,12 +50,12 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { key=DPCode.CONCENTRATION_SET, name="Concentration", icon="mdi:altimeter", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.MATERIAL, name="Material", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.MODE, @@ -66,13 +70,13 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { key=DPCode.RELAY_STATUS, name="Power on Behavior", device_class=DEVICE_CLASS_TUYA_RELAY_STATUS, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.LIGHT_MODE, name="Indicator Light Mode", device_class=DEVICE_CLASS_TUYA_LIGHT_MODE, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Heater @@ -90,12 +94,12 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { SelectEntityDescription( key=DPCode.ALARM_VOLUME, name="Volume", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.BRIGHT_STATE, name="Brightness", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Smart Camera @@ -105,42 +109,42 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { key=DPCode.IPC_WORK_MODE, name="IPC Mode", device_class=DEVICE_CLASS_TUYA_IPC_WORK_MODE, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.DECIBEL_SENSITIVITY, name="Sound Detection Sensitivity", icon="mdi:volume-vibrate", device_class=DEVICE_CLASS_TUYA_DECIBEL_SENSITIVITY, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.RECORD_MODE, name="Record Mode", icon="mdi:record-rec", device_class=DEVICE_CLASS_TUYA_RECORD_MODE, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.BASIC_NIGHTVISION, name="Night Vision", icon="mdi:theme-light-dark", device_class=DEVICE_CLASS_TUYA_BASIC_NIGHTVISION, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.BASIC_ANTI_FLICKER, name="Anti-flicker", icon="mdi:image-outline", device_class=DEVICE_CLASS_TUYA_BASIC_ANTI_FLICKR, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.MOTION_SENSITIVITY, name="Motion Detection Sensitivity", icon="mdi:motion-sensor", device_class=DEVICE_CLASS_TUYA_MOTION_SENSITIVITY, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # IoT Switch? @@ -150,13 +154,13 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { key=DPCode.RELAY_STATUS, name="Power on Behavior", device_class=DEVICE_CLASS_TUYA_RELAY_STATUS, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.LIGHT_MODE, name="Indicator Light Mode", device_class=DEVICE_CLASS_TUYA_LIGHT_MODE, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Dimmer Switch @@ -166,31 +170,31 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { key=DPCode.RELAY_STATUS, name="Power on Behavior", device_class=DEVICE_CLASS_TUYA_RELAY_STATUS, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.LIGHT_MODE, name="Indicator Light Mode", device_class=DEVICE_CLASS_TUYA_LIGHT_MODE, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.LED_TYPE_1, name="Light Source Type", device_class=DEVICE_CLASS_TUYA_LED_TYPE, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.LED_TYPE_2, name="Light 2 Source Type", device_class=DEVICE_CLASS_TUYA_LED_TYPE, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.LED_TYPE_3, name="Light 3 Source Type", device_class=DEVICE_CLASS_TUYA_LED_TYPE, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Dimmer @@ -200,13 +204,47 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { key=DPCode.LED_TYPE_1, name="Light Source Type", device_class=DEVICE_CLASS_TUYA_LED_TYPE, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.LED_TYPE_2, name="Light 2 Source Type", device_class=DEVICE_CLASS_TUYA_LED_TYPE, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, + ), + ), + # Fingerbot + "szjqr": ( + SelectEntityDescription( + key=DPCode.MODE, + name="Mode", + device_class=DEVICE_CLASS_TUYA_FINGERBOT_MODE, + entity_category=EntityCategory.CONFIG, + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + SelectEntityDescription( + key=DPCode.CISTERN, + name="Water Tank Adjustment", + entity_category=EntityCategory.CONFIG, + device_class=DEVICE_CLASS_TUYA_VACUUM_CISTERN, + icon="mdi:water-opacity", + ), + SelectEntityDescription( + key=DPCode.COLLECTION_MODE, + name="Dust Collection Mode", + entity_category=EntityCategory.CONFIG, + device_class=DEVICE_CLASS_TUYA_VACUUM_COLLECTION, + icon="mdi:air-filter", + ), + SelectEntityDescription( + key=DPCode.MODE, + name="Mode", + entity_category=EntityCategory.CONFIG, + device_class=DEVICE_CLASS_TUYA_VACUUM_MODE, + icon="mdi:layers-outline", ), ), } diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 4abd77fb7bd..d6870d4b9bb 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -1,42 +1,33 @@ """Support for Tuya sensors.""" from __future__ import annotations +from dataclasses import dataclass from typing import cast from tuya_iot import TuyaDevice, TuyaDeviceManager from tuya_iot.device import TuyaDeviceStatusRange from homeassistant.components.sensor import ( - DEVICE_CLASS_BATTERY, - STATE_CLASS_MEASUREMENT, + SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - DEVICE_CLASS_CO, - DEVICE_CLASS_CO2, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_PM1, - DEVICE_CLASS_PM10, - DEVICE_CLASS_PM25, - DEVICE_CLASS_POWER, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, - DEVICE_CLASS_VOLTAGE, - ENTITY_CATEGORY_DIAGNOSTIC, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, PERCENTAGE, + POWER_KILO_WATT, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import HomeAssistantTuyaData -from .base import EnumTypeData, IntegerTypeData, TuyaEntity +from .base import ElectricityTypeData, EnumTypeData, IntegerTypeData, TuyaEntity from .const import ( DEVICE_CLASS_TUYA_STATUS, DEVICE_CLASS_UNITS, @@ -46,35 +37,43 @@ from .const import ( UnitOfMeasurement, ) + +@dataclass +class TuyaSensorEntityDescription(SensorEntityDescription): + """Describes Tuya sensor entity.""" + + subkey: str | None = None + + # Commonly used battery sensors, that are re-used in the sensors down below. -BATTERY_SENSORS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( +BATTERY_SENSORS: tuple[TuyaSensorEntityDescription, ...] = ( + TuyaSensorEntityDescription( key=DPCode.BATTERY_PERCENTAGE, name="Battery", native_unit_of_measurement=PERCENTAGE, - device_class=DEVICE_CLASS_BATTERY, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.BATTERY_STATE, name="Battery State", icon="mdi:battery", - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.BATTERY_VALUE, name="Battery", - device_class=DEVICE_CLASS_BATTERY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.VA_BATTERY, name="Battery", - device_class=DEVICE_CLASS_BATTERY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, ), ) @@ -82,23 +81,23 @@ BATTERY_SENSORS: tuple[SensorEntityDescription, ...] = ( # default status set of each category (that don't have a set instruction) # end up being a sensor. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -SENSORS: dict[str, tuple[SensorEntityDescription, ...]] = { +SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { # Smart Kettle # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 "bh": ( - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, name="Current Temperature", - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT_F, name="Current Temperature", - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.STATUS, name="Status", device_class=DEVICE_CLASS_TUYA_STATUS, @@ -107,143 +106,182 @@ SENSORS: dict[str, tuple[SensorEntityDescription, ...]] = { # CO2 Detector # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy "co2bj": ( - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, name="Humidity", - device_class=DEVICE_CLASS_HUMIDITY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, name="Temperature", - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, name="Carbon Dioxide", - device_class=DEVICE_CLASS_CO2, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, ), *BATTERY_SENSORS, ), # CO Detector # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v "cobj": ( - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.CO_VALUE, name="Carbon Monoxide", - device_class=DEVICE_CLASS_CO, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, ), *BATTERY_SENSORS, ), - # Switch - # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s - "kg": ( - SensorEntityDescription( - key=DPCode.CUR_CURRENT, - name="Current", - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - entity_registry_enabled_default=False, + # Air Quality Monitor + # No specification on Tuya portal + "hjjcy": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( - key=DPCode.CUR_POWER, - name="Power", - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - entity_registry_enabled_default=False, + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( - key=DPCode.CUR_VOLTAGE, - name="Voltage", - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_registry_enabled_default=False, + TuyaSensorEntityDescription( + key=DPCode.CO2_VALUE, + name="Carbon Dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CH2O_VALUE, + name="Formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VOC_VALUE, + name="Volatile Organic Compound", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.PM25_VALUE, + name="Particulate Matter 2.5 µm", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, ), ), # Formaldehyde Detector # Note: Not documented "jqbj": ( - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, name="Carbon Dioxide", - device_class=DEVICE_CLASS_CO2, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.VOC_VALUE, name="Volatile Organic Compound", - device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.PM25_VALUE, name="Particulate Matter 2.5 µm", - device_class=DEVICE_CLASS_PM25, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.VA_HUMIDITY, name="Humidity", - device_class=DEVICE_CLASS_HUMIDITY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.VA_TEMPERATURE, name="Temperature", - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, name="Formaldehyde", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), *BATTERY_SENSORS, ), # Methane Detector # https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm "jwbj": ( - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.CH4_SENSOR_VALUE, name="Methane", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), *BATTERY_SENSORS, ), + # Switch + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "kg": ( + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + name="Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + name="Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + name="Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + ), # Luminance Sensor # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 "ldcg": ( - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.BRIGHT_STATE, name="Luminosity", icon="mdi:brightness-6", ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.BRIGHT_VALUE, name="Luminosity", - device_class=DEVICE_CLASS_ILLUMINANCE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, name="Temperature", - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, name="Humidity", - device_class=DEVICE_CLASS_HUMIDITY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, name="Carbon Dioxide", - device_class=DEVICE_CLASS_CO2, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, ), *BATTERY_SENSORS, ), @@ -256,72 +294,72 @@ SENSORS: dict[str, tuple[SensorEntityDescription, ...]] = { # PM2.5 Sensor # https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu "pm2.5": ( - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.PM25_VALUE, name="Particulate Matter 2.5 µm", - device_class=DEVICE_CLASS_PM25, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, name="Formaldehyde", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.VOC_VALUE, name="Volatile Organic Compound", - device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, name="Temperature", - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, name="Carbon Dioxide", - device_class=DEVICE_CLASS_CO2, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, name="Humidity", - device_class=DEVICE_CLASS_HUMIDITY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.PM1, name="Particulate Matter 1.0 µm", - device_class=DEVICE_CLASS_PM1, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.PM1, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.PM10, name="Particulate Matter 10.0 µm", - device_class=DEVICE_CLASS_PM10, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.PM10, + state_class=SensorStateClass.MEASUREMENT, ), *BATTERY_SENSORS, ), # Heater # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm "qn": ( - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.WORK_POWER, name="Power", - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), ), # Gas Detector # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw "rqbj": ( - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.GAS_SENSOR_VALUE, icon="mdi:gas-cylinder", - device_class=STATE_CLASS_MEASUREMENT, + device_class=SensorStateClass.MEASUREMENT, ), *BATTERY_SENSORS, ), @@ -334,129 +372,271 @@ SENSORS: dict[str, tuple[SensorEntityDescription, ...]] = { # Smart Camera # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 "sp": ( - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.SENSOR_TEMPERATURE, name="Temperature", - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.SENSOR_HUMIDITY, name="Humidity", - device_class=DEVICE_CLASS_HUMIDITY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.WIRELESS_ELECTRICITY, name="Battery", - device_class=DEVICE_CLASS_BATTERY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, ), ), + # Fingerbot + "szjqr": BATTERY_SENSORS, # Solar Light # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 "tyndj": BATTERY_SENSORS, # Volatile Organic Compound Sensor # Note: Undocumented in cloud API docs, based on test device "voc": ( - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, name="Carbon Dioxide", - device_class=DEVICE_CLASS_CO2, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.PM25_VALUE, name="Particulate Matter 2.5 µm", - device_class=DEVICE_CLASS_PM25, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, name="Formaldehyde", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, name="Humidity", - device_class=DEVICE_CLASS_HUMIDITY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, name="Temperature", - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.VOC_VALUE, name="Volatile Organic Compound", - device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, ), *BATTERY_SENSORS, ), # Temperature and Humidity Sensor # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 "wsdcg": ( - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.VA_TEMPERATURE, name="Temperature", - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, name="Temperature", - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.VA_HUMIDITY, name="Humidity", - device_class=DEVICE_CLASS_HUMIDITY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, name="Humidity", - device_class=DEVICE_CLASS_HUMIDITY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.BRIGHT_VALUE, name="Luminosity", - device_class=DEVICE_CLASS_ILLUMINANCE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, ), *BATTERY_SENSORS, ), # Pressure Sensor # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm "ylcg": ( - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.PRESSURE_VALUE, - device_class=DEVICE_CLASS_PRESSURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, ), *BATTERY_SENSORS, ), # Smoke Detector # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 "ywbj": ( - SensorEntityDescription( + TuyaSensorEntityDescription( key=DPCode.SMOKE_SENSOR_VALUE, name="Smoke Amount", icon="mdi:smoke-detector", - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - device_class=STATE_CLASS_MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorStateClass.MEASUREMENT, ), *BATTERY_SENSORS, ), # Vibration Sensor # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno "zd": BATTERY_SENSORS, + # Smart Electricity Meter + # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 + "zndb": ( + TuyaSensorEntityDescription( + key=DPCode.FORWARD_ENERGY_TOTAL, + name="Total Energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_A, + name="Phase A Current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + subkey="electriccurrent", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_A, + name="Phase A Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=POWER_KILO_WATT, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_A, + name="Phase A Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + subkey="voltage", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_B, + name="Phase B Current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + subkey="electriccurrent", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_B, + name="Phase B Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=POWER_KILO_WATT, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_B, + name="Phase B Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + subkey="voltage", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + name="Phase C Current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + subkey="electriccurrent", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + name="Phase C Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=POWER_KILO_WATT, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + name="Phase C Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + subkey="voltage", + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + TuyaSensorEntityDescription( + key=DPCode.CLEAN_AREA, + name="Cleaning Area", + icon="mdi:texture-box", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CLEAN_TIME, + name="Cleaning Time", + icon="mdi:progress-clock", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_CLEAN_AREA, + name="Total Cleaning Area", + icon="mdi:texture-box", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_CLEAN_TIME, + name="Total Cleaning Time", + icon="mdi:history", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_CLEAN_COUNT, + name="Total Cleaning Times", + icon="mdi:counter", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.DUSTER_CLOTH, + name="Duster Cloth Life", + icon="mdi:ticket-percent-outline", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.EDGE_BRUSH, + name="Side Brush Life", + icon="mdi:ticket-percent-outline", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.FILTER_LIFE, + name="Filter Life", + icon="mdi:ticket-percent-outline", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.ROLL_BRUSH, + name="Rolling Brush Life", + icon="mdi:ticket-percent-outline", + state_class=SensorStateClass.MEASUREMENT, + ), + ), } # Socket (duplicate of `kg`) @@ -504,6 +684,8 @@ async def async_setup_entry( class TuyaSensorEntity(TuyaEntity, SensorEntity): """Tuya Sensor Entity.""" + entity_description: TuyaSensorEntityDescription + _status_range: TuyaDeviceStatusRange | None = None _type_data: IntegerTypeData | EnumTypeData | None = None _uom: UnitOfMeasurement | None = None @@ -512,12 +694,14 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): self, device: TuyaDevice, device_manager: TuyaDeviceManager, - description: SensorEntityDescription, + description: TuyaSensorEntityDescription, ) -> None: """Init Tuya sensor.""" super().__init__(device, device_manager) self.entity_description = description - self._attr_unique_id = f"{super().unique_id}{description.key}" + self._attr_unique_id = ( + f"{super().unique_id}{description.key}{description.subkey or ''}" + ) if status_range := device.status_range.get(description.key): self._status_range = cast(TuyaDeviceStatusRange, status_range) @@ -573,6 +757,7 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): "Integer", "String", "Enum", + "Json", ): return None @@ -595,5 +780,12 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): ): return None + # Get subkey value from Json string. + if self._status_range.type == "Json": + if self.entity_description.subkey is None: + return None + values = ElectricityTypeData.from_json(value) + return getattr(values, self.entity_description.subkey) + # Valid string or enum value return value diff --git a/homeassistant/components/tuya/strings.select.json b/homeassistant/components/tuya/strings.select.json index ccc78704166..6bc005f8ea8 100644 --- a/homeassistant/components/tuya/strings.select.json +++ b/homeassistant/components/tuya/strings.select.json @@ -44,6 +44,10 @@ "on": "[%key:common::state::on%]", "power_off": "[%key:common::state::off%]", "power_on": "[%key:common::state::on%]" + }, + "tuya__fingerbot_mode": { + "click": "Push", + "switch": "Switch" } } } diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 8395c02cf39..17b0feb70a7 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -6,14 +6,14 @@ from typing import Any from tuya_iot import TuyaDevice, TuyaDeviceManager from homeassistant.components.switch import ( - DEVICE_CLASS_OUTLET, + SwitchDeviceClass, SwitchEntity, SwitchEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENTITY_CATEGORY_CONFIG from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantTuyaData @@ -35,7 +35,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { SwitchEntityDescription( key=DPCode.WARM, name="Heat Preservation", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Pet Water Feeder @@ -45,13 +45,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { key=DPCode.FILTER_RESET, name="Filter reset", icon="mdi:filter", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.PUMP_RESET, name="Water pump reset", icon="mdi:pump", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH, @@ -61,7 +61,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { key=DPCode.WATER_RESET, name="Reset of water usage days", icon="mdi:water-sync", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Light @@ -81,7 +81,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { key=DPCode.CHILD_LOCK, name="Child Lock", icon="mdi:account-lock", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH_1, @@ -95,37 +95,37 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { key=DPCode.CHILD_LOCK, name="Child Lock", icon="mdi:account-lock", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH_1, name="Switch 1", - device_class=DEVICE_CLASS_OUTLET, + device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_2, name="Switch 2", - device_class=DEVICE_CLASS_OUTLET, + device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_3, name="Switch 3", - device_class=DEVICE_CLASS_OUTLET, + device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_4, name="Switch 4", - device_class=DEVICE_CLASS_OUTLET, + device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_5, name="Switch 5", - device_class=DEVICE_CLASS_OUTLET, + device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_6, name="Switch 6", - device_class=DEVICE_CLASS_OUTLET, + device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_USB1, @@ -154,7 +154,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { SwitchEntityDescription( key=DPCode.SWITCH, name="Switch", - device_class=DEVICE_CLASS_OUTLET, + device_class=SwitchDeviceClass.OUTLET, ), ), # Air Purifier @@ -164,19 +164,19 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { key=DPCode.ANION, name="Ionizer", icon="mdi:minus-circle-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.FILTER_RESET, name="Filter cartridge reset", icon="mdi:filter", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.LOCK, name="Child lock", icon="mdi:account-lock", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH, @@ -186,7 +186,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { key=DPCode.WET, name="Humidification", icon="mdi:water-percent", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Air conditioner @@ -196,13 +196,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { key=DPCode.ANION, name="Ionizer", icon="mdi:minus-circle-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.LOCK, name="Child Lock", icon="mdi:account-lock", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Power Socket @@ -212,37 +212,37 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { key=DPCode.CHILD_LOCK, name="Child Lock", icon="mdi:account-lock", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH_1, name="Socket 1", - device_class=DEVICE_CLASS_OUTLET, + device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_2, name="Socket 2", - device_class=DEVICE_CLASS_OUTLET, + device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_3, name="Socket 3", - device_class=DEVICE_CLASS_OUTLET, + device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_4, name="Socket 4", - device_class=DEVICE_CLASS_OUTLET, + device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_5, name="Socket 5", - device_class=DEVICE_CLASS_OUTLET, + device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_6, name="Socket 6", - device_class=DEVICE_CLASS_OUTLET, + device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_USB1, @@ -271,7 +271,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { SwitchEntityDescription( key=DPCode.SWITCH, name="Socket", - device_class=DEVICE_CLASS_OUTLET, + device_class=SwitchDeviceClass.OUTLET, ), ), # Heater @@ -281,13 +281,29 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { key=DPCode.ANION, name="Ionizer", icon="mdi:minus-circle-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.LOCK, name="Child Lock", icon="mdi:account-lock", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + SwitchEntityDescription( + key=DPCode.SWITCH_DISTURB, + name="Do Not Disturb", + icon="mdi:minus-circle", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.VOICE_SWITCH, + name="Voice", + icon="mdi:account-voice", + entity_category=EntityCategory.CONFIG, ), ), # Siren Alarm @@ -296,7 +312,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { SwitchEntityDescription( key=DPCode.MUFFLING, name="Mute", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Smart Camera @@ -306,67 +322,75 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { key=DPCode.WIRELESS_BATTERYLOCK, name="Battery Lock", icon="mdi:battery-lock", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.CRY_DETECTION_SWITCH, icon="mdi:emoticon-cry", name="Cry Detection", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.DECIBEL_SWITCH, icon="mdi:microphone-outline", name="Sound Detection", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.RECORD_SWITCH, icon="mdi:record-rec", name="Video Recording", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.MOTION_RECORD, icon="mdi:record-rec", name="Motion Recording", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.BASIC_PRIVATE, icon="mdi:eye-off", name="Privacy Mode", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.BASIC_FLIP, icon="mdi:flip-horizontal", name="Flip", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.BASIC_OSD, icon="mdi:watermark", name="Time Watermark", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.BASIC_WDR, icon="mdi:watermark", name="Wide Dynamic Range", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.MOTION_TRACKING, icon="mdi:motion-sensor", name="Motion Tracking", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.MOTION_SWITCH, icon="mdi:motion-sensor", name="Motion Alarm", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, + ), + ), + # Fingerbot + "szjqr": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + name="Switch", + icon="mdi:cursor-pointer", ), ), # IoT Switch? @@ -375,23 +399,23 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { SwitchEntityDescription( key=DPCode.SWITCH_1, name="Switch 1", - device_class=DEVICE_CLASS_OUTLET, + device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_2, name="Switch 2", - device_class=DEVICE_CLASS_OUTLET, + device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_3, name="Switch 3", - device_class=DEVICE_CLASS_OUTLET, + device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.CHILD_LOCK, name="Child Lock", icon="mdi:account-lock", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Solar Light @@ -401,7 +425,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { key=DPCode.SWITCH_SAVE_ENERGY, name="Energy Saving", icon="mdi:leaf", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Ceiling Light @@ -411,7 +435,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { key=DPCode.DO_NOT_DISTURB, name="Do not disturb", icon="mdi:minus-circle-outline", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ), # Diffuser @@ -430,7 +454,15 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { key=DPCode.SWITCH_VOICE, name="Voice", icon="mdi:account-voice", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Electricity Meter + # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 + "zndb": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + name="Switch", ), ), } diff --git a/homeassistant/components/tuya/translations/de.json b/homeassistant/components/tuya/translations/de.json index 57e429b9de1..61bdf308246 100644 --- a/homeassistant/components/tuya/translations/de.json +++ b/homeassistant/components/tuya/translations/de.json @@ -16,7 +16,7 @@ "access_id": "Zugangs-ID", "access_secret": "Zugangsgeheimnis", "country_code": "L\u00e4ndercode", - "endpoint": "Verf\u00fcgbarkeitszone", + "endpoint": "Verf\u00fcgbarkeitsbereich", "password": "Passwort", "tuya_app_type": "Mobile App", "username": "Konto" diff --git a/homeassistant/components/tuya/translations/fr.json b/homeassistant/components/tuya/translations/fr.json index 2dea3145558..f74ed38c18c 100644 --- a/homeassistant/components/tuya/translations/fr.json +++ b/homeassistant/components/tuya/translations/fr.json @@ -12,6 +12,9 @@ "step": { "login": { "data": { + "country_code": "Code pays", + "endpoint": "Zone de disponibilit\u00e9", + "password": "Mot de passe", "tuya_app_type": "Application mobile", "username": "Compte" }, diff --git a/homeassistant/components/tuya/translations/hu.json b/homeassistant/components/tuya/translations/hu.json index c01d8dbb2e7..2c2969e589c 100644 --- a/homeassistant/components/tuya/translations/hu.json +++ b/homeassistant/components/tuya/translations/hu.json @@ -36,7 +36,7 @@ "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, "description": "Adja meg Tuya hiteles\u00edt\u0151 adatait.", - "title": "Tuya" + "title": "Tuya integr\u00e1ci\u00f3" } } }, diff --git a/homeassistant/components/tuya/translations/id.json b/homeassistant/components/tuya/translations/id.json index 8b7f196b5a2..91bf29f3ec8 100644 --- a/homeassistant/components/tuya/translations/id.json +++ b/homeassistant/components/tuya/translations/id.json @@ -6,19 +6,37 @@ "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." }, "error": { - "invalid_auth": "Autentikasi tidak valid" + "invalid_auth": "Autentikasi tidak valid", + "login_error": "Kesalahan masuk ({code}): {msg}" }, "flow_title": "Konfigurasi Tuya", "step": { + "login": { + "data": { + "access_id": "ID Akses", + "access_secret": "Kode Rahasia Akses", + "country_code": "Kode Negara", + "endpoint": "Zona Ketersediaan", + "password": "Kata Sandi", + "tuya_app_type": "Aplikasi Seluler", + "username": "Akun" + }, + "description": "Masukkan kredensial Tuya Anda", + "title": "Tuya" + }, "user": { "data": { - "country_code": "Kode negara akun Anda (mis., 1 untuk AS atau 86 untuk China)", + "access_id": "ID Akses Tuya IoT", + "access_secret": "Kode Rahasia Akses Tuya IoT", + "country_code": "Negara", "password": "Kata Sandi", "platform": "Aplikasi tempat akun Anda terdaftar", - "username": "Nama Pengguna" + "region": "Wilayah", + "tuya_project_type": "Jenis proyek awan Tuya", + "username": "Akun" }, "description": "Masukkan kredensial Tuya Anda.", - "title": "Tuya" + "title": "Integrasi Tuya" } } }, diff --git a/homeassistant/components/tuya/translations/ja.json b/homeassistant/components/tuya/translations/ja.json index 6454194b1c7..96b31ac0781 100644 --- a/homeassistant/components/tuya/translations/ja.json +++ b/homeassistant/components/tuya/translations/ja.json @@ -1,20 +1,82 @@ { "config": { + "abort": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "login_error": "\u30ed\u30b0\u30a4\u30f3\u30a8\u30e9\u30fc ({code}): {msg}" + }, + "flow_title": "Tuya\u306e\u8a2d\u5b9a", "step": { "login": { "data": { "access_id": "\u30a2\u30af\u30bb\u30b9ID", + "access_secret": "\u30a2\u30af\u30bb\u30b9\u30b7\u30fc\u30af\u30ec\u30c3\u30c8", "country_code": "\u56fd\u5225\u30b3\u30fc\u30c9", + "endpoint": "\u5229\u7528\u53ef\u80fd\u30be\u30fc\u30f3", "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", "tuya_app_type": "\u30e2\u30d0\u30a4\u30eb\u30a2\u30d7\u30ea", "username": "\u30a2\u30ab\u30a6\u30f3\u30c8" }, + "description": "Tuya\u306e\u8cc7\u683c\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", "title": "Tuya" }, "user": { "data": { - "region": "\u30ea\u30fc\u30b8\u30e7\u30f3" - } + "access_id": "Tuya IoT Access ID", + "access_secret": "Tuya IoT Access Secret", + "country_code": "\u56fd\u5225\u30b3\u30fc\u30c9", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "platform": "\u30a2\u30ab\u30a6\u30f3\u30c8\u304c\u767b\u9332\u3055\u308c\u3066\u3044\u308b\u30a2\u30d7\u30ea", + "region": "\u30ea\u30fc\u30b8\u30e7\u30f3", + "tuya_project_type": "Tuya Cloud\u30d7\u30ed\u30b8\u30a7\u30af\u30c8\u30bf\u30a4\u30d7", + "username": "\u30a2\u30ab\u30a6\u30f3\u30c8" + }, + "description": "Tuya\u306e\u8cc7\u683c\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "Tuya\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3" + } + } + }, + "options": { + "abort": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "error": { + "dev_multi_type": "\u69cb\u6210\u3059\u308b\u8907\u6570\u306e\u9078\u629e\u3055\u308c\u305f\u30c7\u30d0\u30a4\u30b9\u306f\u3001\u540c\u3058\u30bf\u30a4\u30d7\u3067\u3042\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059", + "dev_not_config": "\u30c7\u30d0\u30a4\u30b9\u30bf\u30a4\u30d7\u304c\u8a2d\u5b9a\u3067\u304d\u307e\u305b\u3093", + "dev_not_found": "\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "step": { + "device": { + "data": { + "brightness_range_mode": "\u30c7\u30d0\u30a4\u30b9\u304c\u4f7f\u7528\u3059\u308b\u8f1d\u5ea6\u7bc4\u56f2", + "curr_temp_divider": "\u73fe\u5728\u306e\u6e29\u5ea6\u5024\u306e\u533a\u5207\u308a(0 = \u30c7\u30d5\u30a9\u30eb\u30c8\u3092\u4f7f\u7528)", + "max_kelvin": "\u30b1\u30eb\u30d3\u30f3\u3067\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u308b\u6700\u5927\u8272\u6e29\u5ea6", + "max_temp": "\u6700\u5927\u76ee\u6a19\u6e29\u5ea6(\u30c7\u30d5\u30a9\u30eb\u30c8\u3067\u306f\u6700\u5c0f\u304a\u3088\u3073\u6700\u5927 = 0\u3092\u4f7f\u7528)", + "min_kelvin": "\u30b1\u30eb\u30d3\u30f3\u3067\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u308b\u6700\u5c0f\u8272\u6e29\u5ea6", + "min_temp": "\u6700\u5c0f\u76ee\u6a19\u6e29\u5ea6(\u30c7\u30d5\u30a9\u30eb\u30c8\u3067\u306f\u6700\u5c0f\u304a\u3088\u3073\u6700\u5927 = 0\u3092\u4f7f\u7528)", + "set_temp_divided": "\u8a2d\u5b9a\u6e29\u5ea6\u30b3\u30de\u30f3\u30c9\u306b\u533a\u5207\u3089\u308c\u305f\u6e29\u5ea6\u5024\u3092\u4f7f\u7528", + "support_color": "\u5f37\u5236\u7684\u306b\u30ab\u30e9\u30fc\u3092\u30b5\u30dd\u30fc\u30c8", + "temp_divider": "\u6e29\u5ea6\u5024\u306e\u533a\u5207\u308a(0 = \u30c7\u30d5\u30a9\u30eb\u30c8\u3092\u4f7f\u7528)", + "temp_step_override": "\u76ee\u6a19\u6e29\u5ea6\u30b9\u30c6\u30c3\u30d7", + "tuya_max_coltemp": "\u30c7\u30d0\u30a4\u30b9\u306b\u3088\u3063\u3066\u5831\u544a\u3055\u308c\u305f\u6700\u5927\u8272\u6e29\u5ea6", + "unit_of_measurement": "\u30c7\u30d0\u30a4\u30b9\u304c\u4f7f\u7528\u3059\u308b\u6e29\u5ea6\u5358\u4f4d" + }, + "description": "{device_type} \u30c7\u30d0\u30a4\u30b9 `{device_name}` \u306e\u8868\u793a\u60c5\u5831\u3092\u8abf\u6574\u3059\u308b\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u69cb\u6210\u3057\u307e\u3059", + "title": "Tuya\u30c7\u30d0\u30a4\u30b9\u306e\u8a2d\u5b9a" + }, + "init": { + "data": { + "discovery_interval": "\u30c7\u30d0\u30a4\u30b9\u691c\u51fa\u306e\u30dd\u30fc\u30ea\u30f3\u30b0\u9593\u9694(\u79d2\u5358\u4f4d)", + "list_devices": "\u8a2d\u5b9a\u3059\u308b\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e\u3059\u308b\u304b\u3001\u7a7a\u6b04\u306e\u307e\u307e\u306b\u3057\u3066\u8a2d\u5b9a\u3092\u4fdd\u5b58\u3057\u307e\u3059", + "query_device": "\u30b9\u30c6\u30fc\u30bf\u30b9\u306e\u66f4\u65b0\u3092\u9ad8\u901f\u5316\u3059\u308b\u305f\u3081\u306b\u30af\u30a8\u30ea\u306e\u65b9\u6cd5(query method)\u3092\u4f7f\u7528\u3059\u308b\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e\u3057\u307e\u3059", + "query_interval": "\u30af\u30a8\u30ea\u30c7\u30d0\u30a4\u30b9\u306e\u30dd\u30fc\u30ea\u30f3\u30b0\u9593\u9694(\u79d2)" + }, + "description": "\u30dd\u30fc\u30ea\u30f3\u30b0\u9593\u9694\u306e\u5024\u3092\u4f4e\u304f\u8a2d\u5b9a\u3057\u3059\u304e\u306a\u3044\u3067\u304f\u3060\u3055\u3044\u3002\u30b3\u30fc\u30eb\u306b\u5931\u6557\u3057\u3066\u30ed\u30b0\u306b\u30a8\u30e9\u30fc\u30e1\u30c3\u30bb\u30fc\u30b8\u304c\u751f\u6210\u3055\u308c\u307e\u3059\u3002", + "title": "Tuya\u30aa\u30d7\u30b7\u30e7\u30f3\u306e\u8a2d\u5b9a" } } } diff --git a/homeassistant/components/tuya/translations/pl.json b/homeassistant/components/tuya/translations/pl.json index 28993f5b659..a191c40ca3f 100644 --- a/homeassistant/components/tuya/translations/pl.json +++ b/homeassistant/components/tuya/translations/pl.json @@ -13,21 +13,30 @@ "step": { "login": { "data": { + "access_id": "Identyfikator dost\u0119pu", + "access_secret": "Has\u0142o dost\u0119pu", + "country_code": "Kod kraju", + "endpoint": "Strefa dost\u0119pno\u015bci", "password": "Has\u0142o", + "tuya_app_type": "Aplikacja mobilna", "username": "Konto" }, + "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce Tuya", "title": "Tuya" }, "user": { "data": { - "country_code": "Kod kraju twojego konta (np. 1 dla USA lub 86 dla Chin)", + "access_id": "Identyfikator dost\u0119pu do Tuya IoT", + "access_secret": "Has\u0142o dost\u0119pu do Tuya IoT", + "country_code": "Kraj", "password": "Has\u0142o", "platform": "Aplikacja, w kt\u00f3rej zarejestrowane jest Twoje konto", "region": "Region", - "username": "Nazwa u\u017cytkownika" + "tuya_project_type": "Typ projektu chmury Tuya", + "username": "Konto" }, - "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce", - "title": "Tuya" + "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce Tuya", + "title": "Integracja Tuya" } } }, diff --git a/homeassistant/components/tuya/translations/ru.json b/homeassistant/components/tuya/translations/ru.json index b02562813c6..e13914683f1 100644 --- a/homeassistant/components/tuya/translations/ru.json +++ b/homeassistant/components/tuya/translations/ru.json @@ -21,7 +21,7 @@ "tuya_app_type": "\u041c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435", "username": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Tuya.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Tuya.", "title": "Tuya" }, "user": { @@ -35,7 +35,7 @@ "tuya_project_type": "\u0422\u0438\u043f \u043e\u0431\u043b\u0430\u0447\u043d\u043e\u0433\u043e \u043f\u0440\u043e\u0435\u043a\u0442\u0430 Tuya", "username": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Tuya.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Tuya.", "title": "Tuya" } } diff --git a/homeassistant/components/tuya/translations/select.bg.json b/homeassistant/components/tuya/translations/select.bg.json index 0d9652389ea..9b166dbd262 100644 --- a/homeassistant/components/tuya/translations/select.bg.json +++ b/homeassistant/components/tuya/translations/select.bg.json @@ -1,5 +1,18 @@ { "state": { + "tuya__basic_anti_flickr": { + "1": "50 Hz", + "2": "60 Hz" + }, + "tuya__basic_nightvision": { + "0": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u043d\u043e", + "1": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "2": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d\u043e" + }, + "tuya__decibel_sensitivity": { + "0": "\u041d\u0438\u0441\u043a\u0430 \u0447\u0443\u0432\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u043d\u043e\u0441\u0442", + "1": "\u0412\u0438\u0441\u043e\u043a\u0430 \u0447\u0443\u0432\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u043d\u043e\u0441\u0442" + }, "tuya__led_type": { "halogen": "\u0425\u0430\u043b\u043e\u0433\u0435\u043d\u043d\u0438", "incandescent": "\u0421 \u043d\u0430\u0436\u0435\u0436\u0430\u0435\u043c\u0430 \u0436\u0438\u0447\u043a\u0430", @@ -8,6 +21,11 @@ "tuya__light_mode": { "none": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d\u043e" }, + "tuya__motion_sensitivity": { + "0": "\u041d\u0438\u0441\u043a\u0430 \u0447\u0443\u0432\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u043d\u043e\u0441\u0442", + "1": "\u0421\u0440\u0435\u0434\u043d\u0430 \u0447\u0443\u0432\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u043d\u043e\u0441\u0442", + "2": "\u0412\u0438\u0441\u043e\u043a\u0430 \u0447\u0443\u0432\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u043d\u043e\u0441\u0442" + }, "tuya__relay_status": { "last": "\u0417\u0430\u043f\u043e\u043c\u043d\u044f\u043d\u0435 \u043d\u0430 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u043e\u0442\u043e \u0441\u044a\u0441\u0442\u043e\u044f\u043d\u0438\u0435", "memory": "\u0417\u0430\u043f\u043e\u043c\u043d\u044f\u043d\u0435 \u043d\u0430 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u043e\u0442\u043e \u0441\u044a\u0441\u0442\u043e\u044f\u043d\u0438\u0435", diff --git a/homeassistant/components/tuya/translations/select.ca.json b/homeassistant/components/tuya/translations/select.ca.json index 8aa03aa4bfa..d2b7935a075 100644 --- a/homeassistant/components/tuya/translations/select.ca.json +++ b/homeassistant/components/tuya/translations/select.ca.json @@ -7,13 +7,17 @@ }, "tuya__basic_nightvision": { "0": "Autom\u00e0tic", - "1": "off", - "2": "on" + "1": "OFF", + "2": "ON" }, "tuya__decibel_sensitivity": { "0": "Sensibilitat baixa", "1": "Sensibilitat alta" }, + "tuya__fingerbot_mode": { + "click": "Polsador", + "switch": "Interruptor" + }, "tuya__ipc_work_mode": { "0": "Mode de baix consum", "1": "Mode de funcionament continu" @@ -24,7 +28,7 @@ "led": "LED" }, "tuya__light_mode": { - "none": "off", + "none": "OFF", "pos": "Indica la ubicaci\u00f3 de l'interruptor", "relay": "Indiqueu l'estat, activat/desactivat" }, @@ -40,10 +44,10 @@ "tuya__relay_status": { "last": "Recorda l'\u00faltim estat", "memory": "Recorda l'\u00faltim estat", - "off": "off", - "on": "on", - "power_off": "off", - "power_on": "on" + "off": "OFF", + "on": "ON", + "power_off": "OFF", + "power_on": "ON" } } } \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/select.de.json b/homeassistant/components/tuya/translations/select.de.json index f068b8b4425..7183ba9fc2a 100644 --- a/homeassistant/components/tuya/translations/select.de.json +++ b/homeassistant/components/tuya/translations/select.de.json @@ -14,6 +14,10 @@ "0": "Geringe Empfindlichkeit", "1": "Hohe Empfindlichkeit" }, + "tuya__fingerbot_mode": { + "click": "Dr\u00fccken", + "switch": "Schalter" + }, "tuya__ipc_work_mode": { "0": "Energiesparmodus", "1": "Kontinuierlicher Arbeitsmodus" diff --git a/homeassistant/components/tuya/translations/select.en.json b/homeassistant/components/tuya/translations/select.en.json index 7ac1f656d87..22127d17f8a 100644 --- a/homeassistant/components/tuya/translations/select.en.json +++ b/homeassistant/components/tuya/translations/select.en.json @@ -14,6 +14,10 @@ "0": "Low sensitivity", "1": "High sensitivity" }, + "tuya__fingerbot_mode": { + "click": "Push", + "switch": "Switch" + }, "tuya__ipc_work_mode": { "0": "Low power mode", "1": "Continuous working mode" @@ -44,6 +48,38 @@ "on": "On", "power_off": "Off", "power_on": "On" + }, + "tuya__vacuum_cistern": { + "low": "Low", + "middle": "Middle", + "high": "High", + "closed": "Closed" + }, + "tuya__vacuum_collection": { + "small": "Small", + "middle": "Middle", + "large": "Large" + }, + "tuya__vacuum_mode": { + "standby": "Standby", + "random": "Random", + "smart": "Smart", + "wall_follow": "Follow Wall", + "mop": "Mop", + "spiral": "Spiral", + "left_spiral": "Spiral Left", + "right_spiral": "Spiral Right", + "bow": "Bow", + "left_bow": "Bow Lef", + "right_bow": "Bow Right", + "partial_bow": "Bow Partially", + "chargego": "Return to dock", + "single": "Single", + "zone": "Zone", + "pose": "Pose", + "point": "Point", + "part": "Part", + "pick_zone": "Pick Zone" } } -} \ No newline at end of file +} diff --git a/homeassistant/components/tuya/translations/select.et.json b/homeassistant/components/tuya/translations/select.et.json index 58152555b56..c03901479d8 100644 --- a/homeassistant/components/tuya/translations/select.et.json +++ b/homeassistant/components/tuya/translations/select.et.json @@ -14,6 +14,10 @@ "0": "Madal tundlikkus", "1": "K\u00f5rge tundlikkus" }, + "tuya__fingerbot_mode": { + "click": "Vajutus", + "switch": "L\u00fcliti" + }, "tuya__ipc_work_mode": { "0": "Madala energiatarbega re\u017eiim", "1": "Pidev t\u00f6\u00f6re\u017eiim" diff --git a/homeassistant/components/tuya/translations/select.fr.json b/homeassistant/components/tuya/translations/select.fr.json new file mode 100644 index 00000000000..6ca8d863030 --- /dev/null +++ b/homeassistant/components/tuya/translations/select.fr.json @@ -0,0 +1,17 @@ +{ + "state": { + "tuya__basic_nightvision": { + "1": "Inactif", + "2": "Actif" + }, + "tuya__light_mode": { + "none": "Inactif" + }, + "tuya__relay_status": { + "off": "Inactif", + "on": "Actif", + "power_off": "Inactif", + "power_on": "Actif" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/select.he.json b/homeassistant/components/tuya/translations/select.he.json new file mode 100644 index 00000000000..c9e9a0bd138 --- /dev/null +++ b/homeassistant/components/tuya/translations/select.he.json @@ -0,0 +1,24 @@ +{ + "state": { + "tuya__basic_anti_flickr": { + "1": "50 \u05d4\u05e8\u05e5", + "2": "60 \u05d4\u05e8\u05e5" + }, + "tuya__basic_nightvision": { + "1": "\u05db\u05d1\u05d5\u05d9", + "2": "\u05de\u05d5\u05e4\u05e2\u05dc" + }, + "tuya__led_type": { + "led": "\u05dc\u05d3" + }, + "tuya__light_mode": { + "none": "\u05db\u05d1\u05d5\u05d9" + }, + "tuya__relay_status": { + "off": "\u05db\u05d1\u05d5\u05d9", + "on": "\u05de\u05d5\u05e4\u05e2\u05dc", + "power_off": "\u05db\u05d1\u05d5\u05d9", + "power_on": "\u05de\u05d5\u05e4\u05e2\u05dc" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/select.hu.json b/homeassistant/components/tuya/translations/select.hu.json index 6d9b4846e64..f503c93bd05 100644 --- a/homeassistant/components/tuya/translations/select.hu.json +++ b/homeassistant/components/tuya/translations/select.hu.json @@ -14,6 +14,10 @@ "0": "Alacsony \u00e9rz\u00e9kenys\u00e9g", "1": "Magas \u00e9rz\u00e9kenys\u00e9g" }, + "tuya__fingerbot_mode": { + "click": "Lenyom\u00e1s", + "switch": "Kapcsol\u00e1s" + }, "tuya__ipc_work_mode": { "0": "Alacsony fogyaszt\u00e1s\u00fa m\u00f3d", "1": "Folyamatos \u00fczemm\u00f3d" diff --git a/homeassistant/components/tuya/translations/select.id.json b/homeassistant/components/tuya/translations/select.id.json new file mode 100644 index 00000000000..4ea665e4a79 --- /dev/null +++ b/homeassistant/components/tuya/translations/select.id.json @@ -0,0 +1,53 @@ +{ + "state": { + "tuya__basic_anti_flickr": { + "0": "Dinonaktifkan", + "1": "50Hz", + "2": "60Hz" + }, + "tuya__basic_nightvision": { + "0": "Otomatis", + "1": "Mati", + "2": "Nyala" + }, + "tuya__decibel_sensitivity": { + "0": "Sensitivitas rendah", + "1": "Sensitivitas tinggi" + }, + "tuya__fingerbot_mode": { + "click": "Dorong", + "switch": "Sakelar" + }, + "tuya__ipc_work_mode": { + "0": "Mode daya rendah", + "1": "Mode kerja terus menerus" + }, + "tuya__led_type": { + "halogen": "Halogen", + "incandescent": "Pijar", + "led": "LED" + }, + "tuya__light_mode": { + "none": "Mati", + "pos": "Menunjukkan lokasi sakelar", + "relay": "Menunjukkan status sakelar nyala/mati" + }, + "tuya__motion_sensitivity": { + "0": "Sensitivitas rendah", + "1": "Sensitivitas sedang", + "2": "Sensitivitas tinggi" + }, + "tuya__record_mode": { + "1": "Rekam peristiwa saja", + "2": "Perekaman terus menerus" + }, + "tuya__relay_status": { + "last": "Ingat status terakhir", + "memory": "Ingat status terakhir", + "off": "Mati", + "on": "Nyala", + "power_off": "Mati", + "power_on": "Nyala" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/select.it.json b/homeassistant/components/tuya/translations/select.it.json index a7bed12090c..74410c0a593 100644 --- a/homeassistant/components/tuya/translations/select.it.json +++ b/homeassistant/components/tuya/translations/select.it.json @@ -1,5 +1,23 @@ { "state": { + "tuya__basic_anti_flickr": { + "0": "Disabilitato", + "1": "50 Hz", + "2": "60 Hz" + }, + "tuya__basic_nightvision": { + "0": "Automatico", + "1": "Spento", + "2": "Acceso" + }, + "tuya__decibel_sensitivity": { + "0": "Bassa sensibilit\u00e0", + "1": "Alta sensibilit\u00e0" + }, + "tuya__ipc_work_mode": { + "0": "Modalit\u00e0 a basso consumo", + "1": "Modalit\u00e0 di lavoro continua" + }, "tuya__led_type": { "halogen": "Alogena", "incandescent": "Incandescenza", @@ -10,6 +28,15 @@ "pos": "Indica la posizione dell'interruttore", "relay": "Indica lo stato di accensione/spegnimento dell'interruttore" }, + "tuya__motion_sensitivity": { + "0": "Bassa sensibilit\u00e0", + "1": "Sensibilit\u00e0 media", + "2": "Alta sensibilit\u00e0" + }, + "tuya__record_mode": { + "1": "Registra solo gli eventi", + "2": "Registrazione continua" + }, "tuya__relay_status": { "last": "Ricorda l'ultimo stato", "memory": "Ricorda l'ultimo stato", diff --git a/homeassistant/components/tuya/translations/select.ja.json b/homeassistant/components/tuya/translations/select.ja.json new file mode 100644 index 00000000000..b2bb0a27204 --- /dev/null +++ b/homeassistant/components/tuya/translations/select.ja.json @@ -0,0 +1,53 @@ +{ + "state": { + "tuya__basic_anti_flickr": { + "0": "\u7121\u52b9", + "1": "50 Hz", + "2": "60 Hz" + }, + "tuya__basic_nightvision": { + "0": "\u81ea\u52d5", + "1": "\u30aa\u30d5", + "2": "\u30aa\u30f3" + }, + "tuya__decibel_sensitivity": { + "0": "\u4f4e\u611f\u5ea6", + "1": "\u9ad8\u611f\u5ea6" + }, + "tuya__fingerbot_mode": { + "click": "\u62bc\u3059", + "switch": "\u30b9\u30a4\u30c3\u30c1" + }, + "tuya__ipc_work_mode": { + "0": "\u4f4e\u96fb\u529b\u30e2\u30fc\u30c9", + "1": "\u9023\u7d9a\u4f5c\u696d\u30e2\u30fc\u30c9" + }, + "tuya__led_type": { + "halogen": "\u30cf\u30ed\u30b2\u30f3", + "incandescent": "\u767d\u71b1\u706f", + "led": "LED" + }, + "tuya__light_mode": { + "none": "\u30aa\u30d5", + "pos": "\u30b9\u30a4\u30c3\u30c1\u306e\u4f4d\u7f6e\u3092\u793a\u3059", + "relay": "\u30b9\u30a4\u30c3\u30c1\u306e\u30aa\u30f3/\u30aa\u30d5\u72b6\u614b\u3092\u793a\u3059" + }, + "tuya__motion_sensitivity": { + "0": "\u4f4e\u611f\u5ea6", + "1": "\u4e2d\u7a0b\u5ea6\u306e\u611f\u5ea6", + "2": "\u9ad8\u611f\u5ea6" + }, + "tuya__record_mode": { + "1": "\u30a4\u30d9\u30f3\u30c8\u306e\u307f\u3092\u8a18\u9332", + "2": "\u9023\u7d9a\u8a18\u9332" + }, + "tuya__relay_status": { + "last": "\u6700\u5f8c\u306e\u72b6\u614b\u3092\u8a18\u61b6", + "memory": "\u6700\u5f8c\u306e\u72b6\u614b\u3092\u8a18\u61b6", + "off": "\u30aa\u30d5", + "on": "\u30aa\u30f3", + "power_off": "\u30aa\u30d5", + "power_on": "\u30aa\u30f3" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/select.nl.json b/homeassistant/components/tuya/translations/select.nl.json index 5efdae61193..ef2dff99dbe 100644 --- a/homeassistant/components/tuya/translations/select.nl.json +++ b/homeassistant/components/tuya/translations/select.nl.json @@ -14,12 +14,17 @@ "0": "Lage gevoeligheid", "1": "Hoge gevoeligheid" }, + "tuya__fingerbot_mode": { + "click": "Duw", + "switch": "Schakelaar" + }, "tuya__ipc_work_mode": { "0": "Energiezuinige modus", "1": "Continue werkmodus:" }, "tuya__led_type": { "halogen": "Halogeen", + "incandescent": "Witgloeiend", "led": "LED" }, "tuya__light_mode": { diff --git a/homeassistant/components/tuya/translations/select.no.json b/homeassistant/components/tuya/translations/select.no.json index e5bc8dbba53..459d8c4cfc0 100644 --- a/homeassistant/components/tuya/translations/select.no.json +++ b/homeassistant/components/tuya/translations/select.no.json @@ -14,6 +14,10 @@ "0": "Lav f\u00f8lsomhet", "1": "H\u00f8y f\u00f8lsomhet" }, + "tuya__fingerbot_mode": { + "click": "Trykk", + "switch": "Bryter" + }, "tuya__ipc_work_mode": { "0": "Lav effekt modus", "1": "Kontinuerlig arbeidsmodus" diff --git a/homeassistant/components/tuya/translations/select.pl.json b/homeassistant/components/tuya/translations/select.pl.json new file mode 100644 index 00000000000..c5d71d2eb55 --- /dev/null +++ b/homeassistant/components/tuya/translations/select.pl.json @@ -0,0 +1,49 @@ +{ + "state": { + "tuya__basic_anti_flickr": { + "0": "Wy\u0142\u0105czone", + "1": "50 Hz", + "2": "60 Hz" + }, + "tuya__basic_nightvision": { + "0": "Automatycznie", + "1": "wy\u0142.", + "2": "w\u0142." + }, + "tuya__decibel_sensitivity": { + "0": "Niska czu\u0142o\u015b\u0107", + "1": "Wysoka czu\u0142o\u015b\u0107" + }, + "tuya__ipc_work_mode": { + "0": "Tryb niskiego poboru mocy", + "1": "Tryb pracy ci\u0105g\u0142ej" + }, + "tuya__led_type": { + "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." + }, + "tuya__motion_sensitivity": { + "0": "Niska czu\u0142o\u015b\u0107", + "1": "\u015arednia czu\u0142o\u015b\u0107", + "2": "Wysoka czu\u0142o\u015b\u0107" + }, + "tuya__record_mode": { + "1": "Nagrywaj tylko zdarzenia", + "2": "Nagrywanie ci\u0105g\u0142e" + }, + "tuya__relay_status": { + "last": "Zapami\u0119taj ostatni stan", + "memory": "Zapami\u0119taj ostatni stan", + "off": "wy\u0142.", + "on": "w\u0142.", + "power_off": "wy\u0142.", + "power_on": "w\u0142." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/select.ru.json b/homeassistant/components/tuya/translations/select.ru.json index 3c9401e4249..582c1188778 100644 --- a/homeassistant/components/tuya/translations/select.ru.json +++ b/homeassistant/components/tuya/translations/select.ru.json @@ -14,6 +14,10 @@ "0": "\u041d\u0438\u0437\u043a\u0430\u044f \u0447\u0443\u0432\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u044c", "1": "\u0412\u044b\u0441\u043e\u043a\u0430\u044f \u0447\u0443\u0432\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u044c" }, + "tuya__fingerbot_mode": { + "click": "\u041a\u043d\u043e\u043f\u043a\u0430", + "switch": "\u0412\u044b\u043a\u043b\u044e\u0447\u0430\u0442\u0435\u043b\u044c" + }, "tuya__ipc_work_mode": { "0": "\u0420\u0435\u0436\u0438\u043c \u043d\u0438\u0437\u043a\u043e\u0433\u043e \u044d\u043d\u0435\u0440\u0433\u043e\u043f\u043e\u0442\u0440\u0435\u0431\u043b\u0435\u043d\u0438\u044f", "1": "\u041d\u0435\u043f\u0440\u0435\u0440\u044b\u0432\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c \u0440\u0430\u0431\u043e\u0442\u044b" diff --git a/homeassistant/components/tuya/translations/select.sl.json b/homeassistant/components/tuya/translations/select.sl.json new file mode 100644 index 00000000000..cad127241aa --- /dev/null +++ b/homeassistant/components/tuya/translations/select.sl.json @@ -0,0 +1,34 @@ +{ + "state": { + "tuya__basic_anti_flickr": { + "0": "Onemogo\u010deno" + }, + "tuya__basic_nightvision": { + "0": "Samodejno", + "1": "Izklju\u010den", + "2": "Vklopljen" + }, + "tuya__led_type": { + "halogen": "Halogenska", + "incandescent": "\u017dare\u010de", + "led": "LED" + }, + "tuya__light_mode": { + "none": "Izklju\u010den", + "pos": "Navedite lokacijo stikala", + "relay": "Navedite stanje vklopa/izklopa" + }, + "tuya__record_mode": { + "1": "Snemaj samo dogode", + "2": "Neprekinjeno snemanje" + }, + "tuya__relay_status": { + "last": "Zapomni si zadnje stanje", + "memory": "Zapomni si zadnje stanje", + "off": "Izklju\u010den", + "on": "Vklopljen", + "power_off": "Izklju\u010den", + "power_on": "Vklopljen" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/select.tr.json b/homeassistant/components/tuya/translations/select.tr.json new file mode 100644 index 00000000000..56a875b7d88 --- /dev/null +++ b/homeassistant/components/tuya/translations/select.tr.json @@ -0,0 +1,53 @@ +{ + "state": { + "tuya__basic_anti_flickr": { + "0": "Devre d\u0131\u015f\u0131", + "1": "50 Hz", + "2": "60 Hz" + }, + "tuya__basic_nightvision": { + "0": "Otomatik", + "1": "Kapal\u0131", + "2": "A\u00e7\u0131k" + }, + "tuya__decibel_sensitivity": { + "0": "D\u00fc\u015f\u00fck hassasiyet", + "1": "Y\u00fcksek hassasiyet" + }, + "tuya__fingerbot_mode": { + "click": "Bildirim", + "switch": "Anahtar" + }, + "tuya__ipc_work_mode": { + "0": "D\u00fc\u015f\u00fck g\u00fc\u00e7 modu", + "1": "S\u00fcrekli \u00e7al\u0131\u015fma modu" + }, + "tuya__led_type": { + "halogen": "Halojen", + "incandescent": "Akkor", + "led": "LED" + }, + "tuya__light_mode": { + "none": "Kapal\u0131", + "pos": "Anahtar konumunu belirtin", + "relay": "A\u00e7ma/kapama durumunu belirtin" + }, + "tuya__motion_sensitivity": { + "0": "D\u00fc\u015f\u00fck hassasiyet", + "1": "Orta hassasiyet", + "2": "Y\u00fcksek hassasiyet" + }, + "tuya__record_mode": { + "1": "Yaln\u0131zca olaylar\u0131 kaydet", + "2": "S\u00fcrekli kay\u0131t" + }, + "tuya__relay_status": { + "last": "Son durumu hat\u0131rla", + "memory": "Son durumu hat\u0131rla", + "off": "Kapal\u0131", + "on": "A\u00e7\u0131k", + "power_off": "Kapal\u0131", + "power_on": "A\u00e7\u0131k" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/select.zh-Hant.json b/homeassistant/components/tuya/translations/select.zh-Hant.json index 86cd7342a7f..31656f70859 100644 --- a/homeassistant/components/tuya/translations/select.zh-Hant.json +++ b/homeassistant/components/tuya/translations/select.zh-Hant.json @@ -14,6 +14,10 @@ "0": "\u4f4e\u654f\u611f\u5ea6", "1": "\u9ad8\u654f\u611f\u5ea6" }, + "tuya__fingerbot_mode": { + "click": "\u63a8", + "switch": "\u958b\u95dc" + }, "tuya__ipc_work_mode": { "0": "\u4f4e\u529f\u8017\u6a21\u5f0f", "1": "\u6301\u7e8c\u5de5\u4f5c\u6a21\u5f0f" diff --git a/homeassistant/components/tuya/translations/sensor.bg.json b/homeassistant/components/tuya/translations/sensor.bg.json new file mode 100644 index 00000000000..a6bedcb419c --- /dev/null +++ b/homeassistant/components/tuya/translations/sensor.bg.json @@ -0,0 +1,9 @@ +{ + "state": { + "tuya__status": { + "cooling": "\u041e\u0445\u043b\u0430\u0436\u0434\u0430\u043d\u0435", + "heating": "\u041e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u0435", + "heating_temp": "\u0422\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 \u043d\u0430 \u043e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u0435" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/sensor.ca.json b/homeassistant/components/tuya/translations/sensor.ca.json index c3cfc0c9d08..681ae04107a 100644 --- a/homeassistant/components/tuya/translations/sensor.ca.json +++ b/homeassistant/components/tuya/translations/sensor.ca.json @@ -3,7 +3,13 @@ "tuya__status": { "boiling_temp": "Temperatura d'ebullici\u00f3", "cooling": "Refredant", - "heating": "Escalfant" + "heating": "Escalfant", + "heating_temp": "Temperatura d'escalfament", + "reserve_1": "Reserva 1", + "reserve_2": "Reserva 2", + "reserve_3": "Reserva 3", + "standby": "En espera", + "warm": "Conservaci\u00f3 de calor" } } } \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/sensor.id.json b/homeassistant/components/tuya/translations/sensor.id.json new file mode 100644 index 00000000000..0697075be00 --- /dev/null +++ b/homeassistant/components/tuya/translations/sensor.id.json @@ -0,0 +1,15 @@ +{ + "state": { + "tuya__status": { + "boiling_temp": "Suhu mendidih", + "cooling": "Mendinginkan", + "heating": "Memanaskan", + "heating_temp": "Suhu pemanas", + "reserve_1": "Cadangan 1", + "reserve_2": "Cadangan 2", + "reserve_3": "Cadangan 3", + "standby": "Siaga", + "warm": "Pelestarian panas" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/sensor.it.json b/homeassistant/components/tuya/translations/sensor.it.json new file mode 100644 index 00000000000..a7b7bb272dd --- /dev/null +++ b/homeassistant/components/tuya/translations/sensor.it.json @@ -0,0 +1,15 @@ +{ + "state": { + "tuya__status": { + "boiling_temp": "Temperatura di ebollizione", + "cooling": "Raffreddamento", + "heating": "Riscaldamento", + "heating_temp": "Temperatura di riscaldamento", + "reserve_1": "Riserva 1", + "reserve_2": "Riserva 2", + "reserve_3": "Riserva 3", + "standby": "Pausa", + "warm": "Conservazione del calore" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/sensor.ja.json b/homeassistant/components/tuya/translations/sensor.ja.json new file mode 100644 index 00000000000..aecb556cf17 --- /dev/null +++ b/homeassistant/components/tuya/translations/sensor.ja.json @@ -0,0 +1,15 @@ +{ + "state": { + "tuya__status": { + "boiling_temp": "\u6cb8\u70b9", + "cooling": "\u51b7\u5374", + "heating": "\u6696\u623f(\u52a0\u71b1)", + "heating_temp": "\u6696\u623f(\u52a0\u71b1)\u6e29\u5ea6", + "reserve_1": "Reserve 1", + "reserve_2": "Reserve 2", + "reserve_3": "Reserve 3", + "standby": "\u30b9\u30bf\u30f3\u30d0\u30a4", + "warm": "\u4fdd\u6e29" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/sensor.nl.json b/homeassistant/components/tuya/translations/sensor.nl.json new file mode 100644 index 00000000000..68092c434a3 --- /dev/null +++ b/homeassistant/components/tuya/translations/sensor.nl.json @@ -0,0 +1,15 @@ +{ + "state": { + "tuya__status": { + "boiling_temp": "Kooktemperatuur", + "cooling": "Koeling", + "heating": "Verwarming", + "heating_temp": "Verwarmingstemperatuur", + "reserve_1": "Reserve 1", + "reserve_2": "Reserve 2", + "reserve_3": "Reserve 3", + "standby": "Stand-by", + "warm": "Warmtebehoud" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/sensor.no.json b/homeassistant/components/tuya/translations/sensor.no.json new file mode 100644 index 00000000000..2992fcb2f4b --- /dev/null +++ b/homeassistant/components/tuya/translations/sensor.no.json @@ -0,0 +1,15 @@ +{ + "state": { + "tuya__status": { + "boiling_temp": "Kokende temperatur", + "cooling": "Kj\u00f8ling", + "heating": "Oppvarming", + "heating_temp": "Oppvarmingstemperatur", + "reserve_1": "Reserver 1", + "reserve_2": "Reserver 2", + "reserve_3": "Reserver 3", + "standby": "Avventer", + "warm": "Varmebevaring" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/sensor.pl.json b/homeassistant/components/tuya/translations/sensor.pl.json new file mode 100644 index 00000000000..090849227f8 --- /dev/null +++ b/homeassistant/components/tuya/translations/sensor.pl.json @@ -0,0 +1,15 @@ +{ + "state": { + "tuya__status": { + "boiling_temp": "temperatura wrzenia", + "cooling": "ch\u0142odzenie", + "heating": "grzanie", + "heating_temp": "temperatura ogrzewania", + "reserve_1": "rezerwa 1", + "reserve_2": "rezerwa 2", + "reserve_3": "rezerwa 3", + "standby": "tryb czuwania", + "warm": "utrzymywanie ciep\u0142a" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/sensor.sl.json b/homeassistant/components/tuya/translations/sensor.sl.json new file mode 100644 index 00000000000..68196055212 --- /dev/null +++ b/homeassistant/components/tuya/translations/sensor.sl.json @@ -0,0 +1,10 @@ +{ + "state": { + "tuya__status": { + "boiling_temp": "Temperatura vreli\u0161\u010da", + "cooling": "Hlajenje", + "heating": "Ogrevanje", + "heating_temp": "Temperatura ogrevanja" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/sensor.tr.json b/homeassistant/components/tuya/translations/sensor.tr.json new file mode 100644 index 00000000000..3a3088f51f5 --- /dev/null +++ b/homeassistant/components/tuya/translations/sensor.tr.json @@ -0,0 +1,15 @@ +{ + "state": { + "tuya__status": { + "boiling_temp": "Kaynama s\u0131cakl\u0131\u011f\u0131", + "cooling": "So\u011futma", + "heating": "Is\u0131tma", + "heating_temp": "Is\u0131tma s\u0131cakl\u0131\u011f\u0131", + "reserve_1": "Rezerv 1", + "reserve_2": "Rezerv 2", + "reserve_3": "Rezerv 3", + "standby": "Bekleme modu", + "warm": "Is\u0131 korumas\u0131" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/sl.json b/homeassistant/components/tuya/translations/sl.json index b07ad70adac..52d2fd3e973 100644 --- a/homeassistant/components/tuya/translations/sl.json +++ b/homeassistant/components/tuya/translations/sl.json @@ -1,4 +1,13 @@ { + "config": { + "step": { + "user": { + "data": { + "region": "Regija" + } + } + } + }, "options": { "abort": { "cannot_connect": "Povezovanje ni uspelo." diff --git a/homeassistant/components/tuya/translations/tr.json b/homeassistant/components/tuya/translations/tr.json index 37eae2e8ae0..1bcc2bc627e 100644 --- a/homeassistant/components/tuya/translations/tr.json +++ b/homeassistant/components/tuya/translations/tr.json @@ -6,29 +6,37 @@ "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, "error": { - "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "login_error": "Giri\u015f hatas\u0131 ( {code} ): {msg}" }, "flow_title": "Tuya yap\u0131land\u0131rmas\u0131", "step": { "login": { "data": { + "access_id": "Eri\u015fim Kimli\u011fi", + "access_secret": "Eri\u015fim Anahtar\u0131", "country_code": "\u00dclke Kodu", + "endpoint": "Kullan\u0131labilirlik Alan\u0131", "password": "\u015eifre", "tuya_app_type": "Tuya uygulama tipi", "username": "Kullan\u0131c\u0131 Ad\u0131" }, + "description": "Tuya kimlik bilgilerinizi girin", "title": "Tuya" }, "user": { "data": { - "country_code": "Hesap \u00fclke kodunuz (\u00f6r. ABD i\u00e7in 1 veya \u00c7in i\u00e7in 86)", + "access_id": "Tuya IoT Eri\u015fim Kimli\u011fi", + "access_secret": "Tuya IoT Eri\u015fim Anahtar\u0131", + "country_code": "\u00dclke", "password": "Parola", "platform": "Hesab\u0131n\u0131z\u0131n kay\u0131tl\u0131 oldu\u011fu uygulama", "region": "B\u00f6lge", - "username": "Kullan\u0131c\u0131 Ad\u0131" + "tuya_project_type": "Tuya bulut proje t\u00fcr\u00fc", + "username": "Hesap" }, "description": "Tuya kimlik bilgilerinizi girin.", - "title": "Tuya" + "title": "Tuya Entegrasyonu" } } }, @@ -37,6 +45,7 @@ "cannot_connect": "Ba\u011flanma hatas\u0131" }, "error": { + "dev_multi_type": "Yap\u0131land\u0131r\u0131lacak birden \u00e7ok se\u00e7ili cihaz ayn\u0131 t\u00fcrde olmal\u0131d\u0131r", "dev_not_config": "Cihaz t\u00fcr\u00fc yap\u0131land\u0131r\u0131lamaz", "dev_not_found": "Cihaz bulunamad\u0131" }, @@ -44,15 +53,19 @@ "device": { "data": { "brightness_range_mode": "Cihaz\u0131n kulland\u0131\u011f\u0131 parlakl\u0131k aral\u0131\u011f\u0131", + "curr_temp_divider": "Mevcut S\u0131cakl\u0131k de\u011feri b\u00f6l\u00fcc\u00fc (0 = varsay\u0131lan\u0131 kullan)", + "max_kelvin": "Kelvin'de desteklenen maksimum renk s\u0131cakl\u0131\u011f\u0131", "max_temp": "Maksimum hedef s\u0131cakl\u0131k (varsay\u0131lan olarak min ve maks = 0 kullan\u0131n)", "min_kelvin": "Kelvin destekli min renk s\u0131cakl\u0131\u011f\u0131", "min_temp": "Minimum hedef s\u0131cakl\u0131k (varsay\u0131lan i\u00e7in min ve maks = 0 kullan\u0131n)", + "set_temp_divided": "Ayarlanan s\u0131cakl\u0131k komutu i\u00e7in b\u00f6l\u00fcnm\u00fc\u015f S\u0131cakl\u0131k de\u011ferini kullan\u0131n", "support_color": "Vurgu rengi", "temp_divider": "S\u0131cakl\u0131k de\u011ferleri ay\u0131r\u0131c\u0131 (0 = varsay\u0131lan\u0131 kullan)", + "temp_step_override": "Hedef S\u0131cakl\u0131k ad\u0131m\u0131", "tuya_max_coltemp": "Cihaz taraf\u0131ndan bildirilen maksimum renk s\u0131cakl\u0131\u011f\u0131", "unit_of_measurement": "Cihaz\u0131n kulland\u0131\u011f\u0131 s\u0131cakl\u0131k birimi" }, - "description": "{device_type} ayg\u0131t\u0131 '{device_name}' i\u00e7in g\u00f6r\u00fcnt\u00fclenen bilgileri ayarlamak i\u00e7in se\u00e7enekleri yap\u0131land\u0131r\u0131n", + "description": "{device_type} cihaz\u0131 ` {device_name} device_name} ` i\u00e7in g\u00f6r\u00fcnt\u00fclenen bilgileri ayarlamak \u00fczere se\u00e7enekleri yap\u0131land\u0131r\u0131n", "title": "Tuya Cihaz\u0131n\u0131 Yap\u0131land\u0131r\u0131n" }, "init": { diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 25596da01b5..2afeb1880f7 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -13,12 +13,15 @@ from homeassistant.components.vacuum import ( STATE_RETURNING, SUPPORT_BATTERY, SUPPORT_FAN_SPEED, + SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_START, SUPPORT_STATE, SUPPORT_STATUS, SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, StateVacuumEntity, ) from homeassistant.config_entries import ConfigEntry @@ -93,9 +96,15 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): if DPCode.SWITCH_CHARGE in self.device.status: self._supported_features |= SUPPORT_RETURN_HOME + if DPCode.SEEK in self.device.status: + self._supported_features |= SUPPORT_LOCATE + if DPCode.STATUS in self.device.status: self._supported_features |= SUPPORT_STATE | SUPPORT_STATUS + if DPCode.POWER in self.device.status: + self._supported_features |= SUPPORT_TURN_ON | SUPPORT_TURN_OFF + if DPCode.POWER_GO in self.device.status: self._supported_features |= SUPPORT_STOP | SUPPORT_START @@ -131,7 +140,9 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): @property def state(self) -> str | None: """Return Tuya vacuum device state.""" - if self.device.status.get(DPCode.PAUSE): + if self.device.status.get(DPCode.PAUSE) and not ( + self.device.status.get(DPCode.STATUS) + ): return STATE_PAUSED if not (status := self.device.status.get(DPCode.STATUS)): return None @@ -142,24 +153,32 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): """Flag supported features.""" return self._supported_features - def start(self, **kwargs: Any) -> None: + def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" + self._send_command([{"code": DPCode.POWER, "value": True}]) + + def turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + self._send_command([{"code": DPCode.POWER, "value": False}]) + + def start(self, **kwargs: Any) -> None: + """Start the device.""" self._send_command([{"code": DPCode.POWER_GO, "value": True}]) def stop(self, **kwargs: Any) -> None: - """Turn the device off.""" + """Stop the device.""" self._send_command([{"code": DPCode.POWER_GO, "value": False}]) def pause(self, **kwargs: Any) -> None: """Pause the device.""" - self._send_command([{"code": DPCode.POWER_GO, "value": True}]) + self._send_command([{"code": DPCode.POWER_GO, "value": False}]) def return_to_base(self, **kwargs: Any) -> None: """Return device to dock.""" self._send_command([{"code": DPCode.MODE, "value": "chargego"}]) def locate(self, **kwargs: Any) -> None: - """Return device to dock.""" + """Locate the device.""" self._send_command([{"code": DPCode.SEEK, "value": True}]) def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: diff --git a/homeassistant/components/twentemilieu/__init__.py b/homeassistant/components/twentemilieu/__init__.py index 81e0a040333..60b7d07808b 100644 --- a/homeassistant/components/twentemilieu/__init__.py +++ b/homeassistant/components/twentemilieu/__init__.py @@ -1,10 +1,9 @@ """Support for Twente Milieu.""" from __future__ import annotations -import asyncio -from datetime import timedelta +from datetime import date, timedelta -from twentemilieu import TwenteMilieu +from twentemilieu import TwenteMilieu, WasteType import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -12,17 +11,9 @@ from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import ( - CONF_HOUSE_LETTER, - CONF_HOUSE_NUMBER, - CONF_POST_CODE, - DATA_UPDATE, - DOMAIN, -) +from .const import CONF_HOUSE_LETTER, CONF_HOUSE_NUMBER, CONF_POST_CODE, DOMAIN, LOGGER SCAN_INTERVAL = timedelta(seconds=3600) @@ -32,35 +23,6 @@ SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_ID): cv.string}) PLATFORMS = ["sensor"] -async def _update_twentemilieu(hass: HomeAssistant, unique_id: str | None) -> None: - """Update Twente Milieu.""" - if unique_id is not None: - twentemilieu = hass.data[DOMAIN].get(unique_id) - if twentemilieu is not None: - await twentemilieu.update() - async_dispatcher_send(hass, DATA_UPDATE, unique_id) - else: - await asyncio.wait( - [twentemilieu.update() for twentemilieu in hass.data[DOMAIN].values()] - ) - - for uid in hass.data[DOMAIN]: - async_dispatcher_send(hass, DATA_UPDATE, uid) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Twente Milieu components.""" - - async def update(call) -> None: - """Service call to manually update the data.""" - unique_id = call.data.get(CONF_ID) - await _update_twentemilieu(hass, unique_id) - - hass.services.async_register(DOMAIN, SERVICE_UPDATE, update, schema=SERVICE_SCHEMA) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Twente Milieu from a config entry.""" session = async_get_clientsession(hass) @@ -71,24 +33,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=session, ) - unique_id = entry.data[CONF_ID] - hass.data.setdefault(DOMAIN, {})[unique_id] = twentemilieu + coordinator: DataUpdateCoordinator[ + dict[WasteType, date | None] + ] = DataUpdateCoordinator( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + update_method=twentemilieu.update, + ) + await coordinator.async_config_entry_first_refresh() + # For backwards compat, set unique ID + if entry.unique_id is None: + hass.config_entries.async_update_entry( + entry, unique_id=str(entry.data[CONF_ID]) + ) + + hass.data.setdefault(DOMAIN, {})[entry.data[CONF_ID]] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) - async def _interval_update(now=None) -> None: - """Update Twente Milieu data.""" - await _update_twentemilieu(hass, unique_id) - - async_track_time_interval(hass, _interval_update, SCAN_INTERVAL) - return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Twente Milieu config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - del hass.data[DOMAIN][entry.data[CONF_ID]] - + if unload_ok: + del hass.data[DOMAIN][entry.data[CONF_ID]] return unload_ok diff --git a/homeassistant/components/twentemilieu/config_flow.py b/homeassistant/components/twentemilieu/config_flow.py index 870ce591788..7a00222fe9b 100644 --- a/homeassistant/components/twentemilieu/config_flow.py +++ b/homeassistant/components/twentemilieu/config_flow.py @@ -53,7 +53,7 @@ class TwenteMilieuFlowHandler(ConfigFlow, domain=DOMAIN): twentemilieu = TwenteMilieu( post_code=user_input[CONF_POST_CODE], house_number=user_input[CONF_HOUSE_NUMBER], - house_letter=user_input.get(CONF_HOUSE_LETTER), + house_letter=user_input.get(CONF_HOUSE_LETTER, ""), session=session, ) @@ -66,7 +66,8 @@ class TwenteMilieuFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_address" return await self._show_setup_form(errors) - self._async_abort_entries_match({CONF_ID: unique_id}) + await self.async_set_unique_id(str(unique_id)) + self._abort_if_unique_id_configured() return self.async_create_entry( title=str(unique_id), diff --git a/homeassistant/components/twentemilieu/const.py b/homeassistant/components/twentemilieu/const.py index 30f770efd25..95ab903cc17 100644 --- a/homeassistant/components/twentemilieu/const.py +++ b/homeassistant/components/twentemilieu/const.py @@ -1,8 +1,12 @@ """Constants for the Twente Milieu integration.""" +from datetime import timedelta +import logging +from typing import Final -DOMAIN = "twentemilieu" +DOMAIN: Final = "twentemilieu" -DATA_UPDATE = "twentemilieu_update" +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(hours=1) CONF_POST_CODE = "post_code" CONF_HOUSE_NUMBER = "house_number" diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index a56154cba71..2a9a7915e76 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -3,7 +3,8 @@ "name": "Twente Milieu", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/twentemilieu", - "requirements": ["twentemilieu==0.3.0"], + "requirements": ["twentemilieu==0.5.0"], "codeowners": ["@frenck"], + "quality_scale": "platinum", "iot_class": "cloud_polling" } diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index 89c750ec865..f25b84ace15 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -1,33 +1,81 @@ """Support for Twente Milieu sensors.""" from __future__ import annotations -from twentemilieu import ( - WASTE_TYPE_NON_RECYCLABLE, - WASTE_TYPE_ORGANIC, - WASTE_TYPE_PAPER, - WASTE_TYPE_PLASTIC, - TwenteMilieu, - TwenteMilieuConnectionError, -) +from dataclasses import dataclass +from datetime import date -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_NAME, - CONF_ID, - DEVICE_CLASS_DATE, +from twentemilieu import WasteType + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID +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 ( + CoordinatorEntity, + DataUpdateCoordinator, +) -from .const import DATA_UPDATE, DOMAIN +from .const import DOMAIN -PARALLEL_UPDATES = 1 + +@dataclass +class TwenteMilieuSensorDescriptionMixin: + """Define an entity description mixin.""" + + waste_type: WasteType + + +@dataclass +class TwenteMilieuSensorDescription( + SensorEntityDescription, TwenteMilieuSensorDescriptionMixin +): + """Describe an Ambient PWS binary sensor.""" + + +SENSORS: tuple[TwenteMilieuSensorDescription, ...] = ( + TwenteMilieuSensorDescription( + key="tree", + waste_type=WasteType.TREE, + name="Christmas Tree Pickup", + icon="mdi:pine-tree", + device_class=SensorDeviceClass.DATE, + ), + TwenteMilieuSensorDescription( + key="Non-recyclable", + waste_type=WasteType.NON_RECYCLABLE, + name="Non-recyclable Waste Pickup", + icon="mdi:delete-empty", + device_class=SensorDeviceClass.DATE, + ), + TwenteMilieuSensorDescription( + key="Organic", + waste_type=WasteType.ORGANIC, + name="Organic Waste Pickup", + icon="mdi:delete-empty", + device_class=SensorDeviceClass.DATE, + ), + TwenteMilieuSensorDescription( + key="Paper", + waste_type=WasteType.PAPER, + name="Paper Waste Pickup", + icon="mdi:delete-empty", + device_class=SensorDeviceClass.DATE, + ), + TwenteMilieuSensorDescription( + key="Plastic", + waste_type=WasteType.PACKAGES, + name="Packages Waste Pickup", + icon="mdi:delete-empty", + device_class=SensorDeviceClass.DATE, + ), +) async def async_setup_entry( @@ -36,125 +84,37 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Twente Milieu sensor based on a config entry.""" - twentemilieu = hass.data[DOMAIN][entry.data[CONF_ID]] - - try: - await twentemilieu.update() - except TwenteMilieuConnectionError as exception: - raise PlatformNotReady from exception - - sensors = [ - TwenteMilieuSensor( - twentemilieu, - unique_id=entry.data[CONF_ID], - name=f"{WASTE_TYPE_NON_RECYCLABLE} Waste Pickup", - waste_type=WASTE_TYPE_NON_RECYCLABLE, - icon="mdi:delete-empty", - ), - TwenteMilieuSensor( - twentemilieu, - unique_id=entry.data[CONF_ID], - name=f"{WASTE_TYPE_ORGANIC} Waste Pickup", - waste_type=WASTE_TYPE_ORGANIC, - icon="mdi:delete-empty", - ), - TwenteMilieuSensor( - twentemilieu, - unique_id=entry.data[CONF_ID], - name=f"{WASTE_TYPE_PAPER} Waste Pickup", - waste_type=WASTE_TYPE_PAPER, - icon="mdi:delete-empty", - ), - TwenteMilieuSensor( - twentemilieu, - unique_id=entry.data[CONF_ID], - name=f"{WASTE_TYPE_PLASTIC} Waste Pickup", - waste_type=WASTE_TYPE_PLASTIC, - icon="mdi:delete-empty", - ), - ] - - async_add_entities(sensors, True) + coordinator = hass.data[DOMAIN][entry.data[CONF_ID]] + async_add_entities( + TwenteMilieuSensor(coordinator, description, entry) for description in SENSORS + ) -class TwenteMilieuSensor(SensorEntity): +class TwenteMilieuSensor(CoordinatorEntity, SensorEntity): """Defines a Twente Milieu sensor.""" - _attr_device_class = DEVICE_CLASS_DATE + entity_description: TwenteMilieuSensorDescription + coordinator: DataUpdateCoordinator[dict[WasteType, date | None]] def __init__( self, - twentemilieu: TwenteMilieu, - unique_id: str, - name: str, - waste_type: str, - icon: str, + coordinator: DataUpdateCoordinator, + description: TwenteMilieuSensorDescription, + entry: ConfigEntry, ) -> None: """Initialize the Twente Milieu entity.""" - self._available = True - self._unique_id = unique_id - self._icon = icon - self._name = name - self._twentemilieu = twentemilieu - self._waste_type = waste_type - - self._state = None - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def icon(self) -> str: - """Return the mdi icon of the entity.""" - return self._icon - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return f"{DOMAIN}_{self._unique_id}_{self._waste_type}" - - @property - def should_poll(self) -> bool: - """Return the polling requirement of the entity.""" - return False - - async def async_added_to_hass(self) -> None: - """Connect to dispatcher listening for entity data notifications.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, DATA_UPDATE, self._schedule_immediate_update - ) + super().__init__(coordinator=coordinator) + self.entity_description = description + self._attr_unique_id = f"{DOMAIN}_{entry.data[CONF_ID]}_{description.key}" + self._attr_device_info = DeviceInfo( + configuration_url="https://www.twentemilieu.nl", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, str(entry.data[CONF_ID]))}, + manufacturer="Twente Milieu", + name="Twente Milieu", ) - @callback - def _schedule_immediate_update(self, unique_id: str) -> None: - """Schedule an immediate update of the entity.""" - if unique_id == self._unique_id: - self.async_schedule_update_ha_state(True) - @property - def native_value(self): + def native_value(self) -> date | None: """Return the state of the sensor.""" - return self._state - - async def async_update(self) -> None: - """Update Twente Milieu entity.""" - next_pickup = await self._twentemilieu.next_pickup(self._waste_type) - if next_pickup is not None: - self._state = next_pickup.date().isoformat() - - @property - def device_info(self) -> DeviceInfo: - """Return device information about Twente Milieu.""" - return { - ATTR_IDENTIFIERS: {(DOMAIN, self._unique_id)}, - ATTR_NAME: "Twente Milieu", - ATTR_MANUFACTURER: "Twente Milieu", - } + return self.coordinator.data.get(self.entity_description.waste_type) diff --git a/homeassistant/components/twentemilieu/services.yaml b/homeassistant/components/twentemilieu/services.yaml deleted file mode 100644 index 6227bad1b6d..00000000000 --- a/homeassistant/components/twentemilieu/services.yaml +++ /dev/null @@ -1,11 +0,0 @@ -update: - name: Update - description: Update all entities with fresh data from Twente Milieu - fields: - id: - name: ID - description: Specific unique address ID to update - advanced: true - example: 1300012345 - selector: - text: diff --git a/homeassistant/components/twentemilieu/translations/ja.json b/homeassistant/components/twentemilieu/translations/ja.json new file mode 100644 index 00000000000..8ec65b24fb5 --- /dev/null +++ b/homeassistant/components/twentemilieu/translations/ja.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_address": "Twente Milieu\u30b5\u30fc\u30d3\u30b9\u30a8\u30ea\u30a2\u306b\u4f4f\u6240\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3002" + }, + "step": { + "user": { + "data": { + "house_letter": "\u30cf\u30a6\u30b9\u30ec\u30bf\u30fc/\u8ffd\u52a0", + "house_number": "\u5bb6\u5c4b\u756a\u53f7", + "post_code": "\u90f5\u4fbf\u756a\u53f7" + }, + "description": "\u3042\u306a\u305f\u306e\u4f4f\u6240\u306eTwente Milieu providing waste collection(\u30b4\u30df\u53ce\u96c6\u60c5\u5831)\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7", + "title": "Twente Milieu" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/twentemilieu/translations/tr.json b/homeassistant/components/twentemilieu/translations/tr.json index 590aec1894c..363128d9c1e 100644 --- a/homeassistant/components/twentemilieu/translations/tr.json +++ b/homeassistant/components/twentemilieu/translations/tr.json @@ -4,7 +4,19 @@ "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_address": "Adres Twente Milieu hizmet b\u00f6lgesinde bulunamad\u0131." + }, + "step": { + "user": { + "data": { + "house_letter": "Ev mektubu/ek", + "house_number": "Ev numaras\u0131", + "post_code": "Posta kodu" + }, + "description": "Adresinizde at\u0131k toplama bilgileri sa\u011flayan Twente Milieu'yu kurun.", + "title": "Twente Milieu" + } } } } \ No newline at end of file diff --git a/homeassistant/components/twilio/translations/ja.json b/homeassistant/components/twilio/translations/ja.json new file mode 100644 index 00000000000..45930c49e7b --- /dev/null +++ b/homeassistant/components/twilio/translations/ja.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "webhook_not_internet_accessible": "Webhook\u30e1\u30c3\u30bb\u30fc\u30b8\u3092\u53d7\u4fe1\u3059\u308b\u306b\u306f\u3001Home Assistant\u306e\u30a4\u30f3\u30b9\u30bf\u30f3\u30b9\u306b\u3001\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u304b\u3089\u30a2\u30af\u30bb\u30b9\u3067\u304d\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "create_entry": { + "default": "Home Assistant\u306b\u30a4\u30d9\u30f3\u30c8\u3092\u9001\u4fe1\u3059\u308b\u306b\u306f\u3001[Webhooks with Twilio]({twilio_url})\u3092\u8a2d\u5b9a\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\n\n\u4ee5\u4e0b\u306e\u60c5\u5831\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044:\n\n- URL: `{webhook_url}`\n- Method(\u65b9\u5f0f): POST\n- Content Type: application/x-www-form-urlencoded\n\n\u53d7\u4fe1\u30c7\u30fc\u30bf\u3092\u51e6\u7406\u3059\u308b\u305f\u3081\u306b\u30aa\u30fc\u30c8\u30e1\u30fc\u30b7\u30e7\u30f3\u3092\u8a2d\u5b9a\u3059\u308b\u65b9\u6cd5\u306b\u3064\u3044\u3066\u306f\u3001[\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8]({docs_url})\u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "user": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f", + "title": "Twilio Webhook\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/translations/tr.json b/homeassistant/components/twilio/translations/tr.json index 84adcdf8225..ef684cbc92c 100644 --- a/homeassistant/components/twilio/translations/tr.json +++ b/homeassistant/components/twilio/translations/tr.json @@ -3,6 +3,15 @@ "abort": { "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr.", "webhook_not_internet_accessible": "Webhook mesajlar\u0131n\u0131 alabilmek i\u00e7in Home Assistant \u00f6rne\u011finize internetten eri\u015filebilmelidir." + }, + "create_entry": { + "default": "Etkinlikleri Home Assistant'a g\u00f6ndermek i\u00e7in [Twilio ile Webhook]( {twilio_url} ) kurman\u0131z gerekir. \n\n A\u015fa\u011f\u0131daki bilgileri doldurun: \n\n - URL: ` {webhook_url} `\n - Y\u00f6ntem: POST\n - \u0130\u00e7erik T\u00fcr\u00fc: application/x-www-form-urlencoded \n\n Gelen verileri i\u015flemek i\u00e7in otomasyonlar\u0131n nas\u0131l yap\u0131land\u0131r\u0131laca\u011f\u0131 hakk\u0131nda [belgelere]( {docs_url}" + }, + "step": { + "user": { + "description": "Kuruluma ba\u015flamak ister misiniz?", + "title": "Twilio Webhook'u kurun" + } } } } \ No newline at end of file diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index b0f94a1c52f..2c742ba93f5 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -108,12 +108,12 @@ class TwinklyLight(LightEntity): def device_info(self) -> DeviceInfo | None: """Get device specific attributes.""" return ( - { - "identifiers": {(DOMAIN, self._id)}, - "name": self.name, - "manufacturer": "LEDWORKS", - "model": self.model, - } + DeviceInfo( + identifiers={(DOMAIN, self._id)}, + manufacturer="LEDWORKS", + model=self.model, + name=self.name, + ) if self._id else None # device_info is available only for entities configured from the UI ) diff --git a/homeassistant/components/twinkly/translations/ja.json b/homeassistant/components/twinkly/translations/ja.json new file mode 100644 index 00000000000..fcc91e956d1 --- /dev/null +++ b/homeassistant/components/twinkly/translations/ja.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "device_exists": "\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": "Twinkly device\u306e\u30db\u30b9\u30c8(\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9)" + }, + "description": "Twinkly led string\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7", + "title": "Twinkly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/twitter/notify.py b/homeassistant/components/twitter/notify.py index 54aaf2142e5..fd97303a360 100644 --- a/homeassistant/components/twitter/notify.py +++ b/homeassistant/components/twitter/notify.py @@ -243,7 +243,12 @@ class TwitterNotificationService(BaseNotificationService): def log_error_resp(resp): """Log error response.""" obj = json.loads(resp.text) - error_message = obj["errors"] + if "errors" in obj: + error_message = obj["errors"] + elif "error" in obj: + error_message = obj["error"] + else: + error_message = resp.text _LOGGER.error("Error %s: %s", resp.status_code, error_message) @staticmethod diff --git a/homeassistant/components/ubus/device_tracker.py b/homeassistant/components/ubus/device_tracker.py index dc5cd8857f8..4ccba81d86c 100644 --- a/homeassistant/components/ubus/device_tracker.py +++ b/homeassistant/components/ubus/device_tracker.py @@ -125,9 +125,7 @@ class UbusDeviceScanner(DeviceScanner): results = 0 # for each access point for hostapd in self.hostapd: - result = self.ubus.get_hostapd_clients(hostapd) - - if result: + if result := self.ubus.get_hostapd_clients(hostapd): results = results + 1 # Check for each device is authorized (valid wpa key) for key in result["clients"].keys(): @@ -148,8 +146,7 @@ class DnsmasqUbusDeviceScanner(UbusDeviceScanner): def _generate_mac2name(self): if self.leasefile is None: - result = self.ubus.get_uci_config("dhcp", "dnsmasq") - if result: + if result := self.ubus.get_uci_config("dhcp", "dnsmasq"): values = result["values"].values() self.leasefile = next(iter(values))["leasefile"] else: @@ -170,8 +167,7 @@ class OdhcpdUbusDeviceScanner(UbusDeviceScanner): """Implement the Ubus device scanning for the odhcp DHCP server.""" def _generate_mac2name(self): - result = self.ubus.get_dhcp_method("ipv4leases") - if result: + if result := self.ubus.get_dhcp_method("ipv4leases"): self.mac2name = {} for device in result["device"].values(): for lease in device["leases"]: diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index b935a7d01da..180f1d6752f 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -1,4 +1,4 @@ -"""Integration to UniFi controllers and its various features.""" +"""Integration to UniFi Network and its various features.""" from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback from homeassistant.helpers import device_registry as dr @@ -20,7 +20,7 @@ STORAGE_VERSION = 1 async def async_setup(hass, config): - """Component doesn't support configuration through configuration.yaml.""" + """Integration doesn't support configuration through configuration.yaml.""" hass.data[UNIFI_WIRELESS_CLIENTS] = wireless_clients = UnifiWirelessClients(hass) await wireless_clients.async_load() @@ -28,7 +28,7 @@ async def async_setup(hass, config): async def async_setup_entry(hass, config_entry): - """Set up the UniFi component.""" + """Set up the UniFi Network integration.""" hass.data.setdefault(UNIFI_DOMAIN, {}) # Flat configuration was introduced with 2021.3 @@ -53,7 +53,7 @@ async def async_setup_entry(hass, config_entry): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, controller.shutdown) ) - LOGGER.debug("UniFi config options %s", config_entry.options) + LOGGER.debug("UniFi Network config options %s", config_entry.options) if controller.mac is None: return True @@ -64,8 +64,8 @@ async def async_setup_entry(hass, config_entry): configuration_url=controller.api.url, connections={(CONNECTION_NETWORK_MAC, controller.mac)}, default_manufacturer=ATTR_MANUFACTURER, - default_model="UniFi Controller", - default_name="UniFi Controller", + default_model="UniFi Network", + default_name="UniFi Network", ) return True @@ -106,9 +106,7 @@ class UnifiWirelessClients: async def async_load(self): """Load data from file.""" - data = await self._store.async_load() - - if data is not None: + if (data := await self._store.async_load()) is not None: self.data = data @callback diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index ea737599b20..4ab566eb5b4 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -1,8 +1,8 @@ -"""Config flow for UniFi. +"""Config flow for UniFi Network integration. Provides user initiated configuration flow. -Discovery of controllers hosted on UDM and UDM Pro devices through SSDP. -Reauthentication when issue with credentials are reported. +Discovery of UniFi Network instances hosted on UDM and UDM Pro devices +through SSDP. Reauthentication when issue with credentials are reported. Configuration of options through options flow. """ import socket @@ -20,6 +20,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac @@ -56,7 +57,7 @@ MODEL_PORTS = { class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): - """Handle a UniFi config flow.""" + """Handle a UniFi Network config flow.""" VERSION = 1 @@ -67,7 +68,7 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): return UnifiOptionsFlowHandler(config_entry) def __init__(self): - """Initialize the UniFi flow.""" + """Initialize the UniFi Network flow.""" self.config = {} self.site_ids = {} self.site_names = {} @@ -215,11 +216,11 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): return await self.async_step_user() - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered UniFi device.""" - parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) - model_description = discovery_info[ssdp.ATTR_UPNP_MODEL_DESCRIPTION] - mac_address = format_mac(discovery_info[ssdp.ATTR_UPNP_SERIAL]) + parsed_url = urlparse(discovery_info.ssdp_location) + model_description = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_DESCRIPTION] + mac_address = format_mac(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) self.config = { CONF_HOST: parsed_url.hostname, @@ -242,16 +243,16 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): class UnifiOptionsFlowHandler(config_entries.OptionsFlow): - """Handle Unifi options.""" + """Handle Unifi Network options.""" def __init__(self, config_entry): - """Initialize UniFi options flow.""" + """Initialize UniFi Network options flow.""" self.config_entry = config_entry self.options = dict(config_entry.options) self.controller = None async def async_step_init(self, user_input=None): - """Manage the UniFi options.""" + """Manage the UniFi Network options.""" self.controller = self.hass.data[UNIFI_DOMAIN][self.config_entry.entry_id] self.options[CONF_BLOCK_CLIENT] = self.controller.option_block_clients @@ -416,7 +417,7 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): async def async_discover_unifi(hass): - """Discover UniFi address.""" + """Discover UniFi Network address.""" try: return await hass.async_add_executor_job(socket.gethostbyname, "unifi") except socket.gaierror: diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index 94e2fad35ed..406b8d23a18 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -1,4 +1,4 @@ -"""Constants for the UniFi component.""" +"""Constants for the UniFi Network integration.""" import logging LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index b1ebbbe3475..26463563db2 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -1,4 +1,4 @@ -"""UniFi Controller abstraction.""" +"""UniFi Network abstraction.""" from __future__ import annotations import asyncio @@ -90,7 +90,7 @@ DEVICE_CONNECTED = ( class UniFiController: - """Manages a single UniFi Controller.""" + """Manages a single UniFi Network instance.""" def __init__(self, hass, config_entry): """Initialize the system.""" @@ -198,7 +198,7 @@ class UniFiController: if signal == SIGNAL_CONNECTION_STATE: if data == STATE_DISCONNECTED and self.available: - LOGGER.warning("Lost connection to UniFi controller") + LOGGER.warning("Lost connection to UniFi Network") if (data == STATE_RUNNING and not self.available) or ( data == STATE_DISCONNECTED and self.available @@ -209,7 +209,7 @@ class UniFiController: if not self.available: self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) else: - LOGGER.info("Connected to UniFi controller") + LOGGER.info("Connected to UniFi Network") elif signal == SIGNAL_DATA and data: @@ -301,7 +301,7 @@ class UniFiController: unifi_wireless_clients.update_data(self.wireless_clients, self.config_entry) async def async_setup(self): - """Set up a UniFi controller.""" + """Set up a UniFi Network instance.""" try: self.api = await get_controller( self.hass, @@ -413,13 +413,13 @@ class UniFiController: def reconnect(self, log=False) -> None: """Prepare to reconnect UniFi session.""" if log: - LOGGER.info("Will try to reconnect to UniFi controller") + LOGGER.info("Will try to reconnect to UniFi Network") self.hass.loop.create_task(self.async_reconnect()) async def async_reconnect(self) -> None: - """Try to reconnect UniFi session.""" + """Try to reconnect UniFi Network session.""" try: - with async_timeout.timeout(5): + async with async_timeout.timeout(5): await self.api.login() self.api.start_websocket() @@ -488,13 +488,17 @@ async def get_controller( ) try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): await controller.check_unifi_os() await controller.login() return controller except aiounifi.Unauthorized as err: - LOGGER.warning("Connected to UniFi at %s but not registered: %s", host, err) + LOGGER.warning( + "Connected to UniFi Network at %s but not registered: %s", + host, + err, + ) raise AuthenticationRequired from err except ( @@ -503,13 +507,17 @@ async def get_controller( aiounifi.ServiceUnavailable, aiounifi.RequestError, ) as err: - LOGGER.error("Error connecting to the UniFi controller at %s: %s", host, err) + LOGGER.error("Error connecting to the UniFi Network at %s: %s", host, err) raise CannotConnect from err except aiounifi.LoginRequired as err: - LOGGER.warning("Connected to UniFi at %s but login required: %s", host, err) + LOGGER.warning( + "Connected to UniFi Network at %s but login required: %s", + host, + err, + ) raise AuthenticationRequired from err except aiounifi.AiounifiException as err: - LOGGER.exception("Unknown UniFi communication error occurred: %s", err) + LOGGER.exception("Unknown UniFi Network communication error occurred: %s", err) raise AuthenticationRequired from err diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index ecb549e89ef..035d8b0ae87 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -1,4 +1,4 @@ -"""Track both clients and devices using UniFi controllers.""" +"""Track both clients and devices using UniFi Network.""" from datetime import timedelta from aiounifi.api import SOURCE_DATA, SOURCE_EVENT @@ -18,10 +18,12 @@ from aiounifi.events import ( from homeassistant.components.device_tracker import DOMAIN from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.components.device_tracker.const import SOURCE_TYPE_ROUTER +from homeassistant.const import ATTR_NAME from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo import homeassistant.util.dt as dt_util from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN @@ -70,7 +72,7 @@ WIRELESS_CONNECTION = ( async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up device tracker for UniFi component.""" + """Set up device tracker for UniFi Network integration.""" controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] controller.entities[DOMAIN] = {CLIENT_TRACKER: set(), DEVICE_TRACKER: set()} @@ -405,17 +407,17 @@ class UniFiDeviceTracker(UniFiBase, ScannerEntity): return not self.device.disabled and self.controller.available @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" - info = { - "connections": {(CONNECTION_NETWORK_MAC, self.device.mac)}, - "manufacturer": ATTR_MANUFACTURER, - "model": self.device.model, - "sw_version": self.device.version, - } + info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self.device.mac)}, + manufacturer=ATTR_MANUFACTURER, + model=self.device.model, + sw_version=self.device.version, + ) if self.device.name: - info["name"] = self.device.name + info[ATTR_NAME] = self.device.name return info diff --git a/homeassistant/components/unifi/errors.py b/homeassistant/components/unifi/errors.py index c90c4956312..c3b2bb23d8e 100644 --- a/homeassistant/components/unifi/errors.py +++ b/homeassistant/components/unifi/errors.py @@ -1,9 +1,9 @@ -"""Errors for the UniFi component.""" +"""Errors for the UniFi Network integration.""" from homeassistant.exceptions import HomeAssistantError class UnifiException(HomeAssistantError): - """Base class for UniFi exceptions.""" + """Base class for UniFi Network exceptions.""" class AlreadyConfigured(UnifiException): @@ -19,7 +19,7 @@ class CannotConnect(UnifiException): class LoginRequired(UnifiException): - """Component got logged out.""" + """Integration got logged out.""" class UserLevel(UnifiException): diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index ae8ebd767af..7dbd86c928a 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -1,6 +1,6 @@ { "domain": "unifi", - "name": "Ubiquiti UniFi", + "name": "UniFi Network", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifi", "requirements": [ @@ -23,4 +23,4 @@ } ], "iot_class": "local_push" -} \ No newline at end of file +} diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index a9e89876459..be5b2d03e1c 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -1,4 +1,4 @@ -"""Sensor platform for UniFi integration. +"""Sensor platform for UniFi Network integration. Support for bandwidth sensors of network clients. Support for uptime sensors of network clients. @@ -21,7 +21,7 @@ UPTIME_SENSOR = "uptime" async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up sensors for UniFi integration.""" + """Set up sensors for UniFi Network integration.""" controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] controller.entities[DOMAIN] = { RX_SENSOR: set(), @@ -82,7 +82,7 @@ def add_uptime_entities(controller, async_add_entities, clients): class UniFiBandwidthSensor(UniFiClient, SensorEntity): - """UniFi bandwidth sensor base class.""" + """UniFi Network bandwidth sensor base class.""" DOMAIN = DOMAIN @@ -127,7 +127,7 @@ class UniFiTxBandwidthSensor(UniFiBandwidthSensor): class UniFiUpTimeSensor(UniFiClient, SensorEntity): - """UniFi uptime sensor.""" + """UniFi Network client uptime sensor.""" DOMAIN = DOMAIN TYPE = UPTIME_SENSOR @@ -172,8 +172,8 @@ class UniFiUpTimeSensor(UniFiClient, SensorEntity): def native_value(self) -> datetime: """Return the uptime of the client.""" if self.client.uptime < 1000000000: - return (dt_util.now() - timedelta(seconds=self.client.uptime)).isoformat() - return dt_util.utc_from_timestamp(float(self.client.uptime)).isoformat() + return dt_util.now() - timedelta(seconds=self.client.uptime) + return dt_util.utc_from_timestamp(float(self.client.uptime)) async def options_updated(self) -> None: """Config entry options are updated, remove entity if option is disabled.""" diff --git a/homeassistant/components/unifi/services.py b/homeassistant/components/unifi/services.py index ca15cc83194..edc982d73dd 100644 --- a/homeassistant/components/unifi/services.py +++ b/homeassistant/components/unifi/services.py @@ -1,4 +1,4 @@ -"""UniFi services.""" +"""UniFi Network services.""" import voluptuous as vol @@ -47,7 +47,7 @@ def async_setup_services(hass) -> None: @callback def async_unload_services(hass) -> None: - """Unload UniFi services.""" + """Unload UniFi Network services.""" for service in SUPPORTED_SERVICES: hass.services.async_remove(UNIFI_DOMAIN, service) diff --git a/homeassistant/components/unifi/services.yaml b/homeassistant/components/unifi/services.yaml index 7f06adc88a2..c6a4de3072a 100644 --- a/homeassistant/components/unifi/services.yaml +++ b/homeassistant/components/unifi/services.yaml @@ -1,6 +1,6 @@ reconnect_client: name: Reconnect wireless client - description: Try to get wireless client to reconnect to UniFi network + description: Try to get wireless client to reconnect to UniFi Network fields: device_id: name: Device @@ -11,5 +11,5 @@ reconnect_client: integration: unifi remove_clients: - name: Remove clients from the UniFi Controller + name: Remove clients from the UniFi Network description: Clean up clients that has only been associated with the controller for a short period of time. diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index d625ff79117..476a0bfdd61 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -3,7 +3,7 @@ "flow_title": "{site} ({host})", "step": { "user": { - "title": "Set up UniFi Controller", + "title": "Set up UniFi Network", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", @@ -20,7 +20,7 @@ "unknown_client_mac": "No client available on that MAC address" }, "abort": { - "already_configured": "Controller site is already configured", + "already_configured": "UniFi Network site is already configured", "configuration_updated": "Configuration updated.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } @@ -30,14 +30,14 @@ "device_tracker": { "data": { "detection_time": "Time in seconds from last seen until considered away", - "ignore_wired_bug": "Disable UniFi wired bug logic", + "ignore_wired_bug": "Disable UniFi Network wired bug logic", "ssid_filter": "Select SSIDs to track wireless clients on", "track_clients": "Track network clients", "track_devices": "Track network devices (Ubiquiti devices)", "track_wired_clients": "Include wired network clients" }, "description": "Configure device tracking", - "title": "UniFi options 1/3" + "title": "UniFi Network options 1/3" }, "client_control": { "data": { @@ -46,7 +46,7 @@ "dpi_restrictions": "Allow control of DPI restriction groups" }, "description": "Configure client controls\n\nCreate switches for serial numbers you want to control network access for.", - "title": "UniFi options 2/3" + "title": "UniFi Network options 2/3" }, "simple_options": { "data": { @@ -54,7 +54,7 @@ "track_devices": "[%key:component::unifi::options::step::device_tracker::data::track_devices%]", "block_client": "[%key:component::unifi::options::step::client_control::data::block_client%]" }, - "description": "Configure UniFi integration" + "description": "Configure UniFi Network integration" }, "statistics_sensors": { "data": { @@ -62,7 +62,7 @@ "allow_uptime_sensors": "Uptime sensors for network clients" }, "description": "Configure statistics sensors", - "title": "UniFi options 3/3" + "title": "UniFi Network options 3/3" } } } diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 03cd9056830..075718b4da5 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -1,4 +1,4 @@ -"""Switch platform for UniFi integration. +"""Switch platform for UniFi Network integration. Support for controlling power supply of clients which are powered over Ethernet (POE). Support for controlling network access of clients selected in option flow. @@ -17,6 +17,7 @@ from aiounifi.events import ( from homeassistant.components.switch import DOMAIN, SwitchEntity from homeassistant.const import ENTITY_CATEGORY_CONFIG from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_registry import async_entries_for_config_entry @@ -35,7 +36,7 @@ CLIENT_UNBLOCKED = (WIRED_CLIENT_UNBLOCKED, WIRELESS_CLIENT_UNBLOCKED) async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up switches for UniFi component. + """Set up switches for UniFi Network integration. Switches are controlling network access and switch ports with POE. """ @@ -369,10 +370,10 @@ class UniFiDPIRestrictionSwitch(UniFiBase, SwitchEntity): @property def device_info(self) -> DeviceInfo: """Return a service description for device registry.""" - return { - "identifiers": {(DOMAIN, f"unifi_controller_{self._item.site_id}")}, - "name": "UniFi Controller", - "manufacturer": ATTR_MANUFACTURER, - "model": "UniFi Controller", - "entry_type": "service", - } + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, f"unifi_controller_{self._item.site_id}")}, + manufacturer=ATTR_MANUFACTURER, + model="UniFi Network", + name="UniFi Network", + ) diff --git a/homeassistant/components/unifi/translations/ca.json b/homeassistant/components/unifi/translations/ca.json index 1c6e95bd962..4bb01d82ef6 100644 --- a/homeassistant/components/unifi/translations/ca.json +++ b/homeassistant/components/unifi/translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "El lloc del controlador ja est\u00e0 configurat", + "already_configured": "El lloc web d'UniFi Network ja est\u00e0 configurat", "configuration_updated": "S'ha actualitzat la configuraci\u00f3.", "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, @@ -21,7 +21,7 @@ "username": "[%key::common::config_flow::data::username%]", "verify_ssl": "Verifica el certificat SSL" }, - "title": "Configuraci\u00f3 del controlador UniFi" + "title": "Configuraci\u00f3 d'UniFi Network" } } }, @@ -34,19 +34,19 @@ "poe_clients": "Permet control POE dels clients" }, "description": "Configura els controls del client \n\nConfigura interruptors per als n\u00fameros de s\u00e8rie als quals vulguis controlar l'acc\u00e9s a la xarxa.", - "title": "Opcions d'UniFi 2/3" + "title": "Opcions d'UniFi Network 2/3" }, "device_tracker": { "data": { "detection_time": "Temps (en segons) des de s'ha vist per \u00faltima vegada fins que es considera a fora", - "ignore_wired_bug": "Desactiva la l\u00f2gica d'errors amb UniFi", + "ignore_wired_bug": "Desactiva la l\u00f2gica d'errors d'UniFi Network", "ssid_filter": "Selecciona els SSID's on fer-hi el seguiment de clients", "track_clients": "Segueix clients de la xarxa", "track_devices": "Segueix dispositius de la xarxa (dispositius Ubiquiti)", "track_wired_clients": "Inclou clients de xarxa per cable" }, "description": "Configuraci\u00f3 de seguiment de dispositius", - "title": "Opcions d'UniFi 1/3" + "title": "Opcions d'UniFi Network 1/3" }, "simple_options": { "data": { @@ -54,7 +54,7 @@ "track_clients": "[%key::component::unifi::options::step::device_tracker::data::track_clients%]", "track_devices": "[%key::component::unifi::options::step::device_tracker::data::track_devices%]" }, - "description": "Configura la integraci\u00f3 d'UniFi" + "description": "Configura la integraci\u00f3 UniFi Network" }, "statistics_sensors": { "data": { @@ -62,7 +62,7 @@ "allow_uptime_sensors": "Sensors de temps d'activitat per a clients de xarxa" }, "description": "Configuraci\u00f3 dels sensors d'estad\u00edstiques", - "title": "Opcions d'UniFi 3/3" + "title": "Opcions d'UniFi Network 3/3" } } } diff --git a/homeassistant/components/unifi/translations/de.json b/homeassistant/components/unifi/translations/de.json index ab7f9bb9b16..ce4047ced42 100644 --- a/homeassistant/components/unifi/translations/de.json +++ b/homeassistant/components/unifi/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Controller-Site ist bereits konfiguriert", + "already_configured": "UniFi-Netzwerkstandort ist bereits konfiguriert", "configuration_updated": "Konfiguration aktualisiert.", "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, @@ -21,7 +21,7 @@ "username": "Benutzername", "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" }, - "title": "UniFi-Controller einrichten" + "title": "UniFi-Netzwerk einrichten" } } }, @@ -34,25 +34,19 @@ "poe_clients": "POE-Kontrolle von Clients zulassen" }, "description": "Konfiguriere Client-Steuerelemente \n\nErstelle Switches f\u00fcr Seriennummern, f\u00fcr die du den Netzwerkzugriff steuern m\u00f6chtest.", - "title": "UniFi-Optionen 2/3" + "title": "UniFi Netzwerk Optionen 2/3" }, "device_tracker": { "data": { "detection_time": "Zeit in Sekunden vom letzten Gesehenen bis zur Entfernung", - "ignore_wired_bug": "Deaktivieren der kabelgebundenen UniFi-Fehlerlogik", + "ignore_wired_bug": "Deaktiviere die kabelgebundene Fehlerlogik des UniFi-Netzwerks", "ssid_filter": "W\u00e4hle SSIDs zur Verfolgung von drahtlosen Clients aus", "track_clients": "Nachverfolgen von Netzwerkclients", "track_devices": "Verfolgen von Netzwerkger\u00e4ten (Ubiquiti-Ger\u00e4te)", "track_wired_clients": "Einbinden von kabelgebundenen Netzwerk-Clients" }, "description": "Konfiguriere die Ger\u00e4teverfolgung", - "title": "UniFi-Optionen 1/3" - }, - "init": { - "data": { - "one": "eins", - "other": "andere" - } + "title": "UniFi Netzwerk Optionen 1/3" }, "simple_options": { "data": { @@ -60,7 +54,7 @@ "track_clients": "Nachverfolgen von Netzwerkclients", "track_devices": "Verfolgen von Netzwerkger\u00e4ten (Ubiquiti-Ger\u00e4te)" }, - "description": "Konfiguriere die UniFi-Integration" + "description": "Konfigurieren der UniFi-Netzwerkintegration" }, "statistics_sensors": { "data": { @@ -68,7 +62,7 @@ "allow_uptime_sensors": "Uptime-Sensoren f\u00fcr Netzwerk-Clients" }, "description": "Konfiguriere die Statistiksensoren", - "title": "UniFi-Optionen 3/3" + "title": "UniFi Netzwerk Optionen 3/3" } } } diff --git a/homeassistant/components/unifi/translations/en.json b/homeassistant/components/unifi/translations/en.json index 508e908b14a..5d08c8b816d 100644 --- a/homeassistant/components/unifi/translations/en.json +++ b/homeassistant/components/unifi/translations/en.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Controller site is already configured", + "already_configured": "UniFi Network site is already configured", "configuration_updated": "Configuration updated.", "reauth_successful": "Re-authentication was successful" }, @@ -21,7 +21,7 @@ "username": "Username", "verify_ssl": "Verify SSL certificate" }, - "title": "Set up UniFi Controller" + "title": "Set up UniFi Network" } } }, @@ -34,19 +34,19 @@ "poe_clients": "Allow POE control of clients" }, "description": "Configure client controls\n\nCreate switches for serial numbers you want to control network access for.", - "title": "UniFi options 2/3" + "title": "UniFi Network options 2/3" }, "device_tracker": { "data": { "detection_time": "Time in seconds from last seen until considered away", - "ignore_wired_bug": "Disable UniFi wired bug logic", + "ignore_wired_bug": "Disable UniFi Network wired bug logic", "ssid_filter": "Select SSIDs to track wireless clients on", "track_clients": "Track network clients", "track_devices": "Track network devices (Ubiquiti devices)", "track_wired_clients": "Include wired network clients" }, "description": "Configure device tracking", - "title": "UniFi options 1/3" + "title": "UniFi Network options 1/3" }, "simple_options": { "data": { @@ -54,7 +54,7 @@ "track_clients": "Track network clients", "track_devices": "Track network devices (Ubiquiti devices)" }, - "description": "Configure UniFi integration" + "description": "Configure UniFi Network integration" }, "statistics_sensors": { "data": { @@ -62,7 +62,7 @@ "allow_uptime_sensors": "Uptime sensors for network clients" }, "description": "Configure statistics sensors", - "title": "UniFi options 3/3" + "title": "UniFi Network options 3/3" } } } diff --git a/homeassistant/components/unifi/translations/et.json b/homeassistant/components/unifi/translations/et.json index 443d036bd96..76d93c95d97 100644 --- a/homeassistant/components/unifi/translations/et.json +++ b/homeassistant/components/unifi/translations/et.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Kontroller on juba seadistatud", + "already_configured": "UniFi Network on juba seadistatud", "configuration_updated": "Seaded on v\u00e4rskendatud.", "reauth_successful": "Taastuvastamine \u00f5nnestus" }, @@ -21,7 +21,7 @@ "username": "Kasutajanimi", "verify_ssl": "Kontrolli SSL sertifikaati" }, - "title": "Seadista UniFi kontroller" + "title": "Seadista UniFi Network" } } }, @@ -34,19 +34,19 @@ "poe_clients": "Luba klientide POE kontroll" }, "description": "Seadista kliendi juhtelemendid \n\n Loo seerianumbrite. mille v\u00f5rgule juurdep\u00e4\u00e4su soovite kontrollida, jaoks l\u00fclitid.", - "title": "UniFi valikud 2/3" + "title": "UniFi Network valikud 2/3" }, "device_tracker": { "data": { "detection_time": "Aeg sekundites alates viimasest ilmnemisest kuni olekuni '\u00e4ra'", - "ignore_wired_bug": "UniFi kaabel\u00fchenduse vealoogika keelamine", + "ignore_wired_bug": "UniFi Network kaabel\u00fchenduse vealoogika keelamine", "ssid_filter": "Vali WiFi klientide j\u00e4lgimiseks SSID", "track_clients": "J\u00e4lgi v\u00f5rgu kliente", "track_devices": "V\u00f5rguseadmete j\u00e4lgimine (Ubiquiti seadmed)", "track_wired_clients": "Kaasa juhtmega v\u00f5rgukliendid" }, "description": "Seadista seadme j\u00e4lgimine", - "title": "UniFi valikud 1/3" + "title": "UniFi Network valikud 1/3" }, "simple_options": { "data": { @@ -54,7 +54,7 @@ "track_clients": "J\u00e4lgi v\u00f5rgu kliente", "track_devices": "J\u00e4lgi v\u00f5rgu seadmeid (Ubiquiti seadmed)" }, - "description": "Seadista UniFi sidumine" + "description": "Seadista UniFi Network sidumine" }, "statistics_sensors": { "data": { @@ -62,7 +62,7 @@ "allow_uptime_sensors": "V\u00f5rguklientide t\u00f6\u00f6soleku andurid" }, "description": "Seadista statistikaandurid", - "title": "UniFi valikud 3/3" + "title": "UniFi Network valikud 3/3" } } } diff --git a/homeassistant/components/unifi/translations/id.json b/homeassistant/components/unifi/translations/id.json index ec023fa7363..4c78c96155c 100644 --- a/homeassistant/components/unifi/translations/id.json +++ b/homeassistant/components/unifi/translations/id.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Situs Controller sudah dikonfigurasi", + "already_configured": "Situs Jaringan UniFi sudah dikonfigurasi", "configuration_updated": "Konfigurasi diperbarui.", "reauth_successful": "Autentikasi ulang berhasil" }, @@ -21,7 +21,7 @@ "username": "Nama Pengguna", "verify_ssl": "Verifikasi sertifikat SSL" }, - "title": "Siapkan UniFi Controller" + "title": "Siapkan UniFi Network" } } }, @@ -34,19 +34,19 @@ "poe_clients": "Izinkan kontrol POE klien" }, "description": "Konfigurasikan kontrol klien \n\nBuat sakelar untuk nomor seri yang ingin dikontrol akses jaringannya.", - "title": "Opsi UniFi 2/3" + "title": "Opsi UniFi Network 2/3" }, "device_tracker": { "data": { "detection_time": "Tenggang waktu dalam detik dari terakhir terlihat hingga dianggap sebagai keluar", - "ignore_wired_bug": "Nonaktifkan bug logika kabel UniFi", + "ignore_wired_bug": "Nonaktifkan bug logika kabel UniFi Network", "ssid_filter": "Pilih SSID untuk melacak klien nirkabel", "track_clients": "Lacak klien jaringan", "track_devices": "Lacak perangkat jaringan (perangkat Ubiquiti)", "track_wired_clients": "Sertakan klien jaringan berkabel" }, "description": "Konfigurasikan pelacakan perangkat", - "title": "Opsi UniFi 1/3" + "title": "Opsi UniFi Network 1/3" }, "simple_options": { "data": { @@ -54,7 +54,7 @@ "track_clients": "Lacak klien jaringan", "track_devices": "Lacak perangkat jaringan (perangkat Ubiquiti)" }, - "description": "Konfigurasikan integrasi UniFi" + "description": "Konfigurasikan integrasi UniFi Network" }, "statistics_sensors": { "data": { @@ -62,7 +62,7 @@ "allow_uptime_sensors": "Sensor waktu kerja untuk klien jaringan" }, "description": "Konfigurasikan sensor statistik", - "title": "Opsi UniFi 3/3" + "title": "Opsi UniFi Network 3/3" } } } diff --git a/homeassistant/components/unifi/translations/ja.json b/homeassistant/components/unifi/translations/ja.json new file mode 100644 index 00000000000..1f77f15d519 --- /dev/null +++ b/homeassistant/components/unifi/translations/ja.json @@ -0,0 +1,69 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u30fc\u30b5\u30a4\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "configuration_updated": "\u8a2d\u5b9a\u304c\u66f4\u65b0\u3055\u308c\u307e\u3057\u305f\u3002", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "faulty_credentials": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "service_unavailable": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown_client_mac": "\u305d\u306eMAC\u30a2\u30c9\u30ec\u30b9\u3067\u4f7f\u7528\u53ef\u80fd\u306a\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u304c\u3042\u308a\u307e\u305b\u3093" + }, + "flow_title": "{site} ({host})", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "site": "\u30b5\u30a4\u30c8ID", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + }, + "title": "UniFi\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u30fc\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "client_control": { + "data": { + "block_client": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30a2\u30af\u30bb\u30b9\u5236\u5fa1\u30af\u30e9\u30a4\u30a2\u30f3\u30c8", + "dpi_restrictions": "DPI\u5236\u9650\u30b0\u30eb\u30fc\u30d7\u306e\u5236\u5fa1\u3092\u8a31\u53ef\u3059\u308b", + "poe_clients": "\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u306ePOE\u5236\u5fa1\u3092\u8a31\u53ef\u3059\u308b" + }, + "description": "\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u306e\u8a2d\u5b9a\n\n\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u3078\u306e\u30a2\u30af\u30bb\u30b9\u3092\u5236\u5fa1\u3057\u305f\u3044\u30b7\u30ea\u30a2\u30eb\u30ca\u30f3\u30d0\u30fc\u306e\u30b9\u30a4\u30c3\u30c1\u3092\u4f5c\u6210\u3057\u307e\u3059\u3002", + "title": "UniFi\u30aa\u30d7\u30b7\u30e7\u30f32/3" + }, + "device_tracker": { + "data": { + "detection_time": "\u6700\u5f8c\u306b\u898b\u305f\u3082\u306e\u304b\u3089\u96e2\u308c\u3066\u3044\u308b\u3068\u898b\u306a\u3055\u308c\u308b\u307e\u3067\u306e\u6642\u9593(\u79d2)", + "ignore_wired_bug": "UniFi\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u306e\u6709\u7dda\u30d0\u30b0 \u30ed\u30b8\u30c3\u30af\u3092\u7121\u52b9\u306b\u3059\u308b", + "ssid_filter": "\u30ef\u30a4\u30e4\u30ec\u30b9\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u3092\u8ffd\u8de1\u3059\u308bSSID\u3092\u9078\u629e", + "track_clients": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u3092\u8ffd\u8de1\u3059\u308b", + "track_devices": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30c7\u30d0\u30a4\u30b9\u306e\u8ffd\u8de1(\u30e6\u30d3\u30ad\u30c6\u30a3\u30c7\u30d0\u30a4\u30b9)", + "track_wired_clients": "\u6709\u7dda\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u306e\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u3092\u542b\u3081\u308b" + }, + "description": "\u30c7\u30d0\u30a4\u30b9\u30c8\u30e9\u30c3\u30ad\u30f3\u30b0\u306e\u8a2d\u5b9a", + "title": "UniFi\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30aa\u30d7\u30b7\u30e7\u30f3 1/3" + }, + "simple_options": { + "data": { + "block_client": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30a2\u30af\u30bb\u30b9\u5236\u5fa1\u30af\u30e9\u30a4\u30a2\u30f3\u30c8", + "track_clients": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u3092\u8ffd\u8de1\u3059\u308b", + "track_devices": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30c7\u30d0\u30a4\u30b9\u306e\u8ffd\u8de1(\u30e6\u30d3\u30ad\u30c6\u30a3\u30c7\u30d0\u30a4\u30b9)" + }, + "description": "UniFi\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u8a2d\u5b9a" + }, + "statistics_sensors": { + "data": { + "allow_bandwidth_sensors": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u7528\u306e\u5e2f\u57df\u5e45\u4f7f\u7528\u30bb\u30f3\u30b5\u30fc", + "allow_uptime_sensors": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u306e\u30a2\u30c3\u30d7\u30bf\u30a4\u30e0\u30bb\u30f3\u30b5\u30fc" + }, + "description": "\u7d71\u8a08\u30bb\u30f3\u30b5\u30fc\u306e\u8a2d\u5b9a", + "title": "UniFi\u30aa\u30d7\u30b7\u30e7\u30f33/3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/translations/nl.json b/homeassistant/components/unifi/translations/nl.json index 8be73aad793..5cadc27117c 100644 --- a/homeassistant/components/unifi/translations/nl.json +++ b/homeassistant/components/unifi/translations/nl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Controller site is al geconfigureerd", + "already_configured": "Unifi Network site is al geconfigureerd", "configuration_updated": "Configuratie bijgewerkt.", "reauth_successful": "Herauthenticatie was succesvol" }, @@ -21,7 +21,7 @@ "username": "Gebruikersnaam", "verify_ssl": "SSL-certificaat verifi\u00ebren" }, - "title": "Stel de UniFi-controller in" + "title": "Stel de UniFi Network controller in" } } }, @@ -34,7 +34,7 @@ "poe_clients": "Sta POE-controle van gebruikers toe" }, "description": "Configureer clientbesturingen \n\n Maak schakelaars voor serienummers waarvoor u de netwerktoegang wilt beheren.", - "title": "UniFi-opties 2/3" + "title": "UniFi Network opties 2/3" }, "device_tracker": { "data": { @@ -46,7 +46,7 @@ "track_wired_clients": "Inclusief bedrade netwerkcli\u00ebnten" }, "description": "Apparaattracking configureren", - "title": "UniFi-opties 1/3" + "title": "UniFi Network opties 1/3" }, "init": { "data": { @@ -60,7 +60,7 @@ "track_clients": "Volg netwerkclients", "track_devices": "Volg netwerkapparaten (Ubiquiti-apparaten)" }, - "description": "Configureer UniFi-integratie" + "description": "Configureer UniFi network integratie" }, "statistics_sensors": { "data": { @@ -68,7 +68,7 @@ "allow_uptime_sensors": "Uptime-sensoren voor netwerkclients" }, "description": "Configureer statistische sensoren", - "title": "UniFi-opties 3/3" + "title": "UniFi Network opties 3/3" } } } diff --git a/homeassistant/components/unifi/translations/no.json b/homeassistant/components/unifi/translations/no.json index b1ecb706345..ec43a8f295b 100644 --- a/homeassistant/components/unifi/translations/no.json +++ b/homeassistant/components/unifi/translations/no.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Kontroller nettstedet er allerede konfigurert", + "already_configured": "UniFi Network-nettstedet er allerede konfigurert", "configuration_updated": "Konfigurasjonen er oppdatert.", "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, @@ -21,7 +21,7 @@ "username": "Brukernavn", "verify_ssl": "Verifisere SSL-sertifikat" }, - "title": "Sett opp UniFi kontroller" + "title": "Sett opp UniFi Network" } } }, @@ -34,19 +34,19 @@ "poe_clients": "Tillat POE-kontroll av klienter" }, "description": "Konfigurere klient-kontroller\n\nOpprette brytere for serienumre du \u00f8nsker \u00e5 kontrollere tilgang til nettverk for.", - "title": "UniFi-alternativ 2/3" + "title": "UniFi Network alternativer 2/3" }, "device_tracker": { "data": { "detection_time": "Tid i sekunder fra sist sett til den ble ansett borte", - "ignore_wired_bug": "Deaktiver UniFi kablet feillogikk", + "ignore_wired_bug": "Deaktiver UniFi Network kablet feillogikk", "ssid_filter": "Velg SSID-er for \u00e5 spore tr\u00e5dl\u00f8se klienter p\u00e5", "track_clients": "Spor nettverksklienter", "track_devices": "Spore nettverksenheter (Ubiquiti-enheter)", "track_wired_clients": "Inkluder kablede nettverksklienter" }, "description": "Konfigurere enhetssporing", - "title": "UniFi-alternativ 1/3" + "title": "UniFi-nettverksalternativer 1/3" }, "init": { "data": { @@ -60,7 +60,7 @@ "track_clients": "Spor nettverksklienter", "track_devices": "Spore nettverksenheter (Ubiquiti-enheter)" }, - "description": "Konfigurer UniFi-integrasjon" + "description": "Konfigurer UniFi Network-integrasjon" }, "statistics_sensors": { "data": { @@ -68,7 +68,7 @@ "allow_uptime_sensors": "Oppetidssensorer for nettverksklienter" }, "description": "Konfigurer statistikk sensorer", - "title": "UniFi-alternativ 3/3" + "title": "UniFi Network alternativer 3/3" } } } diff --git a/homeassistant/components/unifi/translations/pl.json b/homeassistant/components/unifi/translations/pl.json index ebfb901871b..6e4d6364b27 100644 --- a/homeassistant/components/unifi/translations/pl.json +++ b/homeassistant/components/unifi/translations/pl.json @@ -62,7 +62,7 @@ "track_clients": "\u015aled\u017a klient\u00f3w sieciowych", "track_devices": "\u015aled\u017a urz\u0105dzenia sieciowe (urz\u0105dzenia Ubiquiti)" }, - "description": "Konfigurowanie integracji z UniFi" + "description": "Konfigurowanie integracji UniFi" }, "statistics_sensors": { "data": { diff --git a/homeassistant/components/unifi/translations/ru.json b/homeassistant/components/unifi/translations/ru.json index a2c9bfe8061..4d9726ab751 100644 --- a/homeassistant/components/unifi/translations/ru.json +++ b/homeassistant/components/unifi/translations/ru.json @@ -21,7 +21,7 @@ "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f", "verify_ssl": "\u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0442\u044c \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 SSL" }, - "title": "UniFi Controller" + "title": "UniFi Network" } } }, @@ -34,19 +34,19 @@ "poe_clients": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c POE \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044c \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432" }, "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432 \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f.\n\n\u0421\u043e\u0437\u0434\u0430\u0439\u0442\u0435 \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0442\u0435\u043b\u0438 \u0434\u043b\u044f \u0441\u0435\u0440\u0438\u0439\u043d\u044b\u0445 \u043d\u043e\u043c\u0435\u0440\u043e\u0432, \u0434\u043b\u044f \u043a\u043e\u0442\u043e\u0440\u044b\u0445 \u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f \u043a \u0441\u0435\u0442\u0438.", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 UniFi. \u0428\u0430\u0433 2" + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 UniFi Network. \u0428\u0430\u0433 2" }, "device_tracker": { "data": { "detection_time": "\u0412\u0440\u0435\u043c\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0434\u043e\u043c\u0430", - "ignore_wired_bug": "\u041e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043b\u043e\u0433\u0438\u043a\u0443 \u043e\u0448\u0438\u0431\u043a\u0438 \u0434\u043b\u044f \u043d\u0435 \u0431\u0435\u0441\u043f\u0440\u043e\u0432\u043e\u0434\u043d\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 UniFi", + "ignore_wired_bug": "\u041e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043b\u043e\u0433\u0438\u043a\u0443 \u043e\u0448\u0438\u0431\u043a\u0438 \u0434\u043b\u044f \u043d\u0435 \u0431\u0435\u0441\u043f\u0440\u043e\u0432\u043e\u0434\u043d\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 UniFi Network", "ssid_filter": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 SSID \u0434\u043b\u044f \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u043d\u0438\u044f \u0431\u0435\u0441\u043f\u0440\u043e\u0432\u043e\u0434\u043d\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432", "track_clients": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u0441\u0435\u0442\u0438", "track_devices": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u0441\u0435\u0442\u0438 (\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Ubiquiti)", "track_wired_clients": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u043f\u0440\u043e\u0432\u043e\u0434\u043d\u043e\u0439 \u0441\u0435\u0442\u0438" }, "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u043d\u0438\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 UniFi. \u0428\u0430\u0433 1" + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 UniFi Network. \u0428\u0430\u0433 1" }, "init": { "data": { @@ -62,7 +62,7 @@ "track_clients": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u0441\u0435\u0442\u0438", "track_devices": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u0441\u0435\u0442\u0438 (\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Ubiquiti)" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 UniFi." + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 UniFi Network." }, "statistics_sensors": { "data": { @@ -70,7 +70,7 @@ "allow_uptime_sensors": "\u0421\u0435\u043d\u0441\u043e\u0440\u044b \u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0440\u0430\u0431\u043e\u0442\u044b \u0434\u043b\u044f \u0441\u0435\u0442\u0435\u0432\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432" }, "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0441\u0435\u043d\u0441\u043e\u0440\u043e\u0432 \u0441\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043a\u0438", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 UniFi. \u0428\u0430\u0433 3" + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 UniFi Network. \u0428\u0430\u0433 3" } } } diff --git a/homeassistant/components/unifi/translations/tr.json b/homeassistant/components/unifi/translations/tr.json index c39fa08217a..6baa8108098 100644 --- a/homeassistant/components/unifi/translations/tr.json +++ b/homeassistant/components/unifi/translations/tr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Denetleyici sitesi zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "already_configured": "UniFi Network sitesi zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "configuration_updated": "Yap\u0131land\u0131rma g\u00fcncellendi.", "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, @@ -10,15 +10,18 @@ "service_unavailable": "Ba\u011flanma hatas\u0131", "unknown_client_mac": "Bu MAC adresinde kullan\u0131labilir istemci yok" }, - "flow_title": "UniFi A\u011f\u0131 {site} ( {host} )", + "flow_title": "{site} ( {host} )", "step": { "user": { "data": { "host": "Ana Bilgisayar", "password": "Parola", "port": "Port", - "username": "Kullan\u0131c\u0131 ad\u0131" - } + "site": "Site Kimli\u011fi", + "username": "Kullan\u0131c\u0131 Ad\u0131", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" + }, + "title": "UniFi Controller'\u0131 kurun" } } }, @@ -26,8 +29,46 @@ "step": { "client_control": { "data": { - "dpi_restrictions": "DPI k\u0131s\u0131tlama gruplar\u0131n\u0131n kontrol\u00fcne izin ver" + "block_client": "A\u011f eri\u015fimi denetimli istemciler", + "dpi_restrictions": "DPI k\u0131s\u0131tlama gruplar\u0131n\u0131n kontrol\u00fcne izin ver", + "poe_clients": "\u0130stemcilerin POE denetimine izin ver" + }, + "description": "\u0130stemci denetimlerini yap\u0131land\u0131r\u0131n \n\n A\u011f eri\u015fimini denetlemek istedi\u011finiz seri numaralar\u0131 i\u00e7in anahtarlar olu\u015fturun.", + "title": "UniFi se\u00e7enekleri 2/3" + }, + "device_tracker": { + "data": { + "detection_time": "Son g\u00f6r\u00fclmeden uzakta say\u0131lana kadar ge\u00e7en saniye cinsinden s\u00fcre", + "ignore_wired_bug": "UniFi kablolu hata mant\u0131\u011f\u0131n\u0131 devre d\u0131\u015f\u0131 b\u0131rak\u0131n", + "ssid_filter": "Kablosuz istemcileri izlemek i\u00e7in SSID'leri se\u00e7in", + "track_clients": "A\u011f istemcilerini takip edin", + "track_devices": "A\u011f cihazlar\u0131n\u0131 takip edin (Ubiquiti cihazlar\u0131)", + "track_wired_clients": "Kablolu a\u011f istemcilerini dahil et" + }, + "description": "Cihaz izlemeyi yap\u0131land\u0131r", + "title": "UniFi se\u00e7enekleri 1/3" + }, + "init": { + "data": { + "one": "Bo\u015f", + "other": "Bo\u015f" } + }, + "simple_options": { + "data": { + "block_client": "A\u011f eri\u015fimi denetimli istemciler", + "track_clients": "A\u011f istemcilerini takip edin", + "track_devices": "A\u011f cihazlar\u0131n\u0131 takip edin (Ubiquiti cihazlar\u0131)" + }, + "description": "UniFi entegrasyonunu yap\u0131land\u0131r\u0131n" + }, + "statistics_sensors": { + "data": { + "allow_bandwidth_sensors": "A\u011f istemcileri i\u00e7in bant geni\u015fli\u011fi kullan\u0131m sens\u00f6rleri", + "allow_uptime_sensors": "A\u011f istemcileri i\u00e7in \u00e7al\u0131\u015fma s\u00fcresi sens\u00f6rleri" + }, + "description": "\u0130statistik sens\u00f6rlerini yap\u0131land\u0131r\u0131n", + "title": "UniFi se\u00e7enekleri 3/3" } } } diff --git a/homeassistant/components/unifi/translations/zh-Hans.json b/homeassistant/components/unifi/translations/zh-Hans.json index 7fe1b741bd5..7bc8a032c6f 100644 --- a/homeassistant/components/unifi/translations/zh-Hans.json +++ b/homeassistant/components/unifi/translations/zh-Hans.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\u63a7\u5236\u5668\u7ad9\u70b9\u5df2\u914d\u7f6e\u5b8c\u6210" + "already_configured": "UniFi \u7f51\u7edc\u7ad9\u70b9\u5df2\u914d\u7f6e\u5b8c\u6210" }, "error": { "faulty_credentials": "\u9519\u8bef\u7684\u7528\u6237\u51ed\u636e", @@ -17,12 +17,15 @@ "username": "\u7528\u6237\u540d", "verify_ssl": "\u4f7f\u7528\u6b63\u786e\u8bc1\u4e66\u7684\u63a7\u5236\u5668" }, - "title": "\u914d\u7f6e UniFi \u63a7\u5236\u5668" + "title": "\u914d\u7f6e UniFi \u7f51\u7edc" } } }, "options": { "step": { + "client_control": { + "title": "UniFi \u7f51\u7edc\u9009\u9879 3/3" + }, "device_tracker": { "data": { "detection_time": "\u8ddd\u79bb\u4e0a\u6b21\u53d1\u73b0\u591a\u5c11\u79d2\u540e\u8ba4\u4e3a\u79bb\u5f00", @@ -32,11 +35,14 @@ "track_wired_clients": "\u5305\u62ec\u6709\u7ebf\u7f51\u7edc\u5ba2\u6237\u7aef" }, "description": "\u914d\u7f6e\u8bbe\u5907\u8ddf\u8e2a", - "title": "UniFi \u9009\u9879" + "title": "UniFi \u7f51\u7edc\u9009\u9879 1/3" + }, + "simple_options": { + "description": "\u914d\u7f6e UniFi \u7f51\u7edc\u96c6\u6210" }, "statistics_sensors": { "description": "\u914d\u7f6e\u7edf\u8ba1\u4f20\u611f\u5668", - "title": "UniFi \u9009\u9879" + "title": "UniFi \u7f51\u7edc\u9009\u9879 2/3" } } } diff --git a/homeassistant/components/unifi/translations/zh-Hant.json b/homeassistant/components/unifi/translations/zh-Hant.json index 22cfeed3d65..1b394bae317 100644 --- a/homeassistant/components/unifi/translations/zh-Hant.json +++ b/homeassistant/components/unifi/translations/zh-Hant.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\u63a7\u5236\u5668\u4f4d\u5740\u5df2\u7d93\u8a2d\u5b9a", + "already_configured": "UniFi \u7db2\u8def\u5df2\u7d93\u8a2d\u5b9a", "configuration_updated": "\u8a2d\u5b9a\u5df2\u66f4\u65b0\u3002", "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, @@ -21,7 +21,7 @@ "username": "\u4f7f\u7528\u8005\u540d\u7a31", "verify_ssl": "\u78ba\u8a8d SSL \u8a8d\u8b49" }, - "title": "\u8a2d\u5b9a UniFi \u63a7\u5236\u5668" + "title": "\u8a2d\u5b9a UniFi \u7db2\u8def" } } }, @@ -34,19 +34,19 @@ "poe_clients": "\u5141\u8a31 POE \u63a7\u5236\u5ba2\u6236\u7aef" }, "description": "\u8a2d\u5b9a\u5ba2\u6236\u7aef\u63a7\u5236\n\n\u65b0\u589e\u9396\u8981\u63a7\u5236\u7db2\u8def\u5b58\u53d6\u7684\u958b\u95dc\u5e8f\u865f\u3002", - "title": "UniFi \u9078\u9805 2/3" + "title": "UniFi \u7db2\u8def\u9078\u9805 2/3" }, "device_tracker": { "data": { "detection_time": "\u6700\u7d42\u51fa\u73fe\u5f8c\u8996\u70ba\u96e2\u958b\u7684\u6642\u9593\uff08\u4ee5\u79d2\u70ba\u55ae\u4f4d\uff09", - "ignore_wired_bug": "\u95dc\u9589 UniFi \u6709\u7dda\u932f\u8aa4\u908f\u8f2f", + "ignore_wired_bug": "\u95dc\u9589 UniFi \u7db2\u8def\u6709\u7dda\u932f\u8aa4\u908f\u8f2f", "ssid_filter": "\u9078\u64c7\u6240\u8981\u8ffd\u8e64\u7684\u7121\u7dda\u7db2\u8def", "track_clients": "\u8ffd\u8e64\u7db2\u8def\u5ba2\u6236\u7aef", "track_devices": "\u8ffd\u8e64\u7db2\u8def\u88dd\u7f6e\uff08Ubiquiti \u88dd\u7f6e\uff09", "track_wired_clients": "\u5305\u542b\u6709\u7dda\u7db2\u8def\u5ba2\u6236\u7aef" }, "description": "\u8a2d\u5b9a\u88dd\u7f6e\u8ffd\u8e64", - "title": "UniFi \u9078\u9805 1/3" + "title": "UniFi \u7db2\u8def\u9078\u9805 1/3" }, "simple_options": { "data": { @@ -54,7 +54,7 @@ "track_clients": "\u8ffd\u8e64\u7db2\u8def\u5ba2\u6236\u7aef", "track_devices": "\u8ffd\u8e64\u7db2\u8def\u88dd\u7f6e\uff08Ubiquiti \u88dd\u7f6e\uff09" }, - "description": "\u8a2d\u5b9a UniFi \u6574\u5408" + "description": "\u8a2d\u5b9a UniFi \u7db2\u8def\u6574\u5408" }, "statistics_sensors": { "data": { @@ -62,7 +62,7 @@ "allow_uptime_sensors": "\u7db2\u8def\u5ba2\u6236\u7aef\u4e0a\u7dda\u6642\u9593\u611f\u6e2c\u5668" }, "description": "\u8a2d\u5b9a\u7d71\u8a08\u6578\u64da\u611f\u61c9\u5668", - "title": "UniFi \u9078\u9805 3/3" + "title": "UniFi \u7db2\u8def\u9078\u9805 3/3" } } } diff --git a/homeassistant/components/unifi/unifi_client.py b/homeassistant/components/unifi/unifi_client.py index 3340616dada..9e90eef518a 100644 --- a/homeassistant/components/unifi/unifi_client.py +++ b/homeassistant/components/unifi/unifi_client.py @@ -47,8 +47,8 @@ class UniFiClient(UniFiBase): @property def device_info(self) -> DeviceInfo: """Return a client description for device registry.""" - return { - "connections": {(CONNECTION_NETWORK_MAC, self.client.mac)}, - "default_name": self.name, - "default_manufacturer": self.client.oui, - } + return DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self.client.mac)}, + default_manufacturer=self.client.oui, + default_name=self.name, + ) diff --git a/homeassistant/components/unifi/unifi_entity_base.py b/homeassistant/components/unifi/unifi_entity_base.py index e3b6e4f9970..25e10ab13ec 100644 --- a/homeassistant/components/unifi/unifi_entity_base.py +++ b/homeassistant/components/unifi/unifi_entity_base.py @@ -1,4 +1,4 @@ -"""Base class for UniFi entities.""" +"""Base class for UniFi Network entities.""" import logging from typing import Any @@ -18,7 +18,7 @@ class UniFiBase(Entity): TYPE = "" def __init__(self, item, controller) -> None: - """Set up UniFi entity base. + """Set up UniFi Network entity base. Register mac to controller entities to cover disabled entities. """ diff --git a/homeassistant/components/upb/__init__.py b/homeassistant/components/upb/__init__.py index a3c3016dc05..90d7c35cf64 100644 --- a/homeassistant/components/upb/__init__.py +++ b/homeassistant/components/upb/__init__.py @@ -4,7 +4,7 @@ import upb_lib from homeassistant.const import ATTR_COMMAND, CONF_FILE_PATH, CONF_HOST from homeassistant.core import callback -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import ( ATTR_ADDRESS, @@ -119,12 +119,12 @@ class UpbAttachedEntity(UpbEntity): """Base class for UPB attached entities.""" @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info for the entity.""" - return { - "name": self._element.name, - "identifiers": {(DOMAIN, self._element.index)}, - "sw_version": self._element.version, - "manufacturer": self._element.manufacturer, - "model": self._element.product, - } + return DeviceInfo( + identifiers={(DOMAIN, self._element.index)}, + manufacturer=self._element.manufacturer, + model=self._element.product, + name=self._element.name, + sw_version=self._element.version, + ) diff --git a/homeassistant/components/upb/translations/bg.json b/homeassistant/components/upb/translations/bg.json index c7fc1a35b8e..45443313c72 100644 --- a/homeassistant/components/upb/translations/bg.json +++ b/homeassistant/components/upb/translations/bg.json @@ -1,7 +1,7 @@ { "config": { "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", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { diff --git a/homeassistant/components/upb/translations/ja.json b/homeassistant/components/upb/translations/ja.json new file mode 100644 index 00000000000..d73bfe2b08a --- /dev/null +++ b/homeassistant/components/upb/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" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_upb_file": "UPB UPStart\u306e\u30a8\u30af\u30b9\u30dd\u30fc\u30c8\u30d5\u30a1\u30a4\u30eb\u304c\u306a\u3044\u304b\u7121\u52b9\u3067\u3059\u3002\u30d5\u30a1\u30a4\u30eb\u540d\u3068\u30d1\u30b9\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "address": "\u30a2\u30c9\u30ec\u30b9(\u4e0a\u8a18\u306e\u8aac\u660e\u3092\u53c2\u7167)", + "file_path": "UPStart UPB\u30a8\u30af\u30b9\u30dd\u30fc\u30c8\u30d5\u30a1\u30a4\u30eb\u306e\u30d1\u30b9\u3068\u540d\u524d\u3002", + "protocol": "\u30d7\u30ed\u30c8\u30b3\u30eb" + }, + "description": "Universal Powerline Bus Powerline Interface Module (UPB PIM)\u3092\u63a5\u7d9a\u3057\u307e\u3059\u3002\u30a2\u30c9\u30ec\u30b9\u6587\u5b57\u5217\u306f\u3001'tcp'\u306e\u5834\u5408\u3001'address[:port]'\u306e\u5f62\u5f0f\u3067\u306a\u3051\u308c\u3070\u306a\u308a\u307e\u305b\u3093\u3002\u30dd\u30fc\u30c8\u306f\u30aa\u30d7\u30b7\u30e7\u30f3\u3067\u3001\u30c7\u30d5\u30a9\u30eb\u30c8\u306f2101\u3067\u3059\u3002\u4f8b: '192.168.1.42'\u3002\u30b7\u30ea\u30a2\u30eb\u30d7\u30ed\u30c8\u30b3\u30eb\u306e\u5834\u5408\u3001\u30a2\u30c9\u30ec\u30b9\u306f\u3001'/dev/ttyS1'\u306e\u5f62\u5f0f\u3067\u306a\u3051\u308c\u3070\u306a\u308a\u307e\u305b\u3093\u3002baud\u306f\u30aa\u30d7\u30b7\u30e7\u30f3\u3067\u3001\u30c7\u30d5\u30a9\u30eb\u30c8\u306f4800\u3067\u3059\u3002\u4f8b: 'tty[:baud]'\u3002", + "title": "UPB PIM\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/upb/translations/tr.json b/homeassistant/components/upb/translations/tr.json index b70e56b88da..0fd1504e32b 100644 --- a/homeassistant/components/upb/translations/tr.json +++ b/homeassistant/components/upb/translations/tr.json @@ -10,7 +10,13 @@ }, "step": { "user": { - "description": "Bir Evrensel Elektrik Hatt\u0131 Veriyolu Elektrik Hatt\u0131 Aray\u00fcz Mod\u00fcl\u00fc (UPB PIM) ba\u011flay\u0131n. Adres dizesi 'tcp' i\u00e7in 'adres[:port]' bi\u00e7iminde olmal\u0131d\u0131r. Ba\u011flant\u0131 noktas\u0131 iste\u011fe ba\u011fl\u0131d\u0131r ve varsay\u0131lan olarak 2101'dir. \u00d6rnek: '192.168.1.42'. Seri protokol i\u00e7in adres 'tty[:baud]' bi\u00e7iminde olmal\u0131d\u0131r. Baud iste\u011fe ba\u011fl\u0131d\u0131r ve varsay\u0131lan olarak 4800'd\u00fcr. \u00d6rnek: '/dev/ttyS1'." + "data": { + "address": "Adres (yukar\u0131daki a\u00e7\u0131klamaya bak\u0131n)", + "file_path": "UPSStart UPB d\u0131\u015fa aktarma dosyas\u0131n\u0131n yolu ve ad\u0131.", + "protocol": "Protokol" + }, + "description": "Bir Evrensel Elektrik Hatt\u0131 Veriyolu Elektrik Hatt\u0131 Aray\u00fcz Mod\u00fcl\u00fc (UPB PIM) ba\u011flay\u0131n. Adres dizesi 'tcp' i\u00e7in 'adres[:port]' bi\u00e7iminde olmal\u0131d\u0131r. Ba\u011flant\u0131 noktas\u0131 iste\u011fe ba\u011fl\u0131d\u0131r ve varsay\u0131lan olarak 2101'dir. \u00d6rnek: '192.168.1.42'. Seri protokol i\u00e7in adres 'tty[:baud]' bi\u00e7iminde olmal\u0131d\u0131r. Baud iste\u011fe ba\u011fl\u0131d\u0131r ve varsay\u0131lan olarak 4800'd\u00fcr. \u00d6rnek: '/dev/ttyS1'.", + "title": "UPB PIM'e ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index 82d42e28589..2ecc6ec7522 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -47,7 +47,6 @@ CONF_SERVERS = "servers" DATA_UPCLOUD = "data_upcloud" DEFAULT_COMPONENT_NAME = "UpCloud {}" -DEFAULT_COMPONENT_DEVICE_CLASS = "power" CONFIG_ENTRY_DOMAINS = {BINARY_SENSOR_DOMAIN, SWITCH_DOMAIN} @@ -177,8 +176,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> class UpCloudServerEntity(CoordinatorEntity): """Entity class for UpCloud servers.""" - _attr_device_class = DEFAULT_COMPONENT_DEVICE_CLASS - def __init__( self, coordinator: DataUpdateCoordinator[dict[str, upcloud_api.Server]], diff --git a/homeassistant/components/upcloud/binary_sensor.py b/homeassistant/components/upcloud/binary_sensor.py index de55a577610..ebdc30b69f1 100644 --- a/homeassistant/components/upcloud/binary_sensor.py +++ b/homeassistant/components/upcloud/binary_sensor.py @@ -2,7 +2,11 @@ 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.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant @@ -29,3 +33,5 @@ async def async_setup_entry( class UpCloudBinarySensor(UpCloudServerEntity, BinarySensorEntity): """Representation of an UpCloud server sensor.""" + + _attr_device_class = BinarySensorDeviceClass.POWER diff --git a/homeassistant/components/upcloud/translations/ja.json b/homeassistant/components/upcloud/translations/ja.json new file mode 100644 index 00000000000..8883e40953f --- /dev/null +++ b/homeassistant/components/upcloud/translations/ja.json @@ -0,0 +1,25 @@ +{ + "config": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u9593\u9694(\u79d2\u3001\u6700\u5c0f30)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/upcloud/translations/tr.json b/homeassistant/components/upcloud/translations/tr.json index f1840698493..1e0f7a4bc4d 100644 --- a/homeassistant/components/upcloud/translations/tr.json +++ b/homeassistant/components/upcloud/translations/tr.json @@ -12,5 +12,14 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Saniye cinsinden g\u00fcncelleme aral\u0131\u011f\u0131, minimum 30" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/updater/__init__.py b/homeassistant/components/updater/__init__.py index f624cf87eda..1d86dbaea8e 100644 --- a/homeassistant/components/updater/__init__.py +++ b/homeassistant/components/updater/__init__.py @@ -125,7 +125,7 @@ async def get_newest_version(hass): """Get the newest Home Assistant version.""" session = async_get_clientsession(hass) - with async_timeout.timeout(30): + async with async_timeout.timeout(30): req = await session.get(UPDATER_URL) try: diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 3982296b419..da0585df987 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -15,7 +15,6 @@ from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.components.binary_sensor import BinarySensorEntityDescription from homeassistant.components.sensor import SensorEntityDescription -from homeassistant.components.ssdp import SsdpChange from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -91,16 +90,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Register device discovered-callback. device_discovered_event = asyncio.Event() - discovery_info: Mapping[str, Any] | None = None + discovery_info: ssdp.SsdpServiceInfo | None = None - async def device_discovered(headers: Mapping[str, Any], change: SsdpChange) -> None: - if change == SsdpChange.BYEBYE: + async def device_discovered( + headers: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange + ) -> None: + if change == ssdp.SsdpChange.BYEBYE: return nonlocal discovery_info - LOGGER.debug( - "Device discovered: %s, at: %s", usn, headers[ssdp.ATTR_SSDP_LOCATION] - ) + LOGGER.debug("Device discovered: %s, at: %s", usn, headers.ssdp_location) discovery_info = headers device_discovered_event.set() @@ -121,9 +120,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: cancel_discovered_callback() # Create device. - location = discovery_info[ # pylint: disable=unsubscriptable-object - ssdp.ATTR_SSDP_LOCATION - ] + location = discovery_info.ssdp_location try: device = await Device.async_create_device(hass, location) except UpnpConnectionError as err: @@ -153,7 +150,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Create device registry entry. - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, connections={(dr.CONNECTION_UPNP, device.udn)}, diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 80c126edbec..74acb88983b 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -10,9 +10,10 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp -from homeassistant.components.ssdp import SsdpChange +from homeassistant.components.ssdp import SsdpChange, SsdpServiceInfo from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult from .const import ( CONFIG_ENTRY_HOSTNAME, @@ -28,22 +29,22 @@ from .const import ( ) -def _friendly_name_from_discovery(discovery_info: Mapping[str, Any]) -> str: +def _friendly_name_from_discovery(discovery_info: ssdp.SsdpServiceInfo) -> str: """Extract user-friendly name from discovery.""" return ( - discovery_info.get("friendlyName") - or discovery_info.get("modeName") - or discovery_info.get("_host", "") + discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) + or discovery_info.ssdp_headers.get("_host", "") ) -def _is_complete_discovery(discovery_info: Mapping[str, Any]) -> bool: +def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool: """Test if discovery is complete and usable.""" return ( - ssdp.ATTR_UPNP_UDN in discovery_info - and ssdp.ATTR_SSDP_ST in discovery_info - and ssdp.ATTR_SSDP_LOCATION in discovery_info - and ssdp.ATTR_SSDP_USN in discovery_info + ssdp.ATTR_UPNP_UDN in discovery_info.upnp + and discovery_info.ssdp_st + and discovery_info.ssdp_location + and discovery_info.ssdp_usn ) @@ -51,14 +52,14 @@ async def _async_wait_for_discoveries(hass: HomeAssistant) -> bool: """Wait for a device to be discovered.""" device_discovered_event = asyncio.Event() - async def device_discovered(info: Mapping[str, Any], change: SsdpChange) -> None: + async def device_discovered(info: SsdpServiceInfo, change: SsdpChange) -> None: if change == SsdpChange.BYEBYE: return LOGGER.info( "Device discovered: %s, at: %s", - info[ssdp.ATTR_SSDP_USN], - info[ssdp.ATTR_SSDP_LOCATION], + info.ssdp_usn, + info.ssdp_location, ) device_discovered_event.set() @@ -90,7 +91,9 @@ async def _async_wait_for_discoveries(hass: HomeAssistant) -> bool: return True -async def _async_discover_igd_devices(hass: HomeAssistant) -> list[Mapping[str, Any]]: +async def _async_discover_igd_devices( + hass: HomeAssistant, +) -> list[ssdp.SsdpServiceInfo]: """Discovery IGD devices.""" return await ssdp.async_get_discovery_info_by_st( hass, ST_IGD_V1 @@ -109,7 +112,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the UPnP/IGD config flow.""" - self._discoveries: Mapping = None + self._discoveries: list[SsdpServiceInfo] | None = None async def async_step_user( self, user_input: Mapping | None = None @@ -122,15 +125,13 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): matching_discoveries = [ discovery for discovery in self._discoveries - if discovery[ssdp.ATTR_SSDP_USN] == user_input["unique_id"] + if discovery.ssdp_usn == user_input["unique_id"] ] if not matching_discoveries: return self.async_abort(reason="no_devices_found") discovery = matching_discoveries[0] - await self.async_set_unique_id( - discovery[ssdp.ATTR_SSDP_USN], raise_on_progress=False - ) + await self.async_set_unique_id(discovery.ssdp_usn, raise_on_progress=False) return await self._async_create_entry_from_discovery(discovery) # Discover devices. @@ -145,7 +146,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): for discovery in discoveries if ( _is_complete_discovery(discovery) - and discovery[ssdp.ATTR_SSDP_USN] not in current_unique_ids + and discovery.ssdp_usn not in current_unique_ids ) ] @@ -157,9 +158,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): { vol.Required("unique_id"): vol.In( { - discovery[ssdp.ATTR_SSDP_USN]: _friendly_name_from_discovery( - discovery - ) + discovery.ssdp_usn: _friendly_name_from_discovery(discovery) for discovery in self._discoveries } ), @@ -201,12 +200,12 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="incomplete_discovery") # Ensure not already configuring/configured. - unique_id = discovery[ssdp.ATTR_SSDP_USN] + unique_id = discovery.ssdp_usn await self.async_set_unique_id(unique_id) return await self._async_create_entry_from_discovery(discovery) - async def async_step_ssdp(self, discovery_info: Mapping) -> Mapping[str, Any]: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered UPnP/IGD device. This flow is triggered by the SSDP component. It will check if the @@ -220,9 +219,9 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="incomplete_discovery") # Ensure not already configuring/configured. - unique_id = discovery_info[ssdp.ATTR_SSDP_USN] + unique_id = discovery_info.ssdp_usn await self.async_set_unique_id(unique_id) - hostname = discovery_info["_host"] + hostname = discovery_info.ssdp_headers["_host"] self._abort_if_unique_id_configured(updates={CONFIG_ENTRY_HOSTNAME: hostname}) # Handle devices changing their UDN, only allow a single host. @@ -266,7 +265,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def _async_create_entry_from_discovery( self, - discovery: Mapping, + discovery: SsdpServiceInfo, ) -> Mapping[str, Any]: """Create an entry from discovery.""" LOGGER.debug( @@ -276,9 +275,9 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): title = _friendly_name_from_discovery(discovery) data = { - CONFIG_ENTRY_UDN: discovery[ssdp.ATTR_UPNP_UDN], - CONFIG_ENTRY_ST: discovery[ssdp.ATTR_SSDP_ST], - CONFIG_ENTRY_HOSTNAME: discovery["_host"], + CONFIG_ENTRY_UDN: discovery.upnp[ssdp.ATTR_UPNP_UDN], + CONFIG_ENTRY_ST: discovery.ssdp_st, + CONFIG_ENTRY_HOSTNAME: discovery.ssdp_headers["_host"], } return self.async_create_entry(title=title, data=data) diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 05464c54914..da12c25c7d1 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,7 +3,7 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.22.10"], + "requirements": ["async-upnp-client==0.22.12"], "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman","@ehendrix23"], "ssdp": [ diff --git a/homeassistant/components/upnp/translations/ja.json b/homeassistant/components/upnp/translations/ja.json new file mode 100644 index 00000000000..2148b0d8a16 --- /dev/null +++ b/homeassistant/components/upnp/translations/ja.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "incomplete_discovery": "\u4e0d\u5b8c\u5168\u306a\u691c\u51fa", + "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": { + "ssdp_confirm": { + "description": "\u3053\u306e\u3001UPnP/IGD\u30c7\u30d0\u30a4\u30b9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "scan_interval": "\u66f4\u65b0\u9593\u9694(\u79d2\u3001\u6700\u5c0f30)", + "unique_id": "\u30c7\u30d0\u30a4\u30b9", + "usn": "\u30c7\u30d0\u30a4\u30b9" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u9593\u9694(\u79d2\u3001\u6700\u5c0f30)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/translations/tr.json b/homeassistant/components/upnp/translations/tr.json index 2715f66e090..8176c0541c7 100644 --- a/homeassistant/components/upnp/translations/tr.json +++ b/homeassistant/components/upnp/translations/tr.json @@ -1,19 +1,39 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "incomplete_discovery": "Tamamlanmam\u0131\u015f tarama", + "no_devices_found": "A\u011fda cihaz bulunamad\u0131" }, - "flow_title": "UPnP / IGD: {name}", + "error": { + "one": "Bo\u015f", + "other": "Bo\u015f" + }, + "flow_title": "{name}", "step": { + "init": { + "one": "Bo\u015f", + "other": "" + }, "ssdp_confirm": { "description": "Bu UPnP / IGD cihaz\u0131n\u0131 kurmak istiyor musunuz?" }, "user": { "data": { "scan_interval": "G\u00fcncelleme aral\u0131\u011f\u0131 (saniye, minimum 30)", + "unique_id": "Cihaz", "usn": "Cihaz" } } } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "G\u00fcncelleme aral\u0131\u011f\u0131 (saniye, minimum 30)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/uptime/sensor.py b/homeassistant/components/uptime/sensor.py index db06b09ea18..4afddc0f834 100644 --- a/homeassistant/components/uptime/sensor.py +++ b/homeassistant/components/uptime/sensor.py @@ -50,4 +50,4 @@ class UptimeSensor(SensorEntity): self._attr_name: str = name self._attr_device_class: str = DEVICE_CLASS_TIMESTAMP self._attr_should_poll: bool = False - self._attr_native_value: str = dt_util.now().isoformat() + self._attr_native_value = dt_util.utcnow() diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index 09ce3262d81..43aed00708d 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -27,7 +27,7 @@ async def async_setup_entry( BinarySensorEntityDescription( key=str(monitor.id), name=monitor.friendly_name, - device_class=DEVICE_CLASS_CONNECTIVITY, + device_class=BinarySensorDeviceClass.CONNECTIVITY, ), monitor=monitor, ) diff --git a/homeassistant/components/uptimerobot/entity.py b/homeassistant/components/uptimerobot/entity.py index eee3d774d98..318e5a5094e 100644 --- a/homeassistant/components/uptimerobot/entity.py +++ b/homeassistant/components/uptimerobot/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from pyuptimerobot import UptimeRobotMonitor +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -31,7 +32,7 @@ class UptimeRobotEntity(CoordinatorEntity): identifiers={(DOMAIN, str(self.monitor.id))}, name=self.monitor.friendly_name, manufacturer="UptimeRobot Team", - entry_type="service", + entry_type=DeviceEntryType.SERVICE, model=self.monitor.type.name, configuration_url=f"https://uptimerobot.com/dashboard#{self.monitor.id}", ) diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index 8f9a9d74103..d19ada33158 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -3,7 +3,7 @@ "name": "UptimeRobot", "documentation": "https://www.home-assistant.io/integrations/uptimerobot", "requirements": [ - "pyuptimerobot==21.9.0" + "pyuptimerobot==21.11.0" ], "codeowners": [ "@ludeeus" diff --git a/homeassistant/components/uptimerobot/translations/id.json b/homeassistant/components/uptimerobot/translations/id.json index e107b1fcac6..dac2e7e2814 100644 --- a/homeassistant/components/uptimerobot/translations/id.json +++ b/homeassistant/components/uptimerobot/translations/id.json @@ -1,12 +1,15 @@ { "config": { "abort": { + "already_configured": "Akun sudah dikonfigurasi", + "reauth_failed_existing": "Tidak dapat memperbarui entri konfigurasi, hapus integrasi dan siapkan kembali.", "reauth_successful": "Autentikasi ulang berhasil", "unknown": "Kesalahan yang tidak diharapkan" }, "error": { "cannot_connect": "Gagal terhubung", "invalid_api_key": "Kunci API tidak valid", + "reauth_failed_matching_account": "Kunci API yang Anda berikan tidak cocok dengan ID akun untuk konfigurasi yang ada.", "unknown": "Kesalahan yang tidak diharapkan" }, "step": { @@ -14,12 +17,14 @@ "data": { "api_key": "Kunci API" }, + "description": "Anda perlu menyediakan kunci API hanya-baca yang baru dari UptimeRobot", "title": "Autentikasi Ulang Integrasi" }, "user": { "data": { "api_key": "Kunci API" - } + }, + "description": "Anda perlu menyediakan kunci API hanya-baca dari UptimeRobot" } } } diff --git a/homeassistant/components/uptimerobot/translations/ja.json b/homeassistant/components/uptimerobot/translations/ja.json new file mode 100644 index 00000000000..d890780eab9 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/ja.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_failed_existing": "\u69cb\u6210\u30a8\u30f3\u30c8\u30ea\u30fc\u3092\u66f4\u65b0\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u524a\u9664\u3057\u3066\u518d\u5ea6\u8a2d\u5b9a\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_api_key": "\u7121\u52b9\u306aAPI\u30ad\u30fc", + "reauth_failed_matching_account": "\u6307\u5b9a\u3055\u308c\u305fAPI\u30ad\u30fc\u304c\u3001\u3059\u3067\u306b\u3042\u308b\u8a2d\u5b9a\u306e\u30a2\u30ab\u30a6\u30f3\u30c8ID\u3068\u4e00\u81f4\u3057\u307e\u305b\u3093\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API\u30ad\u30fc" + }, + "description": "UptimeRobot\u304b\u3089\u65b0\u898f\u306e\u8aad\u307f\u53d6\u308a\u5c02\u7528\u306eAPI\u30ad\u30fc\u3092\u5f97\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "api_key": "API\u30ad\u30fc" + }, + "description": "UptimeRobot\u304b\u3089\u8aad\u307f\u53d6\u308a\u5c02\u7528\u306eAPI\u30ad\u30fc\u3092\u5f97\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/tr.json b/homeassistant/components/uptimerobot/translations/tr.json new file mode 100644 index 00000000000..c209afdfd8e --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/tr.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_failed_existing": "Yap\u0131land\u0131rma giri\u015fi g\u00fcncellenemedi, l\u00fctfen entegrasyonu kald\u0131r\u0131n ve yeniden kurun.", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu", + "unknown": "Beklenmeyen hata" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_api_key": "Ge\u00e7ersiz API anahtar\u0131", + "reauth_failed_matching_account": "Sa\u011flad\u0131\u011f\u0131n\u0131z API anahtar\u0131, mevcut yap\u0131land\u0131rman\u0131n hesap kimli\u011fiyle e\u015fle\u015fmiyor.", + "unknown": "Beklenmeyen hata" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API Anahtar\u0131" + }, + "description": "UptimeRobot'tan yeni bir salt okunur API anahtar\u0131 sa\u011flaman\u0131z gerekiyor", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, + "user": { + "data": { + "api_key": "API Anahtar\u0131" + }, + "description": "UptimeRobot'tan salt okunur bir API anahtar\u0131 sa\u011flaman\u0131z gerekiyor" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 80d01417ea7..87216c65e9e 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -6,6 +6,7 @@ import fnmatch import logging import os import sys +from typing import Any from serial.tools.list_ports import comports from serial.tools.list_ports_common import ListPortInfo @@ -16,8 +17,10 @@ from homeassistant.components import websocket_api from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import discovery_flow, system_info from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.frame import report from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_usb @@ -30,6 +33,36 @@ _LOGGER = logging.getLogger(__name__) REQUEST_SCAN_COOLDOWN = 60 # 1 minute cooldown +@dataclasses.dataclass +class UsbServiceInfo(BaseServiceInfo): + """Prepared info from usb entries.""" + + device: str + vid: str + pid: str + serial_number: str | None + manufacturer: str | None + description: str | None + + # Used to prevent log flooding. To be removed in 2022.6 + _warning_logged: bool = False + + def __getitem__(self, name: str) -> Any: + """ + Allow property access by name for compatibility reason. + + Deprecated, and will be removed in version 2022.6. + """ + if not self._warning_logged: + report( + f"accessed discovery_info['{name}'] instead of discovery_info.{name}; this will fail in version 2022.6", + exclude_integrations={"usb"}, + error_if_core=False, + ) + self._warning_logged = True + return getattr(self, name) + + def human_readable_device_name( device: str, serial_number: str | None, @@ -193,7 +226,14 @@ class USBDiscovery: self.hass, matcher["domain"], {"source": config_entries.SOURCE_USB}, - dataclasses.asdict(device), + UsbServiceInfo( + device=device.device, + vid=device.vid, + pid=device.pid, + serial_number=device.serial_number, + manufacturer=device.manufacturer, + description=device.description, + ), ) @callback diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index c91bbcb7e57..bdb88d05240 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -17,6 +17,7 @@ from .const import ( ATTR_TARIFF, CONF_CRON_PATTERN, CONF_METER, + CONF_METER_DELTA_VALUES, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, CONF_METER_TYPE, @@ -84,6 +85,7 @@ METER_CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_METER_OFFSET, default=DEFAULT_OFFSET): vol.All( cv.time_period, cv.positive_timedelta, max_28_days ), + vol.Optional(CONF_METER_DELTA_VALUES, default=False): cv.boolean, vol.Optional(CONF_METER_NET_CONSUMPTION, default=False): cv.boolean, vol.Optional(CONF_TARIFFS, default=[]): vol.All( cv.ensure_list, [cv.string] diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index 3e127e4a643..097496e231d 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -28,6 +28,7 @@ CONF_METER = "meter" CONF_SOURCE_SENSOR = "source" CONF_METER_TYPE = "cycle" CONF_METER_OFFSET = "offset" +CONF_METER_DELTA_VALUES = "delta_values" CONF_METER_NET_CONSUMPTION = "net_consumption" CONF_PAUSED = "paused" CONF_TARIFFS = "tariffs" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index ec553cce58a..69770ec2445 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -38,6 +38,7 @@ from .const import ( BIMONTHLY, CONF_CRON_PATTERN, CONF_METER, + CONF_METER_DELTA_VALUES, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, CONF_METER_TYPE, @@ -100,6 +101,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= conf_meter_source = hass.data[DATA_UTILITY][meter][CONF_SOURCE_SENSOR] conf_meter_type = hass.data[DATA_UTILITY][meter].get(CONF_METER_TYPE) conf_meter_offset = hass.data[DATA_UTILITY][meter][CONF_METER_OFFSET] + conf_meter_delta_values = hass.data[DATA_UTILITY][meter][ + CONF_METER_DELTA_VALUES + ] conf_meter_net_consumption = hass.data[DATA_UTILITY][meter][ CONF_METER_NET_CONSUMPTION ] @@ -113,6 +117,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= conf.get(CONF_NAME), conf_meter_type, conf_meter_offset, + conf_meter_delta_values, conf_meter_net_consumption, conf.get(CONF_TARIFF), conf_meter_tariff_entity, @@ -143,6 +148,7 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): name, meter_type, meter_offset, + delta_values, net_consumption, tariff=None, tariff_entity=None, @@ -171,6 +177,7 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): _LOGGER.debug("CRON pattern: %s", self._cron_pattern) else: self._cron_pattern = cron_pattern + self._sensor_delta_values = delta_values self._sensor_net_consumption = net_consumption self._tariff = tariff self._tariff_entity = tariff_entity @@ -206,12 +213,15 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): self._unit_of_measurement = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) try: - diff = Decimal(new_state.state) - Decimal(old_state.state) + if self._sensor_delta_values: + adjustment = Decimal(new_state.state) + else: + adjustment = Decimal(new_state.state) - Decimal(old_state.state) - if (not self._sensor_net_consumption) and diff < 0: + if (not self._sensor_net_consumption) and adjustment < 0: # Source sensor just rolled over for unknown reasons, return - self._state += diff + self._state += adjustment except ValueError as err: _LOGGER.warning("While processing state changes: %s", err) @@ -287,8 +297,7 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): async_dispatcher_connect(self.hass, SIGNAL_RESET_METER, self.async_reset_meter) - state = await self.async_get_last_state() - if state: + if state := await self.async_get_last_state(): try: self._state = Decimal(state.state) except InvalidOperation: diff --git a/homeassistant/components/uvc/camera.py b/homeassistant/components/uvc/camera.py index 77ff6a30f95..fa914de1d6a 100644 --- a/homeassistant/components/uvc/camera.py +++ b/homeassistant/components/uvc/camera.py @@ -86,7 +86,7 @@ class UnifiVideoCamera(Camera): self._uuid = uuid self._name = name self._password = password - self.is_streaming = False + self._attr_is_streaming = False self._connect_addr = None self._camera = None self._motion_status = False diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 87d8d6f49f8..db186a464fa 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -40,6 +40,7 @@ from homeassistant.loader import bind_hass _LOGGER = logging.getLogger(__name__) DOMAIN = "vacuum" +ENTITY_ID_FORMAT = DOMAIN + ".{}" SCAN_INTERVAL = timedelta(seconds=20) ATTR_BATTERY_ICON = "battery_icon" diff --git a/homeassistant/components/vacuum/device_condition.py b/homeassistant/components/vacuum/device_condition.py index a66df1323f7..7a973c93694 100644 --- a/homeassistant/components/vacuum/device_condition.py +++ b/homeassistant/components/vacuum/device_condition.py @@ -54,11 +54,9 @@ async def async_get_conditions( @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" - if config_validation: - config = CONDITION_SCHEMA(config) if config[CONF_TYPE] == "is_docked": test_states = [STATE_DOCKED] else: diff --git a/homeassistant/components/vacuum/device_trigger.py b/homeassistant/components/vacuum/device_trigger.py index f4fdbcf972e..25a874a1e69 100644 --- a/homeassistant/components/vacuum/device_trigger.py +++ b/homeassistant/components/vacuum/device_trigger.py @@ -92,7 +92,7 @@ async def async_attach_trigger( } if CONF_FOR in config: state_config[CONF_FOR] = config[CONF_FOR] - state_config = state_trigger.TRIGGER_SCHEMA(state_config) + state_config = await state_trigger.async_validate_trigger_config(hass, state_config) return await state_trigger.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) diff --git a/homeassistant/components/vacuum/translations/ca.json b/homeassistant/components/vacuum/translations/ca.json index 11d431a1810..d98a51a5363 100644 --- a/homeassistant/components/vacuum/translations/ca.json +++ b/homeassistant/components/vacuum/translations/ca.json @@ -19,8 +19,8 @@ "docked": "Aparcat", "error": "Error", "idle": "Inactiu", - "off": "off", - "on": "on", + "off": "OFF", + "on": "ON", "paused": "Pausat/ada", "returning": "Retornant a base" } diff --git a/homeassistant/components/vacuum/translations/ja.json b/homeassistant/components/vacuum/translations/ja.json index ba421a8767c..649cc868971 100644 --- a/homeassistant/components/vacuum/translations/ja.json +++ b/homeassistant/components/vacuum/translations/ja.json @@ -1,7 +1,29 @@ { + "device_automation": { + "action_type": { + "clean": "{entity_name} \u30af\u30ea\u30fc\u30f3\u30a2\u30c3\u30d7\u3057\u307e\u3057\u3087\u3046", + "dock": "{entity_name} \u3092\u30c9\u30c3\u30af\u306b\u623b\u3057\u3066\u307f\u307e\u3057\u3087\u3046" + }, + "condition_type": { + "is_cleaning": "{entity_name} \u306f\u30af\u30ea\u30fc\u30cb\u30f3\u30b0\u4e2d\u3067\u3059", + "is_docked": "{entity_name} \u304c\u30c9\u30c3\u30ad\u30f3\u30b0\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "trigger_type": { + "cleaning": "{entity_name} \u304c\u30af\u30ea\u30fc\u30cb\u30f3\u30b0\u3092\u958b\u59cb", + "docked": "{entity_name} \u30c9\u30c3\u30ad\u30f3\u30b0\u6e08\u307f" + } + }, "state": { "_": { - "docked": "\u30c9\u30c3\u30ad\u30f3\u30b0" + "cleaning": "\u30af\u30ea\u30fc\u30cb\u30f3\u30b0", + "docked": "\u30c9\u30c3\u30ad\u30f3\u30b0", + "error": "\u30a8\u30e9\u30fc", + "idle": "\u30a2\u30a4\u30c9\u30eb", + "off": "\u30aa\u30d5", + "on": "\u30aa\u30f3", + "paused": "\u4e00\u6642\u505c\u6b62", + "returning": "\u30c9\u30c3\u30af\u306b\u623b\u308b" } - } + }, + "title": "\u771f\u7a7a(Vacuum)" } \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/tr.json b/homeassistant/components/vacuum/translations/tr.json index 7d127417cb5..955dfb03a5f 100644 --- a/homeassistant/components/vacuum/translations/tr.json +++ b/homeassistant/components/vacuum/translations/tr.json @@ -1,4 +1,18 @@ { + "device_automation": { + "action_type": { + "clean": "{entity_name} \u00f6\u011fesini temizle", + "dock": "{entity_name} dock'a d\u00f6ns\u00fcn" + }, + "condition_type": { + "is_cleaning": "{entity_name} temizleniyor", + "is_docked": "{entity_name} yerle\u015ftirildi" + }, + "trigger_type": { + "cleaning": "{entity_name} temizlemeye ba\u015flad\u0131", + "docked": "{entity_name} yerle\u015ftirildi" + } + }, "state": { "_": { "cleaning": "Temizleniyor", diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 63b594a5bf2..73dc633834e 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -5,9 +5,11 @@ from dataclasses import dataclass, field import ipaddress import logging from typing import Any, NamedTuple +from uuid import UUID from vallox_websocket_api import PROFILE as VALLOX_PROFILE, Vallox from vallox_websocket_api.exceptions import ValloxApiException +from vallox_websocket_api.vallox import get_uuid as calculate_uuid import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STARTED @@ -114,6 +116,13 @@ class ValloxState: return value + def get_uuid(self) -> UUID | None: + """Return cached UUID value.""" + uuid = calculate_uuid(self.metric_cache) + if not isinstance(uuid, UUID): + raise ValueError + return uuid + class ValloxDataUpdateCoordinator(DataUpdateCoordinator): """The DataUpdateCoordinator for Vallox.""" diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 4d621615aef..6de30302838 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -99,6 +99,8 @@ class ValloxFan(CoordinatorEntity, FanEntity): self._attr_name = name + self._attr_unique_id = str(self.coordinator.data.get_uuid()) + @property def supported_features(self) -> int: """Flag supported features.""" diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index 0b96316b766..6eee46be737 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -52,8 +52,11 @@ class ValloxSensor(CoordinatorEntity, SensorEntity): self._attr_name = f"{name} {description.name}" + uuid = self.coordinator.data.get_uuid() + self._attr_unique_id = f"{uuid}-{description.key}" + @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the value reported by the sensor.""" if (metric_key := self.entity_description.metric_key) is None: return None @@ -81,7 +84,7 @@ class ValloxFanSpeedSensor(ValloxSensor): """Child class for fan speed reporting.""" @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the value reported by the sensor.""" fan_is_on = self.coordinator.data.get_metric(METRIC_KEY_MODE) == MODE_ON return super().native_value if fan_is_on else 0 @@ -91,7 +94,7 @@ class ValloxFilterRemainingSensor(ValloxSensor): """Child class for filter remaining time reporting.""" @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the value reported by the sensor.""" super_native_value = super().native_value @@ -104,7 +107,7 @@ class ValloxFilterRemainingSensor(ValloxSensor): days_remaining_delta = timedelta(days=days_remaining) now = datetime.utcnow().replace(hour=13, minute=0, second=0, microsecond=0) - return (now + days_remaining_delta).isoformat() + return now + days_remaining_delta class ValloxCellStateSensor(ValloxSensor): diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index acc90116269..330a4315a25 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -3,14 +3,17 @@ from __future__ import annotations import logging +from velbusaio.channels import Channel as VelbusChannel from velbusaio.controller import Velbus import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PORT -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_PORT +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import device_registry import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import ( CONF_INTERFACE, @@ -23,34 +26,9 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Schema({vol.Required(CONF_PORT): cv.string})}, extra=vol.ALLOW_EXTRA -) - PLATFORMS = ["switch", "sensor", "binary_sensor", "cover", "climate", "light"] -async def async_setup(hass, config): - """Set up the Velbus platform.""" - # Import from the configuration file if needed - if DOMAIN not in config: - return True - - _LOGGER.warning("Loading VELBUS via configuration.yaml is deprecated") - - port = config[DOMAIN].get(CONF_PORT) - data = {} - - if port: - data = {CONF_PORT: port, CONF_NAME: "Velbus import"} - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=data - ) - ) - return True - - async def velbus_connect_task( controller: Velbus, hass: HomeAssistant, entry_id: str ) -> None: @@ -58,6 +36,22 @@ async def velbus_connect_task( await controller.connect() +def _migrate_device_identifiers(hass: HomeAssistant, entry_id: str) -> None: + """Migrate old device indentifiers.""" + dev_reg = device_registry.async_get(hass) + devices: list[DeviceEntry] = device_registry.async_entries_for_config_entry( + dev_reg, entry_id + ) + for device in devices: + old_identifier = list(next(iter(device.identifiers))) + if len(old_identifier) > 2: + new_identifier = {(old_identifier.pop(0), old_identifier.pop(0))} + _LOGGER.debug( + "migrate identifier '%s' to '%s'", device.identifiers, new_identifier + ) + dev_reg.async_update_device(device.id, new_identifiers=new_identifier) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Establish connection with velbus.""" hass.data.setdefault(DOMAIN, {}) @@ -72,12 +66,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: velbus_connect_task(controller, hass, entry.entry_id) ) + _migrate_device_identifiers(hass, entry.entry_id) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) if hass.services.has_service(DOMAIN, SERVICE_SCAN): return True - def check_entry_id(interface: str): + def check_entry_id(interface: str) -> str: for entry in hass.config_entries.async_entries(DOMAIN): if "port" in entry.data and entry.data["port"] == interface: return entry.entry_id @@ -85,7 +81,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "The interface provided is not defined as a port in a Velbus integration" ) - async def scan(call): + async def scan(call: ServiceCall) -> None: await hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"].scan() hass.services.async_register( @@ -95,7 +91,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), ) - async def syn_clock(call): + async def syn_clock(call: ServiceCall) -> None: await hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"].sync_clock() hass.services.async_register( @@ -105,7 +101,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), ) - async def set_memo_text(call): + async def set_memo_text(call: ServiceCall) -> None: """Handle Memo Text service call.""" memo_text = call.data[CONF_MEMO_TEXT] memo_text.hass = hass @@ -147,47 +143,27 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class VelbusEntity(Entity): """Representation of a Velbus entity.""" - def __init__(self, channel): + _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()}" - @property - def unique_id(self): - """Get unique ID.""" - if (serial := self._channel.get_module_serial()) == 0: - serial = self._channel.get_module_address() - return f"{serial}-{self._channel.get_channel_number()}" - - @property - def name(self): - """Return the display name of this entity.""" - return self._channel.get_name() - - @property - def should_poll(self): - """Disable polling.""" - return False - - async def async_added_to_hass(self): + 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): + async def _on_update(self) -> None: self.async_write_ha_state() - - @property - def device_info(self): - """Return the device info.""" - return { - "identifiers": { - ( - DOMAIN, - self._channel.get_module_address(), - self._channel.get_module_serial(), - ) - }, - "name": self._channel.get_full_name(), - "manufacturer": "Velleman", - "model": self._channel.get_module_type_name(), - "sw_version": self._channel.get_module_sw_version(), - } diff --git a/homeassistant/components/velbus/binary_sensor.py b/homeassistant/components/velbus/binary_sensor.py index be5d8d24698..8c67520dd9a 100644 --- a/homeassistant/components/velbus/binary_sensor.py +++ b/homeassistant/components/velbus/binary_sensor.py @@ -1,11 +1,20 @@ """Support for Velbus Binary Sensors.""" +from velbusaio.channels import Button as VelbusButton + from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import VelbusEntity from .const import DOMAIN -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Velbus switch based on config_entry.""" await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] @@ -18,6 +27,8 @@ async def async_setup_entry(hass, entry, async_add_entities): class VelbusBinarySensor(VelbusEntity, BinarySensorEntity): """Representation of a Velbus Binary Sensor.""" + _channel: VelbusButton + @property def is_on(self) -> bool: """Return true if the sensor is on.""" diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py index 6bc848a92ab..c11698b1358 100644 --- a/homeassistant/components/velbus/climate.py +++ b/homeassistant/components/velbus/climate.py @@ -1,19 +1,30 @@ """Support for Velbus thermostat.""" from __future__ import annotations +from typing import Any + +from velbusaio.channels import Temperature as VelbusTemp + from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( HVAC_MODE_HEAT, SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import VelbusEntity from .const import DOMAIN, PRESET_MODES -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Velbus switch based on config_entry.""" await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] @@ -26,41 +37,18 @@ async def async_setup_entry(hass, entry, async_add_entities): class VelbusClimate(VelbusEntity, ClimateEntity): """Representation of a Velbus thermostat.""" - @property - def supported_features(self) -> int: - """Return the list off supported features.""" - return SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE + _channel: VelbusTemp + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE + _attr_temperature_unit = TEMP_CELSIUS + _attr_hvac_mode = HVAC_MODE_HEAT + _attr_hvac_modes = [HVAC_MODE_HEAT] + _attr_preset_modes = list(PRESET_MODES) @property - def temperature_unit(self) -> str: - """Return the unit.""" - return TEMP_CELSIUS - - @property - def current_temperature(self) -> int | None: - """Return the current temperature.""" - return self._channel.get_state() - - @property - def hvac_mode(self) -> str: - """Return hvac operation ie. heat, cool mode.""" - return HVAC_MODE_HEAT - - @property - def hvac_modes(self) -> list[str]: - """Return the list of available hvac operation modes.""" - return [HVAC_MODE_HEAT] - - @property - def target_temperature(self) -> int | None: + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._channel.get_climate_target() - @property - def preset_modes(self) -> list[str] | None: - """Return a list of all possible presets.""" - return list(PRESET_MODES) - @property def preset_mode(self) -> str | None: """Return the current Preset for this channel.""" @@ -73,7 +61,7 @@ class VelbusClimate(VelbusEntity, ClimateEntity): None, ) - async def async_set_temperature(self, **kwargs) -> None: + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" if (temp := kwargs.get(ATTR_TEMPERATURE)) is None: return diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 3ec5af14397..3a057b482df 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -1,6 +1,8 @@ """Config flow for the Velbus platform.""" from __future__ import annotations +from typing import Any + import velbusaio from velbusaio.exceptions import VelbusConnectionFailed import voluptuous as vol @@ -8,16 +10,17 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.util import slugify from .const import DOMAIN @callback -def velbus_entries(hass: HomeAssistant): +def velbus_entries(hass: HomeAssistant) -> set[str]: """Return connections for Velbus domain.""" return { - (entry.data[CONF_PORT]) for entry in hass.config_entries.async_entries(DOMAIN) + entry.data[CONF_PORT] for entry in hass.config_entries.async_entries(DOMAIN) } @@ -30,11 +33,11 @@ class VelbusConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Initialize the velbus config flow.""" self._errors: dict[str, str] = {} - def _create_device(self, name: str, prt: str): + def _create_device(self, name: str, prt: str) -> FlowResult: """Create an entry async.""" return self.async_create_entry(title=name, data={CONF_PORT: prt}) - async def _test_connection(self, prt): + async def _test_connection(self, prt: str) -> bool: """Try to connect to the velbus with the port specified.""" try: controller = velbusaio.controller.Velbus(prt) @@ -51,7 +54,9 @@ class VelbusConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return True return False - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Step when user initializes a integration.""" self._errors = {} if user_input is not None: @@ -77,13 +82,3 @@ class VelbusConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), errors=self._errors, ) - - async def async_step_import(self, user_input=None): - """Import a config entry.""" - user_input[CONF_NAME] = "Velbus Import" - prt = user_input[CONF_PORT] - if self._prt_in_configuration_exists(prt): - # if the velbus import is already in the config - # we should not proceed the import - return self.async_abort(reason="already_configured") - return await self.async_step_user(user_input) diff --git a/homeassistant/components/velbus/cover.py b/homeassistant/components/velbus/cover.py index 53fd32fad34..2e2ceb761a9 100644 --- a/homeassistant/components/velbus/cover.py +++ b/homeassistant/components/velbus/cover.py @@ -1,4 +1,10 @@ """Support for Velbus covers.""" +from __future__ import annotations + +from typing import Any + +from velbusaio.channels import Blind as VelbusBlind + from homeassistant.components.cover import ( ATTR_POSITION, SUPPORT_CLOSE, @@ -7,12 +13,19 @@ from homeassistant.components.cover import ( SUPPORT_STOP, CoverEntity, ) +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 -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Velbus switch based on config_entry.""" await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] @@ -25,40 +38,44 @@ async def async_setup_entry(hass, entry, async_add_entities): class VelbusCover(VelbusEntity, CoverEntity): """Representation a Velbus cover.""" - @property - def supported_features(self): - """Flag supported features.""" + _channel: VelbusBlind + + def __init__(self, channel: VelbusBlind) -> None: + """Initialize the dimmer.""" + super().__init__(channel) if self._channel.support_position(): - return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP | SUPPORT_SET_POSITION - return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP + self._attr_supported_features = ( + SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP | SUPPORT_SET_POSITION + ) + else: + self._attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed.""" return self._channel.is_closed() @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover. None is unknown, 0 is closed, 100 is fully open Velbus: 100 = closed, 0 = open """ - pos = self._channel.get_position() - return 100 - pos + return 100 - self._channel.get_position() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._channel.open() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self._channel.close() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._channel.stop() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" self._channel.set_position(100 - kwargs[ATTR_POSITION]) diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py index a252930b49d..bd903c76790 100644 --- a/homeassistant/components/velbus/light.py +++ b/homeassistant/components/velbus/light.py @@ -1,4 +1,14 @@ """Support for Velbus light.""" +from __future__ import annotations + +from typing import Any + +from velbusaio.channels import ( + Button as VelbusButton, + Channel as VelbusChannel, + Dimmer as VelbusDimmer, +) + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_FLASH, @@ -10,94 +20,108 @@ from homeassistant.components.light import ( SUPPORT_TRANSITION, LightEntity, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import VelbusEntity from .const import DOMAIN -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Velbus switch based on config_entry.""" await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - entities = [] + entities: list[Entity] = [] for channel in cntrl.get_all("light"): - entities.append(VelbusLight(channel, False)) + entities.append(VelbusLight(channel)) for channel in cntrl.get_all("led"): - entities.append(VelbusLight(channel, True)) + entities.append(VelbusButtonLight(channel)) async_add_entities(entities) class VelbusLight(VelbusEntity, LightEntity): """Representation of a Velbus light.""" - def __init__(self, channel, led): - """Initialize a light Velbus entity.""" - super().__init__(channel) - self._is_led = led + _channel: VelbusDimmer + _attr_supported_feature = SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION @property - def name(self): - """Return the display name of this entity.""" - if self._is_led: - return f"LED {self._channel.get_name()}" - return self._channel.get_name() - - @property - def supported_features(self): - """Flag supported features.""" - if self._is_led: - return SUPPORT_FLASH - return SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION - - @property - def is_on(self): + def is_on(self) -> bool: """Return true if the light is on.""" return self._channel.is_on() @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of the light.""" return int((self._channel.get_dimmer_state() * 255) / 100) - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the Velbus light to turn on.""" - if self._is_led: - if ATTR_FLASH in kwargs: - if kwargs[ATTR_FLASH] == FLASH_LONG: - attr, *args = "set_led_state", "slow" - elif kwargs[ATTR_FLASH] == FLASH_SHORT: - attr, *args = "set_led_state", "fast" - else: - attr, *args = "set_led_state", "on" + if ATTR_BRIGHTNESS in kwargs: + # Make sure a low but non-zero value is not rounded down to zero + if kwargs[ATTR_BRIGHTNESS] == 0: + brightness = 0 else: - attr, *args = "set_led_state", "on" - else: - if ATTR_BRIGHTNESS in kwargs: - # Make sure a low but non-zero value is not rounded down to zero - if kwargs[ATTR_BRIGHTNESS] == 0: - brightness = 0 - else: - brightness = max(int((kwargs[ATTR_BRIGHTNESS] * 100) / 255), 1) - attr, *args = ( - "set_dimmer_state", - brightness, - kwargs.get(ATTR_TRANSITION, 0), - ) - else: - attr, *args = ( - "restore_dimmer_state", - kwargs.get(ATTR_TRANSITION, 0), - ) - await getattr(self._channel, attr)(*args) - - async def async_turn_off(self, **kwargs): - """Instruct the velbus light to turn off.""" - if self._is_led: - attr, *args = "set_led_state", "off" - else: + brightness = max(int((kwargs[ATTR_BRIGHTNESS] * 100) / 255), 1) attr, *args = ( "set_dimmer_state", - 0, + brightness, + kwargs.get(ATTR_TRANSITION, 0), + ) + else: + attr, *args = ( + "restore_dimmer_state", kwargs.get(ATTR_TRANSITION, 0), ) await getattr(self._channel, attr)(*args) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the velbus light to turn off.""" + attr, *args = ( + "set_dimmer_state", + 0, + kwargs.get(ATTR_TRANSITION, 0), + ) + await getattr(self._channel, attr)(*args) + + +class VelbusButtonLight(VelbusEntity, LightEntity): + """Representation of a Velbus light.""" + + _channel: VelbusButton + _attr_entity_registry_enabled_default = False + _attr_supported_feature = SUPPORT_FLASH + + def __init__(self, channel: VelbusChannel) -> None: + """Initialize the button light (led).""" + super().__init__(channel) + self._attr_name = f"LED {self._channel.get_name()}" + + @property + def is_on(self) -> Any: + """Return true if the light is on.""" + return self._channel.is_on() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the Velbus light to turn on.""" + if ATTR_FLASH in kwargs: + if kwargs[ATTR_FLASH] == FLASH_LONG: + attr, *args = "set_led_state", "slow" + elif kwargs[ATTR_FLASH] == FLASH_SHORT: + attr, *args = "set_led_state", "fast" + else: + attr, *args = "set_led_state", "on" + else: + attr, *args = "set_led_state", "on" + await getattr(self._channel, attr)(*args) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the velbus light to turn off.""" + attr, *args = "set_led_state", "off" + await getattr(self._channel, attr)(*args) diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index 32f016b8ce3..34642dd3bf1 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -1,22 +1,31 @@ """Support for Velbus sensors.""" from __future__ import annotations +from velbusaio.channels import ButtonCounter, LightSensor, SensorNumber, Temperature + from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import VelbusEntity from .const import DOMAIN -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Velbus switch based on config_entry.""" await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] @@ -31,62 +40,46 @@ async def async_setup_entry(hass, entry, async_add_entities): class VelbusSensor(VelbusEntity, SensorEntity): """Representation of a sensor.""" - def __init__(self, channel, counter=False): + _channel: ButtonCounter | Temperature | LightSensor | SensorNumber + + def __init__( + self, + channel: ButtonCounter | Temperature | LightSensor | SensorNumber, + counter: bool = False, + ) -> None: """Initialize a sensor Velbus entity.""" super().__init__(channel) - self._is_counter = counter - - @property - def unique_id(self): - """Return unique ID for counter sensors.""" - unique_id = super().unique_id + self._is_counter: bool = counter + # define the unique id if self._is_counter: - unique_id = f"{unique_id}-counter" - return unique_id - - @property - def name(self): - """Return the name for the sensor.""" - name = super().name + self._attr_unique_id = f"{self._attr_unique_id}-counter" + # define the name if self._is_counter: - name = f"{name}-counter" - return name - - @property - def device_class(self): - """Return the device class of the sensor.""" + self._attr_name = f"{self._attr_name}-counter" + # define the device class if self._is_counter: - return DEVICE_CLASS_ENERGY - if self._channel.is_counter_channel(): - return DEVICE_CLASS_POWER - if self._channel.is_temperature(): - return DEVICE_CLASS_TEMPERATURE - return None + self._attr_device_class = DEVICE_CLASS_ENERGY + elif channel.is_counter_channel(): + self._attr_device_class = DEVICE_CLASS_POWER + elif channel.is_temperature(): + self._attr_device_class = DEVICE_CLASS_TEMPERATURE + # define the icon + if self._is_counter: + self._attr_icon = "mdi:counter" + # the state class + if self._is_counter: + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + else: + self._attr_state_class = STATE_CLASS_MEASUREMENT + # unit + if self._is_counter: + self._attr_native_unit_of_measurement = channel.get_counter_unit() + else: + self._attr_native_unit_of_measurement = channel.get_unit() @property - def native_value(self): + def native_value(self) -> float | int | None: """Return the state of the sensor.""" if self._is_counter: - return self._channel.get_counter_state() - return self._channel.get_state() - - @property - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - if self._is_counter: - return self._channel.get_counter_unit() - return self._channel.get_unit() - - @property - def icon(self): - """Icon to use in the frontend.""" - if self._is_counter: - return "mdi:counter" - return None - - @property - def state_class(self): - """Return the state class of this device.""" - if self._is_counter: - return STATE_CLASS_TOTAL_INCREASING - return STATE_CLASS_MEASUREMENT + return float(self._channel.get_counter_state()) + return float(self._channel.get_state()) diff --git a/homeassistant/components/velbus/switch.py b/homeassistant/components/velbus/switch.py index 70c7e1eb457..c3c4c8a5863 100644 --- a/homeassistant/components/velbus/switch.py +++ b/homeassistant/components/velbus/switch.py @@ -1,13 +1,22 @@ """Support for Velbus switches.""" from typing import Any +from velbusaio.channels import Relay as VelbusRelay + from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import VelbusEntity from .const import DOMAIN -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Velbus switch based on config_entry.""" await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] @@ -20,6 +29,8 @@ async def async_setup_entry(hass, entry, async_add_entities): class VelbusSwitch(VelbusEntity, SwitchEntity): """Representation of a switch.""" + _channel: VelbusRelay + @property def is_on(self) -> bool: """Return true if the switch is on.""" diff --git a/homeassistant/components/velbus/translations/ja.json b/homeassistant/components/velbus/translations/ja.json new file mode 100644 index 00000000000..8a1b2055945 --- /dev/null +++ b/homeassistant/components/velbus/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": { + "already_configured": "\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" + }, + "step": { + "user": { + "data": { + "name": "\u3053\u306e\u3001velbus connection\u306e\u540d\u524d", + "port": "\u63a5\u7d9a\u6587\u5b57\u5217" + }, + "title": "velbus connection\u30bf\u30a4\u30d7\u306e\u5b9a\u7fa9" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/velbus/translations/tr.json b/homeassistant/components/velbus/translations/tr.json index e7ee4ea7157..001b2c1d5eb 100644 --- a/homeassistant/components/velbus/translations/tr.json +++ b/homeassistant/components/velbus/translations/tr.json @@ -6,6 +6,15 @@ "error": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "step": { + "user": { + "data": { + "name": "Bu velbus ba\u011flant\u0131s\u0131n\u0131n ad\u0131", + "port": "Ba\u011flant\u0131 dizesi" + }, + "title": "Velbus ba\u011flant\u0131 t\u00fcr\u00fcn\u00fc tan\u0131mlay\u0131n" + } } } } \ No newline at end of file diff --git a/homeassistant/components/venstar/__init__.py b/homeassistant/components/venstar/__init__.py index 27d3e77754a..2ed6080f84f 100644 --- a/homeassistant/components/venstar/__init__.py +++ b/homeassistant/components/venstar/__init__.py @@ -1,9 +1,11 @@ """The venstar component.""" import asyncio +from datetime import timedelta from requests import RequestException from venstarcolortouch import VenstarColorTouch +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -11,12 +13,13 @@ from homeassistant.const import ( CONF_SSL, CONF_USERNAME, ) -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.entity import Entity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import update_coordinator +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import _LOGGER, DOMAIN, VENSTAR_TIMEOUT -PLATFORMS = ["climate"] +PLATFORMS = ["binary_sensor", "climate", "sensor"] async def async_setup_entry(hass, config): @@ -37,11 +40,13 @@ async def async_setup_entry(hass, config): proto=protocol, ) - try: - await hass.async_add_executor_job(client.update_info) - except (OSError, RequestException) as ex: - raise ConfigEntryNotReady(f"Unable to connect to the thermostat: {ex}") from ex - hass.data.setdefault(DOMAIN, {})[config.entry_id] = client + venstar_data_coordinator = VenstarDataUpdateCoordinator( + hass, + venstar_connection=client, + ) + await venstar_data_coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[config.entry_id] = venstar_data_coordinator hass.config_entries.async_setup_platforms(config, PLATFORMS) return True @@ -55,45 +60,74 @@ async def async_unload_entry(hass, config): return unload_ok -class VenstarEntity(Entity): - """Get the latest data and update.""" +class VenstarDataUpdateCoordinator(update_coordinator.DataUpdateCoordinator): + """Class to manage fetching Venstar data.""" - def __init__(self, config, client): - """Initialize the data object.""" - self._config = config - self._client = client + def __init__( + self, + hass: HomeAssistant, + *, + venstar_connection: VenstarColorTouch, + ) -> None: + """Initialize global Venstar data updater.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=60), + ) + self.client = venstar_connection - async def async_update(self): + async def _async_update_data(self) -> None: """Update the state.""" try: - info_success = await self.hass.async_add_executor_job( - self._client.update_info - ) + await self.hass.async_add_executor_job(self.client.update_info) except (OSError, RequestException) as ex: - _LOGGER.error("Exception during info update: %s", ex) + raise update_coordinator.UpdateFailed( + f"Exception during Venstar info update: {ex}" + ) from ex # older venstars sometimes cannot handle rapid sequential connections - await asyncio.sleep(3) + await asyncio.sleep(1) try: - sensor_success = await self.hass.async_add_executor_job( - self._client.update_sensors - ) + await self.hass.async_add_executor_job(self.client.update_sensors) except (OSError, RequestException) as ex: - _LOGGER.error("Exception during sensor update: %s", ex) + raise update_coordinator.UpdateFailed( + f"Exception during Venstar sensor update: {ex}" + ) from ex - if not info_success or not sensor_success: - _LOGGER.error("Failed to update data") + # older venstars sometimes cannot handle rapid sequential connections + await asyncio.sleep(1) - @property - def name(self): - """Return the name of the thermostat.""" - return self._client.name + try: + await self.hass.async_add_executor_job(self.client.update_alerts) + except (OSError, RequestException) as ex: + raise update_coordinator.UpdateFailed( + f"Exception during Venstar alert update: {ex}" + ) from ex + return None - @property - def unique_id(self): - """Set unique_id for this entity.""" - return f"{self._config.entry_id}" + +class VenstarEntity(CoordinatorEntity): + """Representation of a Venstar entity.""" + + coordinator: VenstarDataUpdateCoordinator + + def __init__( + self, + venstar_data_coordinator: VenstarDataUpdateCoordinator, + config: ConfigEntry, + ) -> None: + """Initialize the data object.""" + super().__init__(venstar_data_coordinator) + self._config = config + self._client = venstar_data_coordinator.client + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() @property def device_info(self): @@ -102,8 +136,6 @@ class VenstarEntity(Entity): "identifiers": {(DOMAIN, self._config.entry_id)}, "name": self._client.name, "manufacturer": "Venstar", - # pylint: disable=protected-access - "model": f"{self._client.model}-{self._client._type}", - # pylint: disable=protected-access - "sw_version": self._client._api_ver, + "model": f"{self._client.model}-{self._client.get_type()}", + "sw_version": self._client.get_api_ver(), } diff --git a/homeassistant/components/venstar/binary_sensor.py b/homeassistant/components/venstar/binary_sensor.py new file mode 100644 index 00000000000..1d6c8f49bd8 --- /dev/null +++ b/homeassistant/components/venstar/binary_sensor.py @@ -0,0 +1,42 @@ +"""Alarm sensors for the Venstar Thermostat.""" +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_PROBLEM, + BinarySensorEntity, +) + +from . import VenstarEntity +from .const import DOMAIN + + +async def async_setup_entry(hass, config_entry, async_add_entities) -> None: + """Set up Vensar device binary_sensors based on a config entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + if coordinator.client.alerts is None: + return + async_add_entities( + VenstarBinarySensor(coordinator, config_entry, alert["name"]) + for alert in coordinator.client.alerts + ) + + +class VenstarBinarySensor(VenstarEntity, BinarySensorEntity): + """Represent a Venstar alert.""" + + _attr_device_class = DEVICE_CLASS_PROBLEM + + def __init__(self, coordinator, config, alert): + """Initialize the alert.""" + super().__init__(coordinator, config) + self.alert = alert + self._attr_unique_id = f"{config.entry_id}_{alert.replace(' ', '_')}" + self._attr_name = f"{self._client.name} {alert}" + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + for alert in self._client.alerts: + if alert["name"] == self.alert: + return alert["active"] + + return None diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index d86a5953169..cb4e8ff1527 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -24,7 +24,7 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, ) -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, CONF_HOST, @@ -38,9 +38,11 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VenstarEntity +from . import VenstarDataUpdateCoordinator, VenstarEntity from .const import ( _LOGGER, ATTR_FAN_STATE, @@ -68,10 +70,21 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Venstar thermostat.""" - client = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([VenstarThermostat(config_entry, client)], True) + venstar_data_coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [ + VenstarThermostat( + venstar_data_coordinator, + config_entry, + ) + ], + ) async def async_setup_platform(hass, config, add_entities, discovery_info=None): @@ -95,14 +108,20 @@ async def async_setup_platform(hass, config, add_entities, discovery_info=None): class VenstarThermostat(VenstarEntity, ClimateEntity): """Representation of a Venstar thermostat.""" - def __init__(self, config, client): + def __init__( + self, + venstar_data_coordinator: VenstarDataUpdateCoordinator, + config: ConfigEntry, + ) -> None: """Initialize the thermostat.""" - super().__init__(config, client) + super().__init__(venstar_data_coordinator, config) self._mode_map = { HVAC_MODE_HEAT: self._client.MODE_HEAT, HVAC_MODE_COOL: self._client.MODE_COOL, HVAC_MODE_AUTO: self._client.MODE_AUTO, } + self._attr_unique_id = config.entry_id + self._attr_name = self._client.name @property def supported_features(self): @@ -294,6 +313,7 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): if not success: _LOGGER.error("Failed to change the temperature") + self.schedule_update_ha_state() def set_fan_mode(self, fan_mode): """Set new target fan mode.""" @@ -304,10 +324,12 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): if not success: _LOGGER.error("Failed to change the fan mode") + self.schedule_update_ha_state() def set_hvac_mode(self, hvac_mode): """Set new target operation mode.""" self._set_operation_mode(hvac_mode) + self.schedule_update_ha_state() def set_humidity(self, humidity): """Set new target humidity.""" @@ -315,6 +337,7 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): if not success: _LOGGER.error("Failed to change the target humidity level") + self.schedule_update_ha_state() def set_preset_mode(self, preset_mode): """Set the hold mode.""" @@ -332,3 +355,4 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): if not success: _LOGGER.error("Failed to change the schedule/hold state") + self.schedule_update_ha_state() diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json index 943790b532e..6fef7bf5d57 100644 --- a/homeassistant/components/venstar/manifest.json +++ b/homeassistant/components/venstar/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/venstar", "requirements": [ - "venstarcolortouch==0.14" + "venstarcolortouch==0.15" ], "codeowners": ["@garbled1"], "iot_class": "local_polling" diff --git a/homeassistant/components/venstar/sensor.py b/homeassistant/components/venstar/sensor.py new file mode 100644 index 00000000000..dc13269f7df --- /dev/null +++ b/homeassistant/components/venstar/sensor.py @@ -0,0 +1,135 @@ +"""Representation of Venstar sensors.""" +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.helpers.entity import Entity + +from . import VenstarDataUpdateCoordinator, VenstarEntity +from .const import DOMAIN + + +@dataclass +class VenstarSensorTypeMixin: + """Mixin for sensor required keys.""" + + cls: type[VenstarSensor] + stype: str + + +@dataclass +class VenstarSensorEntityDescription(SensorEntityDescription, VenstarSensorTypeMixin): + """Base description of a Sensor entity.""" + + +async def async_setup_entry(hass, config_entry, async_add_entities) -> None: + """Set up Vensar device binary_sensors based on a config entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + entities: list[Entity] = [] + + sensors = coordinator.client.get_sensor_list() + if not sensors: + return + + entities = [] + + for sensor_name in sensors: + entities.extend( + [ + description.cls(coordinator, config_entry, description, sensor_name) + for description in SENSOR_ENTITIES + if coordinator.client.get_sensor(sensor_name, description.stype) + is not None + ] + ) + + async_add_entities(entities) + + +class VenstarSensor(VenstarEntity, SensorEntity): + """Base class for a Venstar sensor.""" + + entity_description: VenstarSensorEntityDescription + + def __init__( + self, + coordinator: VenstarDataUpdateCoordinator, + config: ConfigEntry, + entity_description: VenstarSensorEntityDescription, + sensor_name: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, config) + self.entity_description = entity_description + self.sensor_name = sensor_name + self._config = config + + @property + def unique_id(self): + """Return the unique id.""" + return f"{self._config.entry_id}_{self.sensor_name.replace(' ', '_')}_{self.entity_description.key}" + + +class VenstarHumiditySensor(VenstarSensor): + """Represent a Venstar humidity sensor.""" + + @property + def name(self): + """Return the name of the device.""" + return f"{self._client.name} {self.sensor_name} Humidity" + + @property + def native_value(self) -> int: + """Return state of the sensor.""" + return self._client.get_sensor(self.sensor_name, "hum") + + +class VenstarTemperatureSensor(VenstarSensor): + """Represent a Venstar temperature sensor.""" + + @property + def name(self): + """Return the name of the device.""" + return ( + f"{self._client.name} {self.sensor_name.replace(' Temp', '')} Temperature" + ) + + @property + def native_unit_of_measurement(self) -> str: + """Return unit of measurement the value is expressed in.""" + if self._client.tempunits == self._client.TEMPUNITS_F: + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + @property + def native_value(self) -> float: + """Return state of the sensor.""" + return round(float(self._client.get_sensor(self.sensor_name, "temp")), 1) + + +SENSOR_ENTITIES: tuple[VenstarSensorEntityDescription, ...] = ( + VenstarSensorEntityDescription( + key="humidity", + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + cls=VenstarHumiditySensor, + stype="hum", + ), + VenstarSensorEntityDescription( + key="temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + cls=VenstarTemperatureSensor, + stype="temp", + ), +) diff --git a/homeassistant/components/venstar/translations/bg.json b/homeassistant/components/venstar/translations/bg.json new file mode 100644 index 00000000000..a1401a7f1b2 --- /dev/null +++ b/homeassistant/components/venstar/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" + }, + "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", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "pin": "\u041f\u0418\u041d \u043a\u043e\u0434", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/venstar/translations/fr.json b/homeassistant/components/venstar/translations/fr.json new file mode 100644 index 00000000000..2be362d5603 --- /dev/null +++ b/homeassistant/components/venstar/translations/fr.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00c9chec de connexion" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "password": "Mot de passe", + "pin": "Code PIN", + "ssl": "Utilise un certificat SSL", + "username": "Nom d'utilisateur" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/venstar/translations/he.json b/homeassistant/components/venstar/translations/he.json new file mode 100644 index 00000000000..88029de05dc --- /dev/null +++ b/homeassistant/components/venstar/translations/he.json @@ -0,0 +1,22 @@ +{ + "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", + "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", + "pin": "\u05e7\u05d5\u05d3 PIN", + "ssl": "\u05e9\u05d9\u05de\u05d5\u05e9 \u05d1\u05d0\u05d9\u05e9\u05d5\u05e8 SSL", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/venstar/translations/id.json b/homeassistant/components/venstar/translations/id.json new file mode 100644 index 00000000000..1f64e8aa6a5 --- /dev/null +++ b/homeassistant/components/venstar/translations/id.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Kata Sandi", + "pin": "Kode PIN", + "ssl": "Menggunakan sertifikat SSL", + "username": "Nama Pengguna" + }, + "title": "Hubungkan ke Termostat Venstar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/venstar/translations/it.json b/homeassistant/components/venstar/translations/it.json new file mode 100644 index 00000000000..66b7fac78bd --- /dev/null +++ b/homeassistant/components/venstar/translations/it.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "pin": "Codice PIN", + "ssl": "Utilizza un certificato SSL", + "username": "Nome utente" + }, + "title": "Collegati al termostato Venstar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/venstar/translations/ja.json b/homeassistant/components/venstar/translations/ja.json new file mode 100644 index 00000000000..c86755bec34 --- /dev/null +++ b/homeassistant/components/venstar/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" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "pin": "PIN\u30b3\u30fc\u30c9", + "ssl": "SSL\u8a3c\u660e\u66f8\u3092\u4f7f\u7528\u3059\u308b", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "Venstar\u793e\u306e\u30b5\u30fc\u30e2\u30b9\u30bf\u30c3\u30c8\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/venstar/translations/nl.json b/homeassistant/components/venstar/translations/nl.json new file mode 100644 index 00000000000..3c8a61faf20 --- /dev/null +++ b/homeassistant/components/venstar/translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "pin": "PIN-code", + "ssl": "Gebruik een SSL-certificaat", + "username": "Gebruikersnaam" + }, + "title": "Maak verbinding met de Venstar-thermostaat" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/venstar/translations/no.json b/homeassistant/components/venstar/translations/no.json new file mode 100644 index 00000000000..6e77da8d723 --- /dev/null +++ b/homeassistant/components/venstar/translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "host": "Vert", + "password": "Passord", + "pin": "PIN kode", + "ssl": "Bruker et SSL-sertifikat", + "username": "Brukernavn" + }, + "title": "Koble til Venstar-termostaten" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/venstar/translations/pl.json b/homeassistant/components/venstar/translations/pl.json index c931afdae8d..ad0e76435dc 100644 --- a/homeassistant/components/venstar/translations/pl.json +++ b/homeassistant/components/venstar/translations/pl.json @@ -4,7 +4,20 @@ "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" }, "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "password": "Has\u0142o", + "pin": "Kod PIN", + "ssl": "Certyfikat SSL", + "username": "Nazwa u\u017cytkownika" + }, + "title": "Po\u0142\u0105cz z termostatem Venstar" + } } } } \ No newline at end of file diff --git a/homeassistant/components/venstar/translations/sl.json b/homeassistant/components/venstar/translations/sl.json new file mode 100644 index 00000000000..d0af0651d27 --- /dev/null +++ b/homeassistant/components/venstar/translations/sl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "cannot_connect": "Povezava ni uspela" + }, + "step": { + "user": { + "data": { + "host": "Gostitelj", + "password": "Geslo", + "pin": "PIN koda", + "username": "Uporabni\u0161ko ime" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/venstar/translations/tr.json b/homeassistant/components/venstar/translations/tr.json new file mode 100644 index 00000000000..a6cb7f6ddd8 --- /dev/null +++ b/homeassistant/components/venstar/translations/tr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "host": "Ana bilgisayar", + "password": "Parola", + "pin": "PIN Kodu", + "ssl": "SSL sertifikas\u0131 kullan\u0131r", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "title": "Venstar Termostat'a ba\u011flan\u0131n" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py index 69d5a3ccbfa..f5dc16cf4ae 100644 --- a/homeassistant/components/vera/climate.py +++ b/homeassistant/components/vera/climate.py @@ -92,8 +92,7 @@ class VeraThermostat(VeraDevice[veraApi.VeraThermostat], ClimateEntity): @property def fan_mode(self) -> str | None: """Return the fan setting.""" - mode = self.vera_device.get_fan_mode() - if mode == "ContinuousOn": + if self.vera_device.get_fan_mode() == "ContinuousOn": return FAN_ON return FAN_AUTO diff --git a/homeassistant/components/vera/translations/ja.json b/homeassistant/components/vera/translations/ja.json new file mode 100644 index 00000000000..2f800ee7b83 --- /dev/null +++ b/homeassistant/components/vera/translations/ja.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "cannot_connect": "URL {base_url} \u3092\u6301\u3064\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u306b\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f" + }, + "step": { + "user": { + "data": { + "exclude": "Home Assistant\u304b\u3089\u9664\u5916\u3059\u308bVera\u30c7\u30d0\u30a4\u30b9\u306eID\u3002", + "lights": "Vera\u306f\u3001Home Assistant\u3067\u30e9\u30a4\u30c8\u3068\u3057\u3066\u6271\u3046\u30c7\u30d0\u30a4\u30b9ID\u3092\u5207\u308a\u66ff\u3048\u307e\u3059\u3002", + "vera_controller_url": "\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u306eURL" + }, + "description": "Vera\u306e\u30b3\u30f3\u30c8\u30ed\u30fc\u30e9\u306eURL\u3092\u4ee5\u4e0b\u306b\u793a\u3057\u307e\u3059: http://192.168.1.161:3480 \u306e\u3088\u3046\u306b\u306a\u3063\u3066\u3044\u308b\u306f\u305a\u3067\u3059\u3002", + "title": "Vera controller\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "exclude": "Home Assistant\u304b\u3089\u9664\u5916\u3059\u308bVera\u30c7\u30d0\u30a4\u30b9\u306eID\u3002", + "lights": "Vera\u306f\u3001Home Assistant\u3067\u30e9\u30a4\u30c8\u3068\u3057\u3066\u6271\u3046\u30c7\u30d0\u30a4\u30b9ID\u3092\u5207\u308a\u66ff\u3048\u307e\u3059\u3002" + }, + "description": "\u30aa\u30d7\u30b7\u30e7\u30f3\u306e\u30d1\u30e9\u30e1\u30fc\u30bf\u306e\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001vera\u306e\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044: https://www.home-assistant.io/integrations/vera/ \u6ce8: \u3053\u3053\u3067\u5909\u66f4\u3092\u884c\u3063\u305f\u5834\u5408\u306f\u3001Home Assistant\u30b5\u30fc\u30d0\u30fc\u3092\u518d\u8d77\u52d5\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\u5024\u3092\u30af\u30ea\u30a2\u3059\u308b\u306b\u306f\u3001\u30b9\u30da\u30fc\u30b9\u3092\u6307\u5b9a\u3057\u307e\u3059\u3002", + "title": "Vera controller\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vera/translations/tr.json b/homeassistant/components/vera/translations/tr.json index 35e81599bb1..fa66c1c9806 100644 --- a/homeassistant/components/vera/translations/tr.json +++ b/homeassistant/components/vera/translations/tr.json @@ -2,11 +2,27 @@ "config": { "abort": { "cannot_connect": "{base_url} url'si ile denetleyiciye ba\u011flan\u0131lamad\u0131" + }, + "step": { + "user": { + "data": { + "exclude": "Home Asistan\u0131'ndan hari\u00e7 tutulacak Vera cihaz kimlikleri.", + "lights": "Vera, Home Assistant'ta \u0131\u015f\u0131k gibi davranmak i\u00e7in cihaz kimliklerini de\u011fi\u015ftirir.", + "vera_controller_url": "Denetleyici URL'si" + }, + "description": "A\u015fa\u011f\u0131da bir Vera denetleyici URL'si sa\u011flay\u0131n. \u015eu \u015fekilde g\u00f6r\u00fcnmelidir: http://192.168.1.161:3480.", + "title": "Vera denetleyicisini kurun" + } } }, "options": { "step": { "init": { + "data": { + "exclude": "Home Asistan\u0131'ndan hari\u00e7 tutulacak Vera cihaz kimlikleri.", + "lights": "Vera, Home Assistant'ta \u0131\u015f\u0131k gibi davranmak i\u00e7in cihaz kimliklerini de\u011fi\u015ftirir." + }, + "description": "\u0130ste\u011fe ba\u011fl\u0131 parametrelerle ilgili ayr\u0131nt\u0131lar i\u00e7in vera belgelerine bak\u0131n: https://www.home-assistant.io/integrations/vera/. Not: Buradaki herhangi bir de\u011fi\u015fiklik, ev asistan\u0131 sunucusunun yeniden ba\u015flat\u0131lmas\u0131n\u0131 gerektirecektir. De\u011ferleri temizlemek i\u00e7in bir bo\u015fluk b\u0131rak\u0131n.", "title": "Vera denetleyici se\u00e7enekleri" } } diff --git a/homeassistant/components/verisure/__init__.py b/homeassistant/components/verisure/__init__.py index 8abb3e59a9f..bea82a4785c 100644 --- a/homeassistant/components/verisure/__init__.py +++ b/homeassistant/components/verisure/__init__.py @@ -31,7 +31,7 @@ PLATFORMS = [ SWITCH_DOMAIN, ] -CONFIG_SCHEMA = cv.deprecated(DOMAIN) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index a14efc7d4b1..ba276aa3675 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -2,8 +2,7 @@ from __future__ import annotations from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_OPENING, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -40,7 +39,7 @@ class VerisureDoorWindowSensor(CoordinatorEntity, BinarySensorEntity): coordinator: VerisureDataUpdateCoordinator - _attr_device_class = DEVICE_CLASS_OPENING + _attr_device_class = BinarySensorDeviceClass.OPENING def __init__( self, coordinator: VerisureDataUpdateCoordinator, serial_number: str @@ -87,7 +86,7 @@ class VerisureEthernetStatus(CoordinatorEntity, BinarySensorEntity): coordinator: VerisureDataUpdateCoordinator _attr_name = "Verisure Ethernet status" - _attr_device_class = DEVICE_CLASS_CONNECTIVITY + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC @property diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index 2016c4dbb83..ea65b1fbcd8 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -2,9 +2,8 @@ from __future__ import annotations from homeassistant.components.sensor import ( - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, + SensorDeviceClass, SensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -51,7 +50,7 @@ class VerisureThermometer(CoordinatorEntity, SensorEntity): coordinator: VerisureDataUpdateCoordinator - _attr_device_class = DEVICE_CLASS_TEMPERATURE + _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_native_unit_of_measurement = TEMP_CELSIUS _attr_state_class = STATE_CLASS_MEASUREMENT @@ -106,7 +105,7 @@ class VerisureHygrometer(CoordinatorEntity, SensorEntity): coordinator: VerisureDataUpdateCoordinator - _attr_device_class = DEVICE_CLASS_HUMIDITY + _attr_device_class = SensorDeviceClass.HUMIDITY _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = STATE_CLASS_MEASUREMENT diff --git a/homeassistant/components/verisure/translations/ja.json b/homeassistant/components/verisure/translations/ja.json new file mode 100644 index 00000000000..5bd85b67e34 --- /dev/null +++ b/homeassistant/components/verisure/translations/ja.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "installation": { + "data": { + "giid": "\u30a4\u30f3\u30b9\u30c8\u30ec\u30fc\u30b7\u30e7\u30f3" + }, + "description": "Home Assistant\u306f\u3001\u30de\u30a4\u30da\u30fc\u30b8\u30a2\u30ab\u30a6\u30f3\u30c8\u3067\u8907\u6570\u306eVerisure\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3092\u691c\u51fa\u3057\u307e\u3057\u305f\u3002Home Assistant\u306b\u8ffd\u52a0\u3059\u308b\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "reauth_confirm": { + "data": { + "description": "Verisure MyPages\u30a2\u30ab\u30a6\u30f3\u30c8\u3067\u518d\u8a8d\u8a3c\u3057\u307e\u3059\u3002", + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + } + }, + "user": { + "data": { + "description": "Verisure My Pages\u30a2\u30ab\u30a6\u30f3\u30c8\u3067\u30b5\u30a4\u30f3\u30a4\u30f3\u3057\u307e\u3059\u3002", + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + } + } + } + }, + "options": { + "error": { + "code_format_mismatch": "\u30c7\u30d5\u30a9\u30eb\u30c8\u306ePIN\u30b3\u30fc\u30c9\u304c\u5fc5\u8981\u306a\u6841\u6570\u3068\u4e00\u81f4\u3057\u307e\u305b\u3093" + }, + "step": { + "init": { + "data": { + "lock_code_digits": "\u30ed\u30c3\u30af\u306ePIN\u30b3\u30fc\u30c9\u306e\u6841\u6570", + "lock_default_code": "\u30ed\u30c3\u30af\u7528\u306e\u30c7\u30d5\u30a9\u30eb\u30c8PIN\u30b3\u30fc\u30c9(\u4f55\u3082\u6307\u5b9a\u3055\u308c\u3066\u3044\u306a\u3044\u5834\u5408\u306b\u4f7f\u7528\u3055\u308c\u307e\u3059)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/verisure/translations/tr.json b/homeassistant/components/verisure/translations/tr.json new file mode 100644 index 00000000000..88a0d943872 --- /dev/null +++ b/homeassistant/components/verisure/translations/tr.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "installation": { + "data": { + "giid": "Kurulum" + }, + "description": "Home Assistant, Sayfalar\u0131m hesab\u0131n\u0131zda birden fazla Verisure y\u00fcklemesi buldu. L\u00fctfen Home Assistant'a eklemek i\u00e7in kurulumu se\u00e7in." + }, + "reauth_confirm": { + "data": { + "description": "Verisure My Pages hesab\u0131n\u0131zla yeniden kimlik do\u011frulamas\u0131 yap\u0131n.", + "email": "E-posta", + "password": "Parola" + } + }, + "user": { + "data": { + "description": "Verisure My Pages hesab\u0131n\u0131zla oturum a\u00e7\u0131n.", + "email": "E-posta", + "password": "Parola" + } + } + } + }, + "options": { + "error": { + "code_format_mismatch": "Varsay\u0131lan PIN kodu, gerekli basamak say\u0131s\u0131yla e\u015fle\u015fmiyor" + }, + "step": { + "init": { + "data": { + "lock_code_digits": "Kilitler i\u00e7in PIN kodundaki hane say\u0131s\u0131", + "lock_default_code": "Kilitler i\u00e7in varsay\u0131lan PIN kodu, kod verilmezse kullan\u0131lacakt\u0131r." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/version/manifest.json b/homeassistant/components/version/manifest.json index aa8a2659dcd..f5dec053399 100644 --- a/homeassistant/components/version/manifest.json +++ b/homeassistant/components/version/manifest.json @@ -3,7 +3,7 @@ "name": "Version", "documentation": "https://www.home-assistant.io/integrations/version", "requirements": [ - "pyhaversion==21.10.0" + "pyhaversion==21.11.1" ], "codeowners": [ "@fabaff", diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index c32ac6d2a25..ce7f833c264 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -19,6 +19,7 @@ _LOGGER = logging.getLogger(__name__) DEV_TYPE_TO_HA = { "LV-PUR131S": "fan", "Core200S": "fan", + "Core400S": "fan", } FAN_MODE_AUTO = "auto" @@ -27,6 +28,7 @@ FAN_MODE_SLEEP = "sleep" PRESET_MODES = { "LV-PUR131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], "Core200S": [FAN_MODE_SLEEP], + "Core400S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], } SPEED_RANGE = (1, 3) # off is not included diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index 70c46d0f02e..cceb0157286 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -3,7 +3,7 @@ "name": "VeSync", "documentation": "https://www.home-assistant.io/integrations/vesync", "codeowners": ["@markperdue", "@webdjoe", "@thegardenmonkey"], - "requirements": ["pyvesync==1.4.0"], + "requirements": ["pyvesync==1.4.1"], "config_flow": true, "iot_class": "cloud_polling" } diff --git a/homeassistant/components/vesync/translations/ja.json b/homeassistant/components/vesync/translations/ja.json new file mode 100644 index 00000000000..66d7cf26ca9 --- /dev/null +++ b/homeassistant/components/vesync/translations/ja.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "E\u30e1\u30fc\u30eb" + }, + "title": "\u30e6\u30fc\u30b6\u30fc\u540d\u3068\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index 0457572e066..c80f4ef42c3 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -70,7 +70,7 @@ async def async_http_request(hass, uri): """Perform actual request.""" try: session = hass.helpers.aiohttp_client.async_get_clientsession(hass) - with async_timeout.timeout(REQUEST_TIMEOUT): + async with async_timeout.timeout(REQUEST_TIMEOUT): req = await session.get(uri) if req.status != HTTPStatus.OK: return {"error": req.status} diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index c1571c2f91b..a79dbf0657d 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -1,14 +1,15 @@ """The ViCare integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Callable from PyViCare.PyViCare import PyViCare from PyViCare.PyViCareDevice import Device import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_NAME, @@ -16,7 +17,7 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_USERNAME, ) -from homeassistant.helpers import discovery +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.storage import STORAGE_DIR @@ -30,7 +31,6 @@ from .const import ( VICARE_API, VICARE_CIRCUITS, VICARE_DEVICE_CONFIG, - VICARE_NAME, HeatingType, ) @@ -61,8 +61,8 @@ CONFIG_SCHEMA = vol.Schema( ): int, # Ignored: All circuits are now supported. Will be removed when switching to Setup via UI. vol.Optional(CONF_NAME, default="ViCare"): cv.string, vol.Optional( - CONF_HEATING_TYPE, default=DEFAULT_HEATING_TYPE - ): cv.enum(HeatingType), + CONF_HEATING_TYPE, default=DEFAULT_HEATING_TYPE.value + ): vol.In([e.value for e in HeatingType]), } ), ) @@ -71,44 +71,75 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass, config): - """Create the ViCare component.""" - conf = config[DOMAIN] - params = {"token_file": hass.config.path(STORAGE_DIR, "vicare_token.save")} +async def async_setup(hass: HomeAssistant, config) -> bool: + """Set up the ViCare component from yaml.""" + if DOMAIN not in config: + # Setup via UI. No need to continue yaml-based setup + return True - params["cacheDuration"] = conf.get(CONF_SCAN_INTERVAL) - params["client_id"] = conf.get(CONF_CLIENT_ID) - - hass.data[DOMAIN] = {} - hass.data[DOMAIN][VICARE_NAME] = conf[CONF_NAME] - setup_vicare_api(hass, conf, hass.data[DOMAIN]) - - hass.data[DOMAIN][CONF_HEATING_TYPE] = conf[CONF_HEATING_TYPE] - - for platform in PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, config) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) return True -def setup_vicare_api(hass, conf, entity_data): - """Set up PyVicare API.""" +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from config entry.""" + _LOGGER.debug("Setting up ViCare component") + + hass.data[DOMAIN] = {} + hass.data[DOMAIN][entry.entry_id] = {} + + await hass.async_add_executor_job(setup_vicare_api, hass, entry) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +def vicare_login(hass, entry_data): + """Login via PyVicare API.""" vicare_api = PyViCare() - vicare_api.setCacheDuration(conf[CONF_SCAN_INTERVAL]) + vicare_api.setCacheDuration(entry_data[CONF_SCAN_INTERVAL]) vicare_api.initWithCredentials( - conf[CONF_USERNAME], - conf[CONF_PASSWORD], - conf[CONF_CLIENT_ID], + entry_data[CONF_USERNAME], + entry_data[CONF_PASSWORD], + entry_data[CONF_CLIENT_ID], hass.config.path(STORAGE_DIR, "vicare_token.save"), ) + return vicare_api + + +def setup_vicare_api(hass, entry): + """Set up PyVicare API.""" + vicare_api = vicare_login(hass, entry.data) - device = vicare_api.devices[0] for device in vicare_api.devices: _LOGGER.info( "Found device: %s (online: %s)", device.getModel(), str(device.isOnline()) ) - entity_data[VICARE_DEVICE_CONFIG] = device - entity_data[VICARE_API] = getattr( - device, HEATING_TYPE_TO_CREATOR_METHOD[conf[CONF_HEATING_TYPE]] + + # Currently we only support a single device + device = vicare_api.devices[0] + hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG] = device + hass.data[DOMAIN][entry.entry_id][VICARE_API] = getattr( + device, + HEATING_TYPE_TO_CREATOR_METHOD[HeatingType(entry.data[CONF_HEATING_TYPE])], )() - entity_data[VICARE_CIRCUITS] = entity_data[VICARE_API].circuits + hass.data[DOMAIN][entry.entry_id][VICARE_CIRCUITS] = hass.data[DOMAIN][ + entry.entry_id + ][VICARE_API].circuits + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload ViCare config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 4484d5f5040..510c332f89f 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -17,9 +17,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.const import CONF_NAME from . import ViCareRequiredKeysMixin -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME +from .const import DOMAIN, VICARE_API, VICARE_CIRCUITS, VICARE_DEVICE_CONFIG _LOGGER = logging.getLogger(__name__) @@ -84,7 +85,7 @@ def _build_entity(name, vicare_api, device_config, sensor): async def _entities_from_descriptions( - hass, name, all_devices, sensor_descriptions, iterables + hass, name, all_devices, sensor_descriptions, iterables, config_entry ): """Create entities from descriptions and list of burners/circuits.""" for description in sensor_descriptions: @@ -96,33 +97,30 @@ async def _entities_from_descriptions( _build_entity, f"{name} {description.name}{suffix}", current, - hass.data[DOMAIN][VICARE_DEVICE_CONFIG], + hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, ) if entity is not None: all_devices.append(entity) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_devices): """Create the ViCare binary sensor devices.""" - if discovery_info is None: - return - - name = hass.data[DOMAIN][VICARE_NAME] - api = hass.data[DOMAIN][VICARE_API] + name = config_entry.data[CONF_NAME] + api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] all_devices = [] for description in CIRCUIT_SENSORS: - for circuit in api.circuits: + for circuit in hass.data[DOMAIN][config_entry.entry_id][VICARE_CIRCUITS]: suffix = "" - if len(api.circuits) > 1: + if len(hass.data[DOMAIN][config_entry.entry_id][VICARE_CIRCUITS]) > 1: suffix = f" {circuit.id}" entity = await hass.async_add_executor_job( _build_entity, f"{name} {description.name}{suffix}", circuit, - hass.data[DOMAIN][VICARE_DEVICE_CONFIG], + hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, ) if entity is not None: @@ -130,19 +128,19 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= try: await _entities_from_descriptions( - hass, name, all_devices, BURNER_SENSORS, api.burners + hass, name, all_devices, BURNER_SENSORS, api.burners, config_entry ) except PyViCareNotSupportedFeatureError: _LOGGER.info("No burners found") try: await _entities_from_descriptions( - hass, name, all_devices, COMPRESSOR_SENSORS, api.compressors + hass, name, all_devices, COMPRESSOR_SENSORS, api.compressors, config_entry ) except PyViCareNotSupportedFeatureError: _LOGGER.info("No compressors found") - async_add_entities(all_devices) + async_add_devices(all_devices) class ViCareBinarySensor(BinarySensorEntity): diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index cdc5e826cab..1521ad1cf89 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -22,7 +22,12 @@ from homeassistant.components.climate.const import ( SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, TEMP_CELSIUS +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_NAME, + PRECISION_WHOLE, + TEMP_CELSIUS, +) from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv @@ -32,7 +37,6 @@ from .const import ( VICARE_API, VICARE_CIRCUITS, VICARE_DEVICE_CONFIG, - VICARE_NAME, ) _LOGGER = logging.getLogger(__name__) @@ -99,33 +103,26 @@ def _build_entity(name, vicare_api, circuit, device_config, heating_type): return ViCareClimate(name, vicare_api, device_config, circuit, heating_type) -async def async_setup_platform( - hass, hass_config, async_add_entities, discovery_info=None -): - """Create the ViCare climate devices.""" - # Legacy setup. Remove after configuration.yaml deprecation end - if discovery_info is None: - return +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the ViCare climate platform.""" + name = config_entry.data[CONF_NAME] - name = hass.data[DOMAIN][VICARE_NAME] all_devices = [] - for circuit in hass.data[DOMAIN][VICARE_CIRCUITS]: + for circuit in hass.data[DOMAIN][config_entry.entry_id][VICARE_CIRCUITS]: suffix = "" - if len(hass.data[DOMAIN][VICARE_CIRCUITS]) > 1: + if len(hass.data[DOMAIN][config_entry.entry_id][VICARE_CIRCUITS]) > 1: suffix = f" {circuit.id}" entity = _build_entity( f"{name} Heating{suffix}", - hass.data[DOMAIN][VICARE_API], - hass.data[DOMAIN][VICARE_DEVICE_CONFIG], + hass.data[DOMAIN][config_entry.entry_id][VICARE_API], + hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], circuit, - hass.data[DOMAIN][CONF_HEATING_TYPE], + config_entry.data[CONF_HEATING_TYPE], ) if entity is not None: all_devices.append(entity) - async_add_entities(all_devices) - platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -134,6 +131,8 @@ async def async_setup_platform( "set_vicare_mode", ) + async_add_devices(all_devices) + class ViCareClimate(ClimateEntity): """Representation of the ViCare heating climate device.""" diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py new file mode 100644 index 00000000000..fcd9a1553ca --- /dev/null +++ b/homeassistant/components/vicare/config_flow.py @@ -0,0 +1,111 @@ +"""Config flow for ViCare integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from PyViCare.PyViCareUtils import PyViCareInvalidCredentialsError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import dhcp +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_NAME, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import format_mac + +from . import vicare_login +from .const import ( + CONF_CIRCUIT, + CONF_HEATING_TYPE, + DEFAULT_HEATING_TYPE, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + HeatingType, +) + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for ViCare.""" + + VERSION = 1 + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Invoke when a user initiates a flow via the user interface.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + data_schema = { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_HEATING_TYPE, default=DEFAULT_HEATING_TYPE.value): vol.In( + [e.value for e in HeatingType] + ), + vol.Optional(CONF_NAME, default="ViCare"): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): vol.All( + vol.Coerce(int), vol.Range(min=30) + ), + } + errors: dict[str, str] = {} + + if user_input is not None: + try: + await self.hass.async_add_executor_job( + vicare_login, self.hass, user_input + ) + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + except PyViCareInvalidCredentialsError as ex: + _LOGGER.debug("Could not log in to ViCare, %s", ex) + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(data_schema), + errors=errors, + ) + + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Invoke when a Viessmann MAC address is discovered on the network.""" + formatted_mac = format_mac(discovery_info.macaddress) + _LOGGER.info("Found device with mac %s", formatted_mac) + + await self.async_set_unique_id(formatted_mac) + self._abort_if_unique_id_configured() + + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + return await self.async_step_user() + + async def async_step_import(self, import_info): + """Handle a flow initiated by a YAML config import.""" + + await self.async_set_unique_id("Configuration.yaml") + self._abort_if_unique_id_configured() + + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + # Remove now unsupported config parameters + if import_info.get(CONF_CIRCUIT): + import_info.pop(CONF_CIRCUIT) + + # Add former optional config if missing + if import_info.get(CONF_HEATING_TYPE) is None: + import_info[CONF_HEATING_TYPE] = DEFAULT_HEATING_TYPE.value + + return self.async_create_entry( + title="Configuration.yaml", + data=import_info, + ) diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index 0cae3f7e0d1..db8b782399e 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -14,7 +14,6 @@ PLATFORMS = ["climate", "sensor", "binary_sensor", "water_heater"] VICARE_DEVICE_CONFIG = "device_conf" VICARE_API = "api" -VICARE_NAME = "name" VICARE_CIRCUITS = "circuits" CONF_CIRCUIT = "circuit" diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 0fe1c1f95e2..dda2ed63a89 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -4,5 +4,11 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "codeowners": ["@oischinger"], "requirements": ["PyViCare==2.13.1"], - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "config_flow": true, + "dhcp": [ + { + "macaddress": "B87424*" + } + ] } diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 2ff8ce4bf7d..6397236f299 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -1,10 +1,10 @@ """Viessmann ViCare sensor device.""" from __future__ import annotations +from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass import logging -from typing import Callable from PyViCare.PyViCareDevice import Device from PyViCare.PyViCareUtils import ( @@ -21,6 +21,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.const import ( + CONF_NAME, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, @@ -36,8 +37,8 @@ from . import ViCareRequiredKeysMixin from .const import ( DOMAIN, VICARE_API, + VICARE_CIRCUITS, VICARE_DEVICE_CONFIG, - VICARE_NAME, VICARE_UNIT_TO_DEVICE_CLASS, VICARE_UNIT_TO_UNIT_OF_MEASUREMENT, ) @@ -338,7 +339,7 @@ def _build_entity(name, vicare_api, device_config, sensor): async def _entities_from_descriptions( - hass, name, all_devices, sensor_descriptions, iterables + hass, name, all_devices, sensor_descriptions, iterables, config_entry ): """Create entities from descriptions and list of burners/circuits.""" for description in sensor_descriptions: @@ -350,20 +351,17 @@ async def _entities_from_descriptions( _build_entity, f"{name} {description.name}{suffix}", current, - hass.data[DOMAIN][VICARE_DEVICE_CONFIG], + hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, ) if entity is not None: all_devices.append(entity) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_devices): """Create the ViCare sensor devices.""" - if discovery_info is None: - return - - name = hass.data[DOMAIN][VICARE_NAME] - api = hass.data[DOMAIN][VICARE_API] + name = config_entry.data[CONF_NAME] + api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] all_devices = [] for description in GLOBAL_SENSORS: @@ -371,22 +369,22 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= _build_entity, f"{name} {description.name}", api, - hass.data[DOMAIN][VICARE_DEVICE_CONFIG], + hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, ) if entity is not None: all_devices.append(entity) for description in CIRCUIT_SENSORS: - for circuit in api.circuits: + for circuit in hass.data[DOMAIN][config_entry.entry_id][VICARE_CIRCUITS]: suffix = "" - if len(api.circuits) > 1: + if len(hass.data[DOMAIN][config_entry.entry_id][VICARE_CIRCUITS]) > 1: suffix = f" {circuit.id}" entity = await hass.async_add_executor_job( _build_entity, f"{name} {description.name}{suffix}", circuit, - hass.data[DOMAIN][VICARE_DEVICE_CONFIG], + hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, ) if entity is not None: @@ -394,19 +392,19 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= try: await _entities_from_descriptions( - hass, name, all_devices, BURNER_SENSORS, api.burners + hass, name, all_devices, BURNER_SENSORS, api.burners, config_entry ) except PyViCareNotSupportedFeatureError: _LOGGER.info("No burners found") try: await _entities_from_descriptions( - hass, name, all_devices, COMPRESSOR_SENSORS, api.compressors + hass, name, all_devices, COMPRESSOR_SENSORS, api.compressors, config_entry ) except PyViCareNotSupportedFeatureError: _LOGGER.info("No compressors found") - async_add_entities(all_devices) + async_add_devices(all_devices) class ViCareSensor(SensorEntity): diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 883bcd3efd5..1b98ad31992 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -13,7 +13,12 @@ from homeassistant.components.water_heater import ( SUPPORT_TARGET_TEMPERATURE, WaterHeaterEntity, ) -from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, TEMP_CELSIUS +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_NAME, + PRECISION_WHOLE, + TEMP_CELSIUS, +) from .const import ( CONF_HEATING_TYPE, @@ -21,7 +26,6 @@ from .const import ( VICARE_API, VICARE_CIRCUITS, VICARE_DEVICE_CONFIG, - VICARE_NAME, ) _LOGGER = logging.getLogger(__name__) @@ -66,29 +70,26 @@ def _build_entity(name, vicare_api, circuit, device_config, heating_type): ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Create the ViCare water_heater devices.""" - if discovery_info is None: - return - - name = hass.data[DOMAIN][VICARE_NAME] +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the ViCare climate platform.""" + name = config_entry.data[CONF_NAME] all_devices = [] - for circuit in hass.data[DOMAIN][VICARE_CIRCUITS]: + for circuit in hass.data[DOMAIN][config_entry.entry_id][VICARE_CIRCUITS]: suffix = "" - if len(hass.data[DOMAIN][VICARE_CIRCUITS]) > 1: + if len(hass.data[DOMAIN][config_entry.entry_id][VICARE_CIRCUITS]) > 1: suffix = f" {circuit.id}" entity = _build_entity( f"{name} Water{suffix}", - hass.data[DOMAIN][VICARE_API], + hass.data[DOMAIN][config_entry.entry_id][VICARE_API], circuit, - hass.data[DOMAIN][VICARE_DEVICE_CONFIG], - hass.data[DOMAIN][CONF_HEATING_TYPE], + hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], + config_entry.data[CONF_HEATING_TYPE], ) if entity is not None: all_devices.append(entity) - async_add_entities(all_devices) + async_add_devices(all_devices) class ViCareWater(WaterHeaterEntity): diff --git a/homeassistant/components/vilfo/translations/ja.json b/homeassistant/components/vilfo/translations/ja.json new file mode 100644 index 00000000000..6f98d264f24 --- /dev/null +++ b/homeassistant/components/vilfo/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" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "access_token": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", + "host": "\u30db\u30b9\u30c8" + }, + "description": "Vilfo Router\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u3002Vilfo Router\u306e\u30db\u30b9\u30c8\u540d/IP\u3068API\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3\u304c\u5fc5\u8981\u3067\u3059\u3002\u3053\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306b\u95a2\u3059\u308b\u8a73\u7d30\u3068\u305d\u308c\u3089\u306e\u53d6\u5f97\u65b9\u6cd5\u306b\u3064\u3044\u3066\u306f\u3001https://www.home-assistant.io/integrations/vilfo \u306b\u30a2\u30af\u30bb\u30b9\u3057\u3066\u304f\u3060\u3055\u3044", + "title": "Vilfo\u30eb\u30fc\u30bf\u30fc\u306b\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/translations/tr.json b/homeassistant/components/vilfo/translations/tr.json index dc66041e35a..2e27132afb6 100644 --- a/homeassistant/components/vilfo/translations/tr.json +++ b/homeassistant/components/vilfo/translations/tr.json @@ -13,7 +13,9 @@ "data": { "access_token": "Eri\u015fim Belirteci", "host": "Ana Bilgisayar" - } + }, + "description": "Vilfo Router entegrasyonunu ayarlay\u0131n. Vilfo Router ana bilgisayar ad\u0131n\u0131za/IP'nize ve bir API eri\u015fim anahtar\u0131na ihtiyac\u0131n\u0131z var. Bu entegrasyon ve bu ayr\u0131nt\u0131lar\u0131n nas\u0131l al\u0131naca\u011f\u0131 hakk\u0131nda ek bilgi i\u00e7in \u015fu adresi ziyaret edin: https://www.home-assistant.io/integrations/vilfo", + "title": "Vilfo Router'a ba\u011flan\u0131n" } } } diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index 97d54f2e874..9cca89f77aa 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -11,6 +11,7 @@ from pyvizio.const import APP_HOME import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.components.media_player import DEVICE_CLASS_SPEAKER, DEVICE_CLASS_TV from homeassistant.config_entries import ( SOURCE_IGNORE, @@ -26,14 +27,11 @@ from homeassistant.const import ( CONF_INCLUDE, CONF_NAME, CONF_PIN, - CONF_PORT, - CONF_TYPE, ) from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.util.network import is_ip_address from .const import ( @@ -338,28 +336,25 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_user(user_input=import_config) async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType | None = None + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" + host = discovery_info.host # If host already has port, no need to add it again - if ":" not in discovery_info[CONF_HOST]: - discovery_info[ - CONF_HOST - ] = f"{discovery_info[CONF_HOST]}:{discovery_info[CONF_PORT]}" + if ":" not in host: + host = f"{host}:{discovery_info.port}" # Set default name to discovered device name by stripping zeroconf service # (`type`) from `name` - num_chars_to_strip = len(discovery_info[CONF_TYPE]) + 1 - discovery_info[CONF_NAME] = discovery_info[CONF_NAME][:-num_chars_to_strip] + num_chars_to_strip = len(discovery_info.type) + 1 + name = discovery_info.name[:-num_chars_to_strip] - discovery_info[CONF_DEVICE_CLASS] = await async_guess_device_type( - discovery_info[CONF_HOST] - ) + device_class = await async_guess_device_type(host) # Set unique ID early for discovery flow so we can abort if needed unique_id = await VizioAsync.get_unique_id( - discovery_info[CONF_HOST], - discovery_info[CONF_DEVICE_CLASS], + host, + device_class, session=async_get_clientsession(self.hass, False), ) @@ -372,7 +367,13 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # Form must be shown after discovery so user can confirm/update configuration # before ConfigEntry creation. self._must_show_form = True - return await self.async_step_user(user_input=discovery_info) + return await self.async_step_user( + user_input={ + CONF_HOST: host, + CONF_NAME: name, + CONF_DEVICE_CLASS: device_class, + } + ) async def async_step_pair_tv(self, user_input: dict[str, Any] = None) -> FlowResult: """ diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index c60ae4582ad..0bff362ad31 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -33,6 +33,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -194,13 +195,13 @@ class VizioDevice(MediaPlayerEntity): self._attr_available = True if not self._attr_device_info: - self._attr_device_info = { - "identifiers": {(DOMAIN, self._attr_unique_id)}, - "name": self._attr_name, - "manufacturer": "VIZIO", - "model": await self._device.get_model_name(log_api_exception=False), - "sw_version": await self._device.get_version(log_api_exception=False), - } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer="VIZIO", + model=await self._device.get_model_name(log_api_exception=False), + name=self._attr_name, + sw_version=await self._device.get_version(log_api_exception=False), + ) if not is_on: self._attr_state = STATE_OFF diff --git a/homeassistant/components/vizio/translations/bg.json b/homeassistant/components/vizio/translations/bg.json index d10f511e3b3..962de7286c7 100644 --- a/homeassistant/components/vizio/translations/bg.json +++ b/homeassistant/components/vizio/translations/bg.json @@ -1,8 +1,12 @@ { "config": { "abort": { + "already_configured_device": "\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" }, + "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/vizio/translations/ja.json b/homeassistant/components/vizio/translations/ja.json new file mode 100644 index 00000000000..b40d9ef8680 --- /dev/null +++ b/homeassistant/components/vizio/translations/ja.json @@ -0,0 +1,54 @@ +{ + "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", + "updated_entry": "\u3053\u306e\u30a8\u30f3\u30c8\u30ea\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u304c\u3001\u8a2d\u5b9a\u3067\u5b9a\u7fa9\u3055\u308c\u305f\u540d\u524d\u3001\u30a2\u30d7\u30ea\u3001\u30aa\u30d7\u30b7\u30e7\u30f3\u304c\u4ee5\u524d\u306b\u30a4\u30f3\u30dd\u30fc\u30c8\u3055\u308c\u305f\u8a2d\u5b9a\u3068\u4e00\u81f4\u3057\u306a\u304b\u3063\u305f\u305f\u3081\u3001\u8a2d\u5b9a\u30a8\u30f3\u30c8\u30ea\u306f\u305d\u308c\u306b\u5fdc\u3058\u3066\u66f4\u65b0\u3055\u308c\u3066\u3044\u307e\u3059\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "complete_pairing_failed": "\u30da\u30a2\u30ea\u30f3\u30b0\u3092\u5b8c\u4e86\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002\u518d\u9001\u4fe1\u3059\u308b\u524d\u306b\u3001\u5165\u529b\u3057\u305fPIN\u304c\u6b63\u3057\u304f\u3001\u30c6\u30ec\u30d3\u306e\u96fb\u6e90\u304c\u5165\u3063\u3066\u3044\u3066\u3001\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u306b\u63a5\u7d9a\u3055\u308c\u3066\u3044\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "existing_config_entry_found": "\u540c\u3058\u30b7\u30ea\u30a2\u30eb\u756a\u53f7\u3092\u6301\u3064\u65e2\u5b58\u306e\u3001VIZIO SmartCast Device\u8a2d\u5b9a\u30a8\u30f3\u30c8\u30ea\u304c\u3059\u3067\u306b\u69cb\u6210\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u3053\u306e\u30a8\u30f3\u30c8\u30ea\u3092\u69cb\u6210\u3059\u308b\u306b\u306f\u3001\u65e2\u5b58\u306e\u30a8\u30f3\u30c8\u30ea\u3092\u524a\u9664\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "step": { + "pair_tv": { + "data": { + "pin": "PIN\u30b3\u30fc\u30c9" + }, + "description": "\u30c6\u30ec\u30d3\u306b\u30b3\u30fc\u30c9\u304c\u8868\u793a\u3055\u308c\u3066\u3044\u308b\u306f\u305a\u3067\u3059\u3002\u305d\u306e\u30b3\u30fc\u30c9\u3092\u30d5\u30a9\u30fc\u30e0\u306b\u5165\u529b\u3057\u3001\u6b21\u306e\u30b9\u30c6\u30c3\u30d7\u306b\u9032\u3080\u3068\u30da\u30a2\u30ea\u30f3\u30b0\u304c\u5b8c\u4e86\u3057\u307e\u3059\u3002", + "title": "\u30da\u30a2\u30ea\u30f3\u30b0\u30d7\u30ed\u30bb\u30b9\u306e\u5b8c\u4e86" + }, + "pairing_complete": { + "description": "\u3042\u306a\u305f\u306e\u3001VIZIO SmartCast Device\u304cHome Assistant\u306b\u63a5\u7d9a\u3055\u308c\u307e\u3057\u305f\u3002", + "title": "\u30da\u30a2\u30ea\u30f3\u30b0\u5b8c\u4e86" + }, + "pairing_complete_import": { + "description": "VIZIO SmartCast Device\u304c\u3001Home Assistant\u306b\u63a5\u7d9a\u3055\u308c\u307e\u3057\u305f\u3002\n\nVIZIO SmartCast Device\u306f\u3001'**{access_token}**' '\u3067\u3059\u3002", + "title": "\u30da\u30a2\u30ea\u30f3\u30b0\u5b8c\u4e86" + }, + "user": { + "data": { + "access_token": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", + "device_class": "\u30c7\u30d0\u30a4\u30b9\u30bf\u30a4\u30d7", + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d" + }, + "description": "\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3\u306f\u30c6\u30ec\u30d3\u306e\u5834\u5408\u306e\u307f\u5fc5\u8981\u3067\u3059\u3002\u30c6\u30ec\u30d3\u3092\u8a2d\u5b9a\u3057\u3066\u3044\u3066\u3001\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3\u3092\u307e\u3060\u6301\u3063\u3066\u3044\u306a\u3044\u5834\u5408\u306f\u3001\u30da\u30a2\u30ea\u30f3\u30b0\u30d7\u30ed\u30bb\u30b9\u3092\u5b9f\u884c\u3059\u308b\u305f\u3081\u306b\u7a7a\u767d\u306e\u307e\u307e\u306b\u3057\u307e\u3059\u3002", + "title": "VIZIO SmartCast Device" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "apps_to_include_or_exclude": "\u542b\u3081\u308b\u30a2\u30d7\u30ea\u3001\u307e\u305f\u306f\u9664\u5916\u3059\u308b\u30a2\u30d7\u30ea", + "include_or_exclude": "\u30a2\u30d7\u30ea\u3092\u542b\u3081\u308b\u304b\u3001\u9664\u5916\u3057\u307e\u3059\u304b\uff1f", + "volume_step": "\u30dc\u30ea\u30e5\u30fc\u30e0 \u30b9\u30c6\u30c3\u30d7\u30b5\u30a4\u30ba" + }, + "description": "\u30b9\u30de\u30fc\u30c8\u30c6\u30ec\u30d3\u3092\u304a\u6301\u3061\u306e\u5834\u5408\u306f\u3001\u30aa\u30d7\u30b7\u30e7\u30f3\u3067\u3001\u30bd\u30fc\u30b9\u30ea\u30b9\u30c8\u306b\u542b\u3081\u308b\u307e\u305f\u306f\u9664\u5916\u3059\u308b\u30a2\u30d7\u30ea\u3092\u9078\u629e\u3057\u3066\u30bd\u30fc\u30b9\u30ea\u30b9\u30c8\u3092\u30d5\u30a3\u30eb\u30bf\u30ea\u30f3\u30b0\u3067\u304d\u307e\u3059\u3002", + "title": "VIZIO SmartCast Device\u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u3092\u66f4\u65b0" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vizio/translations/pl.json b/homeassistant/components/vizio/translations/pl.json index 82339204a16..5b328734f99 100644 --- a/homeassistant/components/vizio/translations/pl.json +++ b/homeassistant/components/vizio/translations/pl.json @@ -7,7 +7,7 @@ }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", - "complete_pairing_failed": "Nie mo\u017cna uko\u0144czy\u0107 parowania. Upewnij si\u0119, \u017ce podany kod PIN jest prawid\u0142owy, a telewizor jest zasilany i pod\u0142\u0105czony do sieci przed ponownym przes\u0142aniem.", + "complete_pairing_failed": "Nie mo\u017cna uko\u0144czy\u0107 parowania. Upewnij si\u0119, \u017ce podany kod PIN jest prawid\u0142owy, a telewizor jest zasilany i pod\u0142\u0105czony do sieci przed ponownym zatwierdzeniem.", "existing_config_entry_found": "Istnieje ju\u017c wpis konfiguracyjny VIZIO SmartCast z tym samym numerem seryjnym. W celu skonfigurowania tego wpisu nale\u017cy usun\u0105\u0107 istniej\u0105cy." }, "step": { diff --git a/homeassistant/components/vizio/translations/tr.json b/homeassistant/components/vizio/translations/tr.json index b976471b56a..093334fb485 100644 --- a/homeassistant/components/vizio/translations/tr.json +++ b/homeassistant/components/vizio/translations/tr.json @@ -2,18 +2,52 @@ "config": { "abort": { "already_configured_device": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "updated_entry": "Bu giri\u015f zaten kuruldu ancak konfig\u00fcrasyonda tan\u0131mlanan ad, uygulamalar ve/veya se\u00e7enekler daha \u00f6nce i\u00e7e aktar\u0131lan konfig\u00fcrasyonla e\u015fle\u015fmiyor, bu nedenle konfig\u00fcrasyon giri\u015fi buna g\u00f6re g\u00fcncellendi." }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", - "complete_pairing_failed": "E\u015fle\u015ftirme tamamlanamad\u0131. Yeniden g\u00f6ndermeden \u00f6nce, verdi\u011finiz PIN'in do\u011fru oldu\u011fundan ve TV'ye g\u00fc\u00e7 verildi\u011finden ve a\u011fa ba\u011fl\u0131 oldu\u011fundan emin olun." + "complete_pairing_failed": "E\u015fle\u015ftirme tamamlanamad\u0131. Yeniden g\u00f6ndermeden \u00f6nce, verdi\u011finiz PIN'in do\u011fru oldu\u011fundan ve TV'ye g\u00fc\u00e7 verildi\u011finden ve a\u011fa ba\u011fl\u0131 oldu\u011fundan emin olun.", + "existing_config_entry_found": "Ayn\u0131 seri numaras\u0131na sahip mevcut bir VIZIO SmartCast Cihaz\u0131 yap\u0131land\u0131rma giri\u015fi zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Bunu konfig\u00fcre etmek i\u00e7in mevcut giri\u015fi silmelisiniz." }, "step": { + "pair_tv": { + "data": { + "pin": "PIN Kodu" + }, + "description": "TV'niz bir kod g\u00f6steriyor olmal\u0131d\u0131r. Bu kodu forma girin ve ard\u0131ndan e\u015fle\u015ftirmeyi tamamlamak i\u00e7in bir sonraki ad\u0131ma ge\u00e7in.", + "title": "E\u015fle\u015ftirme \u0130\u015flemini Tamamlay\u0131n" + }, + "pairing_complete": { + "description": "VIZIO SmartCast Cihaz\u0131 art\u0131k Home Assistant'a ba\u011fl\u0131.", + "title": "E\u015fle\u015ftirme Tamamland\u0131" + }, + "pairing_complete_import": { + "description": "VIZIO SmartCast Cihaz\u0131 art\u0131k Home Assistant'a ba\u011fl\u0131. \n\n Eri\u015fim Anahtar\u0131 de\u011feriniz '** {access_token} **'.", + "title": "E\u015fle\u015ftirme Tamamland\u0131" + }, "user": { "data": { - "access_token": "Eri\u015fim Belirteci", - "host": "Ana Bilgisayar" - } + "access_token": "Eri\u015fim Anahtar\u0131", + "device_class": "Cihaz tipi", + "host": "Ana bilgisayar", + "name": "Ad" + }, + "description": "Eri\u015fim Anahtar\u0131 yaln\u0131zca TV'ler i\u00e7in gereklidir. Bir TV yap\u0131land\u0131r\u0131yorsan\u0131z ve hen\u00fcz bir Eri\u015fim Anahtar\u0131 , e\u015fle\u015ftirme i\u015fleminden ge\u00e7mek i\u00e7in bo\u015f b\u0131rak\u0131n.", + "title": "VIZIO SmartCast Cihaz\u0131" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "apps_to_include_or_exclude": "Dahil veya Hari\u00e7 Tutulacak Uygulamalar", + "include_or_exclude": "Uygulamalar Dahil mi yoksa, Hari\u00e7 tutulsun mu?", + "volume_step": "Birim Ad\u0131m Boyutu" + }, + "description": "Smart TV'niz varsa, kaynak listenize hangi uygulamalar\u0131n dahil edilece\u011fini veya hari\u00e7 tutulaca\u011f\u0131n\u0131 se\u00e7erek iste\u011fe ba\u011fl\u0131 olarak kaynak listenizi filtreleyebilirsiniz.", + "title": "VIZIO SmartCast Cihaz\u0131 Se\u00e7eneklerini g\u00fcncelleyin" } } } diff --git a/homeassistant/components/vlc_telnet/config_flow.py b/homeassistant/components/vlc_telnet/config_flow.py index e86ab635517..bb503229ebb 100644 --- a/homeassistant/components/vlc_telnet/config_flow.py +++ b/homeassistant/components/vlc_telnet/config_flow.py @@ -9,6 +9,7 @@ from aiovlc.exceptions import AuthError, ConnectError import voluptuous as vol from homeassistant import core, exceptions +from homeassistant.components.hassio import HassioServiceInfo from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.data_entry_flow import FlowResult @@ -151,13 +152,13 @@ class VLCTelnetConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_hassio(self, discovery_info: dict[str, Any]) -> FlowResult: + async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Handle the discovery step via hassio.""" await self.async_set_unique_id("hassio") - self._abort_if_unique_id_configured(discovery_info) + self._abort_if_unique_id_configured(discovery_info.config) - self.hassio_discovery = discovery_info - self.context["title_placeholders"] = {"host": discovery_info[CONF_HOST]} + self.hassio_discovery = discovery_info.config + self.context["title_placeholders"] = {"host": discovery_info.config[CONF_HOST]} return await self.async_step_hassio_confirm() async def async_step_hassio_confirm( diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index 624234ce712..0bb157ab2ae 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -36,6 +36,8 @@ from homeassistant.const import ( ) 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 import homeassistant.util.dt as dt_util @@ -103,7 +105,7 @@ def catch_vlc_errors(func: Func) -> Func: """Catch VLC errors.""" @wraps(func) - async def wrapper(self, *args: Any, **kwargs: Any) -> Any: + async def wrapper(self: VlcDevice, *args: Any, **kwargs: Any) -> Any: """Catch VLC errors and modify availability.""" try: await func(self, *args, **kwargs) @@ -140,12 +142,12 @@ class VlcDevice(MediaPlayerEntity): self._media_title: str | None = None config_entry_id = config_entry.entry_id self._attr_unique_id = config_entry_id - self._attr_device_info = { - "name": name, - "identifiers": {(DOMAIN, config_entry_id)}, - "manufacturer": "VideoLAN", - "entry_type": "service", - } + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, config_entry_id)}, + manufacturer="VideoLAN", + name=name, + ) @catch_vlc_errors async def async_update(self) -> None: @@ -205,12 +207,12 @@ class VlcDevice(MediaPlayerEntity): self._media_title = data_info["filename"] @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._name @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" return self._state @@ -225,42 +227,42 @@ class VlcDevice(MediaPlayerEntity): return self._volume @property - def is_volume_muted(self): + def is_volume_muted(self) -> bool | None: """Boolean if volume is currently muted.""" return self._muted @property - def supported_features(self): + def supported_features(self) -> int: """Flag media player features that are supported.""" return SUPPORT_VLC @property - def media_content_type(self): + def media_content_type(self) -> str: """Content type of current playing media.""" return MEDIA_TYPE_MUSIC @property - def media_duration(self): + def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" return self._media_duration @property - def media_position(self): + def media_position(self) -> int | None: """Position of current playing media in seconds.""" return self._media_position @property - def media_position_updated_at(self): + def media_position_updated_at(self) -> datetime | None: """When was the position of the current playing media valid.""" return self._media_position_updated_at @property - def media_title(self): + def media_title(self) -> str | None: """Title of current playing media.""" return self._media_title @property - def media_artist(self): + def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" return self._media_artist diff --git a/homeassistant/components/vlc_telnet/translations/fr.json b/homeassistant/components/vlc_telnet/translations/fr.json new file mode 100644 index 00000000000..9ed67c043de --- /dev/null +++ b/homeassistant/components/vlc_telnet/translations/fr.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", + "unknown": "Erreur inattendue" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Mot de passe" + } + }, + "user": { + "data": { + "password": "Mot de passe", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vlc_telnet/translations/id.json b/homeassistant/components/vlc_telnet/translations/id.json new file mode 100644 index 00000000000..4037cb21b4d --- /dev/null +++ b/homeassistant/components/vlc_telnet/translations/id.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Layanan sudah dikonfigurasi", + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "reauth_successful": "Autentikasi ulang berhasil", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "flow_title": "{host}", + "step": { + "hassio_confirm": { + "description": "Ingin terhubung ke add-on {addon}?" + }, + "reauth_confirm": { + "data": { + "password": "Kata Sandi" + }, + "description": "Harap masukkan kata sandi yang benar untuk host: {host}" + }, + "user": { + "data": { + "host": "Host", + "name": "Nama", + "password": "Kata Sandi", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vlc_telnet/translations/ja.json b/homeassistant/components/vlc_telnet/translations/ja.json index 4aa4b490324..f351eac4dda 100644 --- a/homeassistant/components/vlc_telnet/translations/ja.json +++ b/homeassistant/components/vlc_telnet/translations/ja.json @@ -1,11 +1,27 @@ { "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "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" + }, "flow_title": "{host}", "step": { + "hassio_confirm": { + "description": "\u30a2\u30c9\u30aa\u30f3 {addon} \u306b\u63a5\u7d9a\u3057\u307e\u3059\u304b\uff1f" + }, "reauth_confirm": { "data": { "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" - } + }, + "description": "\u30db\u30b9\u30c8\u306e\u6b63\u3057\u3044\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044: {host}" }, "user": { "data": { diff --git a/homeassistant/components/vlc_telnet/translations/pl.json b/homeassistant/components/vlc_telnet/translations/pl.json index aa4e9ed9c5f..f9d342e2938 100644 --- a/homeassistant/components/vlc_telnet/translations/pl.json +++ b/homeassistant/components/vlc_telnet/translations/pl.json @@ -14,6 +14,9 @@ }, "flow_title": "{host}", "step": { + "hassio_confirm": { + "description": "Czy chcesz si\u0119 po\u0142\u0105czy\u0107 z dodatkiem {addon}?" + }, "reauth_confirm": { "data": { "password": "Has\u0142o" diff --git a/homeassistant/components/vlc_telnet/translations/sl.json b/homeassistant/components/vlc_telnet/translations/sl.json new file mode 100644 index 00000000000..978406ddba0 --- /dev/null +++ b/homeassistant/components/vlc_telnet/translations/sl.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Storitev je \u017ee konfigurirana", + "cannot_connect": "Povezava ni uspela", + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "flow_title": "{host}", + "step": { + "hassio_confirm": { + "description": "Ali se \u017eelite povezati z dodatkom {addon} ?" + }, + "reauth_confirm": { + "data": { + "password": "Geslo" + }, + "description": "Vnesite pravilno geslo za gostitelja: {host}" + }, + "user": { + "data": { + "host": "Gostitelj", + "password": "Geslo", + "port": "Vrata" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vlc_telnet/translations/tr.json b/homeassistant/components/vlc_telnet/translations/tr.json index 40379347fd4..6e71fed22b2 100644 --- a/homeassistant/components/vlc_telnet/translations/tr.json +++ b/homeassistant/components/vlc_telnet/translations/tr.json @@ -2,23 +2,32 @@ "config": { "abort": { "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", - "reauth_successful": "Kimlik do\u011frulama yeniden ba\u015far\u0131l\u0131 oldu" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu", + "unknown": "Beklenmeyen hata" }, "error": { - "cannot_connect": "Ba\u011flant\u0131 ba\u015far\u0131s\u0131z" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" }, + "flow_title": "{host}", "step": { + "hassio_confirm": { + "description": "{addon} eklentisine ba\u011flanmak istiyor musunuz?" + }, "reauth_confirm": { "data": { - "password": "\u015eifre" + "password": "Parola" }, "description": "L\u00fctfen sunucunun do\u011fru \u015fifresini giriniz: {host}" }, "user": { "data": { - "host": "Ana Bilgisayar", - "name": "\u0130sim", - "password": "\u015eifre", + "host": "Ana bilgisayar", + "name": "Ad", + "password": "Parola", "port": "Port" } } diff --git a/homeassistant/components/voicerss/tts.py b/homeassistant/components/voicerss/tts.py index 3558179c4d1..2525393739b 100644 --- a/homeassistant/components/voicerss/tts.py +++ b/homeassistant/components/voicerss/tts.py @@ -196,7 +196,7 @@ class VoiceRSSProvider(Provider): form_data["hl"] = language try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): request = await websession.post(VOICERSS_API_URL, data=form_data) if request.status != HTTPStatus.OK: diff --git a/homeassistant/components/volumio/config_flow.py b/homeassistant/components/volumio/config_flow.py index 45c424b356e..f9703e3c2fb 100644 --- a/homeassistant/components/volumio/config_flow.py +++ b/homeassistant/components/volumio/config_flow.py @@ -7,10 +7,11 @@ from pyvolumio import CannotConnectError, Volumio import voluptuous as vol from homeassistant import config_entries, exceptions +from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_ID, CONF_NAME, CONF_PORT from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import DiscoveryInfoType from .const import DOMAIN @@ -93,12 +94,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle zeroconf discovery.""" - self._host = discovery_info["host"] - self._port = int(discovery_info["port"]) - self._name = discovery_info["properties"]["volumioName"] - self._uuid = discovery_info["properties"]["UUID"] + self._host = discovery_info.host + self._port = discovery_info.port + self._name = discovery_info.properties["volumioName"] + self._uuid = discovery_info.properties["UUID"] await self._set_uid_and_abort() diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 86747519149..06ee0346b7f 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -31,6 +31,7 @@ from homeassistant.const import ( STATE_PAUSED, STATE_PLAYING, ) +from homeassistant.helpers.entity import DeviceInfo from homeassistant.util import Throttle from .browse_media import browse_node, browse_top_level @@ -99,15 +100,15 @@ class Volumio(MediaPlayerEntity): return self._name @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info for this device.""" - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "manufacturer": "Volumio", - "sw_version": self._info["systemversion"], - "model": self._info["hardware"], - } + return DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + manufacturer="Volumio", + model=self._info["hardware"], + name=self.name, + sw_version=self._info["systemversion"], + ) @property def media_content_type(self): diff --git a/homeassistant/components/volumio/translations/bg.json b/homeassistant/components/volumio/translations/bg.json index a610a1f2a64..58bf7bc6cef 100644 --- a/homeassistant/components/volumio/translations/bg.json +++ b/homeassistant/components/volumio/translations/bg.json @@ -1,11 +1,16 @@ { "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\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", + "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", "port": "\u041f\u043e\u0440\u0442" } } diff --git a/homeassistant/components/volumio/translations/ja.json b/homeassistant/components/volumio/translations/ja.json new file mode 100644 index 00000000000..90c3e1598c3 --- /dev/null +++ b/homeassistant/components/volumio/translations/ja.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "\u691c\u51fa\u3055\u308c\u305fVolumio\u306b\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "discovery_confirm": { + "description": "Volumio (`{name}`) \u3092Home Assistant\u306b\u8ffd\u52a0\u3057\u307e\u3059\u304b\uff1f", + "title": "Volumio\u3092\u767a\u898b" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/pl.json b/homeassistant/components/volumio/translations/pl.json index 67d49c4b4be..f3b63230924 100644 --- a/homeassistant/components/volumio/translations/pl.json +++ b/homeassistant/components/volumio/translations/pl.json @@ -11,7 +11,7 @@ "step": { "discovery_confirm": { "description": "Czy chcesz doda\u0107 Volumio (\"{name}\") do Home Assistanta?", - "title": "Wykryte Volumio" + "title": "Wykryto Volumio" }, "user": { "data": { diff --git a/homeassistant/components/volumio/translations/tr.json b/homeassistant/components/volumio/translations/tr.json index 249bb17d64e..1d06eb89d56 100644 --- a/homeassistant/components/volumio/translations/tr.json +++ b/homeassistant/components/volumio/translations/tr.json @@ -9,9 +9,13 @@ "unknown": "Beklenmeyen hata" }, "step": { + "discovery_confirm": { + "description": "Home Asistan\u0131'na Volumio \"{name}\" eklemek istiyor musunuz?", + "title": "Bulunan Volumio" + }, "user": { "data": { - "host": "Ana Bilgisayar", + "host": "Ana bilgisayar", "port": "Port" } } diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 556a5f25114..1d4f95b9341 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -156,7 +156,12 @@ async def async_setup(hass, config): hass, PLATFORMS[instrument.component], DOMAIN, - (vehicle.vin, instrument.component, instrument.attr), + ( + vehicle.vin, + instrument.component, + instrument.attr, + instrument.slug_attr, + ), config, ) ) @@ -192,7 +197,7 @@ class VolvoData: self.config = config[DOMAIN] self.names = self.config.get(CONF_NAME) - def instrument(self, vin, component, attr): + def instrument(self, vin, component, attr, slug_attr): """Return corresponding instrument.""" return next( ( @@ -201,6 +206,7 @@ class VolvoData: if instrument.vehicle.vin == vin and instrument.component == component and instrument.attr == attr + and instrument.slug_attr == slug_attr ), None, ) @@ -223,12 +229,13 @@ class VolvoData: class VolvoEntity(Entity): """Base class for all VOC entities.""" - def __init__(self, data, vin, component, attribute): + def __init__(self, data, vin, component, attribute, slug_attr): """Initialize the entity.""" self.data = data self.vin = vin self.component = component self.attribute = attribute + self.slug_attr = slug_attr async def async_added_to_hass(self): """Register update dispatcher.""" @@ -241,7 +248,9 @@ class VolvoEntity(Entity): @property def instrument(self): """Return corresponding instrument.""" - return self.data.instrument(self.vin, self.component, self.attribute) + return self.data.instrument( + self.vin, self.component, self.attribute, self.slug_attr + ) @property def icon(self): @@ -287,4 +296,7 @@ class VolvoEntity(Entity): @property def unique_id(self) -> str: """Return a unique ID.""" - return f"{self.vin}-{self.component}-{self.attribute}" + slug_override = "" + if self.instrument.slug_override is not None: + slug_override = f"-{self.instrument.slug_override}" + return f"{self.vin}-{self.component}-{self.attribute}{slug_override}" diff --git a/homeassistant/components/volvooncall/device_tracker.py b/homeassistant/components/volvooncall/device_tracker.py index ebc4990db55..15615c9d807 100644 --- a/homeassistant/components/volvooncall/device_tracker.py +++ b/homeassistant/components/volvooncall/device_tracker.py @@ -11,9 +11,9 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): if discovery_info is None: return - vin, component, attr = discovery_info + vin, component, attr, slug_attr = discovery_info data = hass.data[DATA_KEY] - instrument = data.instrument(vin, component, attr) + instrument = data.instrument(vin, component, attr, slug_attr) async def see_vehicle(): """Handle the reporting of the vehicle position.""" diff --git a/homeassistant/components/volvooncall/manifest.json b/homeassistant/components/volvooncall/manifest.json index 5201614ab8b..eac179efa8d 100644 --- a/homeassistant/components/volvooncall/manifest.json +++ b/homeassistant/components/volvooncall/manifest.json @@ -2,7 +2,7 @@ "domain": "volvooncall", "name": "Volvo On Call", "documentation": "https://www.home-assistant.io/integrations/volvooncall", - "requirements": ["volvooncall==0.8.12"], - "codeowners": [], + "requirements": ["volvooncall==0.9.1"], + "codeowners": ["@molobrakos", "@decompil3d"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/wake_on_lan/__init__.py b/homeassistant/components/wake_on_lan/__init__.py index d6a254ebf52..dba25b44d75 100644 --- a/homeassistant/components/wake_on_lan/__init__.py +++ b/homeassistant/components/wake_on_lan/__init__.py @@ -8,9 +8,9 @@ import wakeonlan from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN -DOMAIN = "wake_on_lan" +_LOGGER = logging.getLogger(__name__) SERVICE_SEND_MAGIC_PACKET = "send_magic_packet" diff --git a/homeassistant/components/wake_on_lan/const.py b/homeassistant/components/wake_on_lan/const.py new file mode 100644 index 00000000000..14f2bd0263f --- /dev/null +++ b/homeassistant/components/wake_on_lan/const.py @@ -0,0 +1,2 @@ +"""Constants for the Wake-On-LAN component.""" +DOMAIN = "wake_on_lan" diff --git a/homeassistant/components/wake_on_lan/switch.py b/homeassistant/components/wake_on_lan/switch.py index 4bbd1522c91..f7e5426c73f 100644 --- a/homeassistant/components/wake_on_lan/switch.py +++ b/homeassistant/components/wake_on_lan/switch.py @@ -18,6 +18,8 @@ from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.script import Script +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) CONF_OFF_ACTION = "turn_off" @@ -82,9 +84,8 @@ class WolSwitch(SwitchEntity): self._mac_address = mac_address self._broadcast_address = broadcast_address self._broadcast_port = broadcast_port - domain = __name__.split(".")[-2] self._off_script = ( - Script(hass, off_action, name, domain) if off_action else None + Script(hass, off_action, name, DOMAIN) if off_action else None ) self._state = False self._assumed_state = host is None diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index 410a3115f9f..c40d3fb37a8 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -1,7 +1,10 @@ """The Wallbox integration.""" +from __future__ import annotations + from datetime import timedelta from http import HTTPStatus import logging +from typing import Any, Dict import requests from wallbox import Wallbox @@ -9,18 +12,10 @@ from wallbox import Wallbox from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import ( - CONF_CONNECTIONS, - CONF_DATA_KEY, - CONF_MAX_CHARGING_CURRENT_KEY, - CONF_ROUND, - CONF_SENSOR_TYPES, - CONF_STATION, - DOMAIN, -) +from .const import CONF_DATA_KEY, CONF_MAX_CHARGING_CURRENT_KEY, CONF_STATION, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -28,10 +23,10 @@ PLATFORMS = ["sensor", "number"] UPDATE_INTERVAL = 30 -class WallboxCoordinator(DataUpdateCoordinator): +class WallboxCoordinator(DataUpdateCoordinator[Dict[str, Any]]): """Wallbox Coordinator class.""" - def __init__(self, station, wallbox, hass): + def __init__(self, station: str, wallbox: Wallbox, hass: HomeAssistant) -> None: """Initialize.""" self._station = station self._wallbox = wallbox @@ -43,49 +38,39 @@ class WallboxCoordinator(DataUpdateCoordinator): update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - def _authenticate(self): + def _authenticate(self) -> None: """Authenticate using Wallbox API.""" try: self._wallbox.authenticate() - return True except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN: - raise InvalidAuth from wallbox_connection_error + raise ConfigEntryAuthFailed from wallbox_connection_error raise ConnectionError from wallbox_connection_error - def _validate(self): + def _validate(self) -> None: """Authenticate using Wallbox API.""" try: self._wallbox.authenticate() - return True except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error raise ConnectionError from wallbox_connection_error - def _get_data(self): + def _get_data(self) -> dict[str, Any]: """Get new sensor data for Wallbox component.""" try: self._authenticate() - data = self._wallbox.getChargerStatus(self._station) + data: dict[str, Any] = self._wallbox.getChargerStatus(self._station) data[CONF_MAX_CHARGING_CURRENT_KEY] = data[CONF_DATA_KEY][ CONF_MAX_CHARGING_CURRENT_KEY ] - filtered_data = {k: data[k] for k in CONF_SENSOR_TYPES if k in data} + return data - for key, value in filtered_data.items(): - if (sensor_round := CONF_SENSOR_TYPES[key][CONF_ROUND]) is not None: - try: - filtered_data[key] = round(value, sensor_round) - except TypeError: - _LOGGER.debug("Cannot format %s", key) - - return filtered_data except requests.exceptions.HTTPError as wallbox_connection_error: raise ConnectionError from wallbox_connection_error - def _set_charging_current(self, charging_current): + def _set_charging_current(self, charging_current: float) -> None: """Set maximum charging current for Wallbox.""" try: self._authenticate() @@ -95,22 +80,21 @@ class WallboxCoordinator(DataUpdateCoordinator): raise InvalidAuth from wallbox_connection_error raise ConnectionError from wallbox_connection_error - async def async_set_charging_current(self, charging_current): + async def async_set_charging_current(self, charging_current: float) -> None: """Set maximum charging current for Wallbox.""" await self.hass.async_add_executor_job( self._set_charging_current, charging_current ) await self.async_request_refresh() - async def _async_update_data(self) -> bool: + async def _async_update_data(self) -> dict[str, Any]: """Get new sensor data for Wallbox component.""" data = await self.hass.async_add_executor_job(self._get_data) return data - async def async_validate_input(self) -> bool: + async def async_validate_input(self) -> None: """Get new sensor data for Wallbox component.""" - data = await self.hass.async_add_executor_job(self._validate) - return data + await self.hass.async_add_executor_job(self._validate) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -122,18 +106,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, ) - await wallbox_coordinator.async_validate_input() + try: + await wallbox_coordinator.async_validate_input() + + except InvalidAuth as ex: + raise ConfigEntryAuthFailed from ex await wallbox_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {CONF_CONNECTIONS: {}}) - hass.data[DOMAIN][CONF_CONNECTIONS][entry.entry_id] = wallbox_coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = wallbox_coordinator - for platform in PLATFORMS: - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, platform) - ) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -142,7 +125,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN][CONF_CONNECTIONS].pop(entry.entry_id) + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/wallbox/config_flow.py b/homeassistant/components/wallbox/config_flow.py index f123ad0cd2d..d2c0a048fa1 100644 --- a/homeassistant/components/wallbox/config_flow.py +++ b/homeassistant/components/wallbox/config_flow.py @@ -1,9 +1,14 @@ """Config flow for Wallbox integration.""" +from __future__ import annotations + +from typing import Any + import voluptuous as vol from wallbox import Wallbox from homeassistant import config_entries, core from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult from . import InvalidAuth, WallboxCoordinator from .const import CONF_STATION, DOMAIN @@ -19,7 +24,9 @@ STEP_USER_DATA_SCHEMA = vol.Schema( ) -async def validate_input(hass: core.HomeAssistant, data): +async def validate_input( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, str]: """Validate the user input allows to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. @@ -36,7 +43,23 @@ async def validate_input(hass: core.HomeAssistant, data): class ConfigFlow(config_entries.ConfigFlow, domain=COMPONENT_DOMAIN): """Handle a config flow for Wallbox.""" - async def async_step_user(self, user_input=None): + def __init__(self) -> None: + """Start the Wallbox config flow.""" + self._reauth_entry: config_entries.ConfigEntry | None = None + + async def async_step_reauth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """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_user() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" if user_input is None: return self.async_show_form( @@ -47,14 +70,27 @@ class ConfigFlow(config_entries.ConfigFlow, domain=COMPONENT_DOMAIN): errors = {} try: - info = await validate_input(self.hass, user_input) + await self.async_set_unique_id(user_input["station"]) + if not self._reauth_entry: + self._abort_if_unique_id_configured() + info = await validate_input(self.hass, user_input) + return self.async_create_entry(title=info["title"], data=user_input) + if user_input["station"] == self._reauth_entry.data[CONF_STATION]: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input, unique_id=user_input["station"] + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + errors["base"] = "reauth_invalid" except ConnectionError: errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - else: - 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 + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, ) diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index 62c9b2f6efd..e753d548987 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -1,19 +1,4 @@ """Constants for the Wallbox integration.""" -from homeassistant.const import ( - CONF_DEVICE_CLASS, - CONF_ICON, - CONF_NAME, - CONF_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - ELECTRIC_CURRENT_AMPERE, - ENERGY_KILO_WATT_HOUR, - LENGTH_KILOMETERS, - PERCENTAGE, - POWER_KILO_WATT, -) DOMAIN = "wallbox" @@ -31,93 +16,4 @@ CONF_MAX_AVAILABLE_POWER_KEY = "max_available_power" CONF_MAX_CHARGING_CURRENT_KEY = "max_charging_current" CONF_STATE_OF_CHARGE_KEY = "state_of_charge" CONF_STATUS_DESCRIPTION_KEY = "status_description" - CONF_CONNECTIONS = "connections" -CONF_ROUND = "round" - -CONF_SENSOR_TYPES = { - CONF_CHARGING_POWER_KEY: { - CONF_ICON: None, - CONF_NAME: "Charging Power", - CONF_ROUND: 2, - CONF_UNIT_OF_MEASUREMENT: POWER_KILO_WATT, - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - }, - CONF_MAX_AVAILABLE_POWER_KEY: { - CONF_ICON: None, - CONF_NAME: "Max Available Power", - CONF_ROUND: 0, - CONF_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, - CONF_DEVICE_CLASS: DEVICE_CLASS_CURRENT, - }, - CONF_CHARGING_SPEED_KEY: { - CONF_ICON: "mdi:speedometer", - CONF_NAME: "Charging Speed", - CONF_ROUND: 0, - CONF_UNIT_OF_MEASUREMENT: None, - CONF_DEVICE_CLASS: None, - }, - CONF_ADDED_RANGE_KEY: { - CONF_ICON: "mdi:map-marker-distance", - CONF_NAME: "Added Range", - CONF_ROUND: 0, - CONF_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, - CONF_DEVICE_CLASS: None, - }, - CONF_ADDED_ENERGY_KEY: { - CONF_ICON: None, - CONF_NAME: "Added Energy", - CONF_ROUND: 2, - CONF_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - }, - CONF_CHARGING_TIME_KEY: { - CONF_ICON: "mdi:timer", - CONF_NAME: "Charging Time", - CONF_ROUND: None, - CONF_UNIT_OF_MEASUREMENT: None, - CONF_DEVICE_CLASS: None, - }, - CONF_COST_KEY: { - CONF_ICON: "mdi:ev-station", - CONF_NAME: "Cost", - CONF_ROUND: None, - CONF_UNIT_OF_MEASUREMENT: None, - CONF_DEVICE_CLASS: None, - }, - CONF_STATE_OF_CHARGE_KEY: { - CONF_ICON: None, - CONF_NAME: "State of Charge", - CONF_ROUND: None, - CONF_UNIT_OF_MEASUREMENT: PERCENTAGE, - CONF_DEVICE_CLASS: DEVICE_CLASS_BATTERY, - }, - CONF_CURRENT_MODE_KEY: { - CONF_ICON: "mdi:ev-station", - CONF_NAME: "Current Mode", - CONF_ROUND: None, - CONF_UNIT_OF_MEASUREMENT: None, - CONF_DEVICE_CLASS: None, - }, - CONF_DEPOT_PRICE_KEY: { - CONF_ICON: "mdi:ev-station", - CONF_NAME: "Depot Price", - CONF_ROUND: 2, - CONF_UNIT_OF_MEASUREMENT: None, - CONF_DEVICE_CLASS: None, - }, - CONF_STATUS_DESCRIPTION_KEY: { - CONF_ICON: "mdi:ev-station", - CONF_NAME: "Status Description", - CONF_ROUND: None, - CONF_UNIT_OF_MEASUREMENT: None, - CONF_DEVICE_CLASS: None, - }, - CONF_MAX_CHARGING_CURRENT_KEY: { - CONF_ICON: None, - CONF_NAME: "Max. Charging Current", - CONF_ROUND: None, - CONF_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, - CONF_DEVICE_CLASS: DEVICE_CLASS_CURRENT, - }, -} diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index d99d6511822..64c1f1e1abb 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -1,56 +1,90 @@ """Home Assistant component for accessing the Wallbox Portal API. The sensor component creates multiple sensors regarding wallbox performance.""" +from __future__ import annotations -from homeassistant.components.number import NumberEntity -from homeassistant.const import CONF_DEVICE_CLASS +from dataclasses import dataclass +from typing import Optional, cast + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_CLASS_CURRENT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import InvalidAuth -from .const import ( - CONF_CONNECTIONS, - CONF_MAX_AVAILABLE_POWER_KEY, - CONF_MAX_CHARGING_CURRENT_KEY, - CONF_NAME, - CONF_SENSOR_TYPES, - DOMAIN, -) +from . import InvalidAuth, WallboxCoordinator +from .const import CONF_MAX_AVAILABLE_POWER_KEY, CONF_MAX_CHARGING_CURRENT_KEY, DOMAIN -async def async_setup_entry(hass, config, async_add_entities): +@dataclass +class WallboxNumberEntityDescription(NumberEntityDescription): + """Describes Wallbox sensor entity.""" + + min_value: float = 0 + + +NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { + CONF_MAX_CHARGING_CURRENT_KEY: WallboxNumberEntityDescription( + key=CONF_MAX_CHARGING_CURRENT_KEY, + name="Max. Charging Current", + device_class=DEVICE_CLASS_CURRENT, + min_value=6, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Create wallbox sensor entities in HASS.""" - coordinator = hass.data[DOMAIN][CONF_CONNECTIONS][config.entry_id] + coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] # Check if the user is authorized to change current, if so, add number component: try: await coordinator.async_set_charging_current( coordinator.data[CONF_MAX_CHARGING_CURRENT_KEY] ) except InvalidAuth: - pass - else: - async_add_entities([WallboxNumber(coordinator, config)]) + return + + async_add_entities( + [ + WallboxNumber(coordinator, entry, description) + for ent in coordinator.data + if (description := NUMBER_TYPES.get(ent)) + ] + ) class WallboxNumber(CoordinatorEntity, NumberEntity): """Representation of the Wallbox portal.""" - def __init__(self, coordinator, config): + entity_description: WallboxNumberEntityDescription + coordinator: WallboxCoordinator + + def __init__( + self, + coordinator: WallboxCoordinator, + entry: ConfigEntry, + description: WallboxNumberEntityDescription, + ) -> None: """Initialize a Wallbox sensor.""" super().__init__(coordinator) - _properties = CONF_SENSOR_TYPES[CONF_MAX_CHARGING_CURRENT_KEY] + self.entity_description = description self._coordinator = coordinator - self._attr_name = f"{config.title} {_properties[CONF_NAME]}" - self._attr_min_value = 6 - self._attr_device_class = _properties[CONF_DEVICE_CLASS] + self._attr_name = f"{entry.title} {description.name}" + self._attr_min_value = description.min_value @property - def max_value(self): + def max_value(self) -> float: """Return the maximum available current.""" - return self._coordinator.data[CONF_MAX_AVAILABLE_POWER_KEY] + return cast(float, self._coordinator.data[CONF_MAX_AVAILABLE_POWER_KEY]) @property - def value(self): + def value(self) -> float | None: """Return the state of the sensor.""" - return self._coordinator.data[CONF_MAX_CHARGING_CURRENT_KEY] + return cast( + Optional[float], self._coordinator.data[CONF_MAX_CHARGING_CURRENT_KEY] + ) - async def async_set_value(self, value: float): + async def async_set_value(self, value: float) -> None: """Set the value of the entity.""" await self._coordinator.async_set_charging_current(value) diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 37450a5ea79..835a8c405da 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -1,47 +1,185 @@ """Home Assistant component for accessing the Wallbox Portal API. The sensor component creates multiple sensors regarding wallbox performance.""" +from __future__ import annotations -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_DEVICE_CLASS +from dataclasses import dataclass +import logging +from typing import cast + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.components.wallbox import WallboxCoordinator +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + ELECTRIC_CURRENT_AMPERE, + ENERGY_KILO_WATT_HOUR, + LENGTH_KILOMETERS, + PERCENTAGE, + POWER_KILO_WATT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( - CONF_CONNECTIONS, - CONF_ICON, - CONF_NAME, - CONF_SENSOR_TYPES, - CONF_UNIT_OF_MEASUREMENT, + CONF_ADDED_ENERGY_KEY, + CONF_ADDED_RANGE_KEY, + CONF_CHARGING_POWER_KEY, + CONF_CHARGING_SPEED_KEY, + CONF_COST_KEY, + CONF_CURRENT_MODE_KEY, + CONF_DEPOT_PRICE_KEY, + CONF_MAX_AVAILABLE_POWER_KEY, + CONF_MAX_CHARGING_CURRENT_KEY, + CONF_STATE_OF_CHARGE_KEY, + CONF_STATUS_DESCRIPTION_KEY, DOMAIN, ) CONF_STATION = "station" UPDATE_INTERVAL = 30 +_LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, config, async_add_entities): + +@dataclass +class WallboxSensorEntityDescription(SensorEntityDescription): + """Describes Wallbox sensor entity.""" + + precision: int | None = None + + +SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { + CONF_CHARGING_POWER_KEY: WallboxSensorEntityDescription( + key=CONF_CHARGING_POWER_KEY, + name="Charging Power", + precision=2, + native_unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + CONF_MAX_AVAILABLE_POWER_KEY: WallboxSensorEntityDescription( + key=CONF_MAX_AVAILABLE_POWER_KEY, + name="Max Available Power", + precision=0, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + CONF_CHARGING_SPEED_KEY: WallboxSensorEntityDescription( + key=CONF_CHARGING_SPEED_KEY, + icon="mdi:speedometer", + name="Charging Speed", + precision=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + CONF_ADDED_RANGE_KEY: WallboxSensorEntityDescription( + key=CONF_ADDED_RANGE_KEY, + icon="mdi:map-marker-distance", + name="Added Range", + precision=0, + native_unit_of_measurement=LENGTH_KILOMETERS, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + CONF_ADDED_ENERGY_KEY: WallboxSensorEntityDescription( + key=CONF_ADDED_ENERGY_KEY, + name="Added Energy", + precision=2, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + CONF_COST_KEY: WallboxSensorEntityDescription( + key=CONF_COST_KEY, + icon="mdi:ev-station", + name="Cost", + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + CONF_STATE_OF_CHARGE_KEY: WallboxSensorEntityDescription( + key=CONF_STATE_OF_CHARGE_KEY, + name="State of Charge", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + ), + CONF_CURRENT_MODE_KEY: WallboxSensorEntityDescription( + key=CONF_CURRENT_MODE_KEY, + icon="mdi:ev-station", + name="Current Mode", + ), + CONF_DEPOT_PRICE_KEY: WallboxSensorEntityDescription( + key=CONF_DEPOT_PRICE_KEY, + icon="mdi:ev-station", + name="Depot Price", + precision=2, + ), + CONF_STATUS_DESCRIPTION_KEY: WallboxSensorEntityDescription( + key=CONF_STATUS_DESCRIPTION_KEY, + icon="mdi:ev-station", + name="Status Description", + ), + CONF_MAX_CHARGING_CURRENT_KEY: WallboxSensorEntityDescription( + key=CONF_MAX_CHARGING_CURRENT_KEY, + name="Max. Charging Current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Create wallbox sensor entities in HASS.""" - coordinator = hass.data[DOMAIN][CONF_CONNECTIONS][config.entry_id] + coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - WallboxSensor(coordinator, idx, ent, config) - for idx, ent in enumerate(coordinator.data) + [ + WallboxSensor(coordinator, entry, description) + for ent in coordinator.data + if (description := SENSOR_TYPES.get(ent)) + ] ) class WallboxSensor(CoordinatorEntity, SensorEntity): """Representation of the Wallbox portal.""" - def __init__(self, coordinator, idx, ent, config): + entity_description: WallboxSensorEntityDescription + coordinator: WallboxCoordinator + + def __init__( + self, + coordinator: WallboxCoordinator, + entry: ConfigEntry, + description: WallboxSensorEntityDescription, + ) -> None: """Initialize a Wallbox sensor.""" super().__init__(coordinator) - self._attr_name = f"{config.title} {CONF_SENSOR_TYPES[ent][CONF_NAME]}" - self._attr_icon = CONF_SENSOR_TYPES[ent][CONF_ICON] - self._attr_native_unit_of_measurement = CONF_SENSOR_TYPES[ent][ - CONF_UNIT_OF_MEASUREMENT - ] - self._attr_device_class = CONF_SENSOR_TYPES[ent][CONF_DEVICE_CLASS] - self._ent = ent + self.entity_description = description + self._attr_name = f"{entry.title} {description.name}" @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the sensor.""" - return self.coordinator.data[self._ent] + if (sensor_round := self.entity_description.precision) is not None: + try: + return cast( + StateType, + round( + self.coordinator.data[self.entity_description.key], sensor_round + ), + ) + except TypeError: + _LOGGER.debug("Cannot format %s", self._attr_name) + return None + return cast(StateType, self.coordinator.data[self.entity_description.key]) diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index 6824a1343fc..4cde9c6d255 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -7,15 +7,23 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "reauth_invalid": "Re-authentication failed; Serial Number does not match original" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/homeassistant/components/wallbox/translations/bg.json b/homeassistant/components/wallbox/translations/bg.json index 648be54571d..25f3bb50845 100644 --- a/homeassistant/components/wallbox/translations/bg.json +++ b/homeassistant/components/wallbox/translations/bg.json @@ -1,14 +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\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_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": { + "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": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", diff --git a/homeassistant/components/wallbox/translations/ca.json b/homeassistant/components/wallbox/translations/ca.json index 55240065548..b6a10e16e2a 100644 --- a/homeassistant/components/wallbox/translations/ca.json +++ b/homeassistant/components/wallbox/translations/ca.json @@ -1,14 +1,22 @@ { "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", "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "reauth_invalid": "Ha fallat la re-autenticaci\u00f3; el n\u00famero de s\u00e8rie no coincideix amb l'original", "unknown": "Error inesperat" }, "step": { + "reauth_confirm": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + } + }, "user": { "data": { "password": "Contrasenya", diff --git a/homeassistant/components/wallbox/translations/de.json b/homeassistant/components/wallbox/translations/de.json index 89362597b85..d415b4f9e7a 100644 --- a/homeassistant/components/wallbox/translations/de.json +++ b/homeassistant/components/wallbox/translations/de.json @@ -1,14 +1,22 @@ { "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", "invalid_auth": "Ung\u00fcltige Authentifizierung", + "reauth_invalid": "Die erneute Authentifizierung ist fehlgeschlagen; Seriennummer stimmt nicht mit Original \u00fcberein", "unknown": "Unerwarteter Fehler" }, "step": { + "reauth_confirm": { + "data": { + "password": "Passwort", + "username": "Benutzername" + } + }, "user": { "data": { "password": "Passwort", diff --git a/homeassistant/components/wallbox/translations/en.json b/homeassistant/components/wallbox/translations/en.json index 3d75e0bc276..28ec5d08235 100644 --- a/homeassistant/components/wallbox/translations/en.json +++ b/homeassistant/components/wallbox/translations/en.json @@ -1,14 +1,22 @@ { "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", "invalid_auth": "Invalid authentication", + "reauth_invalid": "Re-authentication failed; Serial Number does not match original", "unknown": "Unexpected error" }, "step": { + "reauth_confirm": { + "data": { + "password": "Password", + "username": "Username" + } + }, "user": { "data": { "password": "Password", @@ -17,5 +25,6 @@ } } } - } + }, + "title": "Wallbox" } \ No newline at end of file diff --git a/homeassistant/components/wallbox/translations/es.json b/homeassistant/components/wallbox/translations/es.json index 1252e5eaca1..3adfd671804 100644 --- a/homeassistant/components/wallbox/translations/es.json +++ b/homeassistant/components/wallbox/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "El dispositivo ya est\u00e1 configurado" + "already_configured": "El dispositivo ya est\u00e1 configurado", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { "cannot_connect": "No se pudo conectar", @@ -9,6 +10,12 @@ "unknown": "Error inesperado" }, "step": { + "reauth_confirm": { + "data": { + "password": "Contrase\u00f1a", + "username": "Usuario" + } + }, "user": { "data": { "password": "Contrase\u00f1a", diff --git a/homeassistant/components/wallbox/translations/et.json b/homeassistant/components/wallbox/translations/et.json index 12e24fd83ba..f5d3b3aac73 100644 --- a/homeassistant/components/wallbox/translations/et.json +++ b/homeassistant/components/wallbox/translations/et.json @@ -1,14 +1,22 @@ { "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", "invalid_auth": "Tuvastamine nurjus", + "reauth_invalid": "Taastuvastamine nurjus, seerianumber ei vasta originaalile", "unknown": "Tundmatu t\u00f5rge" }, "step": { + "reauth_confirm": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + } + }, "user": { "data": { "password": "Salas\u00f5na", diff --git a/homeassistant/components/wallbox/translations/fr.json b/homeassistant/components/wallbox/translations/fr.json index 05e57f9adc4..4b2eddb6ef9 100644 --- a/homeassistant/components/wallbox/translations/fr.json +++ b/homeassistant/components/wallbox/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,12 @@ "unknown": "Erreur inattendue" }, "step": { + "reauth_confirm": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + }, "user": { "data": { "password": "Mot de passe", diff --git a/homeassistant/components/wallbox/translations/he.json b/homeassistant/components/wallbox/translations/he.json index ca1c7a93c5c..6109bb22195 100644 --- a/homeassistant/components/wallbox/translations/he.json +++ b/homeassistant/components/wallbox/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,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", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, "user": { "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", diff --git a/homeassistant/components/wallbox/translations/hu.json b/homeassistant/components/wallbox/translations/hu.json index 097ba53f02e..5579da0fe88 100644 --- a/homeassistant/components/wallbox/translations/hu.json +++ b/homeassistant/components/wallbox/translations/hu.json @@ -1,14 +1,22 @@ { "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", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "reauth_invalid": "Az \u00fajrahiteles\u00edt\u00e9s sikertelen volt; A sorozatsz\u00e1m nem egyezik az eredetivel", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + }, "user": { "data": { "password": "Jelsz\u00f3", diff --git a/homeassistant/components/wallbox/translations/id.json b/homeassistant/components/wallbox/translations/id.json index becbcbe817f..08611ab3c2e 100644 --- a/homeassistant/components/wallbox/translations/id.json +++ b/homeassistant/components/wallbox/translations/id.json @@ -1,17 +1,26 @@ { "config": { "abort": { - "already_configured": "Perangkat sudah dikonfigurasi" + "already_configured": "Perangkat sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "cannot_connect": "Gagal terhubung", "invalid_auth": "Autentikasi tidak valid", + "reauth_invalid": "Autentikasi ulang gagal; Nomor seri tidak cocok dengan aslinya", "unknown": "Kesalahan yang tidak diharapkan" }, "step": { + "reauth_confirm": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + }, "user": { "data": { "password": "Kata Sandi", + "station": "Nomor Seri Stasiun", "username": "Nama Pengguna" } } diff --git a/homeassistant/components/wallbox/translations/it.json b/homeassistant/components/wallbox/translations/it.json index 5b8828860e7..112a0c13970 100644 --- a/homeassistant/components/wallbox/translations/it.json +++ b/homeassistant/components/wallbox/translations/it.json @@ -1,14 +1,22 @@ { "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", "invalid_auth": "Autenticazione non valida", + "reauth_invalid": "Riautenticazione non riuscita; Il numero di serie non corrisponde all'originale", "unknown": "Errore imprevisto" }, "step": { + "reauth_confirm": { + "data": { + "password": "Password", + "username": "Nome utente" + } + }, "user": { "data": { "password": "Password", diff --git a/homeassistant/components/wallbox/translations/ja.json b/homeassistant/components/wallbox/translations/ja.json new file mode 100644 index 00000000000..8924bf891b9 --- /dev/null +++ b/homeassistant/components/wallbox/translations/ja.json @@ -0,0 +1,30 @@ +{ + "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" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "reauth_invalid": "\u518d\u8a8d\u8a3c\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u30b7\u30ea\u30a2\u30eb\u756a\u53f7\u304c\u30aa\u30ea\u30b8\u30ca\u30eb\u3068\u4e00\u81f4\u3057\u307e\u305b\u3093", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "station": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u306e\u30b7\u30ea\u30a2\u30eb\u756a\u53f7", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + }, + "title": "Wallbox" +} \ No newline at end of file diff --git a/homeassistant/components/wallbox/translations/nl.json b/homeassistant/components/wallbox/translations/nl.json index dd406ea3b90..dbe8bd91f72 100644 --- a/homeassistant/components/wallbox/translations/nl.json +++ b/homeassistant/components/wallbox/translations/nl.json @@ -7,17 +7,17 @@ "error": { "cannot_connect": "Kan geen verbinding maken", "invalid_auth": "Ongeldige authenticatie", + "reauth_invalid": "Herauthenticatie mislukt; serial-nummer komt niet overeen met het origineel", "unknown": "Onverwachte fout" }, "step": { - "user": { + "reauth_confirm": { "data": { "password": "Wachtwoord", - "station": "Station Serienummer", "username": "Gebruikersnaam" } }, - "reauth_confirm": { + "user": { "data": { "password": "Wachtwoord", "station": "Station Serienummer", diff --git a/homeassistant/components/wallbox/translations/no.json b/homeassistant/components/wallbox/translations/no.json index 42368703121..74bbb1f39d5 100644 --- a/homeassistant/components/wallbox/translations/no.json +++ b/homeassistant/components/wallbox/translations/no.json @@ -1,14 +1,22 @@ { "config": { "abort": { - "already_configured": "Enheten er allerede konfigurert" + "already_configured": "Enheten er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", "invalid_auth": "Ugyldig godkjenning", + "reauth_invalid": "Re-autentisering mislyktes; Serienummeret samsvarer ikke med originalen", "unknown": "Uventet feil" }, "step": { + "reauth_confirm": { + "data": { + "password": "Passord", + "username": "Brukernavn" + } + }, "user": { "data": { "password": "Passord", diff --git a/homeassistant/components/wallbox/translations/pl.json b/homeassistant/components/wallbox/translations/pl.json index 2728f1cae31..51180d4c68e 100644 --- a/homeassistant/components/wallbox/translations/pl.json +++ b/homeassistant/components/wallbox/translations/pl.json @@ -1,14 +1,22 @@ { "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", "invalid_auth": "Niepoprawne uwierzytelnienie", + "reauth_invalid": "Ponowne uwierzytelnienie nie powiod\u0142o si\u0119. Numer seryjny nie pasuje do orygina\u0142u.", "unknown": "Nieoczekiwany b\u0142\u0105d" }, "step": { + "reauth_confirm": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + }, "user": { "data": { "password": "Has\u0142o", diff --git a/homeassistant/components/wallbox/translations/ru.json b/homeassistant/components/wallbox/translations/ru.json index b6b33a6eb48..426c07c9423 100644 --- a/homeassistant/components/wallbox/translations/ru.json +++ b/homeassistant/components/wallbox/translations/ru.json @@ -1,14 +1,22 @@ { "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.", "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "reauth_invalid": "\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. \u0421\u0435\u0440\u0438\u0439\u043d\u044b\u0439 \u043d\u043e\u043c\u0435\u0440 \u043d\u0435 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0438\u0441\u0445\u043e\u0434\u043d\u043e\u043c\u0443.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "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" + } + }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u044c", diff --git a/homeassistant/components/wallbox/translations/sl.json b/homeassistant/components/wallbox/translations/sl.json new file mode 100644 index 00000000000..3b7b34cb86d --- /dev/null +++ b/homeassistant/components/wallbox/translations/sl.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "password": "Geslo", + "username": "Uporabni\u0161ko ime" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wallbox/translations/tr.json b/homeassistant/components/wallbox/translations/tr.json new file mode 100644 index 00000000000..1bee24a696d --- /dev/null +++ b/homeassistant/components/wallbox/translations/tr.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "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", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "reauth_invalid": "Yeniden kimlik do\u011frulama ba\u015far\u0131s\u0131z oldu; Seri Numaras\u0131 orijinalle e\u015fle\u015fmiyor", + "unknown": "Beklenmeyen hata" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + }, + "user": { + "data": { + "password": "Parola", + "station": "\u0130stasyon Seri Numaras\u0131", + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + } + } + }, + "title": "Wallbox" +} \ No newline at end of file diff --git a/homeassistant/components/wallbox/translations/zh-Hant.json b/homeassistant/components/wallbox/translations/zh-Hant.json index 78a752f9a0d..3f282cda1f9 100644 --- a/homeassistant/components/wallbox/translations/zh-Hant.json +++ b/homeassistant/components/wallbox/translations/zh-Hant.json @@ -1,14 +1,22 @@ { "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", "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "reauth_invalid": "\u91cd\u65b0\u8a8d\u8b49\u5931\u6557\uff1b\u8207\u539f\u59cb\u5e8f\u865f\u4e0d\u7b26\u5408", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + }, "user": { "data": { "password": "\u5bc6\u78bc", diff --git a/homeassistant/components/water_heater/translations/ca.json b/homeassistant/components/water_heater/translations/ca.json index 6033868ccaa..022b5e887d8 100644 --- a/homeassistant/components/water_heater/translations/ca.json +++ b/homeassistant/components/water_heater/translations/ca.json @@ -12,7 +12,7 @@ "gas": "Gas", "heat_pump": "Bomba de calor", "high_demand": "Alta demanda", - "off": "off", + "off": "OFF", "performance": "Rendiment" } } diff --git a/homeassistant/components/water_heater/translations/ja.json b/homeassistant/components/water_heater/translations/ja.json new file mode 100644 index 00000000000..dc1775101a9 --- /dev/null +++ b/homeassistant/components/water_heater/translations/ja.json @@ -0,0 +1,19 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "\u30aa\u30d5\u306b\u3059\u308b {entity_name}", + "turn_on": "\u30aa\u30f3\u306b\u3059\u308b {entity_name}" + } + }, + "state": { + "_": { + "eco": "\u30a8\u30b3", + "electric": "\u30a8\u30ec\u30af\u30c8\u30ea\u30c3\u30af", + "gas": "\u30ac\u30b9", + "heat_pump": "\u30d2\u30fc\u30c8\u30dd\u30f3\u30d7", + "high_demand": "\u9ad8\u9700\u8981(High Demand)", + "off": "\u30aa\u30d5", + "performance": "\u30d1\u30d5\u30a9\u30fc\u30de\u30f3\u30b9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/water_heater/translations/tr.json b/homeassistant/components/water_heater/translations/tr.json index 3010c9e622b..c4633d419c3 100644 --- a/homeassistant/components/water_heater/translations/tr.json +++ b/homeassistant/components/water_heater/translations/tr.json @@ -4,5 +4,16 @@ "turn_off": "{entity_name} kapat", "turn_on": "{entity_name} a\u00e7\u0131n" } + }, + "state": { + "_": { + "eco": "Eko", + "electric": "Elektrik", + "gas": "Gaz", + "heat_pump": "Is\u0131 pompas\u0131", + "high_demand": "Y\u00fcksek talep", + "off": "Kapal\u0131", + "performance": "Performans" + } } } \ No newline at end of file diff --git a/homeassistant/components/watson_iot/__init__.py b/homeassistant/components/watson_iot/__init__.py index 8b81a9d741b..cd3599683d0 100644 --- a/homeassistant/components/watson_iot/__init__.py +++ b/homeassistant/components/watson_iot/__init__.py @@ -217,8 +217,7 @@ class WatsonIOTThread(threading.Thread): def run(self): """Process incoming events.""" while not self.shutdown: - event = self.get_events_json() - if event: + if event := self.get_events_json(): self.write_to_watson(event) self.queue.task_done() diff --git a/homeassistant/components/watttime/__init__.py b/homeassistant/components/watttime/__init__.py index 779fc0791b6..d33e6fceb2e 100644 --- a/homeassistant/components/watttime/__init__.py +++ b/homeassistant/components/watttime/__init__.py @@ -19,7 +19,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DATA_COORDINATOR, DOMAIN, LOGGER +from .const import DOMAIN, LOGGER DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5) @@ -28,9 +28,6 @@ PLATFORMS: list[str] = ["sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up WattTime from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} - session = aiohttp_client.async_get_clientsession(hass) try: @@ -65,7 +62,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] = coordinator + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/watttime/const.py b/homeassistant/components/watttime/const.py index 07ea0e47167..5bb8cb50d40 100644 --- a/homeassistant/components/watttime/const.py +++ b/homeassistant/components/watttime/const.py @@ -8,5 +8,3 @@ LOGGER = logging.getLogger(__package__) CONF_BALANCING_AUTHORITY = "balancing_authority" CONF_BALANCING_AUTHORITY_ABBREV = "balancing_authority_abbreviation" CONF_SHOW_ON_MAP = "show_on_map" - -DATA_COORDINATOR = "coordinator" diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py index b1ebc262134..0b1ae54b5d1 100644 --- a/homeassistant/components/watttime/sensor.py +++ b/homeassistant/components/watttime/sensor.py @@ -10,13 +10,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_LATITUDE, - ATTR_LONGITUDE, - MASS_POUNDS, - PERCENTAGE, -) +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, MASS_POUNDS, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -29,14 +23,11 @@ from .const import ( CONF_BALANCING_AUTHORITY, CONF_BALANCING_AUTHORITY_ABBREV, CONF_SHOW_ON_MAP, - DATA_COORDINATOR, DOMAIN, ) ATTR_BALANCING_AUTHORITY = "balancing_authority" -DEFAULT_ATTRIBUTION = "Pickup data provided by WattTime" - SENSOR_TYPE_REALTIME_EMISSIONS_MOER = "moer" SENSOR_TYPE_REALTIME_EMISSIONS_PERCENT = "percent" @@ -63,7 +54,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up WattTime sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ RealtimeEmissionsSensor(coordinator, entry, description) @@ -96,7 +87,6 @@ class RealtimeEmissionsSensor(CoordinatorEntity, SensorEntity): def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return entity specific state attributes.""" attrs = { - ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION, ATTR_BALANCING_AUTHORITY: self._entry.data[CONF_BALANCING_AUTHORITY], } diff --git a/homeassistant/components/watttime/translations/fr.json b/homeassistant/components/watttime/translations/fr.json new file mode 100644 index 00000000000..fb916a3f333 --- /dev/null +++ b/homeassistant/components/watttime/translations/fr.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" + }, + "error": { + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "coordinates": { + "data": { + "longitude": "Longitude" + } + }, + "location": { + "data": { + "location_type": "Emplacement" + } + }, + "reauth_confirm": { + "data": { + "password": "Mot de passe" + }, + "title": "R\u00e9-authentifier l'int\u00e9gration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/id.json b/homeassistant/components/watttime/translations/id.json index 2549bd6f4ff..6cb1cc1c646 100644 --- a/homeassistant/components/watttime/translations/id.json +++ b/homeassistant/components/watttime/translations/id.json @@ -1,5 +1,14 @@ { "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan", + "unknown_coordinates": "Tidak ada data untuk garis lintang/bujur" + }, "step": { "coordinates": { "data": { @@ -14,6 +23,13 @@ }, "description": "Pilih lokasi untuk dipantau:" }, + "reauth_confirm": { + "data": { + "password": "Kata Sandi" + }, + "description": "Masukkan kembali kata sandi untuk {username} :", + "title": "Autentikasi Ulang Integrasi" + }, "user": { "data": { "password": "Kata Sandi", @@ -22,5 +38,15 @@ "description": "Masukkan nama pengguna dan kata sandi Anda:" } } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Tampilkan lokasi yang dipantau di peta" + }, + "title": "Konfigurasikan WattTime" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/it.json b/homeassistant/components/watttime/translations/it.json index 4be720042f1..ecca75e5b5d 100644 --- a/homeassistant/components/watttime/translations/it.json +++ b/homeassistant/components/watttime/translations/it.json @@ -38,5 +38,15 @@ "description": "Inserisci il tuo nome utente e password:" } } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Mostra la posizione monitorata sulla mappa" + }, + "title": "Configura WattTime" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/ja.json b/homeassistant/components/watttime/translations/ja.json new file mode 100644 index 00000000000..87b71e0675f --- /dev/null +++ b/homeassistant/components/watttime/translations/ja.json @@ -0,0 +1,52 @@ +{ + "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" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc", + "unknown_coordinates": "\u7def\u5ea6/\u7d4c\u5ea6\u306e\u30c7\u30fc\u30bf\u306a\u3057" + }, + "step": { + "coordinates": { + "data": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6" + }, + "description": "\u76e3\u8996\u3059\u308b\u7def\u5ea6(latitude)\u3068\u7d4c\u5ea6(longitude )\u3092\u5165\u529b:" + }, + "location": { + "data": { + "location_type": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3" + }, + "description": "\u76e3\u8996\u3059\u308b\u5834\u6240\u3092\u9078\u629e:" + }, + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "{username} \u306e\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u518d\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044:", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u30e6\u30fc\u30b6\u30fc\u540d\u3068\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044:" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "\u76e3\u8996\u5bfe\u8c61\u306e\u5834\u6240\u3092\u5730\u56f3\u4e0a\u306b\u8868\u793a" + }, + "title": "WattTime\u306e\u8a2d\u5b9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/nl.json b/homeassistant/components/watttime/translations/nl.json index 045533d2336..72758f4a0d7 100644 --- a/homeassistant/components/watttime/translations/nl.json +++ b/homeassistant/components/watttime/translations/nl.json @@ -38,5 +38,15 @@ "description": "Voer uw gebruikersnaam en wachtwoord in:" } } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Toon gemonitorde locatie op de kaart" + }, + "title": "Configureer WattTime" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/no.json b/homeassistant/components/watttime/translations/no.json index bef57982658..19ec82e863c 100644 --- a/homeassistant/components/watttime/translations/no.json +++ b/homeassistant/components/watttime/translations/no.json @@ -38,5 +38,15 @@ "description": "Skriv inn brukernavn og passord:" } } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Vis overv\u00e5ket plassering p\u00e5 kartet" + }, + "title": "Konfigurer WattTime" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/pl.json b/homeassistant/components/watttime/translations/pl.json index b135f54a6c0..6634f79356f 100644 --- a/homeassistant/components/watttime/translations/pl.json +++ b/homeassistant/components/watttime/translations/pl.json @@ -27,6 +27,7 @@ "data": { "password": "Has\u0142o" }, + "description": "Wprowad\u017a ponownie has\u0142o dla u\u017cytkownika {username}:", "title": "Ponownie uwierzytelnij integracj\u0119" }, "user": { @@ -37,5 +38,15 @@ "description": "Wprowad\u017a swoj\u0105 nazw\u0119 u\u017cytkownika i has\u0142o:" } } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Poka\u017c monitorowan\u0105 lokalizacj\u0119 na mapie" + }, + "title": "Konfiguracja WattTime" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/sl.json b/homeassistant/components/watttime/translations/sl.json new file mode 100644 index 00000000000..56d6a5b830a --- /dev/null +++ b/homeassistant/components/watttime/translations/sl.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "password": "Geslo" + }, + "description": "Ponovno vnesite geslo za {username} :" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/tr.json b/homeassistant/components/watttime/translations/tr.json index 866fc513d4a..a9531c6deaf 100644 --- a/homeassistant/components/watttime/translations/tr.json +++ b/homeassistant/components/watttime/translations/tr.json @@ -1,10 +1,51 @@ { "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata", + "unknown_coordinates": "Enlem/boylam i\u00e7in veri yok" + }, "step": { + "coordinates": { + "data": { + "latitude": "Enlem", + "longitude": "Boylam" + }, + "description": "\u0130zlenecek enlem ve boylam\u0131 girin:" + }, + "location": { + "data": { + "location_type": "Konum" + }, + "description": "\u0130zlemek i\u00e7in bir konum se\u00e7in:" + }, "reauth_confirm": { "data": { - "password": "\u015eifre" - } + "password": "Parola" + }, + "description": "L\u00fctfen {username} parolas\u0131n\u0131 yeniden girin:", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, + "user": { + "data": { + "password": "\u015eifre", + "username": "Kullan\u0131c\u0131 ad\u0131" + }, + "description": "Kullan\u0131c\u0131 ad\u0131n\u0131z\u0131 ve \u015fifrenizi girin:" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Haritada izlenen konumu g\u00f6ster" + }, + "title": "WattTime'\u0131 yap\u0131land\u0131r\u0131n" } } } diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 81ee48ebd2f..9df95daf9ee 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -22,6 +22,8 @@ from homeassistant.const import ( ) from homeassistant.core import Config, CoreState, 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 DiscoveryInfoType @@ -163,11 +165,12 @@ class WazeTravelTime(SensorEntity): """Representation of a Waze travel time sensor.""" _attr_native_unit_of_measurement = TIME_MINUTES - _attr_device_info = { - "name": "Waze", - "identifiers": {(DOMAIN, DOMAIN)}, - "entry_type": "service", - } + _attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name="Waze", + identifiers={(DOMAIN, DOMAIN)}, + configuration_url="https://www.waze.com", + ) def __init__(self, unique_id, name, origin, destination, waze_data): """Initialize the Waze travel time sensor.""" diff --git a/homeassistant/components/waze_travel_time/translations/bg.json b/homeassistant/components/waze_travel_time/translations/bg.json new file mode 100644 index 00000000000..35cfa0ad1d7 --- /dev/null +++ b/homeassistant/components/waze_travel_time/translations/bg.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "\u0418\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/waze_travel_time/translations/ja.json b/homeassistant/components/waze_travel_time/translations/ja.json new file mode 100644 index 00000000000..5ed0741a67e --- /dev/null +++ b/homeassistant/components/waze_travel_time/translations/ja.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\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": { + "destination": "\u76ee\u7684\u5730", + "name": "\u540d\u524d", + "origin": "\u30aa\u30ea\u30b8\u30f3", + "region": "\u30ea\u30fc\u30b8\u30e7\u30f3" + }, + "description": "\u51fa\u767a\u5730\u3068\u76ee\u7684\u5730\u306b\u3001\u5834\u6240\u306e\u4f4f\u6240\u307e\u305f\u306fGPS\u5ea7\u6a19\u3092\u5165\u529b\u3057\u307e\u3059(GPS\u306e\u5ea7\u6a19\u306f\u30b3\u30f3\u30de\u3067\u533a\u5207\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059)\u3002\u3053\u306e\u60c5\u5831\u3092\u72b6\u614b(state)\u3067\u63d0\u4f9b\u3059\u308b\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3ID\u3001\u7def\u5ea6\u3068\u7d4c\u5ea6\u306e\u5c5e\u6027\u3092\u6301\u3064\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3ID\u3001\u307e\u305f\u306f\u30be\u30fc\u30f3\u306e\u30d5\u30ec\u30f3\u30c9\u30ea\u30fc\u540d\u3092\u5165\u529b\u3059\u308b\u3053\u3068\u3082\u3067\u304d\u307e\u3059\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "avoid_ferries": "\u30d5\u30a7\u30ea\u30fc\u3092\u907f\u3051\u307e\u3059\u304b\uff1f", + "avoid_subscription_roads": "Vignette / Subscription\u3092\u5fc5\u8981\u3068\u3059\u308b\u9053\u8def\u3092\u907f\u3051\u307e\u3059\u304b\uff1f", + "avoid_toll_roads": "\u6709\u6599\u9053\u8def\u3092\u907f\u3051\u307e\u3059\u304b\uff1f", + "excl_filter": "\u9078\u629e\u3055\u308c\u305f\u30eb\u30fc\u30c8\u306e\u8aac\u660e\u306b\u542b\u307e\u308c\u306a\u3044Substring", + "incl_filter": "\u9078\u629e\u3055\u308c\u305f\u30eb\u30fc\u30c8\u306e\u8aac\u660e\u306b\u542b\u307e\u308c\u308bSubstring", + "realtime": "\u30ea\u30a2\u30eb\u30bf\u30a4\u30e0\u3067\u306e\u79fb\u52d5\u6642\u9593\uff1f", + "units": "\u5358\u4f4d", + "vehicle_type": "\u8eca\u4e21\u30bf\u30a4\u30d7" + }, + "description": "`substring`\u30a4\u30f3\u30d7\u30c3\u30c8\u3092\u4f7f\u7528\u3059\u308b\u3068\u3001\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u7279\u5b9a\u306e\u30eb\u30fc\u30c8\u3092\u4f7f\u7528\u3059\u308b\u3088\u3046\u306b\u5f37\u5236\u3057\u305f\u308a\u3001\u9006\u306b\u7279\u5b9a\u306e\u30eb\u30fc\u30c8\u3092\u56de\u907f\u3057\u305f\u30bf\u30a4\u30e0\u30c8\u30e9\u30d9\u30eb\u306e\u8a08\u7b97\u3092\u884c\u3046\u3053\u3068\u304c\u3067\u304d\u307e\u3059\u3002" + } + } + }, + "title": "Waze Travel Time" +} \ No newline at end of file diff --git a/homeassistant/components/waze_travel_time/translations/no.json b/homeassistant/components/waze_travel_time/translations/no.json index c9baef06743..15e30100519 100644 --- a/homeassistant/components/waze_travel_time/translations/no.json +++ b/homeassistant/components/waze_travel_time/translations/no.json @@ -14,7 +14,7 @@ "origin": "Opprinnelse", "region": "Region" }, - "description": "For opprinnelse og destinasjon, skriv inn adressen eller GPS-koordinatene til stedet (GPS-koordinatene m\u00e5 v\u00e6re atskilt med komma). Du kan ogs\u00e5 angi en enhets-ID som gir denne informasjonen i sin tilstand, en enhets-id med breddegrad og lengdegrad eller attributt navn." + "description": "For opprinnelse og destinasjon, skriv inn adressen eller GPS-koordinatene til stedet (GPS-koordinatene m\u00e5 v\u00e6re atskilt med komma). Du kan ogs\u00e5 angi en entitets-ID som gir denne informasjonen i sin tilstand, en entitets-id med breddegrad og lengdegrad eller attributt navn." } } }, diff --git a/homeassistant/components/waze_travel_time/translations/tr.json b/homeassistant/components/waze_travel_time/translations/tr.json new file mode 100644 index 00000000000..71e65efb333 --- /dev/null +++ b/homeassistant/components/waze_travel_time/translations/tr.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "step": { + "user": { + "data": { + "destination": "Hedef", + "name": "Ad", + "origin": "Men\u015fei", + "region": "B\u00f6lge" + }, + "description": "Ba\u015flang\u0131\u00e7 ve Var\u0131\u015f Yeri i\u00e7in, konumun adresini veya GPS koordinatlar\u0131n\u0131 girin (GPS koordinatlar\u0131 virg\u00fclle ayr\u0131lmal\u0131d\u0131r). Bu bilgiyi kendi durumunda sa\u011flayan bir varl\u0131k kimli\u011fi, enlem ve boylam niteliklerine sahip bir varl\u0131k kimli\u011fi veya b\u00f6lge dostu ad da girebilirsiniz." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "avoid_ferries": "Feribotlardan Ka\u00e7\u0131n\u0131n?", + "avoid_subscription_roads": "Vinyet / Abonelik Gerektiren Yollardan Ka\u00e7\u0131n\u0131n?", + "avoid_toll_roads": "\u00dccretli Yollardan Ka\u00e7\u0131n\u0131n?", + "excl_filter": "Alt dize, Se\u00e7ili Rotan\u0131n A\u00e7\u0131klamas\u0131nda DE\u011e\u0130L", + "incl_filter": "Se\u00e7ili Rotan\u0131n A\u00e7\u0131klamas\u0131nda Alt Dize Olu\u015fturma", + "realtime": "Ger\u00e7ek Zamanl\u0131 Seyahat S\u00fcresi?", + "units": "Birimler", + "vehicle_type": "Ara\u00e7 Tipi" + }, + "description": "'Alt dizi' giri\u015fleri, entegrasyonu belirli bir rotay\u0131 kullanmaya zorlaman\u0131za veya zaman yolculu\u011fu hesaplamas\u0131nda belirli bir rotadan ka\u00e7\u0131nman\u0131za izin verecektir." + } + } + }, + "title": "Waze Seyahat S\u00fcresi" +} \ No newline at end of file diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 81d245c19bb..b9fa7e2ae39 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -61,6 +61,8 @@ ENTITY_ID_FORMAT = DOMAIN + ".{}" SCAN_INTERVAL = timedelta(seconds=30) +ROUNDING_PRECISION = 2 + class Forecast(TypedDict, total=False): """Typed weather forecast dict.""" @@ -112,38 +114,52 @@ class WeatherEntity(Entity): _attr_ozone: float | None = None _attr_precision: float _attr_pressure: float | None = None + _attr_pressure_unit: str | None = None _attr_state: None = None _attr_temperature_unit: str _attr_temperature: float | None _attr_visibility: float | None = None + _attr_visibility_unit: str | None = None + _attr_precipitation_unit: str | None = None _attr_wind_bearing: float | str | None = None _attr_wind_speed: float | None = None + _attr_wind_speed_unit: str | None = None @property def temperature(self) -> float | None: - """Return the platform temperature.""" + """Return the platform temperature in native units (i.e. not converted).""" return self._attr_temperature @property def temperature_unit(self) -> str: - """Return the unit of measurement.""" + """Return the native unit of measurement for temperature.""" return self._attr_temperature_unit @property def pressure(self) -> float | None: - """Return the pressure.""" + """Return the pressure in native units.""" return self._attr_pressure + @property + def pressure_unit(self) -> str | None: + """Return the native unit of measurement for pressure.""" + return self._attr_pressure_unit + @property def humidity(self) -> float | None: - """Return the humidity.""" + """Return the humidity in native units.""" return self._attr_humidity @property def wind_speed(self) -> float | None: - """Return the wind speed.""" + """Return the wind speed in native units.""" return self._attr_wind_speed + @property + def wind_speed_unit(self) -> str | None: + """Return the native unit of measurement for wind speed.""" + return self._attr_wind_speed_unit + @property def wind_bearing(self) -> float | str | None: """Return the wind bearing.""" @@ -156,17 +172,27 @@ class WeatherEntity(Entity): @property def visibility(self) -> float | None: - """Return the visibility.""" + """Return the visibility in native units.""" return self._attr_visibility + @property + def visibility_unit(self) -> str | None: + """Return the native unit of measurement for visibility.""" + return self._attr_visibility_unit + @property def forecast(self) -> list[Forecast] | None: - """Return the forecast.""" + """Return the forecast in native units.""" return self._attr_forecast + @property + def precipitation_unit(self) -> str | None: + """Return the native unit of measurement for accumulated precipitation.""" + return self._attr_precipitation_unit + @property def precision(self) -> float: - """Return the precision of the temperature value.""" + """Return the precision of the temperature value, after unit conversion.""" if hasattr(self, "_attr_precision"): return self._attr_precision return ( @@ -178,11 +204,14 @@ class WeatherEntity(Entity): @final @property def state_attributes(self): - """Return the state attributes.""" + """Return the state attributes, converted from native units to user-configured units.""" data = {} if self.temperature is not None: data[ATTR_WEATHER_TEMPERATURE] = show_temp( - self.hass, self.temperature, self.temperature_unit, self.precision + self.hass, + self.temperature, + self.temperature_unit, + self.precision, ) if (humidity := self.humidity) is not None: @@ -192,15 +221,28 @@ class WeatherEntity(Entity): data[ATTR_WEATHER_OZONE] = ozone if (pressure := self.pressure) is not None: + if (unit := self.pressure_unit) is not None: + pressure = round( + self.hass.config.units.pressure(pressure, unit), ROUNDING_PRECISION + ) data[ATTR_WEATHER_PRESSURE] = pressure if (wind_bearing := self.wind_bearing) is not None: data[ATTR_WEATHER_WIND_BEARING] = wind_bearing if (wind_speed := self.wind_speed) is not None: + if (unit := self.wind_speed_unit) is not None: + wind_speed = round( + self.hass.config.units.wind_speed(wind_speed, unit), + ROUNDING_PRECISION, + ) data[ATTR_WEATHER_WIND_SPEED] = wind_speed if (visibility := self.visibility) is not None: + if (unit := self.visibility_unit) is not None: + visibility = round( + self.hass.config.units.length(visibility, unit), ROUNDING_PRECISION + ) data[ATTR_WEATHER_VISIBILITY] = visibility if self.forecast is not None: @@ -220,6 +262,34 @@ class WeatherEntity(Entity): self.temperature_unit, self.precision, ) + if ATTR_FORECAST_PRESSURE in forecast_entry: + if (unit := self.pressure_unit) is not None: + pressure = round( + self.hass.config.units.pressure( + forecast_entry[ATTR_FORECAST_PRESSURE], unit + ), + ROUNDING_PRECISION, + ) + forecast_entry[ATTR_FORECAST_PRESSURE] = pressure + if ATTR_FORECAST_WIND_SPEED in forecast_entry: + if (unit := self.wind_speed_unit) is not None: + wind_speed = round( + self.hass.config.units.wind_speed( + forecast_entry[ATTR_FORECAST_WIND_SPEED], unit + ), + ROUNDING_PRECISION, + ) + forecast_entry[ATTR_FORECAST_WIND_SPEED] = wind_speed + if ATTR_FORECAST_PRECIPITATION in forecast_entry: + if (unit := self.precipitation_unit) is not None: + precipitation = round( + self.hass.config.units.accumulated_precipitation( + forecast_entry[ATTR_FORECAST_PRECIPITATION], unit + ), + ROUNDING_PRECISION, + ) + forecast_entry[ATTR_FORECAST_PRECIPITATION] = precipitation + forecast.append(forecast_entry) data[ATTR_FORECAST] = forecast diff --git a/homeassistant/components/weather/translations/ja.json b/homeassistant/components/weather/translations/ja.json index 8b2d8a46d74..787791be8cd 100644 --- a/homeassistant/components/weather/translations/ja.json +++ b/homeassistant/components/weather/translations/ja.json @@ -3,6 +3,7 @@ "_": { "clear-night": "\u6674\u308c\u305f\u591c", "cloudy": "\u66c7\u308a", + "exceptional": "\u534a\u7aef\u3058\u3083\u306a\u3044", "fog": "\u9727", "hail": "\u96f9", "lightning": "\u96f7", @@ -13,7 +14,8 @@ "snowy": "\u96ea", "snowy-rainy": "\u307f\u305e\u308c", "sunny": "\u6674\u308c", - "windy": "\u5f37\u98a8" + "windy": "\u5f37\u98a8", + "windy-variant": "\u5f37\u98a8" } } } \ No newline at end of file diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 4216eae5a09..a4abbd30dff 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -495,7 +495,11 @@ async def handle_test_condition( # pylint: disable=import-outside-toplevel from homeassistant.helpers import condition - check_condition = await condition.async_from_config(hass, msg["condition"]) + # Do static + dynamic validation of the condition + config = cv.CONDITION_SCHEMA(msg["condition"]) + config = await condition.async_validate_condition_config(hass, config) + # Test the condition + check_condition = await condition.async_from_config(hass, config) connection.send_result( msg["id"], {"result": check_condition(hass, msg.get("variables"))} ) diff --git a/homeassistant/components/websocket_api/decorators.py b/homeassistant/components/websocket_api/decorators.py index eff82a8c71d..296271c7cfd 100644 --- a/homeassistant/components/websocket_api/decorators.py +++ b/homeassistant/components/websocket_api/decorators.py @@ -8,6 +8,7 @@ from typing import Any import voluptuous as vol +from homeassistant.const import HASSIO_USER_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import Unauthorized @@ -70,6 +71,7 @@ def ws_require_user( allow_system_user: bool = True, only_active_user: bool = True, only_inactive_user: bool = False, + only_supervisor: bool = False, ) -> Callable[[const.WebSocketCommandHandler], const.WebSocketCommandHandler]: """Decorate function validating login user exist in current WS connection. @@ -111,6 +113,10 @@ def ws_require_user( output_error("only_inactive_user", "Not allowed as active user") return + if only_supervisor and connection.user.name != HASSIO_USER_NAME: + output_error("only_supervisor", "Only allowed as Supervisor") + return + return func(hass, connection, msg) return check_current_user diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 8d75d50e59a..b657b5f5d94 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -73,8 +73,7 @@ class WebSocketHandler: # Exceptions if Socket disconnected or cancelled by connection handler with suppress(RuntimeError, ConnectionResetError, *CANCELLATION_ERRORS): while not self.wsock.closed: - message = await self._to_write.get() - if message is None: + if (message := await self._to_write.get()) is None: break self._logger.debug("Sending %s", message) @@ -177,7 +176,7 @@ class WebSocketHandler: # Auth Phase try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): msg = await wsock.receive() except asyncio.TimeoutError as err: disconnect_warn = "Did not receive auth message within 10 seconds" diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 980675bd4ff..c46a4e78440 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -17,6 +17,7 @@ from homeassistant.components.light import ( from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo import homeassistant.util.color as color_util from .const import DOMAIN as WEMO_DOMAIN @@ -99,15 +100,15 @@ class WemoLight(WemoEntity, LightEntity): return self.light.uniqueID @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info.""" - return { - "name": self.name, - "connections": {(CONNECTION_ZIGBEE, self._unique_id)}, - "identifiers": {(WEMO_DOMAIN, self._unique_id)}, - "model": self._model_name, - "manufacturer": "Belkin", - } + return DeviceInfo( + connections={(CONNECTION_ZIGBEE, self._unique_id)}, + identifiers={(WEMO_DOMAIN, self._unique_id)}, + manufacturer="Belkin", + model=self._model_name, + name=self.name, + ) @property def brightness(self): diff --git a/homeassistant/components/wemo/translations/ja.json b/homeassistant/components/wemo/translations/ja.json new file mode 100644 index 00000000000..f86e1e80520 --- /dev/null +++ b/homeassistant/components/wemo/translations/ja.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "confirm": { + "description": "Wemo\u3092\u8a2d\u5b9a\u3092\u3057\u307e\u3059\u304b\uff1f" + } + } + }, + "device_automation": { + "trigger_type": { + "long_press": "Wemo\u30dc\u30bf\u30f3\u304c\u30012\u79d2\u9593\u62bc\u3055\u308c\u307e\u3057\u305f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wemo/translations/tr.json b/homeassistant/components/wemo/translations/tr.json index a87d832eece..3ddfe182a86 100644 --- a/homeassistant/components/wemo/translations/tr.json +++ b/homeassistant/components/wemo/translations/tr.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "no_devices_found": "A\u011fda Wemo cihaz\u0131 bulunamad\u0131.", + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, "step": { @@ -9,5 +9,10 @@ "description": "Wemo'yu kurmak istiyor musunuz?" } } + }, + "device_automation": { + "trigger_type": { + "long_press": "Wemo d\u00fc\u011fmesine 2 saniye bas\u0131ld\u0131" + } } } \ No newline at end of file diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 1690d30e082..a4f20eb55f5 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -17,6 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import async_get as async_get_device_registry +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, WEMO_SUBSCRIPTION_EVENT @@ -120,13 +121,13 @@ class DeviceCoordinator(DataUpdateCoordinator): raise UpdateFailed("WeMo update failed") from err -def _device_info(wemo: WeMoDevice): - return { - "name": wemo.name, - "identifiers": {(DOMAIN, wemo.serialnumber)}, - "model": wemo.model_name, - "manufacturer": "Belkin", - } +def _device_info(wemo: WeMoDevice) -> DeviceInfo: + return DeviceInfo( + identifiers={(DOMAIN, wemo.serialnumber)}, + manufacturer="Belkin", + model=wemo.model_name, + name=wemo.name, + ) async def async_register_device( diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index eb1b88dcc13..ceb68ec29eb 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -64,8 +64,7 @@ SUPPORTED_TARGET_TEMPERATURE_STEP = 1 async def async_setup_entry(hass, config_entry, async_add_entities): """Set up entry.""" auth: Auth = hass.data[DOMAIN][config_entry.entry_id][AUTH_INSTANCE_KEY] - said_list = auth.get_said_list() - if not said_list: + if not (said_list := auth.get_said_list()): _LOGGER.debug("No appliances found") return diff --git a/homeassistant/components/whirlpool/translations/fr.json b/homeassistant/components/whirlpool/translations/fr.json new file mode 100644 index 00000000000..0cfccfa88ad --- /dev/null +++ b/homeassistant/components/whirlpool/translations/fr.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Nom d'utilisateur" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/ja.json b/homeassistant/components/whirlpool/translations/ja.json new file mode 100644 index 00000000000..1defa16a2fa --- /dev/null +++ b/homeassistant/components/whirlpool/translations/ja.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/tr.json b/homeassistant/components/whirlpool/translations/tr.json new file mode 100644 index 00000000000..6bb59e7943a --- /dev/null +++ b/homeassistant/components/whirlpool/translations/tr.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index 5f87141e423..a7f6b8a7b22 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.dt import utcnow @@ -141,16 +141,15 @@ class WiffiEntity(Entity): def __init__(self, device, metric, options): """Initialize the base elements of a wiffi entity.""" self._id = generate_unique_id(device, metric) - self._device_info = { - "connections": { - (device_registry.CONNECTION_NETWORK_MAC, device.mac_address) - }, - "identifiers": {(DOMAIN, device.mac_address)}, - "manufacturer": "stall.biz", - "name": f"{device.moduletype} {device.mac_address}", - "model": device.moduletype, - "sw_version": device.sw_version, - } + self._device_info = DeviceInfo( + connections={(device_registry.CONNECTION_NETWORK_MAC, device.mac_address)}, + identifiers={(DOMAIN, device.mac_address)}, + manufacturer="stall.biz", + model=device.moduletype, + name=f"{device.moduletype} {device.mac_address}", + sw_version=device.sw_version, + configuration_url=device.configuration_url, + ) self._name = metric.description self._expiration_date = None self._value = None diff --git a/homeassistant/components/wiffi/manifest.json b/homeassistant/components/wiffi/manifest.json index 803c5f7e520..58d0f9778d7 100644 --- a/homeassistant/components/wiffi/manifest.json +++ b/homeassistant/components/wiffi/manifest.json @@ -3,7 +3,7 @@ "name": "Wiffi", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wiffi", - "requirements": ["wiffi==1.0.1"], + "requirements": ["wiffi==1.1.0"], "codeowners": ["@mampfes"], "iot_class": "local_push" } diff --git a/homeassistant/components/wiffi/translations/ja.json b/homeassistant/components/wiffi/translations/ja.json new file mode 100644 index 00000000000..ba9f63c6e7c --- /dev/null +++ b/homeassistant/components/wiffi/translations/ja.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "addr_in_use": "\u30b5\u30fc\u30d0\u30fc\u30dd\u30fc\u30c8\u306f\u3059\u3067\u306b\u4f7f\u7528\u3055\u308c\u3066\u3044\u307e\u3059\u3002", + "start_server_failed": "\u30b5\u30fc\u30d0\u30fc\u306e\u8d77\u52d5\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002" + }, + "step": { + "user": { + "data": { + "port": "\u30dd\u30fc\u30c8" + }, + "title": "WIFFI\u30c7\u30d0\u30a4\u30b9\u7528\u306eTCP\u30b5\u30fc\u30d0\u30fc\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "timeout": "\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8(\u5206)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wiffi/translations/tr.json b/homeassistant/components/wiffi/translations/tr.json index 26ec2e61e00..281300caee3 100644 --- a/homeassistant/components/wiffi/translations/tr.json +++ b/homeassistant/components/wiffi/translations/tr.json @@ -8,7 +8,8 @@ "user": { "data": { "port": "Port" - } + }, + "title": "WIFI cihazlar\u0131 i\u00e7in TCP sunucusunu kurun" } } }, diff --git a/homeassistant/components/wilight/config_flow.py b/homeassistant/components/wilight/config_flow.py index 4bfc331a543..dc8e5fc39cc 100644 --- a/homeassistant/components/wilight/config_flow.py +++ b/homeassistant/components/wilight/config_flow.py @@ -6,6 +6,7 @@ import pywilight from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult from . import DOMAIN @@ -49,24 +50,24 @@ class WiLightFlowHandler(ConfigFlow, domain=DOMAIN): } return self.async_create_entry(title=self._title, data=data) - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered WiLight.""" # Filter out basic information if ( - ssdp.ATTR_SSDP_LOCATION not in discovery_info - or ssdp.ATTR_UPNP_MANUFACTURER not in discovery_info - or ssdp.ATTR_UPNP_SERIAL not in discovery_info - or ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info - or ssdp.ATTR_UPNP_MODEL_NUMBER not in discovery_info + not discovery_info.ssdp_location + or ssdp.ATTR_UPNP_MANUFACTURER not in discovery_info.upnp + or ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp + or ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info.upnp + or ssdp.ATTR_UPNP_MODEL_NUMBER not in discovery_info.upnp ): return self.async_abort(reason="not_wilight_device") # Filter out non-WiLight devices - if discovery_info[ssdp.ATTR_UPNP_MANUFACTURER] != WILIGHT_MANUFACTURER: + if discovery_info.upnp[ssdp.ATTR_UPNP_MANUFACTURER] != WILIGHT_MANUFACTURER: return self.async_abort(reason="not_wilight_device") - host = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname - serial_number = discovery_info[ssdp.ATTR_UPNP_SERIAL] - model_name = discovery_info[ssdp.ATTR_UPNP_MODEL_NAME] + host = urlparse(discovery_info.ssdp_location).hostname + serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + model_name = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] if not self._wilight_update(host, serial_number, model_name): return self.async_abort(reason="not_wilight_device") diff --git a/homeassistant/components/wilight/translations/ja.json b/homeassistant/components/wilight/translations/ja.json new file mode 100644 index 00000000000..0a01f69d43a --- /dev/null +++ b/homeassistant/components/wilight/translations/ja.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "not_supported_device": "\u3053\u306eWiLight\u306f\u73fe\u5728\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002", + "not_wilight_device": "\u3053\u306e\u30c7\u30d0\u30a4\u30b9\u306f\u3001WiLight\u3067\u306f\u3042\u308a\u307e\u305b\u3093" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "WiLight {name} \u3092\u8a2d\u5b9a\u3057\u307e\u3059\u304b\uff1f\n\n \u5bfe\u5fdc\u3057\u3066\u3044\u308b: {components}", + "title": "WiLight" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wilight/translations/tr.json b/homeassistant/components/wilight/translations/tr.json index 5307276a71d..446504f49d7 100644 --- a/homeassistant/components/wilight/translations/tr.json +++ b/homeassistant/components/wilight/translations/tr.json @@ -1,7 +1,16 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "not_supported_device": "Bu WiLight \u015fu anda desteklenmiyor", + "not_wilight_device": "Bu Cihaz WiLight de\u011fil" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "{name} kurmak istiyor musunuz? \n\n \u015eunlar\u0131 destekler: {components}", + "title": "WiLight" + } } } } \ No newline at end of file diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index b7de56d8eff..a7555d15bb9 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -3,7 +3,8 @@ import logging from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol -from wirelesstagpy import WirelessTags, WirelessTagsException +from wirelesstagpy import WirelessTags +from wirelesstagpy.exceptions import WirelessTagsException from homeassistant.const import ( ATTR_BATTERY_LEVEL, diff --git a/homeassistant/components/wirelesstag/manifest.json b/homeassistant/components/wirelesstag/manifest.json index 37c1b82cba9..6074b64d664 100644 --- a/homeassistant/components/wirelesstag/manifest.json +++ b/homeassistant/components/wirelesstag/manifest.json @@ -2,7 +2,7 @@ "domain": "wirelesstag", "name": "Wireless Sensor Tags", "documentation": "https://www.home-assistant.io/integrations/wirelesstag", - "requirements": ["wirelesstagpy==0.5.0"], + "requirements": ["wirelesstagpy==0.8.1"], "codeowners": ["@sergeymaysak"], "iot_class": "cloud_push" } diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index 7ad0a7f52c2..8038b42bffe 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -1,10 +1,22 @@ """Sensor support for Wireless Sensor Tags platform.""" +from __future__ import annotations + import logging import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, +) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -14,16 +26,45 @@ from . import DOMAIN as WIRELESSTAG_DOMAIN, SIGNAL_TAG_UPDATE, WirelessTagBaseSe _LOGGER = logging.getLogger(__name__) SENSOR_TEMPERATURE = "temperature" +SENSOR_AMBIENT_TEMPERATURE = "ambient_temperature" SENSOR_HUMIDITY = "humidity" SENSOR_MOISTURE = "moisture" SENSOR_LIGHT = "light" -SENSOR_TYPES = [SENSOR_TEMPERATURE, SENSOR_HUMIDITY, SENSOR_MOISTURE, SENSOR_LIGHT] +SENSOR_TYPES: dict[str, SensorEntityDescription] = { + SENSOR_TEMPERATURE: SensorEntityDescription( + key=SENSOR_TEMPERATURE, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SENSOR_AMBIENT_TEMPERATURE: SensorEntityDescription( + key=SENSOR_AMBIENT_TEMPERATURE, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SENSOR_HUMIDITY: SensorEntityDescription( + key=SENSOR_HUMIDITY, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + SENSOR_MOISTURE: SensorEntityDescription( + key=SENSOR_MOISTURE, + device_class=SENSOR_MOISTURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SENSOR_LIGHT: SensorEntityDescription( + key=SENSOR_LIGHT, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), +} + +SENSOR_KEYS: list[str] = list(SENSOR_TYPES) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ) } ) @@ -35,11 +76,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): sensors = [] tags = platform.tags for tag in tags.values(): - for sensor_type in config.get(CONF_MONITORED_CONDITIONS): - if sensor_type in tag.allowed_sensor_types: - sensors.append( - WirelessTagSensor(platform, tag, sensor_type, hass.config) - ) + for key in config[CONF_MONITORED_CONDITIONS]: + if key not in tag.allowed_sensor_types: + continue + description = SENSOR_TYPES[key] + sensors.append(WirelessTagSensor(platform, tag, description)) add_entities(sensors, True) @@ -47,11 +88,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): """Representation of a Sensor.""" - def __init__(self, api, tag, sensor_type, config): + entity_description: SensorEntityDescription + + def __init__(self, api, tag, description): """Initialize a WirelessTag sensor.""" super().__init__(api, tag) - self._sensor_type = sensor_type + self._sensor_type = description.key + self.entity_description = description self._name = self._tag.name # I want to see entity_id as: @@ -87,11 +131,6 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): """Return the state of the sensor.""" return self._state - @property - def device_class(self): - """Return the class of the sensor.""" - return self._sensor_type - @property def native_unit_of_measurement(self): """Return the unit of measurement.""" diff --git a/homeassistant/components/withings/translations/bg.json b/homeassistant/components/withings/translations/bg.json index d0445aed41f..9d5313ee391 100644 --- a/homeassistant/components/withings/translations/bg.json +++ b/homeassistant/components/withings/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "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." + }, "create_entry": { "default": "\u0423\u0441\u043f\u0435\u0448\u043d\u043e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u043d\u0435 \u0441 Withings \u0437\u0430 \u0438\u0437\u0431\u0440\u0430\u043d\u0438\u044f \u043f\u0440\u043e\u0444\u0438\u043b." }, @@ -14,6 +17,9 @@ }, "description": "\u041a\u043e\u0439 \u043f\u0440\u043e\u0444\u0438\u043b \u0441\u0442\u0435 \u0438\u0437\u0431\u0440\u0430\u043b\u0438 \u043d\u0430 \u0443\u0435\u0431\u0441\u0430\u0439\u0442\u0430 \u043d\u0430 Withings? \u0412\u0430\u0436\u043d\u043e \u0435 \u043f\u0440\u043e\u0444\u0438\u043b\u0438\u0442\u0435 \u0434\u0430 \u0441\u044a\u0432\u043f\u0430\u0434\u0430\u0442, \u0432 \u043f\u0440\u043e\u0442\u0438\u0432\u0435\u043d \u0441\u043b\u0443\u0447\u0430\u0439 \u0434\u0430\u043d\u043d\u0438\u0442\u0435 \u0449\u0435 \u0431\u044a\u0434\u0430\u0442 \u043d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u043d\u043e \u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438.", "title": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u0438 \u043f\u0440\u043e\u0444\u0438\u043b." + }, + "reauth": { + "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/withings/translations/ja.json b/homeassistant/components/withings/translations/ja.json new file mode 100644 index 00000000000..c409579fbc8 --- /dev/null +++ b/homeassistant/components/withings/translations/ja.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u306e\u8a2d\u5b9a\u3092\u66f4\u65b0\u3057\u307e\u3057\u305f\u3002", + "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})" + }, + "create_entry": { + "default": "\u9078\u629e\u3057\u305fWithings\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u306f\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f\u3002" + }, + "error": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "flow_title": "{profile}", + "step": { + "pick_implementation": { + "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" + }, + "profile": { + "data": { + "profile": "\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u540d" + }, + "description": "\u3053\u306e\u30c7\u30fc\u30bf\u306b\u56fa\u6709\u306e\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u540d\u3092\u6307\u5b9a\u3057\u307e\u3059\u3002\u901a\u5e38\u3001\u3053\u308c\u306f\u524d\u306e\u624b\u9806\u3067\u9078\u629e\u3057\u305f\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u306e\u540d\u524d\u3067\u3059\u3002", + "title": "\u30e6\u30fc\u30b6\u30fc\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u3002" + }, + "reauth": { + "description": "Withings data\u306e\u53d7\u4fe1\u3092\u7d99\u7d9a\u3059\u308b\u306b\u306f\u3001\"{profile}\" \u306e\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u3092\u518d\u8a8d\u8a3c\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/translations/tr.json b/homeassistant/components/withings/translations/tr.json index 4e0228708ea..31b2a424071 100644 --- a/homeassistant/components/withings/translations/tr.json +++ b/homeassistant/components/withings/translations/tr.json @@ -1,17 +1,32 @@ { "config": { "abort": { - "already_configured": "Profil i\u00e7in yap\u0131land\u0131rma g\u00fcncellendi." + "already_configured": "Profil i\u00e7in yap\u0131land\u0131rma g\u00fcncellendi.", + "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})" + }, + "create_entry": { + "default": "Withings ile ba\u015far\u0131yla do\u011fruland\u0131." }, "error": { "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, + "flow_title": "{profile}", "step": { + "pick_implementation": { + "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7" + }, "profile": { "data": { "profile": "Profil Ad\u0131" }, + "description": "Bu veriler i\u00e7in benzersiz bir profil ad\u0131 sa\u011flay\u0131n. Genellikle bu, \u00f6nceki ad\u0131mda se\u00e7ti\u011finiz profilin ad\u0131d\u0131r.", "title": "Kullan\u0131c\u0131 profili." + }, + "reauth": { + "description": "Withings verilerini almaya devam etmek i\u00e7in \" {profile}", + "title": "Entegrasyonu Yeniden Do\u011frula" } } } diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index e7697676014..659df1baad9 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -1,18 +1,22 @@ """Support for WLED.""" from __future__ import annotations -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN -from homeassistant.components.select import DOMAIN as SELECT_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import WLEDDataUpdateCoordinator -PLATFORMS = (LIGHT_DOMAIN, SELECT_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN, NUMBER_DOMAIN) +PLATFORMS = ( + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.LIGHT, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/wled/binary_sensor.py b/homeassistant/components/wled/binary_sensor.py new file mode 100644 index 00000000000..ce082691ffe --- /dev/null +++ b/homeassistant/components/wled/binary_sensor.py @@ -0,0 +1,58 @@ +"""Support for WLED binary sensor.""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import WLEDDataUpdateCoordinator +from .models import WLEDEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a WLED binary sensor based on a config entry.""" + coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + WLEDUpdateBinarySensor(coordinator), + ] + ) + + +class WLEDUpdateBinarySensor(WLEDEntity, BinarySensorEntity): + """Defines a WLED firmware binary sensor.""" + + _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + _attr_device_class = BinarySensorDeviceClass.UPDATE + + def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: + """Initialize the button entity.""" + super().__init__(coordinator=coordinator) + self._attr_name = f"{coordinator.data.info.name} Firmware" + self._attr_unique_id = f"{coordinator.data.info.mac_address}_update" + + @property + def is_on(self) -> bool: + """Return the state of the sensor.""" + current = self.coordinator.data.info.version + beta = self.coordinator.data.info.version_latest_beta + stable = self.coordinator.data.info.version_latest_stable + + return current is not None and ( + (stable is not None and stable > current) + or ( + beta is not None + and (current.alpha or current.beta or current.release_candidate) + and beta > current + ) + ) diff --git a/homeassistant/components/wled/button.py b/homeassistant/components/wled/button.py new file mode 100644 index 00000000000..191242fe0dc --- /dev/null +++ b/homeassistant/components/wled/button.py @@ -0,0 +1,102 @@ +"""Support for WLED button.""" +from __future__ import annotations + +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_CONFIG +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import WLEDDataUpdateCoordinator +from .helpers import wled_exception_handler +from .models import WLEDEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up WLED button based on a config entry.""" + coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + WLEDRestartButton(coordinator), + WLEDUpdateButton(coordinator), + ] + ) + + +class WLEDRestartButton(WLEDEntity, ButtonEntity): + """Defines a WLED restart button.""" + + _attr_device_class = ButtonDeviceClass.RESTART + _attr_entity_category = ENTITY_CATEGORY_CONFIG + + def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: + """Initialize the button entity.""" + super().__init__(coordinator=coordinator) + self._attr_name = f"{coordinator.data.info.name} Restart" + self._attr_unique_id = f"{coordinator.data.info.mac_address}_restart" + + @wled_exception_handler + async def async_press(self) -> None: + """Send out a restart command.""" + await self.coordinator.wled.reset() + + +class WLEDUpdateButton(WLEDEntity, ButtonEntity): + """Defines a WLED update button.""" + + _attr_device_class = ButtonDeviceClass.UPDATE + _attr_entity_category = ENTITY_CATEGORY_CONFIG + + def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: + """Initialize the button entity.""" + super().__init__(coordinator=coordinator) + self._attr_name = f"{coordinator.data.info.name} Update" + self._attr_unique_id = f"{coordinator.data.info.mac_address}_update" + + @property + def available(self) -> bool: + """Return if the entity and an update is available.""" + current = self.coordinator.data.info.version + beta = self.coordinator.data.info.version_latest_beta + stable = self.coordinator.data.info.version_latest_stable + + # If we already run a pre-release, allow upgrading to a newer + # pre-release offer a normal upgrade otherwise. + return ( + super().available + and current is not None + and ( + (stable is not None and stable > current) + or ( + beta is not None + and (current.alpha or current.beta or current.release_candidate) + and beta > current + ) + ) + ) + + @wled_exception_handler + async def async_press(self) -> None: + """Send out a update command.""" + current = self.coordinator.data.info.version + beta = self.coordinator.data.info.version_latest_beta + stable = self.coordinator.data.info.version_latest_stable + + # If we already run a pre-release, allow update to a newer + # pre-release or newer stable, otherwise, offer a normal stable updates. + version = stable + if ( + current is not None + and beta is not None + and (current.alpha or current.beta or current.release_candidate) + and beta > current + and beta > stable + ): + version = beta + + await self.coordinator.wled.upgrade(version=str(version)) diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 7f4d006d122..485afef4f6c 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -6,6 +6,7 @@ from typing import Any import voluptuous as vol from wled import WLED, WLEDConnectionError +from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_ZEROCONF, ConfigEntry, @@ -16,7 +17,6 @@ from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import DiscoveryInfoType from .const import CONF_KEEP_MASTER_LIGHT, DEFAULT_KEEP_MASTER_LIGHT, DOMAIN @@ -39,25 +39,25 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): return await self._handle_config_flow(user_input) async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" # Hostname is format: wled-livingroom.local. - host = discovery_info["hostname"].rstrip(".") + host = discovery_info.hostname.rstrip(".") name, _ = host.rsplit(".") self.context.update( { - CONF_HOST: discovery_info["host"], + CONF_HOST: discovery_info.host, CONF_NAME: name, - CONF_MAC: discovery_info["properties"].get(CONF_MAC), + CONF_MAC: discovery_info.properties.get(CONF_MAC), "title_placeholders": {"name": name}, } ) # Prepare configuration flow - return await self._handle_config_flow(discovery_info, True) + return await self._handle_config_flow({}, True) async def async_step_zeroconf_confirm( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/wled/const.py b/homeassistant/components/wled/const.py index 180ef89c1b7..e323b5ab87b 100644 --- a/homeassistant/components/wled/const.py +++ b/homeassistant/components/wled/const.py @@ -1,6 +1,7 @@ """Constants for the WLED integration.""" from datetime import timedelta import logging +from typing import Final # Integration domain DOMAIN = "wled" @@ -17,18 +18,12 @@ ATTR_COLOR_PRIMARY = "color_primary" ATTR_DURATION = "duration" ATTR_FADE = "fade" ATTR_INTENSITY = "intensity" -ATTR_LED_COUNT = "led_count" -ATTR_MAX_POWER = "max_power" ATTR_ON = "on" -ATTR_PALETTE = "palette" -ATTR_PRESET = "preset" -ATTR_REVERSE = "reverse" ATTR_SEGMENT_ID = "segment_id" ATTR_SOFTWARE_VERSION = "sw_version" ATTR_SPEED = "speed" ATTR_TARGET_BRIGHTNESS = "target_brightness" ATTR_UDP_PORT = "udp_port" -# Services -SERVICE_EFFECT = "effect" -SERVICE_PRESET = "preset" +# Device classes +DEVICE_CLASS_WLED_LIVE_OVERRIDE: Final = "wled__live_override" diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index 2081208e398..b42c7b0a8b4 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -4,8 +4,6 @@ from __future__ import annotations from functools import partial from typing import Any, Tuple, cast -import voluptuous as vol - from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_EFFECT, @@ -21,23 +19,9 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ATTR_COLOR_PRIMARY, - ATTR_INTENSITY, - ATTR_ON, - ATTR_PALETTE, - ATTR_PRESET, - ATTR_REVERSE, - ATTR_SEGMENT_ID, - ATTR_SPEED, - DOMAIN, - LOGGER, - SERVICE_EFFECT, - SERVICE_PRESET, -) +from .const import ATTR_COLOR_PRIMARY, ATTR_ON, ATTR_SEGMENT_ID, DOMAIN from .coordinator import WLEDDataUpdateCoordinator from .helpers import wled_exception_handler from .models import WLEDEntity @@ -52,35 +36,6 @@ async def async_setup_entry( ) -> None: """Set up WLED light based on a config entry.""" coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - - platform = entity_platform.async_get_current_platform() - - platform.async_register_entity_service( - SERVICE_EFFECT, - { - vol.Optional(ATTR_EFFECT): vol.Any(cv.positive_int, cv.string), - vol.Optional(ATTR_INTENSITY): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - vol.Optional(ATTR_PALETTE): vol.Any(cv.positive_int, cv.string), - vol.Optional(ATTR_REVERSE): cv.boolean, - vol.Optional(ATTR_SPEED): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - }, - "async_effect", - ) - - platform.async_register_entity_service( - SERVICE_PRESET, - { - vol.Required(ATTR_PRESET): vol.All( - vol.Coerce(int), vol.Range(min=-1, max=65535) - ), - }, - "async_preset", - ) - if coordinator.keep_master_light: async_add_entities([WLEDMasterLight(coordinator=coordinator)]) @@ -146,32 +101,6 @@ class WLEDMasterLight(WLEDEntity, LightEntity): on=True, brightness=kwargs.get(ATTR_BRIGHTNESS), transition=transition ) - async def async_effect( - self, - effect: int | str | None = None, - intensity: int | None = None, - palette: int | str | None = None, - reverse: bool | None = None, - speed: int | None = None, - ) -> None: - """Set the effect of a WLED light.""" - # Master light does not have an effect setting. - - @wled_exception_handler - async def async_preset( - self, - preset: int, - ) -> None: - """Set a WLED light to a saved preset.""" - # The WLED preset service is replaced by a preset select entity - # and marked deprecated as of Home Assistant 2021.8 - LOGGER.warning( - "The 'wled.preset' service is deprecated and replaced by a " - "dedicated preset select entity; Please use that entity to " - "change presets instead" - ) - await self.coordinator.wled.preset(preset=preset) - class WLEDSegmentLight(WLEDEntity, LightEntity): """Defines a WLED light based on a segment.""" @@ -216,17 +145,6 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): return super().available - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes of the entity.""" - segment = self.coordinator.data.state.segments[self._segment] - return { - ATTR_INTENSITY: segment.intensity, - ATTR_PALETTE: segment.palette.name, - ATTR_REVERSE: segment.reverse, - ATTR_SPEED: segment.speed, - } - @property def rgb_color(self) -> tuple[int, int, int] | None: """Return the color value.""" @@ -334,33 +252,6 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): await self.coordinator.wled.segment(**data) - @wled_exception_handler - async def async_effect( - self, - effect: int | str | None = None, - intensity: int | None = None, - palette: int | str | None = None, - reverse: bool | None = None, - speed: int | None = None, - ) -> None: - """Set the effect of a WLED light.""" - await self.coordinator.wled.segment( - segment_id=self._segment, - effect=effect, - intensity=intensity, - palette=palette, - reverse=reverse, - speed=speed, - ) - - @wled_exception_handler - async def async_preset( - self, - preset: int, - ) -> None: - """Set a WLED light to a saved preset.""" - await self.coordinator.wled.preset(preset=preset) - @callback def async_update_segments( diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index 5ece2d4b9d8..a99278a80c6 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -3,7 +3,7 @@ "name": "WLED", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wled", - "requirements": ["wled==0.8.0"], + "requirements": ["wled==0.10.1"], "zeroconf": ["_wled._tcp.local."], "codeowners": ["@frenck"], "quality_scale": "platinum", diff --git a/homeassistant/components/wled/models.py b/homeassistant/components/wled/models.py index 93b04b9f49b..a71491daf7a 100644 --- a/homeassistant/components/wled/models.py +++ b/homeassistant/components/wled/models.py @@ -19,6 +19,6 @@ class WLEDEntity(CoordinatorEntity): name=self.coordinator.data.info.name, manufacturer=self.coordinator.data.info.brand, model=self.coordinator.data.info.product, - sw_version=self.coordinator.data.info.version, + sw_version=str(self.coordinator.data.info.version), configuration_url=f"http://{self.coordinator.wled.host}", ) diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index 47942359b0f..d82f12cffd7 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -3,7 +3,7 @@ from __future__ import annotations from functools import partial -from wled import Playlist, Preset +from wled import Live, Playlist, Preset from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry @@ -11,7 +11,7 @@ from homeassistant.const import ENTITY_CATEGORY_CONFIG from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DEVICE_CLASS_WLED_LIVE_OVERRIDE, DOMAIN from .coordinator import WLEDDataUpdateCoordinator from .helpers import wled_exception_handler from .models import WLEDEntity @@ -27,7 +27,13 @@ async def async_setup_entry( """Set up WLED select based on a config entry.""" coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([WLEDPlaylistSelect(coordinator), WLEDPresetSelect(coordinator)]) + async_add_entities( + [ + WLEDLiveOverrideSelect(coordinator), + WLEDPlaylistSelect(coordinator), + WLEDPresetSelect(coordinator), + ] + ) update_segments = partial( async_update_segments, @@ -39,6 +45,32 @@ async def async_setup_entry( update_segments() +class WLEDLiveOverrideSelect(WLEDEntity, SelectEntity): + """Defined a WLED Live Override select.""" + + _attr_device_class = DEVICE_CLASS_WLED_LIVE_OVERRIDE + _attr_entity_category = ENTITY_CATEGORY_CONFIG + _attr_icon = "mdi:theater" + + def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: + """Initialize WLED .""" + super().__init__(coordinator=coordinator) + + self._attr_name = f"{coordinator.data.info.name} Live Override" + self._attr_unique_id = f"{coordinator.data.info.mac_address}_live_override" + self._attr_options = [str(live.value) for live in Live] + + @property + def current_option(self) -> str: + """Return the current selected live override.""" + return str(self.coordinator.data.state.lor.value) + + @wled_exception_handler + async def async_select_option(self, option: str) -> None: + """Set WLED state to the selected live override state.""" + await self.coordinator.wled.live(live=Live(int(option))) + + class WLEDPresetSelect(WLEDEntity, SelectEntity): """Defined a WLED Preset select.""" diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index b5982798852..0bde4107688 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -1,15 +1,21 @@ """Support for WLED sensors.""" from __future__ import annotations -from datetime import timedelta -from typing import Any +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta -from homeassistant.components.sensor import DEVICE_CLASS_CURRENT, SensorEntity +from wled import Device as WLEDDevice + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DATA_BYTES, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_TIMESTAMP, ELECTRIC_CURRENT_MILLIAMPERE, ENTITY_CATEGORY_DIAGNOSTIC, PERCENTAGE, @@ -17,13 +23,107 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .const import ATTR_LED_COUNT, ATTR_MAX_POWER, DOMAIN +from .const import DOMAIN from .coordinator import WLEDDataUpdateCoordinator from .models import WLEDEntity +@dataclass +class WLEDSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[WLEDDevice], datetime | StateType] + + +@dataclass +class WLEDSensorEntityDescription( + SensorEntityDescription, WLEDSensorEntityDescriptionMixin +): + """Describes WLED sensor entity.""" + + +SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( + WLEDSensorEntityDescription( + key="estimated_current", + name="Estimated Current", + native_unit_of_measurement=ELECTRIC_CURRENT_MILLIAMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + value_fn=lambda device: device.info.leds.power, + ), + WLEDSensorEntityDescription( + key="info_leds_count", + name="LED Count", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + value_fn=lambda device: device.info.leds.count, + ), + WLEDSensorEntityDescription( + key="info_leds_max_power", + name="Max Current", + native_unit_of_measurement=ELECTRIC_CURRENT_MILLIAMPERE, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=SensorDeviceClass.CURRENT, + value_fn=lambda device: device.info.leds.max_power, + ), + WLEDSensorEntityDescription( + key="uptime", + name="Uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda device: (utcnow() - timedelta(seconds=device.info.uptime)), + ), + WLEDSensorEntityDescription( + key="free_heap", + name="Free Memory", + icon="mdi:memory", + native_unit_of_measurement=DATA_BYTES, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda device: device.info.free_heap, + ), + WLEDSensorEntityDescription( + key="wifi_signal", + name="Wi-Fi Signal", + icon="mdi:wifi", + native_unit_of_measurement=PERCENTAGE, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda device: device.info.wifi.signal if device.info.wifi else None, + ), + WLEDSensorEntityDescription( + key="wifi_rssi", + name="Wi-Fi RSSI", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda device: device.info.wifi.rssi if device.info.wifi else None, + ), + WLEDSensorEntityDescription( + key="wifi_channel", + name="Wi-Fi Channel", + icon="mdi:wifi", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda device: device.info.wifi.channel if device.info.wifi else None, + ), + WLEDSensorEntityDescription( + key="wifi_bssid", + name="Wi-Fi BSSID", + icon="mdi:wifi", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda device: device.info.wifi.bssid if device.info.wifi else None, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -31,169 +131,28 @@ async def async_setup_entry( ) -> None: """Set up WLED sensor based on a config entry.""" coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - - sensors = [ - WLEDEstimatedCurrentSensor(coordinator), - WLEDUptimeSensor(coordinator), - WLEDFreeHeapSensor(coordinator), - WLEDWifiBSSIDSensor(coordinator), - WLEDWifiChannelSensor(coordinator), - WLEDWifiRSSISensor(coordinator), - WLEDWifiSignalSensor(coordinator), - ] - - async_add_entities(sensors) + async_add_entities( + WLEDSensorEntity(coordinator, description) for description in SENSORS + ) -class WLEDEstimatedCurrentSensor(WLEDEntity, SensorEntity): - """Defines a WLED estimated current sensor.""" +class WLEDSensorEntity(WLEDEntity, SensorEntity): + """Defines a WLED sensor entity.""" - _attr_icon = "mdi:power" - _attr_native_unit_of_measurement = ELECTRIC_CURRENT_MILLIAMPERE - _attr_device_class = DEVICE_CLASS_CURRENT - _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + entity_description: WLEDSensorEntityDescription - def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: - """Initialize WLED estimated current sensor.""" + def __init__( + self, + coordinator: WLEDDataUpdateCoordinator, + description: WLEDSensorEntityDescription, + ) -> None: + """Initialize a WLED sensor entity.""" super().__init__(coordinator=coordinator) - self._attr_name = f"{coordinator.data.info.name} Estimated Current" - self._attr_unique_id = f"{coordinator.data.info.mac_address}_estimated_current" + self.entity_description = description + self._attr_name = f"{coordinator.data.info.name} {description.name}" + self._attr_unique_id = f"{coordinator.data.info.mac_address}_{description.key}" @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes of the entity.""" - return { - ATTR_LED_COUNT: self.coordinator.data.info.leds.count, - ATTR_MAX_POWER: self.coordinator.data.info.leds.max_power, - } - - @property - def native_value(self) -> int: + def native_value(self) -> datetime | StateType: """Return the state of the sensor.""" - return self.coordinator.data.info.leds.power - - -class WLEDUptimeSensor(WLEDEntity, SensorEntity): - """Defines a WLED uptime sensor.""" - - _attr_device_class = DEVICE_CLASS_TIMESTAMP - _attr_entity_registry_enabled_default = False - _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC - - def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: - """Initialize WLED uptime sensor.""" - super().__init__(coordinator=coordinator) - self._attr_name = f"{coordinator.data.info.name} Uptime" - self._attr_unique_id = f"{coordinator.data.info.mac_address}_uptime" - - @property - def native_value(self) -> str: - """Return the state of the sensor.""" - uptime = utcnow() - timedelta(seconds=self.coordinator.data.info.uptime) - return uptime.replace(microsecond=0).isoformat() - - -class WLEDFreeHeapSensor(WLEDEntity, SensorEntity): - """Defines a WLED free heap sensor.""" - - _attr_icon = "mdi:memory" - _attr_entity_registry_enabled_default = False - _attr_native_unit_of_measurement = DATA_BYTES - _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC - - def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: - """Initialize WLED free heap sensor.""" - super().__init__(coordinator=coordinator) - self._attr_name = f"{coordinator.data.info.name} Free Memory" - self._attr_unique_id = f"{coordinator.data.info.mac_address}_free_heap" - - @property - def native_value(self) -> int: - """Return the state of the sensor.""" - return self.coordinator.data.info.free_heap - - -class WLEDWifiSignalSensor(WLEDEntity, SensorEntity): - """Defines a WLED Wi-Fi signal sensor.""" - - _attr_icon = "mdi:wifi" - _attr_native_unit_of_measurement = PERCENTAGE - _attr_entity_registry_enabled_default = False - _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC - - def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: - """Initialize WLED Wi-Fi signal sensor.""" - super().__init__(coordinator=coordinator) - self._attr_name = f"{coordinator.data.info.name} Wi-Fi Signal" - self._attr_unique_id = f"{coordinator.data.info.mac_address}_wifi_signal" - - @property - def native_value(self) -> int | None: - """Return the state of the sensor.""" - if not self.coordinator.data.info.wifi: - return None - return self.coordinator.data.info.wifi.signal - - -class WLEDWifiRSSISensor(WLEDEntity, SensorEntity): - """Defines a WLED Wi-Fi RSSI sensor.""" - - _attr_device_class = DEVICE_CLASS_SIGNAL_STRENGTH - _attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT - _attr_entity_registry_enabled_default = False - _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC - - def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: - """Initialize WLED Wi-Fi RSSI sensor.""" - super().__init__(coordinator=coordinator) - self._attr_name = f"{coordinator.data.info.name} Wi-Fi RSSI" - self._attr_unique_id = f"{coordinator.data.info.mac_address}_wifi_rssi" - - @property - def native_value(self) -> int | None: - """Return the state of the sensor.""" - if not self.coordinator.data.info.wifi: - return None - return self.coordinator.data.info.wifi.rssi - - -class WLEDWifiChannelSensor(WLEDEntity, SensorEntity): - """Defines a WLED Wi-Fi Channel sensor.""" - - _attr_icon = "mdi:wifi" - _attr_entity_registry_enabled_default = False - _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC - - def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: - """Initialize WLED Wi-Fi Channel sensor.""" - super().__init__(coordinator=coordinator) - self._attr_name = f"{coordinator.data.info.name} Wi-Fi Channel" - self._attr_unique_id = f"{coordinator.data.info.mac_address}_wifi_channel" - - @property - def native_value(self) -> int | None: - """Return the state of the sensor.""" - if not self.coordinator.data.info.wifi: - return None - return self.coordinator.data.info.wifi.channel - - -class WLEDWifiBSSIDSensor(WLEDEntity, SensorEntity): - """Defines a WLED Wi-Fi BSSID sensor.""" - - _attr_icon = "mdi:wifi" - _attr_entity_registry_enabled_default = False - _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC - - def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: - """Initialize WLED Wi-Fi BSSID sensor.""" - super().__init__(coordinator=coordinator) - self._attr_name = f"{coordinator.data.info.name} Wi-Fi BSSID" - self._attr_unique_id = f"{coordinator.data.info.mac_address}_wifi_bssid" - - @property - def native_value(self) -> str | None: - """Return the state of the sensor.""" - if not self.coordinator.data.info.wifi: - return None - return self.coordinator.data.info.wifi.bssid + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/wled/strings.select.json b/homeassistant/components/wled/strings.select.json new file mode 100644 index 00000000000..9f678e380b4 --- /dev/null +++ b/homeassistant/components/wled/strings.select.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "[%key:common::state::off%]", + "1": "[%key:common::state::on%]", + "2": "Until device restarts" + } + } +} diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py index 376132c18a7..a05a0ddaf08 100644 --- a/homeassistant/components/wled/switch.py +++ b/homeassistant/components/wled/switch.py @@ -1,12 +1,13 @@ """Support for WLED switches.""" from __future__ import annotations +from functools import partial from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ENTITY_CATEGORY_CONFIG -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -31,12 +32,22 @@ async def async_setup_entry( """Set up WLED switch based on a config entry.""" coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - switches = [ - WLEDNightlightSwitch(coordinator), - WLEDSyncSendSwitch(coordinator), - WLEDSyncReceiveSwitch(coordinator), - ] - async_add_entities(switches) + async_add_entities( + [ + WLEDNightlightSwitch(coordinator), + WLEDSyncSendSwitch(coordinator), + WLEDSyncReceiveSwitch(coordinator), + ] + ) + + update_segments = partial( + async_update_segments, + coordinator, + set(), + async_add_entities, + ) + coordinator.async_add_listener(update_segments) + update_segments() class WLEDNightlightSwitch(WLEDEntity, SwitchEntity): @@ -140,3 +151,69 @@ class WLEDSyncReceiveSwitch(WLEDEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the WLED sync receive switch.""" await self.coordinator.wled.sync(receive=True) + + +class WLEDReverseSwitch(WLEDEntity, SwitchEntity): + """Defines a WLED reverse effect switch.""" + + _attr_icon = "mdi:swap-horizontal-bold" + _attr_entity_category = ENTITY_CATEGORY_CONFIG + _segment: int + + def __init__(self, coordinator: WLEDDataUpdateCoordinator, segment: int) -> None: + """Initialize WLED reverse effect switch.""" + super().__init__(coordinator=coordinator) + + # Segment 0 uses a simpler name, which is more natural for when using + # a single segment / using WLED with one big LED strip. + self._attr_name = f"{coordinator.data.info.name} Segment {segment} Reverse" + if segment == 0: + self._attr_name = f"{coordinator.data.info.name} Reverse" + + self._attr_unique_id = f"{coordinator.data.info.mac_address}_reverse_{segment}" + self._segment = segment + + @property + def available(self) -> bool: + """Return True if entity is available.""" + try: + self.coordinator.data.state.segments[self._segment] + except IndexError: + return False + + return super().available + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + return self.coordinator.data.state.segments[self._segment].reverse + + @wled_exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the WLED reverse effect switch.""" + await self.coordinator.wled.segment(segment_id=self._segment, reverse=False) + + @wled_exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the WLED reverse effect switch.""" + await self.coordinator.wled.segment(segment_id=self._segment, reverse=True) + + +@callback +def async_update_segments( + coordinator: WLEDDataUpdateCoordinator, + current_ids: set[int], + async_add_entities, +) -> None: + """Update segments.""" + segment_ids = {segment.segment_id for segment in coordinator.data.state.segments} + + new_entities = [] + + # Process new segments, add them to Home Assistant + for segment_id in segment_ids - current_ids: + current_ids.add(segment_id) + new_entities.append(WLEDReverseSwitch(coordinator, segment_id)) + + if new_entities: + async_add_entities(new_entities) diff --git a/homeassistant/components/wled/translations/id.json b/homeassistant/components/wled/translations/id.json index 122cfd9da0b..621b11e4af5 100644 --- a/homeassistant/components/wled/translations/id.json +++ b/homeassistant/components/wled/translations/id.json @@ -20,5 +20,14 @@ "title": "Peranti WLED yang ditemukan" } } + }, + "options": { + "step": { + "init": { + "data": { + "keep_master_light": "Pertahankan cahaya master, bahkan dengan 1 segmen LED." + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/wled/translations/ja.json b/homeassistant/components/wled/translations/ja.json new file mode 100644 index 00000000000..0efbe56e3e0 --- /dev/null +++ b/homeassistant/components/wled/translations/ja.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\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" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "WLED\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3001Home Assistant\u3068\u9023\u643a\u3059\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002" + }, + "zeroconf_confirm": { + "description": "Home Assistant\u306b\u3001`{name}` \u3068\u3044\u3046\u540d\u524d\u306eWLED\u3092\u8ffd\u52a0\u3057\u307e\u3059\u304b\uff1f", + "title": "\u767a\u898b\u3055\u308c\u305fWLED device" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "keep_master_light": "1\u3064\u306eLED\u30bb\u30b0\u30e1\u30f3\u30c8\u3067\u3082\u3001\u30de\u30b9\u30bf\u30fc\u30e9\u30a4\u30c8\u3092\u4fdd\u3064(keep)\u3002" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.bg.json b/homeassistant/components/wled/translations/select.bg.json new file mode 100644 index 00000000000..66b7b19e292 --- /dev/null +++ b/homeassistant/components/wled/translations/select.bg.json @@ -0,0 +1,8 @@ +{ + "state": { + "wled__live_override": { + "0": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "1": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d\u043e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.ca.json b/homeassistant/components/wled/translations/select.ca.json new file mode 100644 index 00000000000..5992a3b25f3 --- /dev/null +++ b/homeassistant/components/wled/translations/select.ca.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "OFF", + "1": "ON", + "2": "Fins que el dispositiu es reinici\u00ef" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.de.json b/homeassistant/components/wled/translations/select.de.json new file mode 100644 index 00000000000..3af2afb8cce --- /dev/null +++ b/homeassistant/components/wled/translations/select.de.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "Aus", + "1": "An", + "2": "Bis zum Neustart des Ger\u00e4ts" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.en.json b/homeassistant/components/wled/translations/select.en.json new file mode 100644 index 00000000000..14a685345a6 --- /dev/null +++ b/homeassistant/components/wled/translations/select.en.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "Off", + "1": "On", + "2": "Until device restarts" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.et.json b/homeassistant/components/wled/translations/select.et.json new file mode 100644 index 00000000000..8d52083bca5 --- /dev/null +++ b/homeassistant/components/wled/translations/select.et.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "V\u00e4ljas", + "1": "Sees", + "2": "Seade taask\u00e4ivitub" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.he.json b/homeassistant/components/wled/translations/select.he.json new file mode 100644 index 00000000000..6a2dfa5e45c --- /dev/null +++ b/homeassistant/components/wled/translations/select.he.json @@ -0,0 +1,8 @@ +{ + "state": { + "wled__live_override": { + "0": "\u05db\u05d1\u05d5\u05d9", + "1": "\u05de\u05d5\u05e4\u05e2\u05dc" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.hu.json b/homeassistant/components/wled/translations/select.hu.json new file mode 100644 index 00000000000..415352b2e88 --- /dev/null +++ b/homeassistant/components/wled/translations/select.hu.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "Ki", + "1": "Be", + "2": "Am\u00edg az eszk\u00f6z \u00fajraindul" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.id.json b/homeassistant/components/wled/translations/select.id.json new file mode 100644 index 00000000000..00ddc2878ff --- /dev/null +++ b/homeassistant/components/wled/translations/select.id.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "Mati", + "1": "Nyala", + "2": "Hingga perangkat dimulai ulang" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.ja.json b/homeassistant/components/wled/translations/select.ja.json new file mode 100644 index 00000000000..32e63bcae8c --- /dev/null +++ b/homeassistant/components/wled/translations/select.ja.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "\u30aa\u30d5", + "1": "\u30aa\u30f3", + "2": "\u30c7\u30d0\u30a4\u30b9\u304c\u518d\u8d77\u52d5\u3059\u308b\u307e\u3067" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.nl.json b/homeassistant/components/wled/translations/select.nl.json new file mode 100644 index 00000000000..f2ac9da54cb --- /dev/null +++ b/homeassistant/components/wled/translations/select.nl.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "Uit", + "1": "Aan", + "2": "Totdat het apparaat opnieuw opstart" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.no.json b/homeassistant/components/wled/translations/select.no.json new file mode 100644 index 00000000000..c093835a76d --- /dev/null +++ b/homeassistant/components/wled/translations/select.no.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "Av", + "1": "P\u00e5", + "2": "Inntil enheten starter p\u00e5 nytt" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.pl.json b/homeassistant/components/wled/translations/select.pl.json new file mode 100644 index 00000000000..381f2306d26 --- /dev/null +++ b/homeassistant/components/wled/translations/select.pl.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "wy\u0142.", + "1": "w\u0142.", + "2": "Do czasu ponownego uruchomienia urz\u0105dzenia" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.ru.json b/homeassistant/components/wled/translations/select.ru.json new file mode 100644 index 00000000000..7dc9f76c13f --- /dev/null +++ b/homeassistant/components/wled/translations/select.ru.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "\u0412\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "1": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "2": "\u0414\u043e \u043f\u0435\u0440\u0435\u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.sl.json b/homeassistant/components/wled/translations/select.sl.json new file mode 100644 index 00000000000..b752ae28d89 --- /dev/null +++ b/homeassistant/components/wled/translations/select.sl.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "Izklju\u010den", + "1": "Vklopljen", + "2": "Dokler se naprava znova ne za\u017eene" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.tr.json b/homeassistant/components/wled/translations/select.tr.json new file mode 100644 index 00000000000..5191153eee1 --- /dev/null +++ b/homeassistant/components/wled/translations/select.tr.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "Kapal\u0131", + "1": "A\u00e7\u0131k", + "2": "Cihaz yeniden ba\u015flat\u0131lana kadar" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.zh-Hant.json b/homeassistant/components/wled/translations/select.zh-Hant.json new file mode 100644 index 00000000000..fe8229341f1 --- /dev/null +++ b/homeassistant/components/wled/translations/select.zh-Hant.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "\u95dc\u9589", + "1": "\u958b\u555f", + "2": "\u76f4\u5230\u88dd\u7f6e\u91cd\u555f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/tr.json b/homeassistant/components/wled/translations/tr.json index f02764c8aba..938fa8d7f69 100644 --- a/homeassistant/components/wled/translations/tr.json +++ b/homeassistant/components/wled/translations/tr.json @@ -7,16 +7,26 @@ "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, - "flow_title": "WLED: {name}", + "flow_title": "{name}", "step": { "user": { "data": { - "host": "Ana Bilgisayar" + "host": "Ana bilgisayar" }, "description": "WLED'inizi Home Assistant ile t\u00fcmle\u015ftirmek i\u00e7in ayarlay\u0131n." }, "zeroconf_confirm": { - "description": "Home Assistant'a '{name}' adl\u0131 WLED'i eklemek istiyor musunuz?" + "description": "{name} ` adl\u0131 WLED'i Home Assistant'a eklemek istiyor musunuz?", + "title": "Bulunan WLED cihaz\u0131" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "keep_master_light": "1 LED segmenti ile bile ana \u0131\u015f\u0131\u011f\u0131 koruyun." + } } } } diff --git a/homeassistant/components/wolflink/translations/bg.json b/homeassistant/components/wolflink/translations/bg.json new file mode 100644 index 00000000000..3caea097ed7 --- /dev/null +++ b/homeassistant/components/wolflink/translations/bg.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "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": { + "device": { + "data": { + "device_name": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + } + }, + "user": { + "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" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/ja.json b/homeassistant/components/wolflink/translations/ja.json new file mode 100644 index 00000000000..fca5ea8f5e0 --- /dev/null +++ b/homeassistant/components/wolflink/translations/ja.json @@ -0,0 +1,27 @@ +{ + "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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "device": { + "data": { + "device_name": "\u30c7\u30d0\u30a4\u30b9" + }, + "title": "WOLF device\u306e\u9078\u629e" + }, + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "title": "WOLF SmartSet\u306e\u63a5\u7d9a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.bg.json b/homeassistant/components/wolflink/translations/sensor.bg.json index d9a9400e4d5..4a402cfe75b 100644 --- a/homeassistant/components/wolflink/translations/sensor.bg.json +++ b/homeassistant/components/wolflink/translations/sensor.bg.json @@ -1,6 +1,8 @@ { "state": { "wolflink__state": { + "1_x_warmwasser": "1 x DHW", + "auto": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u043d", "test": "\u0422\u0435\u0441\u0442", "tpw": "TPW", "urlaubsmodus": "\u0412\u0430\u043a\u0430\u043d\u0446\u0438\u043e\u043d\u0435\u043d \u0440\u0435\u0436\u0438\u043c", diff --git a/homeassistant/components/wolflink/translations/sensor.id.json b/homeassistant/components/wolflink/translations/sensor.id.json index 12b755a9c24..bcef8fe6d2c 100644 --- a/homeassistant/components/wolflink/translations/sensor.id.json +++ b/homeassistant/components/wolflink/translations/sensor.id.json @@ -40,6 +40,9 @@ "solarbetrieb": "Mode surya", "sparbetrieb": "Mode ekonomi", "sparen": "Ekonomi", + "standby": "Siaga", + "start": "Mulai", + "storung": "Kesalahan", "urlaubsmodus": "Mode liburan", "ventilprufung": "Uji katup" } diff --git a/homeassistant/components/wolflink/translations/sensor.ja.json b/homeassistant/components/wolflink/translations/sensor.ja.json new file mode 100644 index 00000000000..0b9fa47c9ea --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.ja.json @@ -0,0 +1,80 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 x DHW", + "abgasklappe": "\u6392\u7159\u30ac\u30b9\u30c0\u30f3\u30d1\u30fc(Flue gas damper)", + "absenkbetrieb": "\u30bb\u30c3\u30c8\u30d0\u30c3\u30af\u30e2\u30fc\u30c9", + "absenkstop": "\u30bb\u30c3\u30c8\u30d0\u30c3\u30af \u30b9\u30c8\u30c3\u30d7", + "aktiviert": "\u6709\u52b9\u5316", + "antilegionellenfunktion": "\u30ec\u30b8\u30aa\u30cd\u30e9\u83cc\u5bfe\u7b56\u6a5f\u80fd", + "at_abschaltung": "OT\u30b7\u30e3\u30c3\u30c8\u30c0\u30a6\u30f3", + "at_frostschutz": "OT\u971c\u9632\u6b62", + "aus": "\u7121\u52b9", + "auto": "\u30aa\u30fc\u30c8", + "automatik_aus": "\u81ea\u52d5\u30aa\u30d5", + "automatik_ein": "\u81ea\u52d5\u30aa\u30f3", + "bereit_keine_ladung": "\u6e96\u5099\u5b8c\u4e86\u3001\u8aad\u307f\u8fbc\u307f\u4e2d\u3067\u306f\u306a\u3044", + "betrieb_ohne_brenner": "\u30d0\u30fc\u30ca\u30fc\u306a\u3057\u3067\u306e\u4f5c\u696d", + "cooling": "\u51b7\u5374", + "deaktiviert": "\u975e\u6d3b\u6027", + "dhw_prior": "DHWPrior", + "eco": "\u30a8\u30b3", + "ein": "\u6709\u52b9", + "externe_deaktivierung": "\u5916\u90e8\u306e\u975e\u30a2\u30af\u30c6\u30a3\u30d6\u5316", + "fernschalter_ein": "\u30ea\u30e2\u30fc\u30c8\u5236\u5fa1\u304c\u6709\u52b9", + "frost_heizkreis": "\u6696\u623f(\u52a0\u71b1)\u56de\u8def\u306e\u971c", + "frost_warmwasser": "DHW\u30d5\u30ed\u30b9\u30c8", + "frostschutz": "\u971c\u9632\u6b62", + "gasdruck": "\u30ac\u30b9\u5727", + "glt_betrieb": "BMS\u30e2\u30fc\u30c9", + "gradienten_uberwachung": "\u50be\u659c\u30e2\u30cb\u30bf\u30ea\u30f3\u30b0", + "heizbetrieb": "\u6696\u623f(\u52a0\u71b1)\u30e2\u30fc\u30c9", + "heizgerat_mit_speicher": "\u30b7\u30ea\u30f3\u30c0\u30fc\u4ed8\u304d\u30dc\u30a4\u30e9\u30fc", + "heizung": "\u6696\u623f(\u52a0\u71b1)", + "initialisierung": "\u521d\u671f\u5316", + "kalibration": "\u30ad\u30e3\u30ea\u30d6\u30ec\u30fc\u30b7\u30e7\u30f3", + "kalibration_heizbetrieb": "\u6696\u623f(\u52a0\u71b1)\u306e\u30ad\u30e3\u30ea\u30d6\u30ec\u30fc\u30b7\u30e7\u30f3", + "kalibration_kombibetrieb": "\u30b3\u30f3\u30d3\u30e2\u30fc\u30c9 \u30ad\u30e3\u30ea\u30d6\u30ec\u30fc\u30b7\u30e7\u30f3", + "kalibration_warmwasserbetrieb": "DHW\u30ad\u30e3\u30ea\u30d6\u30ec\u30fc\u30b7\u30e7\u30f3", + "kombibetrieb": "\u30b3\u30f3\u30d3\u30e2\u30fc\u30c9", + "kombigerat": "\u30b3\u30f3\u30d3 \u30dc\u30a4\u30e9\u30fc", + "nachlauf_heizkreispumpe": "\u6696\u623f(\u52a0\u71b1)\u56de\u8def\u30dd\u30f3\u30d7\u306e\u4f5c\u52d5", + "nachspulen": "\u30d5\u30e9\u30c3\u30b7\u30e5\u5f8c(Post-flush)", + "nur_heizgerat": "\u30dc\u30a4\u30e9\u30fc\u306e\u307f", + "parallelbetrieb": "\u30d1\u30e9\u30ec\u30eb\u30e2\u30fc\u30c9", + "partymodus": "\u30d1\u30fc\u30c6\u30a3\u30fc\u30e2\u30fc\u30c9", + "perm_cooling": "PermCooling", + "permanent": "\u6c38\u7d9a", + "permanentbetrieb": "\u30d1\u30fc\u30de\u30cd\u30f3\u30c8\u30e2\u30fc\u30c9", + "reduzierter_betrieb": "\u5236\u9650\u4ed8\u304d\u30e2\u30fc\u30c9", + "rt_abschaltung": "RT\u30b7\u30e3\u30c3\u30c8\u30c0\u30a6\u30f3", + "rt_frostschutz": "RT\u971c\u9632\u6b62", + "ruhekontakt": "\u6b8b\u308a\u306e\u9023\u7d61\u5148(Rest contact)", + "schornsteinfeger": "\u6392\u51fa\u91cf\u30c6\u30b9\u30c8", + "smart_grid": "\u30b9\u30de\u30fc\u30c8\u30b0\u30ea\u30c3\u30c9", + "smart_home": "\u30b9\u30de\u30fc\u30c8\u30db\u30fc\u30e0", + "softstart": "\u30bd\u30d5\u30c8\u30b9\u30bf\u30fc\u30c8", + "solarbetrieb": "\u30bd\u30fc\u30e9\u30fc\u30e2\u30fc\u30c9", + "sparbetrieb": "\u30a8\u30b3\u30ce\u30df\u30fc\u30e2\u30fc\u30c9", + "sparen": "\u30a8\u30b3\u30ce\u30df\u30fc", + "spreizung_hoch": "dT\u304c\u5e83\u3059\u304e\u308b(dT too wide)", + "spreizung_kf": "\u5e83\u3052\u308b(Spread) KF", + "stabilisierung": "\u5b89\u5b9a\u5316", + "standby": "\u30b9\u30bf\u30f3\u30d0\u30a4", + "start": "\u8d77\u52d5", + "storung": "\u30d5\u30a9\u30fc\u30eb\u30c8", + "taktsperre": "\u30a2\u30f3\u30c1\u30b5\u30a4\u30af\u30eb", + "telefonfernschalter": "\u96fb\u8a71\u306e\u30ea\u30e2\u30fc\u30c8\u30b9\u30a4\u30c3\u30c1", + "test": "\u30c6\u30b9\u30c8", + "tpw": "TPW", + "urlaubsmodus": "\u30db\u30ea\u30c7\u30fc(\u4f11\u65e5)\u30e2\u30fc\u30c9", + "ventilprufung": "\u30d0\u30eb\u30d6\u30c6\u30b9\u30c8", + "warmwasser": "DHW", + "warmwasser_schnellstart": "DHW\u30af\u30a4\u30c3\u30af\u30b9\u30bf\u30fc\u30c8", + "warmwasserbetrieb": "DHW\u30e2\u30fc\u30c9", + "warmwassernachlauf": "DHW run-on", + "warmwasservorrang": "DHW\u306e\u512a\u5148\u9806\u4f4d", + "zunden": "\u30a4\u30b0\u30cb\u30c3\u30b7\u30e7\u30f3" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.tr.json b/homeassistant/components/wolflink/translations/sensor.tr.json index 4b1e2778af1..8415adf6e6b 100644 --- a/homeassistant/components/wolflink/translations/sensor.tr.json +++ b/homeassistant/components/wolflink/translations/sensor.tr.json @@ -1,19 +1,87 @@ { "state": { "wolflink__state": { + "1_x_warmwasser": "1 x DHW", + "abgasklappe": "Baca gaz\u0131 damperi", + "absenkbetrieb": "Gerileme modu", + "absenkstop": "Gerileme durdurma", + "aktiviert": "Aktif", + "antilegionellenfunktion": "Anti-lejyonella Fonksiyonu", + "at_abschaltung": "OT kapatma", + "at_frostschutz": "OT donma korumas\u0131", + "aus": "Devre d\u0131\u015f\u0131", + "auto": "Otomatik", + "auto_off_cool": "Otomatik Kapal\u0131So\u011futma", + "auto_on_cool": "Otomatik A\u00e7\u0131kSo\u011futma", + "automatik_aus": "Otomatik KAPALI", + "automatik_ein": "Otomatik A\u00c7IK", + "bereit_keine_ladung": "Haz\u0131r, y\u00fcklenmiyor", + "betrieb_ohne_brenner": "Br\u00fcl\u00f6rs\u00fcz \u00e7al\u0131\u015fma", + "cooling": "So\u011futma", + "deaktiviert": "Etkin de\u011fil", + "dhw_prior": "DHWPrior", + "eco": "Eko", + "ein": "Etkin", + "estrichtrocknung": "\u015eap kurutma", + "externe_deaktivierung": "Harici devre d\u0131\u015f\u0131 b\u0131rakma", + "fernschalter_ein": "Uzaktan kumanda etkin", + "frost_heizkreis": "Is\u0131tma devresi donmas\u0131", + "frost_warmwasser": "DHW donma koruma", + "frostschutz": "Donma korumas\u0131", + "gasdruck": "Gaz bas\u0131nc\u0131", "glt_betrieb": "BMS modu", + "gradienten_uberwachung": "Gradyan izleme", "heizbetrieb": "Is\u0131tma modu", + "heizgerat_mit_speicher": "Silindirli kazan", + "heizung": "Is\u0131tma", + "initialisierung": "Ba\u015flatma", + "kalibration": "Kalibrasyon", "kalibration_heizbetrieb": "Is\u0131tma modu kalibrasyonu", "kalibration_kombibetrieb": "Kombi modu kalibrasyonu", + "kalibration_warmwasserbetrieb": "DHW kalibrasyonu", + "kaskadenbetrieb": "Kademeli i\u015flem", + "kombibetrieb": "Kombi modu", + "kombigerat": "Kombi", + "kombigerat_mit_solareinbindung": "G\u00fcne\u015f enerjisi entegreli kombi", + "mindest_kombizeit": "Minimum kombi s\u00fcresi", + "nachlauf_heizkreispumpe": "Is\u0131tma devresi pompas\u0131 \u00e7al\u0131\u015fmas\u0131", + "nachspulen": "Temizleme sonras\u0131", + "nur_heizgerat": "Sadece kazan", + "parallelbetrieb": "Paralel mod", + "partymodus": "Parti modu", + "perm_cooling": "PermSo\u011futma", + "permanent": "Kal\u0131c\u0131", + "permanentbetrieb": "Kal\u0131c\u0131 mod", "reduzierter_betrieb": "S\u0131n\u0131rl\u0131 mod", + "rt_abschaltung": "RT kapatma", + "rt_frostschutz": "RT donma korumas\u0131", + "ruhekontakt": "Dinlenme konta\u011f\u0131", + "schornsteinfeger": "Emisyon testi", + "smart_grid": "SmartGrid", + "smart_home": "Ak\u0131ll\u0131 Ev", + "softstart": "Yumu\u015fak ba\u015flang\u0131\u00e7", "solarbetrieb": "G\u00fcne\u015f modu", "sparbetrieb": "Ekonomi modu", + "sparen": "Ekonomi", + "spreizung_hoch": "dT \u00e7ok geni\u015f", + "spreizung_kf": "KF'yi yay\u0131n", + "stabilisierung": "Stabilizasyon", "standby": "Bekleme", "start": "Ba\u015flat", "storung": "Hata", + "taktsperre": "Anti-d\u00f6ng\u00fc", + "telefonfernschalter": "Telefon uzaktan anahtar\u0131", "test": "Test", + "tpw": "TPW", "urlaubsmodus": "Tatil modu", - "warmwasserbetrieb": "DHW modu" + "ventilprufung": "Valf testi", + "vorspulen": "Giri\u015f durulama", + "warmwasser": "DHW", + "warmwasser_schnellstart": "DHW h\u0131zl\u0131 ba\u015flang\u0131\u00e7", + "warmwasserbetrieb": "DHW modu", + "warmwassernachlauf": "DHW \u00e7al\u0131\u015fmas\u0131", + "warmwasservorrang": "DHW \u00f6nceli\u011fi", + "zunden": "Ate\u015fleme" } } } \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/tr.json b/homeassistant/components/wolflink/translations/tr.json index 6ed28a58c79..aa7dbb1747a 100644 --- a/homeassistant/components/wolflink/translations/tr.json +++ b/homeassistant/components/wolflink/translations/tr.json @@ -9,11 +9,18 @@ "unknown": "Beklenmeyen hata" }, "step": { + "device": { + "data": { + "device_name": "Cihaz" + }, + "title": "WOLF ayg\u0131t\u0131 se\u00e7in" + }, "user": { "data": { "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" - } + }, + "title": "KURT SmartSet ba\u011flant\u0131s\u0131" } } } diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py index b34481d0990..98b7470b7a8 100644 --- a/homeassistant/components/worxlandroid/sensor.py +++ b/homeassistant/components/worxlandroid/sensor.py @@ -85,7 +85,7 @@ class WorxLandroidSensor(SensorEntity): try: session = async_get_clientsession(self.hass) - with async_timeout.timeout(self.timeout): + async with async_timeout.timeout(self.timeout): auth = aiohttp.helpers.BasicAuth("admin", self.pin) mower_response = await session.get(self.url, auth=auth) except (asyncio.TimeoutError, aiohttp.ClientError): @@ -134,9 +134,7 @@ class WorxLandroidSensor(SensorEntity): def get_state(self, obj): """Get the state of the mower.""" - state = self.get_error(obj) - - if state is None: + if (state := self.get_error(obj)) is None: if obj["batteryChargerState"] == "charging": return obj["batteryChargerState"] diff --git a/homeassistant/components/xbox/base_sensor.py b/homeassistant/components/xbox/base_sensor.py index c463b31d3c5..100685b6c34 100644 --- a/homeassistant/components/xbox/base_sensor.py +++ b/homeassistant/components/xbox/base_sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations from yarl import URL +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PresenceData, XboxUpdateCoordinator @@ -66,12 +68,12 @@ class XboxBaseSensorEntity(CoordinatorEntity): return self.attribute == "online" @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" - return { - "identifiers": {(DOMAIN, "xbox_live")}, - "name": "Xbox Live", - "manufacturer": "Microsoft", - "model": "Xbox Live", - "entry_type": "service", - } + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, "xbox_live")}, + manufacturer="Microsoft", + model="Xbox Live", + name="Xbox Live", + ) diff --git a/homeassistant/components/xbox/media_player.py b/homeassistant/components/xbox/media_player.py index 17390f81fad..cdeb016d604 100644 --- a/homeassistant/components/xbox/media_player.py +++ b/homeassistant/components/xbox/media_player.py @@ -29,6 +29,7 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_STEP, ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import ConsoleData, XboxUpdateCoordinator @@ -213,21 +214,20 @@ class XboxMediaPlayer(CoordinatorEntity, MediaPlayerEntity): await self.client.smartglass.launch_app(self._console.id, media_id) @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" # Turns "XboxOneX" into "Xbox One X" for display matches = re.finditer( ".+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)", self._console.console_type, ) - model = " ".join([m.group(0) for m in matches]) - return { - "identifiers": {(DOMAIN, self._console.id)}, - "name": self._console.name, - "manufacturer": "Microsoft", - "model": model, - } + return DeviceInfo( + identifiers={(DOMAIN, self._console.id)}, + manufacturer="Microsoft", + model=" ".join([m.group(0) for m in matches]), + name=self._console.name, + ) def _find_media_image(images: list[Image]) -> Image | None: diff --git a/homeassistant/components/xbox/remote.py b/homeassistant/components/xbox/remote.py index 31e6220172a..04f25c5f632 100644 --- a/homeassistant/components/xbox/remote.py +++ b/homeassistant/components/xbox/remote.py @@ -20,6 +20,7 @@ from homeassistant.components.remote import ( DEFAULT_DELAY_SECS, RemoteEntity, ) +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import ConsoleData, XboxUpdateCoordinator @@ -98,18 +99,17 @@ class XboxRemote(CoordinatorEntity, RemoteEntity): await asyncio.sleep(delay) @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" # Turns "XboxOneX" into "Xbox One X" for display matches = re.finditer( ".+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)", self._console.console_type, ) - model = " ".join([m.group(0) for m in matches]) - return { - "identifiers": {(DOMAIN, self._console.id)}, - "name": self._console.name, - "manufacturer": "Microsoft", - "model": model, - } + return DeviceInfo( + identifiers={(DOMAIN, self._console.id)}, + manufacturer="Microsoft", + model=" ".join([m.group(0) for m in matches]), + name=self._console.name, + ) diff --git a/homeassistant/components/xbox/translations/ja.json b/homeassistant/components/xbox/translations/ja.json new file mode 100644 index 00000000000..7a9337c9332 --- /dev/null +++ b/homeassistant/components/xbox/translations/ja.json @@ -0,0 +1,17 @@ +{ + "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", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "create_entry": { + "default": "\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f" + }, + "step": { + "pick_implementation": { + "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xbox/translations/tr.json b/homeassistant/components/xbox/translations/tr.json index a152eb19468..3f4025ade5d 100644 --- a/homeassistant/components/xbox/translations/tr.json +++ b/homeassistant/components/xbox/translations/tr.json @@ -1,7 +1,17 @@ { "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.", "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/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py index c09b707cba0..bbd44498dab 100644 --- a/homeassistant/components/xbox_live/sensor.py +++ b/homeassistant/components/xbox_live/sensor.py @@ -47,8 +47,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): interval = config.get(CONF_SCAN_INTERVAL, interval) for xuid in users: - gamercard = get_user_gamercard(api, xuid) - if gamercard is None: + if (gamercard := get_user_gamercard(api, xuid)) is None: continue entities.append(XboxSensor(api, xuid, gamercard, interval)) diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index b365dbb1bee..7aff6ece0e1 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -172,7 +172,7 @@ async def async_setup_entry( entry.data[CONF_HOST], ) - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.unique_id)}, diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py index 3fbe7a71496..f9f8a761321 100644 --- a/homeassistant/components/xiaomi_aqara/config_flow.py +++ b/homeassistant/components/xiaomi_aqara/config_flow.py @@ -6,8 +6,10 @@ import voluptuous as vol from xiaomi_gateway import MULTICAST_PORT, XiaomiGateway, XiaomiGatewayDiscovery from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_PROTOCOL from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import format_mac from .const import ( @@ -144,11 +146,13 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="select", data_schema=select_schema, errors=errors ) - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle zeroconf discovery.""" - name = discovery_info.get("name") - self.host = discovery_info.get("host") - mac_address = discovery_info.get("properties", {}).get("mac") + name = discovery_info.name + self.host = discovery_info.host + mac_address = discovery_info.properties.get("mac") if not name or not self.host or not mac_address: return self.async_abort(reason="not_xiaomi_aqara") diff --git a/homeassistant/components/xiaomi_aqara/translations/bg.json b/homeassistant/components/xiaomi_aqara/translations/bg.json new file mode 100644 index 00000000000..61cc221fa2c --- /dev/null +++ b/homeassistant/components/xiaomi_aqara/translations/bg.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "invalid_mac": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d Mac \u0430\u0434\u0440\u0435\u0441" + }, + "flow_title": "{name}", + "step": { + "select": { + "data": { + "select_ip": "IP \u0430\u0434\u0440\u0435\u0441" + } + }, + "settings": { + "data": { + "name": "\u0418\u043c\u0435 \u043d\u0430 \u0448\u043b\u044e\u0437\u0430" + } + }, + "user": { + "data": { + "host": "IP \u0430\u0434\u0440\u0435\u0441 (\u043f\u043e \u0438\u0437\u0431\u043e\u0440)", + "mac": "Mac \u0430\u0434\u0440\u0435\u0441 (\u043f\u043e \u0438\u0437\u0431\u043e\u0440)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_aqara/translations/ja.json b/homeassistant/components/xiaomi_aqara/translations/ja.json new file mode 100644 index 00000000000..87e757a2c5d --- /dev/null +++ b/homeassistant/components/xiaomi_aqara/translations/ja.json @@ -0,0 +1,43 @@ +{ + "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", + "not_xiaomi_aqara": "Xiaomi Aqara Gateway\u3067\u306f\u306a\u304f\u3001\u691c\u51fa\u3055\u308c\u305f\u30c7\u30d0\u30a4\u30b9\u304c\u65e2\u77e5\u306e\u30b2\u30fc\u30c8\u30a6\u30a7\u30a4\u3068\u4e00\u81f4\u3057\u307e\u305b\u3093\u3067\u3057\u305f" + }, + "error": { + "discovery_error": "Xiaomi Aqara Gateway\u306e\u691c\u51fa\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002Home Assistant\u3092\u5b9f\u884c\u3057\u3066\u3044\u308b\u30c7\u30d0\u30a4\u30b9\u306eIP\u3092\u30a4\u30f3\u30bf\u30fc\u30d5\u30a7\u30a4\u30b9\u3068\u3057\u3066\u4f7f\u7528\u3057\u3066\u307f\u3066\u304f\u3060\u3055\u3044", + "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9 https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044", + "invalid_interface": "\u7121\u52b9\u306a\u30cd\u30c3\u30c8\u30ef\u30fc\u30af \u30a4\u30f3\u30bf\u30fc\u30d5\u30a7\u30a4\u30b9", + "invalid_key": "\u7121\u52b9\u306a\u30b2\u30fc\u30c8\u30a6\u30a7\u30a4 \u30ad\u30fc", + "invalid_mac": "\u7121\u52b9\u306aMAC\u30a2\u30c9\u30ec\u30b9" + }, + "flow_title": "{name}", + "step": { + "select": { + "data": { + "select_ip": "IP\u30a2\u30c9\u30ec\u30b9" + }, + "description": "\u8ffd\u52a0\u306e\u30b2\u30fc\u30c8\u30a6\u30a7\u30a4\u3092\u63a5\u7d9a\u3059\u308b\u5834\u5408\u306f\u3001\u518d\u5ea6\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u5b9f\u884c\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "\u63a5\u7d9a\u3057\u305f\u3044Xiaomi Aqara Gateway\u3092\u9078\u629e\u3057\u307e\u3059\u3002" + }, + "settings": { + "data": { + "key": "\u3042\u306a\u305f\u306e\u30b2\u30fc\u30c8\u30a6\u30a7\u30a4\u306e\u30ad\u30fc", + "name": "\u30b2\u30fc\u30c8\u30a6\u30a7\u30a4\u306e\u540d\u524d" + }, + "description": "\u30ad\u30fc(\u30d1\u30b9\u30ef\u30fc\u30c9)\u306f\u3001\u3053\u3061\u3089\u306e\u30c1\u30e5\u30fc\u30c8\u30ea\u30a2\u30eb: https://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)#Adding_the_Xiaomi_Gateway_to_Domoticz \u3092\u53c2\u8003\u306b\u3057\u3066\u53d6\u5f97\u3059\u308b\u3053\u3068\u304c\u3067\u304d\u307e\u3059\u3002\u30ad\u30fc\u304c\u63d0\u4f9b\u3055\u308c\u3066\u3044\u306a\u3044\u5834\u5408\u306f\u3001\u30bb\u30f3\u30b5\u30fc\u306e\u307f\u306b\u30a2\u30af\u30bb\u30b9\u3067\u304d\u307e\u3059\u3002", + "title": "Xiaomi Aqara Gateway\u3001\u30aa\u30d7\u30b7\u30e7\u30f3\u8a2d\u5b9a" + }, + "user": { + "data": { + "host": "IP\u30a2\u30c9\u30ec\u30b9(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "interface": "\u4f7f\u7528\u3059\u308b\u30cd\u30c3\u30c8\u30ef\u30fc\u30af \u30a4\u30f3\u30bf\u30fc\u30d5\u30a7\u30a4\u30b9", + "mac": "Mac\u30a2\u30c9\u30ec\u30b9 (\u30aa\u30d7\u30b7\u30e7\u30f3)" + }, + "description": "Xiaomi Aqara Gateway\u306b\u63a5\u7d9a\u3057\u307e\u3059\u3002IP\u30a2\u30c9\u30ec\u30b9\u3068MAC\u30a2\u30c9\u30ec\u30b9\u304c\u7a7a\u306e\u307e\u307e\u306e\u5834\u5408\u3001\u81ea\u52d5\u691c\u51fa\u304c\u4f7f\u7528\u3055\u308c\u307e\u3059", + "title": "Xiaomi Aqara Gateway" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_aqara/translations/ru.json b/homeassistant/components/xiaomi_aqara/translations/ru.json index 46b46e2beec..aa18808d222 100644 --- a/homeassistant/components/xiaomi_aqara/translations/ru.json +++ b/homeassistant/components/xiaomi_aqara/translations/ru.json @@ -35,7 +35,7 @@ "interface": "\u0421\u0435\u0442\u0435\u0432\u043e\u0439 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441", "mac": "MAC-\u0430\u0434\u0440\u0435\u0441 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441\u043e \u0448\u043b\u044e\u0437\u043e\u043c Xiaomi Aqara. \u0414\u043b\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043e \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u044f \u0448\u043b\u044e\u0437\u0430, \u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u043e\u043b\u044f IP \u0438 MAC \u0430\u0434\u0440\u0435\u0441\u043e\u0432 \u043f\u0443\u0441\u0442\u044b\u043c\u0438.", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441\u043e \u0448\u043b\u044e\u0437\u043e\u043c Xiaomi Aqara. \u0414\u043b\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043e \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u044f \u0448\u043b\u044e\u0437\u0430, \u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u043e\u043b\u044f IP \u0438 MAC \u0430\u0434\u0440\u0435\u0441\u043e\u0432 \u043f\u0443\u0441\u0442\u044b\u043c\u0438.", "title": "\u0428\u043b\u044e\u0437 Xiaomi Aqara" } } diff --git a/homeassistant/components/xiaomi_aqara/translations/tr.json b/homeassistant/components/xiaomi_aqara/translations/tr.json index 24da29417d1..6281b7a397e 100644 --- a/homeassistant/components/xiaomi_aqara/translations/tr.json +++ b/homeassistant/components/xiaomi_aqara/translations/tr.json @@ -7,15 +7,16 @@ }, "error": { "discovery_error": "Bir Xiaomi Aqara A\u011f Ge\u00e7idi ke\u015ffedilemedi, HomeAssistant'\u0131 aray\u00fcz olarak \u00e7al\u0131\u015ft\u0131ran cihaz\u0131n IP'sini kullanmay\u0131 deneyin", + "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi , bkz. https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem", "invalid_interface": "Ge\u00e7ersiz a\u011f aray\u00fcz\u00fc", "invalid_key": "Ge\u00e7ersiz a\u011f ge\u00e7idi anahtar\u0131", "invalid_mac": "Ge\u00e7ersiz Mac Adresi" }, - "flow_title": "Xiaomi Aqara A\u011f Ge\u00e7idi: {name}", + "flow_title": "{name}", "step": { "select": { "data": { - "select_ip": "\u0130p Adresi" + "select_ip": "IP Adresi" }, "description": "Ek a\u011f ge\u00e7itlerini ba\u011flamak istiyorsan\u0131z kurulumu tekrar \u00e7al\u0131\u015ft\u0131r\u0131n.", "title": "Ba\u011flamak istedi\u011finiz Xiaomi Aqara A\u011f Ge\u00e7idini se\u00e7in" @@ -25,13 +26,17 @@ "key": "A\u011f ge\u00e7idinizin anahtar\u0131", "name": "A\u011f Ge\u00e7idinin Ad\u0131" }, - "description": "Anahtar (parola) bu \u00f6\u011fretici kullan\u0131larak al\u0131nabilir: https://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)#Adding_the_Xiaomi_Gateway_to_Domoticz. Anahtar sa\u011flanmazsa, yaln\u0131zca sens\u00f6rlere eri\u015filebilir" + "description": "Anahtar (parola) bu \u00f6\u011fretici kullan\u0131larak al\u0131nabilir: https://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)#Adding_the_Xiaomi_Gateway_to_Domoticz. Anahtar sa\u011flanmazsa, yaln\u0131zca sens\u00f6rlere eri\u015filebilir", + "title": "Xiaomi Aqara A\u011f Ge\u00e7idi, iste\u011fe ba\u011fl\u0131 ayarlar" }, "user": { "data": { - "host": "\u0130p Adresi (iste\u011fe ba\u011fl\u0131)", + "host": "IP Adresi (iste\u011fe ba\u011fl\u0131)", + "interface": "Kullan\u0131lacak a\u011f aray\u00fcz\u00fc", "mac": "Mac Adresi (iste\u011fe ba\u011fl\u0131)" - } + }, + "description": "Xiaomi Aqara Gateway'inize ba\u011flan\u0131n, IP ve MAC adresleri bo\u015f b\u0131rak\u0131l\u0131rsa otomatik ke\u015fif kullan\u0131l\u0131r", + "title": "Xiaomi Aqara A\u011f Ge\u00e7idi" } } } diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 0f36d884207..62935f5e38b 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -26,8 +26,8 @@ from miio import ( FanP10, FanP11, FanZA5, + RoborockVacuum, Timer, - Vacuum, VacuumStatus, ) from miio.gateway.gateway import GatewayException @@ -212,7 +212,7 @@ class VacuumCoordinatorDataAttributes: fan_speeds_reverse: str = "fan_speeds_reverse" -def _async_update_data_vacuum(hass, device: Vacuum): +def _async_update_data_vacuum(hass, device: RoborockVacuum): def update() -> VacuumCoordinatorData: timer = [] @@ -313,7 +313,7 @@ async def async_create_miio_device_and_coordinator( or model.startswith(ROBOROCK_GENERIC) or model.startswith(ROCKROBO_GENERIC) ): - device = Vacuum(host, token) + device = RoborockVacuum(host, token) update_method = _async_update_data_vacuum coordinator_class = DataUpdateCoordinator[VacuumCoordinatorData] # Pedestal fans @@ -385,7 +385,7 @@ async def async_setup_gateway_entry( gateway_model = f"{gateway_info.model}-{gateway_info.hardware_version}" - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, gateway_info.mac_address)}, diff --git a/homeassistant/components/xiaomi_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py index 372a1b62e73..271beae131c 100644 --- a/homeassistant/components/xiaomi_miio/air_quality.py +++ b/homeassistant/components/xiaomi_miio/air_quality.py @@ -117,8 +117,7 @@ class AirMonitorB1(XiaomiMiioEntity, AirQualityEntity): data = {} for prop, attr in PROP_TO_ATTR.items(): - value = getattr(self, prop) - if value is not None: + if (value := getattr(self, prop)) is not None: data[attr] = value return data diff --git a/homeassistant/components/xiaomi_miio/alarm_control_panel.py b/homeassistant/components/xiaomi_miio/alarm_control_panel.py index 26421770771..1fccbcf8056 100644 --- a/homeassistant/components/xiaomi_miio/alarm_control_panel.py +++ b/homeassistant/components/xiaomi_miio/alarm_control_panel.py @@ -14,6 +14,7 @@ from homeassistant.const import ( STATE_ALARM_ARMING, STATE_ALARM_DISARMED, ) +from homeassistant.helpers.entity import DeviceInfo from .const import CONF_GATEWAY, DOMAIN @@ -65,11 +66,11 @@ class XiaomiGatewayAlarm(AlarmControlPanelEntity): return self._gateway_device_id @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info of the gateway.""" - return { - "identifiers": {(DOMAIN, self._gateway_device_id)}, - } + return DeviceInfo( + identifiers={(DOMAIN, self._gateway_device_id)}, + ) @property def name(self): diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py index ef172bbbbc5..26fa82b7df2 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -1,9 +1,9 @@ """Support for Xiaomi Miio binary sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Callable from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index 96a06f6a33d..04e2b58ad0f 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -7,9 +7,11 @@ from micloud.micloudexception import MiCloudAccessDenied import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import format_mac from .const import ( @@ -154,15 +156,16 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" return await self.async_step_cloud() - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle zeroconf discovery.""" - name = discovery_info.get("name") - self.host = discovery_info.get("host") - self.mac = discovery_info.get("properties", {}).get("mac") + name = discovery_info.name + self.host = discovery_info.host + self.mac = discovery_info.properties.get("mac") if self.mac is None: - poch = discovery_info.get("properties", {}).get("poch", "") - result = search(r"mac=\w+", poch) - if result is not None: + poch = discovery_info.properties.get("poch", "") + if (result := search(r"mac=\w+", poch)) is not None: self.mac = result.group(0).split("=")[1] if not name or not self.host or not self.mac: diff --git a/homeassistant/components/xiaomi_miio/device.py b/homeassistant/components/xiaomi_miio/device.py index cb708fd4379..5a186c23570 100644 --- a/homeassistant/components/xiaomi_miio/device.py +++ b/homeassistant/components/xiaomi_miio/device.py @@ -7,8 +7,9 @@ import logging from construct.core import ChecksumError from miio import Device, DeviceException +from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_MAC, CONF_MODEL, DOMAIN, AuthException, SetupException @@ -85,17 +86,17 @@ class XiaomiMiioEntity(Entity): return self._name @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info.""" - device_info = { - "identifiers": {(DOMAIN, self._device_id)}, - "manufacturer": "Xiaomi", - "name": self._name, - "model": self._model, - } + device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer="Xiaomi", + model=self._model, + name=self._name, + ) if self._mac is not None: - device_info["connections"] = {(dr.CONNECTION_NETWORK_MAC, self._mac)} + device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, self._mac)} return device_info @@ -125,17 +126,17 @@ class XiaomiCoordinatedMiioEntity(CoordinatorEntity): return self._name @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info.""" - device_info = { - "identifiers": {(DOMAIN, self._device_id)}, - "manufacturer": "Xiaomi", - "name": self._device_name, - "model": self._model, - } + device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer="Xiaomi", + model=self._model, + name=self._device_name, + ) if self._mac is not None: - device_info["connections"] = {(dr.CONNECTION_NETWORK_MAC, self._mac)} + device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, self._mac)} return device_info diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 04597eadf81..03722380a69 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -26,6 +26,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_TOKEN import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.util import color, dt from .const import ( @@ -944,11 +945,11 @@ class XiaomiGatewayLight(LightEntity): return self._unique_id @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info of the gateway.""" - return { - "identifiers": {(DOMAIN, self._gateway_device_id)}, - } + return DeviceInfo( + identifiers={(DOMAIN, self._gateway_device_id)}, + ) @property def name(self): diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index 37c6b8f8a09..757fca8be1f 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -3,7 +3,7 @@ "name": "Xiaomi Miio", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", - "requirements": ["construct==2.10.56", "micloud==0.4", "python-miio==0.5.8"], + "requirements": ["construct==2.10.56", "micloud==0.4", "python-miio==0.5.9.1"], "codeowners": ["@rytilahti", "@syssi", "@starkillerOG", "@bieniu"], "zeroconf": ["_miio._udp.local."], "iot_class": "local_polling" diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index 87ccdd63d0f..2823c6c2582 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -33,6 +33,7 @@ from .const import ( FEATURE_SET_FAN_LEVEL, FEATURE_SET_FAVORITE_LEVEL, FEATURE_SET_FAVORITE_RPM, + FEATURE_SET_LED_BRIGHTNESS, FEATURE_SET_LED_BRIGHTNESS_LEVEL, FEATURE_SET_MOTOR_SPEED, FEATURE_SET_OSCILLATION_ANGLE, @@ -70,6 +71,7 @@ ATTR_DELAY_OFF_COUNTDOWN = "delay_off_countdown" ATTR_FAN_LEVEL = "fan_level" ATTR_FAVORITE_LEVEL = "favorite_level" ATTR_FAVORITE_RPM = "favorite_rpm" +ATTR_LED_BRIGHTNESS = "led_brightness" ATTR_LED_BRIGHTNESS_LEVEL = "led_brightness_level" ATTR_MOTOR_SPEED = "motor_speed" ATTR_OSCILLATION_ANGLE = "angle" @@ -161,6 +163,16 @@ NUMBER_TYPES = { method="async_set_delay_off_countdown", entity_category=ENTITY_CATEGORY_CONFIG, ), + FEATURE_SET_LED_BRIGHTNESS: XiaomiMiioNumberDescription( + key=ATTR_LED_BRIGHTNESS, + name="Led Brightness", + icon="mdi:brightness-6", + min_value=0, + max_value=100, + step=1, + method="async_set_led_brightness", + entity_category=ENTITY_CATEGORY_CONFIG, + ), FEATURE_SET_LED_BRIGHTNESS_LEVEL: XiaomiMiioNumberDescription( key=ATTR_LED_BRIGHTNESS_LEVEL, name="Led Brightness", @@ -244,6 +256,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): description.max_value = OSCILLATION_ANGLE_VALUES[model].max_value description.min_value = OSCILLATION_ANGLE_VALUES[model].min_value description.step = OSCILLATION_ANGLE_VALUES[model].step + entities.append( XiaomiNumberEntity( f"{config_entry.title} {description.name}", @@ -354,6 +367,14 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): level, ) + async def async_set_led_brightness(self, level: int): + """Set the led brightness level.""" + return await self._try_command( + "Setting the led brightness level of the miio device failed.", + self._device.set_led_brightness, + level, + ) + async def async_set_favorite_rpm(self, rpm: int): """Set the target motor speed.""" return await self._try_command( diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 0d67014ced9..ccf55a04e17 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -49,6 +49,7 @@ from homeassistant.const import ( VOLUME_CUBIC_METERS, ) from homeassistant.core import callback +from homeassistant.util import dt as dt_util from . import VacuumCoordinatorDataAttributes from .const import ( @@ -689,14 +690,24 @@ class XiaomiGenericSensor(XiaomiCoordinatedMiioEntity, SensorEntity): def _determine_native_value(self): """Determine native value.""" if self.entity_description.parent_key is not None: - return self._extract_value_from_attribute( + native_value = self._extract_value_from_attribute( getattr(self.coordinator.data, self.entity_description.parent_key), self.entity_description.key, ) + else: + native_value = self._extract_value_from_attribute( + self.coordinator.data, self.entity_description.key + ) - return self._extract_value_from_attribute( - self.coordinator.data, self.entity_description.key - ) + if ( + self.device_class == DEVICE_CLASS_TIMESTAMP + and native_value is not None + and (native_datetime := dt_util.parse_datetime(str(native_value))) + is not None + ): + return native_datetime.astimezone(dt_util.UTC) + + return native_value class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): diff --git a/homeassistant/components/xiaomi_miio/translations/bg.json b/homeassistant/components/xiaomi_miio/translations/bg.json index b692bd4bb9f..a73df3c5609 100644 --- a/homeassistant/components/xiaomi_miio/translations/bg.json +++ b/homeassistant/components/xiaomi_miio/translations/bg.json @@ -8,6 +8,7 @@ "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "no_device_selected": "\u041d\u0435 \u0435 \u0438\u0437\u0431\u0440\u0430\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e, \u043c\u043e\u043b\u044f, \u0438\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0435\u0434\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e." }, + "flow_title": "{name}", "step": { "cloud": { "data": { diff --git a/homeassistant/components/xiaomi_miio/translations/fr.json b/homeassistant/components/xiaomi_miio/translations/fr.json index 06a0d29c608..65e7f90d253 100644 --- a/homeassistant/components/xiaomi_miio/translations/fr.json +++ b/homeassistant/components/xiaomi_miio/translations/fr.json @@ -13,7 +13,8 @@ "cloud_login_error": "Impossible de se connecter \u00e0 Xioami Miio Cloud, v\u00e9rifiez les informations d'identification.", "cloud_no_devices": "Aucun appareil trouv\u00e9 dans ce compte cloud Xiaomi Miio.", "no_device_selected": "Aucun appareil s\u00e9lectionn\u00e9, veuillez s\u00e9lectionner un appareil.", - "unknown_device": "Le mod\u00e8le d'appareil n'est pas connu, impossible de configurer l'appareil \u00e0 l'aide du flux de configuration." + "unknown_device": "Le mod\u00e8le d'appareil n'est pas connu, impossible de configurer l'appareil \u00e0 l'aide du flux de configuration.", + "wrong_token": "Erreur de somme de contr\u00f4le, jeton incorrect" }, "flow_title": "Xiaomi Miio: {name}", "step": { diff --git a/homeassistant/components/xiaomi_miio/translations/id.json b/homeassistant/components/xiaomi_miio/translations/id.json index a6217b52eb1..4aae8d6396c 100644 --- a/homeassistant/components/xiaomi_miio/translations/id.json +++ b/homeassistant/components/xiaomi_miio/translations/id.json @@ -3,12 +3,18 @@ "abort": { "already_configured": "Perangkat sudah dikonfigurasi", "already_in_progress": "Alur konfigurasi sedang berlangsung", + "incomplete_info": "Informasi tidak lengkap untuk menyiapkan perangkat, tidak ada host atau token yang disediakan.", + "not_xiaomi_miio": "Perangkat (masih) tidak didukung oleh Xiaomi Miio.", "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "cannot_connect": "Gagal terhubung", + "cloud_credentials_incomplete": "Kredensial cloud tidak lengkap, isi nama pengguna, kata sandi, dan negara", + "cloud_login_error": "Tidak dapat masuk ke Xiaomi Miio Cloud, periksa kredensialnya.", + "cloud_no_devices": "Tidak ada perangkat yang ditemukan di akun cloud Xiaomi Miio ini.", "no_device_selected": "Tidak ada perangkat yang dipilih, pilih satu perangkat.", - "unknown_device": "Model perangkat tidak diketahui, tidak dapat menyiapkan perangkat menggunakan alur konfigurasi." + "unknown_device": "Model perangkat tidak diketahui, tidak dapat menyiapkan perangkat menggunakan alur konfigurasi.", + "wrong_token": "Kesalahan checksum, token salah" }, "flow_title": "{name}", "step": { @@ -53,9 +59,11 @@ "host": "Alamat IP", "token": "Token API" }, + "description": "Anda akan membutuhkan Token API 32 karakter, baca petunjuknya di https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token. Perhatikan bahwa Token API ini berbeda dengan kunci yang digunakan untuk integrasi Xiaomi Aqara.", "title": "Hubungkan ke Perangkat Xiaomi Miio atau Xiaomi Gateway" }, "reauth_confirm": { + "description": "Integrasi Xiaomi Miio perlu mengautentikasi ulang akun Anda untuk memperbarui token atau menambahkan kredensial cloud yang hilang.", "title": "Autentikasi Ulang Integrasi" }, "select": { @@ -75,6 +83,9 @@ } }, "options": { + "error": { + "cloud_credentials_incomplete": "Kredensial cloud tidak lengkap, isi nama pengguna, kata sandi, dan negara" + }, "step": { "init": { "data": { diff --git a/homeassistant/components/xiaomi_miio/translations/ja.json b/homeassistant/components/xiaomi_miio/translations/ja.json index a138f746baf..e9b6c7a6cc9 100644 --- a/homeassistant/components/xiaomi_miio/translations/ja.json +++ b/homeassistant/components/xiaomi_miio/translations/ja.json @@ -1,7 +1,99 @@ { "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", + "incomplete_info": "\u30c7\u30d0\u30a4\u30b9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3059\u308b\u305f\u3081\u306e\u60c5\u5831\u304c\u4e0d\u5b8c\u5168\u3067\u3059\u3002\u30db\u30b9\u30c8\u307e\u305f\u306f\u30c8\u30fc\u30af\u30f3\u304c\u63d0\u4f9b\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002", + "not_xiaomi_miio": "\u30c7\u30d0\u30a4\u30b9\u306f(\u307e\u3060) Xiaomi Miio\u3067\u30b5\u30dd\u30fc\u30c8\u3055\u308c\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", + "cloud_credentials_incomplete": "\u30af\u30e9\u30a6\u30c9\u8a8d\u8a3c\u304c\u4e0d\u5b8c\u5168\u3067\u3059\u3002\u30e6\u30fc\u30b6\u30fc\u540d\u3001\u30d1\u30b9\u30ef\u30fc\u30c9\u3001\u56fd\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "cloud_login_error": "Xiaomi Miio Cloud\u306b\u30ed\u30b0\u30a4\u30f3\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002\u8a8d\u8a3c\u60c5\u5831\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "cloud_no_devices": "\u3053\u306eXiaomi Miio cloud\u306e\u30a2\u30ab\u30a6\u30f3\u30c8\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3002", + "no_device_selected": "\u30c7\u30d0\u30a4\u30b9\u304c\u9078\u629e\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\u30c7\u30d0\u30a4\u30b9\u30921\u3064\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "unknown_device": "\u30c7\u30d0\u30a4\u30b9\u30e2\u30c7\u30eb\u304c\u4e0d\u660e\u306a\u306e\u3067\u3001\u69cb\u6210\u30d5\u30ed\u30fc\u3092\u4f7f\u7528\u3057\u3066\u30c7\u30d0\u30a4\u30b9\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3067\u304d\u307e\u305b\u3093\u3002", "wrong_token": "\u30c1\u30a7\u30c3\u30af\u30b5\u30e0\u30a8\u30e9\u30fc\u3001\u9593\u9055\u3063\u305f\u30c8\u30fc\u30af\u30f3" + }, + "flow_title": "{name}", + "step": { + "cloud": { + "data": { + "cloud_country": "\u30af\u30e9\u30a6\u30c9\u30b5\u30fc\u30d0\u30fc\u306e\u56fd", + "cloud_password": "\u30af\u30e9\u30a6\u30c9\u30d1\u30b9\u30ef\u30fc\u30c9", + "cloud_username": "\u30af\u30e9\u30a6\u30c9\u306e\u30e6\u30fc\u30b6\u30fc\u540d", + "manual": "\u624b\u52d5\u3067\u8a2d\u5b9a\u3059\u308b(\u975e\u63a8\u5968)" + }, + "description": "Xiaomi Miio cloud\u306b\u30ed\u30b0\u30a4\u30f3\u3057\u307e\u3059\u3002\u4f7f\u7528\u3059\u308b\u30af\u30e9\u30a6\u30c9\u30b5\u30fc\u30d0\u30fc\u306b\u3064\u3044\u3066\u306f\u3001https://www.openhab.org/addons/bindings/miio/#country-servers \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "Xiaomi Miio\u30c7\u30d0\u30a4\u30b9\u307e\u305f\u306f\u3001Xiaomi Gateway\u306b\u63a5\u7d9a" + }, + "connect": { + "data": { + "model": "\u30c7\u30d0\u30a4\u30b9\u30e2\u30c7\u30eb" + }, + "description": "\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u308b\u30e2\u30c7\u30eb\u304b\u3089\u30c7\u30d0\u30a4\u30b9 \u30e2\u30c7\u30eb\u3092\u624b\u52d5\u3067\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "Xiaomi Miio\u30c7\u30d0\u30a4\u30b9\u307e\u305f\u306f\u3001Xiaomi Gateway\u306b\u63a5\u7d9a" + }, + "device": { + "data": { + "host": "IP\u30a2\u30c9\u30ec\u30b9", + "model": "\u30c7\u30d0\u30a4\u30b9\u30e2\u30c7\u30eb(\u30aa\u30d7\u30b7\u30e7\u30f3)", + "name": "\u30c7\u30d0\u30a4\u30b9\u306e\u540d\u524d", + "token": "API\u30c8\u30fc\u30af\u30f3" + }, + "description": "32\u6587\u5b57\u306eAPI\u30c8\u30fc\u30af\u30f3\u304c\u5fc5\u8981\u306b\u306a\u308a\u307e\u3059\u3002\u624b\u9806\u306f\u3001https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u6ce8\u610f\u4e8b\u9805: \u3053\u306eAPI\u30c8\u30fc\u30af\u30f3\u306f\u3001Xiaomi Aqara\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u4f7f\u7528\u3055\u308c\u3066\u3044\u308b\u30ad\u30fc\u3068\u306f\u7570\u306a\u308a\u307e\u3059\u3002", + "title": "Xiaomi Miio\u30c7\u30d0\u30a4\u30b9\u307e\u305f\u306f\u3001Xiaomi Gateway\u306b\u63a5\u7d9a" + }, + "gateway": { + "data": { + "host": "IP\u30a2\u30c9\u30ec\u30b9", + "name": "\u30b2\u30fc\u30c8\u30a6\u30a7\u30a4\u306e\u540d\u524d", + "token": "API\u30c8\u30fc\u30af\u30f3" + }, + "description": "32\u6587\u5b57\u306eAPI\u30c8\u30fc\u30af\u30f3\u304c\u5fc5\u8981\u306b\u306a\u308a\u307e\u3059\u3002\u624b\u9806\u306f\u3001https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u6ce8\u610f\u4e8b\u9805: \u3053\u306eAPI\u30c8\u30fc\u30af\u30f3\u306f\u3001Xiaomi Aqara\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u4f7f\u7528\u3055\u308c\u3066\u3044\u308b\u30ad\u30fc\u3068\u306f\u7570\u306a\u308a\u307e\u3059\u3002", + "title": "Xiaomi Gateway\u306b\u63a5\u7d9a" + }, + "manual": { + "data": { + "host": "IP\u30a2\u30c9\u30ec\u30b9", + "token": "API\u30c8\u30fc\u30af\u30f3" + }, + "description": "32\u6587\u5b57\u306eAPI\u30c8\u30fc\u30af\u30f3\u304c\u5fc5\u8981\u306b\u306a\u308a\u307e\u3059\u3002\u624b\u9806\u306f\u3001https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u6ce8\u610f\u4e8b\u9805: \u3053\u306eAPI\u30c8\u30fc\u30af\u30f3\u306f\u3001Xiaomi Aqara\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u4f7f\u7528\u3055\u308c\u3066\u3044\u308b\u30ad\u30fc\u3068\u306f\u7570\u306a\u308a\u307e\u3059\u3002", + "title": "Xiaomi Miio\u30c7\u30d0\u30a4\u30b9\u307e\u305f\u306f\u3001Xiaomi Gateway\u306b\u63a5\u7d9a" + }, + "reauth_confirm": { + "description": "Xiaomi Miio\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u306f\u3001\u30c8\u30fc\u30af\u30f3\u3092\u66f4\u65b0\u3057\u305f\u308a\u3001\u4e0d\u8db3\u3057\u3066\u3044\u308b\u30af\u30e9\u30a6\u30c9\u306e\u8cc7\u683c\u60c5\u5831\u3092\u8ffd\u52a0\u3059\u308b\u305f\u3081\u306b\u3001\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u518d\u8a8d\u8a3c\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, + "select": { + "data": { + "select_device": "Miio\u30c7\u30d0\u30a4\u30b9" + }, + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3059\u308bXiaomi Miio\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "Xiaomi Miio\u30c7\u30d0\u30a4\u30b9\u307e\u305f\u306f\u3001Xiaomi Gateway\u306b\u63a5\u7d9a" + }, + "user": { + "data": { + "gateway": "Xiaomi Gateway\u306b\u63a5\u7d9a" + }, + "description": "\u63a5\u7d9a\u3059\u308b\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e\u3057\u307e\u3059\u3002", + "title": "Xiaomi Miio" + } + } + }, + "options": { + "error": { + "cloud_credentials_incomplete": "\u30af\u30e9\u30a6\u30c9\u8a8d\u8a3c\u304c\u4e0d\u5b8c\u5168\u3067\u3059\u3002\u30e6\u30fc\u30b6\u30fc\u540d\u3001\u30d1\u30b9\u30ef\u30fc\u30c9\u3001\u56fd\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "step": { + "init": { + "data": { + "cloud_subdevices": "\u30af\u30e9\u30a6\u30c9\u3092\u4f7f\u7528\u3057\u3066\u63a5\u7d9a\u3055\u308c\u305f\u30b5\u30d6\u30c7\u30d0\u30a4\u30b9\u3092\u53d6\u5f97\u3059\u308b" + }, + "description": "\u30aa\u30d7\u30b7\u30e7\u30f3\u8a2d\u5b9a\u306e\u6307\u5b9a", + "title": "Xiaomi Miio" + } } } } \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/pl.json b/homeassistant/components/xiaomi_miio/translations/pl.json index 879d0b8d7ba..60c82020a77 100644 --- a/homeassistant/components/xiaomi_miio/translations/pl.json +++ b/homeassistant/components/xiaomi_miio/translations/pl.json @@ -13,7 +13,8 @@ "cloud_login_error": "Nie mo\u017cna zalogowa\u0107 si\u0119 do chmury Xiaomi Miio, sprawd\u017a po\u015bwiadczenia.", "cloud_no_devices": "Na tym koncie Xiaomi Miio nie znaleziono \u017cadnych urz\u0105dze\u0144.", "no_device_selected": "Nie wybrano \u017cadnego urz\u0105dzenia, wybierz jedno urz\u0105dzenie", - "unknown_device": "Model urz\u0105dzenia nie jest znany, nie mo\u017cna skonfigurowa\u0107 urz\u0105dzenia przy u\u017cyciu interfejsu u\u017cytkownika." + "unknown_device": "Model urz\u0105dzenia nie jest znany, nie mo\u017cna skonfigurowa\u0107 urz\u0105dzenia przy u\u017cyciu interfejsu u\u017cytkownika.", + "wrong_token": "B\u0142\u0105d sumy kontrolnej, niew\u0142a\u015bciwy token" }, "flow_title": "{name}", "step": { diff --git a/homeassistant/components/xiaomi_miio/translations/select.ca.json b/homeassistant/components/xiaomi_miio/translations/select.ca.json index eb54883ffa6..bc96de04645 100644 --- a/homeassistant/components/xiaomi_miio/translations/select.ca.json +++ b/homeassistant/components/xiaomi_miio/translations/select.ca.json @@ -3,7 +3,7 @@ "xiaomi_miio__led_brightness": { "bright": "Brillant", "dim": "Atenua", - "off": "off" + "off": "OFF" } } } \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.ja.json b/homeassistant/components/xiaomi_miio/translations/select.ja.json new file mode 100644 index 00000000000..22a7a4ea058 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.ja.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "\u660e\u308b\u3044", + "dim": "\u8584\u6697\u3044", + "off": "\u30aa\u30d5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.tr.json b/homeassistant/components/xiaomi_miio/translations/select.tr.json new file mode 100644 index 00000000000..7767a54fe2d --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.tr.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Ayd\u0131nl\u0131k", + "dim": "Dim", + "off": "Kapal\u0131" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/sl.json b/homeassistant/components/xiaomi_miio/translations/sl.json index 45aad4ab691..472317182bf 100644 --- a/homeassistant/components/xiaomi_miio/translations/sl.json +++ b/homeassistant/components/xiaomi_miio/translations/sl.json @@ -4,7 +4,8 @@ "already_configured": "Naprava je \u017ee konfigurirana" }, "error": { - "no_device_selected": "Izbrana ni nobena naprava, izberite eno napravo." + "no_device_selected": "Izbrana ni nobena naprava, izberite eno napravo.", + "wrong_token": "Napaka kontrolne vsote, napa\u010den \u017eeton" }, "step": { "gateway": { diff --git a/homeassistant/components/xiaomi_miio/translations/tr.json b/homeassistant/components/xiaomi_miio/translations/tr.json index 3dbf08bd6f1..097ccdab6ac 100644 --- a/homeassistant/components/xiaomi_miio/translations/tr.json +++ b/homeassistant/components/xiaomi_miio/translations/tr.json @@ -2,17 +2,48 @@ "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" + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "incomplete_info": "Kurulum cihaz\u0131 i\u00e7in eksik bilgi, ana bilgisayar veya anahtar sa\u011flanmad\u0131.", + "not_xiaomi_miio": "Cihaz (hen\u00fcz) Xiaomi Miio taraf\u0131ndan desteklenmiyor.", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", - "no_device_selected": "Cihaz se\u00e7ilmedi, l\u00fctfen bir cihaz se\u00e7in." + "cloud_credentials_incomplete": "Bulut kimlik bilgileri eksik, l\u00fctfen kullan\u0131c\u0131 ad\u0131n\u0131, \u015fifreyi ve \u00fclkeyi girin", + "cloud_login_error": "Xiaomi Miio Cloud'da oturum a\u00e7\u0131lamad\u0131, kimlik bilgilerini kontrol edin.", + "cloud_no_devices": "Bu Xiaomi Miio bulut hesab\u0131nda cihaz bulunamad\u0131.", + "no_device_selected": "Cihaz se\u00e7ilmedi, l\u00fctfen bir cihaz se\u00e7in.", + "unknown_device": "Cihaz modeli bilinmiyor, cihaz yap\u0131land\u0131rma ak\u0131\u015f\u0131n\u0131 kullanarak kurulam\u0131yor.", + "wrong_token": "Sa\u011flama toplam\u0131 hatas\u0131, yanl\u0131\u015f anahtar" }, + "flow_title": "{name}", "step": { + "cloud": { + "data": { + "cloud_country": "Bulut sunucusu \u00fclkesi", + "cloud_password": "Bulut \u015fifresi", + "cloud_username": "Bulut kullan\u0131c\u0131 ad\u0131", + "manual": "Manuel olarak yap\u0131land\u0131r\u0131n (\u00f6nerilmez)" + }, + "description": "Xiaomi Miio bulutunda oturum a\u00e7\u0131n, bulut sunucusunun kullanmas\u0131 i\u00e7in https://www.openhab.org/addons/bindings/miio/#country-servers adresine bak\u0131n.", + "title": "Bir Xiaomi Miio Cihaz\u0131na veya Xiaomi A\u011f Ge\u00e7idine Ba\u011flan" + }, + "connect": { + "data": { + "model": "Cihaz modeli" + }, + "description": "Desteklenen modellerden cihaz modelini manuel olarak se\u00e7in.", + "title": "Bir Xiaomi Miio Cihaz\u0131na veya Xiaomi A\u011f Ge\u00e7idine Ba\u011flan" + }, "device": { "data": { - "name": "Cihaz\u0131n ad\u0131" - } + "host": "IP Adresi", + "model": "Cihaz modeli (Opsiyonel)", + "name": "Cihaz\u0131n ad\u0131", + "token": "API Anahtar\u0131" + }, + "description": "32 karaktere API Anahtar\u0131 , talimatlar i\u00e7in https://www.home-assistant.io/integrations/xiaomi_miio#retriiving-the-access-token adresine bak\u0131n. L\u00fctfen bu API Anahtar\u0131 \u00f6\u011fesinin Xiaomi Aqara entegrasyonu taraf\u0131ndan kullan\u0131lan anahtardan farkl\u0131 oldu\u011funu unutmay\u0131n.", + "title": "Bir Xiaomi Miio Cihaz\u0131na veya Xiaomi A\u011f Ge\u00e7idine Ba\u011flan" }, "gateway": { "data": { @@ -20,8 +51,28 @@ "name": "A\u011f Ge\u00e7idinin Ad\u0131", "token": "API Belirteci" }, + "description": "32 karaktere API Anahtar\u0131 , bkz. talimatlar i\u00e7in. L\u00fctfen bu API Anahtar\u0131 \u00f6\u011fesinin Xiaomi Aqara entegrasyonu taraf\u0131ndan kullan\u0131lan anahtardan farkl\u0131 oldu\u011funu unutmay\u0131n.", "title": "Bir Xiaomi A\u011f Ge\u00e7idine ba\u011flan\u0131n" }, + "manual": { + "data": { + "host": "IP Adresi", + "token": "API Anahtar\u0131" + }, + "description": "32 karaktere API Anahtar\u0131 , talimatlar i\u00e7in https://www.home-assistant.io/integrations/xiaomi_miio#retriiving-the-access-token adresine bak\u0131n. L\u00fctfen bu API Anahtar\u0131 \u00f6\u011fesinin Xiaomi Aqara entegrasyonu taraf\u0131ndan kullan\u0131lan anahtardan farkl\u0131 oldu\u011funu unutmay\u0131n.", + "title": "Bir Xiaomi Miio Cihaz\u0131na veya Xiaomi A\u011f Ge\u00e7idine Ba\u011flan" + }, + "reauth_confirm": { + "description": "Anahtarlar\u0131 g\u00fcncellemek veya eksik bulut kimlik bilgilerini eklemek i\u00e7in Xiaomi Miio entegrasyonunun hesab\u0131n\u0131z\u0131 yeniden do\u011frulamas\u0131 gerekir.", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, + "select": { + "data": { + "select_device": "Miio cihaz\u0131" + }, + "description": "Kurulumu i\u00e7in Xiaomi Miio cihaz\u0131n\u0131 se\u00e7in.", + "title": "Bir Xiaomi Miio Cihaz\u0131na veya Xiaomi A\u011f Ge\u00e7idine Ba\u011flan" + }, "user": { "data": { "gateway": "Bir Xiaomi A\u011f Ge\u00e7idine ba\u011flan\u0131n" @@ -30,5 +81,19 @@ "title": "Xiaomi Miio" } } + }, + "options": { + "error": { + "cloud_credentials_incomplete": "Bulut kimlik bilgileri eksik, l\u00fctfen kullan\u0131c\u0131 ad\u0131n\u0131, \u015fifreyi ve \u00fclkeyi girin" + }, + "step": { + "init": { + "data": { + "cloud_subdevices": "Ba\u011fl\u0131 alt cihazlar almak i\u00e7in bulutu kullan\u0131n" + }, + "description": "\u0130ste\u011fe ba\u011fl\u0131 ayarlar\u0131 belirtin", + "title": "Xiaomi Miio" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index 60d557837fb..2362fcf8996 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -92,6 +92,7 @@ STATE_CODE_TO_STATE = { 16: STATE_CLEANING, # "Going to target" 17: STATE_CLEANING, # "Zoned cleaning" 18: STATE_CLEANING, # "Segment cleaning" + 22: STATE_DOCKED, # "Emptying the bin" on s7+ 100: STATE_DOCKED, # "Charging complete" 101: STATE_ERROR, # "Device offline" } diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py index ae5596ee2e1..4011e7dfbdc 100644 --- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py +++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py @@ -12,15 +12,7 @@ from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_HOME, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_NAME, - CONF_NAME, - CONF_PASSWORD, - CONF_USERNAME, -) +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo @@ -96,12 +88,12 @@ class YaleAlarmDevice(CoordinatorEntity, AlarmControlPanelEntity): @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - return { - ATTR_NAME: str(self.name), - ATTR_MANUFACTURER: MANUFACTURER, - ATTR_MODEL: MODEL, - ATTR_IDENTIFIERS: {(DOMAIN, self._identifier)}, - } + return DeviceInfo( + identifiers={(DOMAIN, self._identifier)}, + manufacturer=MANUFACTURER, + model=MODEL, + name=str(self.name), + ) @property def state(self): diff --git a/homeassistant/components/yale_smart_alarm/const.py b/homeassistant/components/yale_smart_alarm/const.py index 618f9ad073a..cbb96579f4f 100644 --- a/homeassistant/components/yale_smart_alarm/const.py +++ b/homeassistant/components/yale_smart_alarm/const.py @@ -25,7 +25,7 @@ COORDINATOR = "coordinator" DEFAULT_SCAN_INTERVAL = 15 -LOGGER = logging.getLogger(__name__) +LOGGER = logging.getLogger(__package__) ATTR_ONLINE = "online" ATTR_STATUS = "status" diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py index 8b09507e956..3cef1876e3a 100644 --- a/homeassistant/components/yale_smart_alarm/coordinator.py +++ b/homeassistant/components/yale_smart_alarm/coordinator.py @@ -3,12 +3,17 @@ from __future__ import annotations from datetime import timedelta +import requests from yalesmartalarmclient.client import AuthenticationError, YaleSmartAlarmClient -from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import ( + ConfigEntryAuthFailed, + DataUpdateCoordinator, + UpdateFailed, +) from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER @@ -40,14 +45,28 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator): if device["type"] == "device_type.door_lock": lock_status_str = device["minigw_lock_status"] lock_status = int(str(lock_status_str or 0), 16) + jammed = (lock_status & 48) == 48 closed = (lock_status & 16) == 16 locked = (lock_status & 1) == 1 if not lock_status and "device_status.lock" in state: device["_state"] = "locked" + device["_state2"] = "unknown" locks.append(device) continue if not lock_status and "device_status.unlock" in state: device["_state"] = "unlocked" + device["_state2"] = "unknown" + locks.append(device) + continue + if ( + lock_status + and ( + "device_status.lock" in state or "device_status.unlock" in state + ) + and jammed + ): + device["_state"] = "jammed" + device["_state2"] = "closed" locks.append(device) continue if ( @@ -59,6 +78,7 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator): and locked ): device["_state"] = "locked" + device["_state2"] = "closed" locks.append(device) continue if ( @@ -70,6 +90,7 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator): and not locked ): device["_state"] = "unlocked" + device["_state2"] = "closed" locks.append(device) continue if ( @@ -80,6 +101,7 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator): and not closed ): device["_state"] = "unlocked" + device["_state2"] = "open" locks.append(device) continue device["_state"] = "unavailable" @@ -110,9 +132,16 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator): """Fetch data from Yale.""" if self.yale is None: - self.yale = YaleSmartAlarmClient( - self.entry.data[CONF_USERNAME], self.entry.data[CONF_PASSWORD] - ) + try: + self.yale = YaleSmartAlarmClient( + self.entry.data[CONF_USERNAME], self.entry.data[CONF_PASSWORD] + ) + except AuthenticationError as error: + raise ConfigEntryAuthFailed from error + except requests.HTTPError as error: + if error.response.status_code == 401: + raise ConfigEntryAuthFailed from error + raise UpdateFailed from error try: arm_status = self.yale.get_armed_status() @@ -121,14 +150,12 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator): online = self.yale.get_online() except AuthenticationError as error: - LOGGER.error("Authentication failed. Check credentials %s", error) - self.hass.async_create_task( - self.hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_REAUTH, "entry_id": self.entry.entry_id}, - data=self.entry.data, - ) - ) + raise ConfigEntryAuthFailed from error + except requests.HTTPError as error: + if error.response.status_code == 401: + raise ConfigEntryAuthFailed from error + raise UpdateFailed from error + except requests.RequestException as error: raise UpdateFailed from error return { diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index 4fb61f5a5f1..cec588a3cc8 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" diff --git a/homeassistant/components/yale_smart_alarm/translations/ca.json b/homeassistant/components/yale_smart_alarm/translations/ca.json index 04e894afe1b..6e14f2d6e20 100644 --- a/homeassistant/components/yale_smart_alarm/translations/ca.json +++ b/homeassistant/components/yale_smart_alarm/translations/ca.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "El compte ja est\u00e0 configurat" + "already_configured": "El compte ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" diff --git a/homeassistant/components/yale_smart_alarm/translations/de.json b/homeassistant/components/yale_smart_alarm/translations/de.json index b3434a70b7e..6050bafa645 100644 --- a/homeassistant/components/yale_smart_alarm/translations/de.json +++ b/homeassistant/components/yale_smart_alarm/translations/de.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Konto wurde bereits konfiguriert" + "already_configured": "Konto wurde bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "invalid_auth": "Ung\u00fcltige Authentifizierung" diff --git a/homeassistant/components/yale_smart_alarm/translations/en.json b/homeassistant/components/yale_smart_alarm/translations/en.json index a439971fb3f..e198b0329b9 100644 --- a/homeassistant/components/yale_smart_alarm/translations/en.json +++ b/homeassistant/components/yale_smart_alarm/translations/en.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Account is already configured" + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { "invalid_auth": "Invalid authentication" diff --git a/homeassistant/components/yale_smart_alarm/translations/et.json b/homeassistant/components/yale_smart_alarm/translations/et.json index e773e628d1e..dd55b1ebd7d 100644 --- a/homeassistant/components/yale_smart_alarm/translations/et.json +++ b/homeassistant/components/yale_smart_alarm/translations/et.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Konto on juba seadistatud" + "already_configured": "Konto on juba seadistatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { "invalid_auth": "Tuvastamine nurjus" diff --git a/homeassistant/components/yale_smart_alarm/translations/hu.json b/homeassistant/components/yale_smart_alarm/translations/hu.json index 8c60574227d..6845e245f2d 100644 --- a/homeassistant/components/yale_smart_alarm/translations/hu.json +++ b/homeassistant/components/yale_smart_alarm/translations/hu.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." }, "error": { "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" diff --git a/homeassistant/components/yale_smart_alarm/translations/id.json b/homeassistant/components/yale_smart_alarm/translations/id.json index ee24f03a33c..37b88ddc68c 100644 --- a/homeassistant/components/yale_smart_alarm/translations/id.json +++ b/homeassistant/components/yale_smart_alarm/translations/id.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Akun sudah dikonfigurasi" + "already_configured": "Akun sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "invalid_auth": "Autentikasi tidak valid" diff --git a/homeassistant/components/yale_smart_alarm/translations/ja.json b/homeassistant/components/yale_smart_alarm/translations/ja.json new file mode 100644 index 00000000000..53d868fe351 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/ja.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "\u30a8\u30ea\u30a2ID", + "name": "\u540d\u524d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + }, + "user": { + "data": { + "area_id": "\u30a8\u30ea\u30a2ID", + "name": "\u540d\u524d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/nl.json b/homeassistant/components/yale_smart_alarm/translations/nl.json index 53c1b8fb086..bf2f3409e42 100644 --- a/homeassistant/components/yale_smart_alarm/translations/nl.json +++ b/homeassistant/components/yale_smart_alarm/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Account is al geconfigureerd" + "already_configured": "Account is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { "invalid_auth": "Ongeldige authenticatie" diff --git a/homeassistant/components/yale_smart_alarm/translations/no.json b/homeassistant/components/yale_smart_alarm/translations/no.json index eba8861fa46..4d306dc3cad 100644 --- a/homeassistant/components/yale_smart_alarm/translations/no.json +++ b/homeassistant/components/yale_smart_alarm/translations/no.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Kontoen er allerede konfigurert" + "already_configured": "Kontoen er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning" diff --git a/homeassistant/components/yale_smart_alarm/translations/pl.json b/homeassistant/components/yale_smart_alarm/translations/pl.json index 553d05ee439..b409b7026c1 100644 --- a/homeassistant/components/yale_smart_alarm/translations/pl.json +++ b/homeassistant/components/yale_smart_alarm/translations/pl.json @@ -18,7 +18,7 @@ "user": { "data": { "area_id": "Identyfikator obszaru", - "name": "[%key::common::config_flow::data::name%]", + "name": "Nazwa", "password": "Has\u0142o", "username": "Nazwa u\u017cytkownika" } diff --git a/homeassistant/components/yale_smart_alarm/translations/ru.json b/homeassistant/components/yale_smart_alarm/translations/ru.json index 1f2410be1dc..4a9132c7546 100644 --- a/homeassistant/components/yale_smart_alarm/translations/ru.json +++ b/homeassistant/components/yale_smart_alarm/translations/ru.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\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." + "already_configured": "\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.", + "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": { "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." diff --git a/homeassistant/components/yale_smart_alarm/translations/tr.json b/homeassistant/components/yale_smart_alarm/translations/tr.json index e1780e06fce..24b37440160 100644 --- a/homeassistant/components/yale_smart_alarm/translations/tr.json +++ b/homeassistant/components/yale_smart_alarm/translations/tr.json @@ -1,10 +1,17 @@ { "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" + }, "step": { "reauth_confirm": { "data": { "area_id": "Alan Kodu", - "name": "\u0130sim", + "name": "Ad", "password": "\u015eifre", "username": "Kullan\u0131c\u0131 Ad\u0131" } diff --git a/homeassistant/components/yale_smart_alarm/translations/zh-Hant.json b/homeassistant/components/yale_smart_alarm/translations/zh-Hant.json index e02b74f27a1..5d7c14b07b2 100644 --- a/homeassistant/components/yale_smart_alarm/translations/zh-Hant.json +++ b/homeassistant/components/yale_smart_alarm/translations/zh-Hant.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 9f691773e11..194168b2eee 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -5,6 +5,7 @@ from datetime import timedelta import logging from aiomusiccast import MusicCastConnectionException +from aiomusiccast.capabilities import Capability from aiomusiccast.musiccast_device import MusicCastData, MusicCastDevice from homeassistant.components import ssdp @@ -20,9 +21,16 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import BRAND, CONF_SERIAL, CONF_UPNP_DESC, DOMAIN +from .const import ( + BRAND, + CONF_SERIAL, + CONF_UPNP_DESC, + DEFAULT_ZONE, + DOMAIN, + ENTITY_CATEGORY_MAPPING, +) -PLATFORMS = ["media_player"] +PLATFORMS = ["media_player", "number"] _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=60) @@ -31,11 +39,10 @@ SCAN_INTERVAL = timedelta(seconds=60) async def get_upnp_desc(hass: HomeAssistant, host: str): """Get the upnp description URL for a given host, using the SSPD scanner.""" ssdp_entries = await ssdp.async_get_discovery_info_by_st(hass, "upnp:rootdevice") - matches = [w for w in ssdp_entries if w.get("_host", "") == host] + matches = [w for w in ssdp_entries if w.ssdp_headers.get("_host", "") == host] upnp_desc = None for match in matches: - if match.get(ssdp.ATTR_SSDP_LOCATION): - upnp_desc = match[ssdp.ATTR_SSDP_LOCATION] + if upnp_desc := match.ssdp_location: break if not upnp_desc: @@ -66,10 +73,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) coordinator = MusicCastDataUpdateCoordinator(hass, client=client) await coordinator.async_config_entry_first_refresh() + coordinator.musiccast.build_capabilities() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator + await coordinator.musiccast.device.enable_polling() + hass.config_entries.async_setup_platforms(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) @@ -80,6 +90,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: + hass.data[DOMAIN][entry.entry_id].musiccast.device.disable_polling() hass.data[DOMAIN].pop(entry.entry_id) return unload_ok @@ -147,22 +158,76 @@ class MusicCastEntity(CoordinatorEntity): class MusicCastDeviceEntity(MusicCastEntity): """Defines a MusicCast device entity.""" + _zone_id: str = DEFAULT_ZONE + + @property + def device_id(self): + """Return the ID of the current device.""" + if self._zone_id == DEFAULT_ZONE: + return self.coordinator.data.device_id + return f"{self.coordinator.data.device_id}_{self._zone_id}" + + @property + def device_name(self): + """Return the name of the current device.""" + return self.coordinator.data.zones[self._zone_id].name + @property def device_info(self) -> DeviceInfo: """Return device information about this MusicCast device.""" - return DeviceInfo( - connections={ - (CONNECTION_NETWORK_MAC, format_mac(mac)) - for mac in self.coordinator.data.mac_addresses.values() - }, + + device_info = DeviceInfo( + name=self.device_name, identifiers={ ( DOMAIN, - self.coordinator.data.device_id, + self.device_id, ) }, - name=self.coordinator.data.network_name, manufacturer=BRAND, model=self.coordinator.data.model_name, sw_version=self.coordinator.data.system_version, ) + + if self._zone_id == DEFAULT_ZONE: + device_info["connections"] = { + (CONNECTION_NETWORK_MAC, format_mac(mac)) + for mac in self.coordinator.data.mac_addresses.values() + } + else: + device_info["via_device"] = (DOMAIN, self.coordinator.data.device_id) + + return device_info + + +class MusicCastCapabilityEntity(MusicCastDeviceEntity): + """Base Entity type for all capabilities.""" + + def __init__( + self, + coordinator: MusicCastDataUpdateCoordinator, + capability: Capability, + zone_id: str = None, + ) -> None: + """Initialize a capability based entity.""" + if zone_id is not None: + self._zone_id = zone_id + self.capability = capability + super().__init__(name=capability.name, icon="", coordinator=coordinator) + self._attr_entity_category = ENTITY_CATEGORY_MAPPING.get(capability.entity_type) + + async def async_added_to_hass(self): + """Run when this Entity has been added to HA.""" + await super().async_added_to_hass() + # All capability based entities should register callbacks to update HA when their state changes + self.coordinator.musiccast.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self): + """Entity being removed from hass.""" + await super().async_added_to_hass() + self.coordinator.musiccast.remove_callback(self.async_write_ha_state) + + @property + def unique_id(self) -> str: + """Return the unique ID for this entity.""" + return f"{self.device_id}_{self.capability.id}" diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index 6645af20d83..4049a5d6a37 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -82,16 +82,23 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors or {}, ) - async def async_step_ssdp(self, discovery_info) -> data_entry_flow.FlowResult: + async def async_step_ssdp( + self, discovery_info: ssdp.SsdpServiceInfo + ) -> data_entry_flow.FlowResult: """Handle ssdp discoveries.""" if not await MusicCastDevice.check_yamaha_ssdp( - discovery_info[ssdp.ATTR_SSDP_LOCATION], async_get_clientsession(self.hass) + discovery_info.ssdp_location, async_get_clientsession(self.hass) ): return self.async_abort(reason="yxc_control_url_missing") - self.serial_number = discovery_info[ssdp.ATTR_UPNP_SERIAL] - self.host = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname - self.upnp_description = discovery_info[ssdp.ATTR_SSDP_LOCATION] + self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + self.upnp_description = discovery_info.ssdp_location + + # ssdp_location and hostname have been checked in check_yamaha_ssdp so it is safe to ignore type assignment + self.host = urlparse( + discovery_info.ssdp_location + ).hostname # type: ignore[assignment] + await self.async_set_unique_id(self.serial_number) self._abort_if_unique_id_configured( { @@ -102,7 +109,9 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): self.context.update( { "title_placeholders": { - "name": discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host) + "name": discovery_info.upnp.get( + ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host + ) } } ) diff --git a/homeassistant/components/yamaha_musiccast/const.py b/homeassistant/components/yamaha_musiccast/const.py index 55ce3920fa1..5384cc56694 100644 --- a/homeassistant/components/yamaha_musiccast/const.py +++ b/homeassistant/components/yamaha_musiccast/const.py @@ -1,5 +1,7 @@ """Constants for the MusicCast integration.""" +from aiomusiccast.capabilities import EntityType + from homeassistant.components.media_player.const import ( MEDIA_CLASS_DIRECTORY, MEDIA_CLASS_TRACK, @@ -7,6 +9,11 @@ from homeassistant.components.media_player.const import ( REPEAT_MODE_OFF, REPEAT_MODE_ONE, ) +from homeassistant.const import ( + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, + ENTITY_CATEGORY_SYSTEM, +) DOMAIN = "yamaha_musiccast" @@ -42,3 +49,10 @@ MEDIA_CLASS_MAPPING = { "directory": MEDIA_CLASS_DIRECTORY, "categories": MEDIA_CLASS_DIRECTORY, } + +ENTITY_CATEGORY_MAPPING = { + EntityType.CONFIG: ENTITY_CATEGORY_CONFIG, + EntityType.REGULAR: None, + EntityType.DIAGNOSTIC: ENTITY_CATEGORY_DIAGNOSTIC, + EntityType.SYSTEM: ENTITY_CATEGORY_SYSTEM, +} diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json index 0ace71dc7dd..329fa2354d5 100644 --- a/homeassistant/components/yamaha_musiccast/manifest.json +++ b/homeassistant/components/yamaha_musiccast/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", "requirements": [ - "aiomusiccast==0.11.0" + "aiomusiccast==0.14.2" ], "ssdp": [ { diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index 502a0b0c3f1..b1d0bdcd2e9 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -342,9 +342,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): parts = media_id.split(":") if parts[0] == "list": - index = parts[3] - - if index == "-1": + if (index := parts[3]) == "-1": index = "0" await self.coordinator.musiccast.play_list_media(index, self._zone_id) diff --git a/homeassistant/components/yamaha_musiccast/number.py b/homeassistant/components/yamaha_musiccast/number.py new file mode 100644 index 00000000000..daef8bacd12 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/number.py @@ -0,0 +1,62 @@ +"""Number entities for musiccast.""" + +from aiomusiccast.capabilities import NumberSetter + +from homeassistant.components.number import NumberEntity +from homeassistant.components.yamaha_musiccast import ( + DOMAIN, + MusicCastCapabilityEntity, + MusicCastDataUpdateCoordinator, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MusicCast number entities based on a config entry.""" + coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + number_entities = [] + + for capability in coordinator.data.capabilities: + if isinstance(capability, NumberSetter): + number_entities.append(NumberCapability(coordinator, capability)) + + for zone, data in coordinator.data.zones.items(): + for capability in data.capabilities: + if isinstance(capability, NumberSetter): + number_entities.append(NumberCapability(coordinator, capability, zone)) + + async_add_entities(number_entities) + + +class NumberCapability(MusicCastCapabilityEntity, NumberEntity): + """Representation of a MusicCast Number entity.""" + + capability: NumberSetter + + def __init__( + self, + coordinator: MusicCastDataUpdateCoordinator, + capability: NumberSetter, + zone_id: str = None, + ) -> None: + """Initialize the number entity.""" + super().__init__(coordinator, capability, zone_id) + self._attr_min_value = capability.value_range.minimum + self._attr_max_value = capability.value_range.maximum + self._attr_step = capability.value_range.step + + @property + def value(self): + """Return the current value.""" + return self.capability.current + + async def async_set_value(self, value: float): + """Set a new value.""" + await self.capability.set(value) diff --git a/homeassistant/components/yamaha_musiccast/translations/bg.json b/homeassistant/components/yamaha_musiccast/translations/bg.json index 6814831ecff..eec876d2be6 100644 --- a/homeassistant/components/yamaha_musiccast/translations/bg.json +++ b/homeassistant/components/yamaha_musiccast/translations/bg.json @@ -4,6 +4,9 @@ "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" }, "step": { + "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\u0432\u0430\u043d\u0435\u0442\u043e?" + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442" diff --git a/homeassistant/components/yamaha_musiccast/translations/id.json b/homeassistant/components/yamaha_musiccast/translations/id.json index 72a79af2041..9f1a68abdfd 100644 --- a/homeassistant/components/yamaha_musiccast/translations/id.json +++ b/homeassistant/components/yamaha_musiccast/translations/id.json @@ -1,7 +1,11 @@ { "config": { "abort": { - "already_configured": "Perangkat sudah dikonfigurasi" + "already_configured": "Perangkat sudah dikonfigurasi", + "yxc_control_url_missing": "URL kontrol tidak diberikan dalam deskripsi SSDP." + }, + "error": { + "no_musiccast_device": "Perangkat ini tampaknya bukan Perangkat MusicCast." }, "flow_title": "MusicCast: {name}", "step": { @@ -11,7 +15,8 @@ "user": { "data": { "host": "Host" - } + }, + "description": "Siapkan MusicCast untuk diintegrasikan dengan Home Assistant." } } } diff --git a/homeassistant/components/yamaha_musiccast/translations/ja.json b/homeassistant/components/yamaha_musiccast/translations/ja.json new file mode 100644 index 00000000000..3ddd882479e --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/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", + "yxc_control_url_missing": "\u30b3\u30f3\u30c8\u30ed\u30fc\u30ebURL\u306f\u3001ssdp\u306e\u8a18\u8ff0\u306b\u3042\u308a\u307e\u305b\u3093\u3002" + }, + "error": { + "no_musiccast_device": "\u3053\u306e\u30c7\u30d0\u30a4\u30b9\u306fMusicCast\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u306a\u3044\u3088\u3046\u3067\u3059\u3002" + }, + "flow_title": "MusicCast: {name}", + "step": { + "confirm": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "MusicCast\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u3066\u3001Home Assistant\u3068\u9023\u643a\u3059\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/ru.json b/homeassistant/components/yamaha_musiccast/translations/ru.json index 161a97ad15d..a09b1b4cf3c 100644 --- a/homeassistant/components/yamaha_musiccast/translations/ru.json +++ b/homeassistant/components/yamaha_musiccast/translations/ru.json @@ -16,7 +16,7 @@ "data": { "host": "\u0425\u043e\u0441\u0442" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 MusicCast." + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 MusicCast." } } } diff --git a/homeassistant/components/yamaha_musiccast/translations/tr.json b/homeassistant/components/yamaha_musiccast/translations/tr.json new file mode 100644 index 00000000000..37be5646212 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/tr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "yxc_control_url_missing": "Kontrol URL'si ssdp a\u00e7\u0131klamas\u0131nda verilmez." + }, + "error": { + "no_musiccast_device": "Bu cihaz, MusicCast Cihaz\u0131 de\u011fil gibi g\u00f6r\u00fcn\u00fcyor." + }, + "flow_title": "MusicCast: {name}", + "step": { + "confirm": { + "description": "Kuruluma ba\u015flamak ister misiniz?" + }, + "user": { + "data": { + "host": "Ana bilgisayar" + }, + "description": "Home Assistant ile entegre etmek i\u00e7in MusicCast'i kurun." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yandex_transport/manifest.json b/homeassistant/components/yandex_transport/manifest.json index 680336fe47b..22872259a6f 100644 --- a/homeassistant/components/yandex_transport/manifest.json +++ b/homeassistant/components/yandex_transport/manifest.json @@ -2,7 +2,7 @@ "domain": "yandex_transport", "name": "Yandex Transport", "documentation": "https://www.home-assistant.io/integrations/yandex_transport", - "requirements": ["aioymaps==1.2.1"], + "requirements": ["aioymaps==1.2.2"], "codeowners": ["@rishatik92", "@devbis"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index bd5d85d3ffe..724fca14725 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -129,9 +129,7 @@ class DiscoverYandexTransport(SensorEntity): if closer_time is None: self._state = None else: - self._state = dt_util.utc_from_timestamp(closer_time).isoformat( - timespec="seconds" - ) + self._state = dt_util.utc_from_timestamp(closer_time).replace(microsecond=0) self._attrs = attrs @property diff --git a/homeassistant/components/yandextts/tts.py b/homeassistant/components/yandextts/tts.py index ec0868b2443..4cbbc679e42 100644 --- a/homeassistant/components/yandextts/tts.py +++ b/homeassistant/components/yandextts/tts.py @@ -121,7 +121,7 @@ class YandexSpeechKitProvider(Provider): options = options or {} try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): url_param = { "text": message, "lang": actual_language, diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 1408cb56709..d1b8e9d4f46 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -2,19 +2,12 @@ from __future__ import annotations import asyncio -import contextlib -from datetime import timedelta -from ipaddress import IPv4Address, IPv6Address import logging -from urllib.parse import urlparse -from async_upnp_client.search import SsdpSearchListener import voluptuous as vol from yeelight import BulbException -from yeelight.aio import KEY_CONNECTED, AsyncBulb +from yeelight.aio import AsyncBulb -from homeassistant import config_entries -from homeassistant.components import network from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryNotReady from homeassistant.const import ( CONF_DEVICES, @@ -25,82 +18,45 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo, Entity -from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType +from .const import ( + ACTION_OFF, + ACTION_RECOVER, + ACTION_STAY, + ATTR_ACTION, + ATTR_COUNT, + ATTR_TRANSITIONS, + CONF_CUSTOM_EFFECTS, + CONF_DETECTED_MODEL, + CONF_FLOW_PARAMS, + CONF_MODE_MUSIC, + CONF_MODEL, + CONF_NIGHTLIGHT_SWITCH, + CONF_NIGHTLIGHT_SWITCH_TYPE, + CONF_SAVE_ON_CHANGE, + CONF_TRANSITION, + DATA_CONFIG_ENTRIES, + DATA_CUSTOM_EFFECTS, + DATA_DEVICE, + DEFAULT_MODE_MUSIC, + DEFAULT_NAME, + DEFAULT_NIGHTLIGHT_SWITCH, + DEFAULT_SAVE_ON_CHANGE, + DEFAULT_TRANSITION, + DOMAIN, + NIGHTLIGHT_SWITCH_TYPE_LIGHT, + PLATFORMS, + YEELIGHT_HSV_TRANSACTION, + YEELIGHT_RGB_TRANSITION, + YEELIGHT_SLEEP_TRANSACTION, + YEELIGHT_TEMPERATURE_TRANSACTION, +) +from .device import YeelightDevice, async_format_id +from .scanner import YeelightScanner + _LOGGER = logging.getLogger(__name__) -STATE_CHANGE_TIME = 0.40 # seconds -POWER_STATE_CHANGE_TIME = 1 # seconds - -# -# These models do not transition correctly when turning on, and -# yeelight is no longer updating the firmware on older devices -# -# https://github.com/home-assistant/core/issues/58315 -# -# The problem can be worked around by always setting the brightness -# even when the bulb is reporting the brightness is already at the -# desired level. -# -MODELS_WITH_DELAYED_ON_TRANSITION = { - "color", # YLDP02YL -} - -DOMAIN = "yeelight" -DATA_YEELIGHT = DOMAIN -DATA_UPDATED = "yeelight_{}_data_updated" - -DEFAULT_NAME = "Yeelight" -DEFAULT_TRANSITION = 350 -DEFAULT_MODE_MUSIC = False -DEFAULT_SAVE_ON_CHANGE = False -DEFAULT_NIGHTLIGHT_SWITCH = False - -CONF_MODEL = "model" -CONF_DETECTED_MODEL = "detected_model" -CONF_TRANSITION = "transition" -CONF_SAVE_ON_CHANGE = "save_on_change" -CONF_MODE_MUSIC = "use_music_mode" -CONF_FLOW_PARAMS = "flow_params" -CONF_CUSTOM_EFFECTS = "custom_effects" -CONF_NIGHTLIGHT_SWITCH_TYPE = "nightlight_switch_type" -CONF_NIGHTLIGHT_SWITCH = "nightlight_switch" - -DATA_CONFIG_ENTRIES = "config_entries" -DATA_CUSTOM_EFFECTS = "custom_effects" -DATA_DEVICE = "device" -DATA_REMOVE_INIT_DISPATCHER = "remove_init_dispatcher" -DATA_PLATFORMS_LOADED = "platforms_loaded" - -ATTR_COUNT = "count" -ATTR_ACTION = "action" -ATTR_TRANSITIONS = "transitions" -ATTR_MODE_MUSIC = "music_mode" - -ACTION_RECOVER = "recover" -ACTION_STAY = "stay" -ACTION_OFF = "off" - -ACTIVE_MODE_NIGHTLIGHT = 1 -ACTIVE_COLOR_FLOWING = 1 - -NIGHTLIGHT_SWITCH_TYPE_LIGHT = "light" - -DISCOVERY_INTERVAL = timedelta(seconds=60) -SSDP_TARGET = ("239.255.255.250", 1982) -SSDP_ST = "wifi_bulb" -DISCOVERY_ATTEMPTS = 3 -DISCOVERY_SEARCH_INTERVAL = timedelta(seconds=2) -DISCOVERY_TIMEOUT = 8 - - -YEELIGHT_RGB_TRANSITION = "RGBTransition" -YEELIGHT_HSV_TRANSACTION = "HSVTransition" -YEELIGHT_TEMPERATURE_TRANSACTION = "TemperatureTransition" -YEELIGHT_SLEEP_TRANSACTION = "SleepTransition" YEELIGHT_FLOW_TRANSITION_SCHEMA = { vol.Optional(ATTR_COUNT, default=0): cv.positive_int, @@ -155,31 +111,6 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -UPDATE_REQUEST_PROPERTIES = [ - "power", - "main_power", - "bright", - "ct", - "rgb", - "hue", - "sat", - "color_mode", - "flowing", - "bg_power", - "bg_lmode", - "bg_flowing", - "bg_ct", - "bg_bright", - "bg_hue", - "bg_sat", - "bg_rgb", - "nl_br", - "active_mode", -] - - -PLATFORMS = ["binary_sensor", "light"] - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Yeelight bulbs.""" @@ -299,410 +230,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -@callback -def async_format_model(model: str) -> str: - """Generate a more human readable model.""" - return model.replace("_", " ").title() - - -@callback -def async_format_id(id_: str) -> str: - """Generate a more human readable id.""" - return hex(int(id_, 16)) if id_ else "None" - - -@callback -def async_format_model_id(model: str, id_: str) -> str: - """Generate a more human readable name.""" - return f"{async_format_model(model)} {async_format_id(id_)}" - - -@callback -def _async_unique_name(capabilities: dict) -> str: - """Generate name from capabilities.""" - model_id = async_format_model_id(capabilities["model"], capabilities["id"]) - return f"Yeelight {model_id}" - - async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -class YeelightScanner: - """Scan for Yeelight devices.""" - - _scanner = None - - @classmethod - @callback - def async_get(cls, hass: HomeAssistant): - """Get scanner instance.""" - if cls._scanner is None: - cls._scanner = cls(hass) - return cls._scanner - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize class.""" - self._hass = hass - self._host_discovered_events = {} - self._unique_id_capabilities = {} - self._host_capabilities = {} - self._track_interval = None - self._listeners = [] - self._connected_events = [] - - async def async_setup(self): - """Set up the scanner.""" - if self._connected_events: - await self._async_wait_connected() - return - - for idx, source_ip in enumerate(await self._async_build_source_set()): - self._connected_events.append(asyncio.Event()) - - def _wrap_async_connected_idx(idx): - """Create a function to capture the idx cell variable.""" - - async def _async_connected(): - self._connected_events[idx].set() - - return _async_connected - - self._listeners.append( - SsdpSearchListener( - async_callback=self._async_process_entry, - service_type=SSDP_ST, - target=SSDP_TARGET, - source_ip=source_ip, - async_connect_callback=_wrap_async_connected_idx(idx), - ) - ) - - results = await asyncio.gather( - *(listener.async_start() for listener in self._listeners), - return_exceptions=True, - ) - failed_listeners = [] - for idx, result in enumerate(results): - if not isinstance(result, Exception): - continue - _LOGGER.warning( - "Failed to setup listener for %s: %s", - self._listeners[idx].source_ip, - result, - ) - failed_listeners.append(self._listeners[idx]) - self._connected_events[idx].set() - - for listener in failed_listeners: - self._listeners.remove(listener) - - await self._async_wait_connected() - self._track_interval = async_track_time_interval( - self._hass, self.async_scan, DISCOVERY_INTERVAL - ) - self.async_scan() - - async def _async_wait_connected(self): - """Wait for the listeners to be up and connected.""" - await asyncio.gather(*(event.wait() for event in self._connected_events)) - - async def _async_build_source_set(self) -> set[IPv4Address]: - """Build the list of ssdp sources.""" - adapters = await network.async_get_adapters(self._hass) - sources: set[IPv4Address] = set() - if network.async_only_default_interface_enabled(adapters): - sources.add(IPv4Address("0.0.0.0")) - return 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 isinstance(source_ip, IPv6Address) - } - - async def async_discover(self): - """Discover bulbs.""" - _LOGGER.debug("Yeelight discover with interval %s", DISCOVERY_SEARCH_INTERVAL) - await self.async_setup() - for _ in range(DISCOVERY_ATTEMPTS): - self.async_scan() - await asyncio.sleep(DISCOVERY_SEARCH_INTERVAL.total_seconds()) - return self._unique_id_capabilities.values() - - @callback - def async_scan(self, *_): - """Send discovery packets.""" - _LOGGER.debug("Yeelight scanning") - for listener in self._listeners: - listener.async_search() - - async def async_get_capabilities(self, host): - """Get capabilities via SSDP.""" - if host in self._host_capabilities: - return self._host_capabilities[host] - - host_event = asyncio.Event() - self._host_discovered_events.setdefault(host, []).append(host_event) - await self.async_setup() - - for listener in self._listeners: - listener.async_search((host, SSDP_TARGET[1])) - - with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for(host_event.wait(), timeout=DISCOVERY_TIMEOUT) - - self._host_discovered_events[host].remove(host_event) - return self._host_capabilities.get(host) - - def _async_discovered_by_ssdp(self, response): - @callback - def _async_start_flow(*_): - asyncio.create_task( - self._hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=response, - ) - ) - - # Delay starting the flow in case the discovery is the result - # of another discovery - async_call_later(self._hass, 1, _async_start_flow) - - async def _async_process_entry(self, response): - """Process a discovery.""" - _LOGGER.debug("Discovered via SSDP: %s", response) - unique_id = response["id"] - host = urlparse(response["location"]).hostname - current_entry = self._unique_id_capabilities.get(unique_id) - # Make sure we handle ip changes - if not current_entry or host != urlparse(current_entry["location"]).hostname: - _LOGGER.debug("Yeelight discovered with %s", response) - self._async_discovered_by_ssdp(response) - self._host_capabilities[host] = response - self._unique_id_capabilities[unique_id] = response - for event in self._host_discovered_events.get(host, []): - event.set() - - -def update_needs_bg_power_workaround(data): - """Check if a push update needs the bg_power workaround. - - Some devices will push the incorrect state for bg_power. - - To work around this any time we are pushed an update - with bg_power, we force poll state which will be correct. - """ - return "bg_power" in data - - -class YeelightDevice: - """Represents single Yeelight device.""" - - def __init__(self, hass, host, config, bulb): - """Initialize device.""" - self._hass = hass - self._config = config - self._host = host - self._bulb_device = bulb - self.capabilities = {} - self._device_type = None - self._available = True - self._initialized = False - self._name = None - - @property - def bulb(self): - """Return bulb device.""" - return self._bulb_device - - @property - def name(self): - """Return the name of the device if any.""" - return self._name - - @property - def config(self): - """Return device config.""" - return self._config - - @property - def host(self): - """Return hostname.""" - return self._host - - @property - def available(self): - """Return true is device is available.""" - return self._available - - @callback - def async_mark_unavailable(self): - """Set unavailable on api call failure due to a network issue.""" - self._available = False - - @property - def model(self): - """Return configured/autodetected device model.""" - return self._bulb_device.model or self.capabilities.get("model") - - @property - def fw_version(self): - """Return the firmware version.""" - return self.capabilities.get("fw_ver") - - @property - def is_nightlight_supported(self) -> bool: - """ - Return true / false if nightlight is supported. - - Uses brightness as it appears to be supported in both ceiling and other lights. - """ - return self._nightlight_brightness is not None - - @property - def is_nightlight_enabled(self) -> bool: - """Return true / false if nightlight is currently enabled.""" - # Only ceiling lights have active_mode, from SDK docs: - # active_mode 0: daylight mode / 1: moonlight mode (ceiling light only) - if self._active_mode is not None: - return int(self._active_mode) == ACTIVE_MODE_NIGHTLIGHT - - if self._nightlight_brightness is not None: - return int(self._nightlight_brightness) > 0 - - return False - - @property - def is_color_flow_enabled(self) -> bool: - """Return true / false if color flow is currently running.""" - return self._color_flow and int(self._color_flow) == ACTIVE_COLOR_FLOWING - - @property - def _active_mode(self): - return self.bulb.last_properties.get("active_mode") - - @property - def _color_flow(self): - return self.bulb.last_properties.get("flowing") - - @property - def _nightlight_brightness(self): - return self.bulb.last_properties.get("nl_br") - - @property - def type(self): - """Return bulb type.""" - if not self._device_type: - self._device_type = self.bulb.bulb_type - - return self._device_type - - async def _async_update_properties(self): - """Read new properties from the device.""" - try: - await self.bulb.async_get_properties(UPDATE_REQUEST_PROPERTIES) - self._available = True - if not self._initialized: - self._initialized = True - except OSError as ex: - if self._available: # just inform once - _LOGGER.error( - "Unable to update device %s, %s: %s", self._host, self.name, ex - ) - self._available = False - except asyncio.TimeoutError as ex: - _LOGGER.debug( - "timed out while trying to update device %s, %s: %s", - self._host, - self.name, - ex, - ) - except BulbException as ex: - _LOGGER.debug( - "Unable to update device %s, %s: %s", self._host, self.name, ex - ) - - async def async_setup(self): - """Fetch capabilities and setup name if available.""" - scanner = YeelightScanner.async_get(self._hass) - self.capabilities = await scanner.async_get_capabilities(self._host) or {} - if self.capabilities: - self._bulb_device.set_capabilities(self.capabilities) - if name := self._config.get(CONF_NAME): - # Override default name when name is set in config - self._name = name - elif self.capabilities: - # Generate name from model and id when capabilities is available - self._name = _async_unique_name(self.capabilities) - else: - self._name = self._host # Default name is host - - async def async_update(self, force=False): - """Update device properties and send data updated signal.""" - if not force and self._initialized and self._available: - # No need to poll unless force, already connected - return - await self._async_update_properties() - async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) - - async def _async_forced_update(self, _now): - """Call a forced update.""" - await self.async_update(True) - - @callback - def async_update_callback(self, data): - """Update push from device.""" - was_available = self._available - self._available = data.get(KEY_CONNECTED, True) - if update_needs_bg_power_workaround(data) or ( - not was_available and self._available - ): - # On reconnect the properties may be out of sync - # - # If the device drops the connection right away, we do not want to - # do a property resync via async_update since its about - # to be called when async_setup_entry reaches the end of the - # function - # - async_call_later(self._hass, STATE_CHANGE_TIME, self._async_forced_update) - async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) - - -class YeelightEntity(Entity): - """Represents single Yeelight entity.""" - - _attr_should_poll = False - - def __init__(self, device: YeelightDevice, entry: ConfigEntry) -> None: - """Initialize the entity.""" - self._device = device - self._unique_id = entry.unique_id or entry.entry_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._unique_id)}, - name=self._device.name, - manufacturer="Yeelight", - model=self._device.model, - sw_version=self._device.fw_version, - ) - - @property - def unique_id(self) -> str: - """Return the unique ID.""" - return self._unique_id - - @property - def available(self) -> bool: - """Return if bulb is available.""" - return self._device.available - - async def async_update(self) -> None: - """Update the entity.""" - await self._device.async_update() - - async def _async_get_device( hass: HomeAssistant, host: str, entry: ConfigEntry ) -> YeelightDevice: @@ -712,7 +244,7 @@ async def _async_get_device( # Set up device bulb = AsyncBulb(host, model=model or None) - device = YeelightDevice(hass, host, entry.options, bulb) + device = YeelightDevice(hass, host, {**entry.options, **entry.data}, bulb) # start listening for local pushes await device.bulb.async_listen(device.async_update_callback) diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py index 185bb504a1b..89eb910f942 100644 --- a/homeassistant/components/yeelight/binary_sensor.py +++ b/homeassistant/components/yeelight/binary_sensor.py @@ -6,7 +6,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DATA_CONFIG_ENTRIES, DATA_DEVICE, DATA_UPDATED, DOMAIN, YeelightEntity +from .const import DATA_CONFIG_ENTRIES, DATA_DEVICE, DATA_UPDATED, DOMAIN +from .entity import YeelightEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index fbc9270d72a..0419824492a 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -8,13 +8,14 @@ from yeelight.aio import AsyncBulb from yeelight.main import get_known_models from homeassistant import config_entries, exceptions -from homeassistant.components.dhcp import IP_ADDRESS +from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_NAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from . import ( +from .const import ( CONF_DETECTED_MODEL, CONF_MODE_MUSIC, CONF_MODEL, @@ -24,12 +25,14 @@ from . import ( CONF_TRANSITION, DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, - YeelightScanner, +) +from .device import ( _async_unique_name, async_format_id, async_format_model, async_format_model_id, ) +from .scanner import YeelightScanner MODEL_UNKNOWN = "unknown" @@ -53,28 +56,32 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._discovered_model = None self._discovered_ip = None - async def async_step_homekit(self, discovery_info): + async def async_step_homekit( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle discovery from homekit.""" - self._discovered_ip = discovery_info["host"] + self._discovered_ip = discovery_info.host return await self._async_handle_discovery() - async def async_step_dhcp(self, discovery_info): + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle discovery from dhcp.""" - self._discovered_ip = discovery_info[IP_ADDRESS] + self._discovered_ip = discovery_info.ip return await self._async_handle_discovery() - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle discovery from zeroconf.""" - self._discovered_ip = discovery_info["host"] + self._discovered_ip = discovery_info.host await self.async_set_unique_id( - "{0:#0{1}x}".format(int(discovery_info["name"][-26:-18]), 18) + "{0:#0{1}x}".format(int(discovery_info.name[-26:-18]), 18) ) return await self._async_handle_discovery_with_unique_id() - async def async_step_ssdp(self, discovery_info): + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle discovery from ssdp.""" - self._discovered_ip = urlparse(discovery_info["location"]).hostname - await self.async_set_unique_id(discovery_info["id"]) + self._discovered_ip = urlparse(discovery_info.ssdp_headers["location"]).hostname + await self.async_set_unique_id(discovery_info.ssdp_headers["id"]) return await self._async_handle_discovery_with_unique_id() async def _async_handle_discovery_with_unique_id(self): diff --git a/homeassistant/components/yeelight/const.py b/homeassistant/components/yeelight/const.py new file mode 100644 index 00000000000..2b494bf9ef2 --- /dev/null +++ b/homeassistant/components/yeelight/const.py @@ -0,0 +1,103 @@ +"""Support for Xiaomi Yeelight WiFi color bulb.""" + +from datetime import timedelta + +DOMAIN = "yeelight" + + +STATE_CHANGE_TIME = 0.40 # seconds +POWER_STATE_CHANGE_TIME = 1 # seconds + + +# +# These models do not transition correctly when turning on, and +# yeelight is no longer updating the firmware on older devices +# +# https://github.com/home-assistant/core/issues/58315 +# +# The problem can be worked around by always setting the brightness +# even when the bulb is reporting the brightness is already at the +# desired level. +# +MODELS_WITH_DELAYED_ON_TRANSITION = { + "color", # YLDP02YL +} + +DATA_UPDATED = "yeelight_{}_data_updated" + +DEFAULT_NAME = "Yeelight" +DEFAULT_TRANSITION = 350 +DEFAULT_MODE_MUSIC = False +DEFAULT_SAVE_ON_CHANGE = False +DEFAULT_NIGHTLIGHT_SWITCH = False + +CONF_MODEL = "model" +CONF_DETECTED_MODEL = "detected_model" +CONF_TRANSITION = "transition" + +CONF_SAVE_ON_CHANGE = "save_on_change" +CONF_MODE_MUSIC = "use_music_mode" +CONF_FLOW_PARAMS = "flow_params" +CONF_CUSTOM_EFFECTS = "custom_effects" +CONF_NIGHTLIGHT_SWITCH_TYPE = "nightlight_switch_type" +CONF_NIGHTLIGHT_SWITCH = "nightlight_switch" + +DATA_CONFIG_ENTRIES = "config_entries" +DATA_CUSTOM_EFFECTS = "custom_effects" +DATA_DEVICE = "device" +DATA_REMOVE_INIT_DISPATCHER = "remove_init_dispatcher" +DATA_PLATFORMS_LOADED = "platforms_loaded" + +ATTR_COUNT = "count" +ATTR_ACTION = "action" +ATTR_TRANSITIONS = "transitions" +ATTR_MODE_MUSIC = "music_mode" + +ACTION_RECOVER = "recover" +ACTION_STAY = "stay" +ACTION_OFF = "off" + +ACTIVE_MODE_NIGHTLIGHT = 1 +ACTIVE_COLOR_FLOWING = 1 + + +NIGHTLIGHT_SWITCH_TYPE_LIGHT = "light" + +DISCOVERY_INTERVAL = timedelta(seconds=60) +SSDP_TARGET = ("239.255.255.250", 1982) +SSDP_ST = "wifi_bulb" +DISCOVERY_ATTEMPTS = 3 +DISCOVERY_SEARCH_INTERVAL = timedelta(seconds=2) +DISCOVERY_TIMEOUT = 8 + + +YEELIGHT_RGB_TRANSITION = "RGBTransition" +YEELIGHT_HSV_TRANSACTION = "HSVTransition" +YEELIGHT_TEMPERATURE_TRANSACTION = "TemperatureTransition" +YEELIGHT_SLEEP_TRANSACTION = "SleepTransition" + + +UPDATE_REQUEST_PROPERTIES = [ + "power", + "main_power", + "bright", + "ct", + "rgb", + "hue", + "sat", + "color_mode", + "flowing", + "bg_power", + "bg_lmode", + "bg_flowing", + "bg_ct", + "bg_bright", + "bg_hue", + "bg_sat", + "bg_rgb", + "nl_br", + "active_mode", +] + + +PLATFORMS = ["binary_sensor", "light"] diff --git a/homeassistant/components/yeelight/device.py b/homeassistant/components/yeelight/device.py new file mode 100644 index 00000000000..02228e5d9fc --- /dev/null +++ b/homeassistant/components/yeelight/device.py @@ -0,0 +1,235 @@ +"""Support for Xiaomi Yeelight WiFi color bulb.""" +from __future__ import annotations + +import asyncio +import logging + +from yeelight import BulbException +from yeelight.aio import KEY_CONNECTED + +from homeassistant.const import CONF_ID, CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later + +from .const import ( + ACTIVE_COLOR_FLOWING, + ACTIVE_MODE_NIGHTLIGHT, + DATA_UPDATED, + STATE_CHANGE_TIME, + UPDATE_REQUEST_PROPERTIES, +) +from .scanner import YeelightScanner + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_format_model(model: str) -> str: + """Generate a more human readable model.""" + return model.replace("_", " ").title() + + +@callback +def async_format_id(id_: str) -> str: + """Generate a more human readable id.""" + return hex(int(id_, 16)) if id_ else "None" + + +@callback +def async_format_model_id(model: str, id_: str) -> str: + """Generate a more human readable name.""" + return f"{async_format_model(model)} {async_format_id(id_)}" + + +@callback +def _async_unique_name(capabilities: dict) -> str: + """Generate name from capabilities.""" + model_id = async_format_model_id(capabilities["model"], capabilities["id"]) + return f"Yeelight {model_id}" + + +def update_needs_bg_power_workaround(data): + """Check if a push update needs the bg_power workaround. + + Some devices will push the incorrect state for bg_power. + + To work around this any time we are pushed an update + with bg_power, we force poll state which will be correct. + """ + return "bg_power" in data + + +class YeelightDevice: + """Represents single Yeelight device.""" + + def __init__(self, hass, host, config, bulb): + """Initialize device.""" + self._hass = hass + self._config = config + self._host = host + self._bulb_device = bulb + self.capabilities = {} + self._device_type = None + self._available = True + self._initialized = False + self._name = None + + @property + def bulb(self): + """Return bulb device.""" + return self._bulb_device + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def config(self): + """Return device config.""" + return self._config + + @property + def host(self): + """Return hostname.""" + return self._host + + @property + def available(self): + """Return true is device is available.""" + return self._available + + @callback + def async_mark_unavailable(self): + """Set unavailable on api call failure due to a network issue.""" + self._available = False + + @property + def model(self): + """Return configured/autodetected device model.""" + return self._bulb_device.model or self.capabilities.get("model") + + @property + def fw_version(self): + """Return the firmware version.""" + return self.capabilities.get("fw_ver") + + @property + def is_nightlight_supported(self) -> bool: + """ + Return true / false if nightlight is supported. + + Uses brightness as it appears to be supported in both ceiling and other lights. + """ + return self._nightlight_brightness is not None + + @property + def is_nightlight_enabled(self) -> bool: + """Return true / false if nightlight is currently enabled.""" + # Only ceiling lights have active_mode, from SDK docs: + # active_mode 0: daylight mode / 1: moonlight mode (ceiling light only) + if self._active_mode is not None: + return int(self._active_mode) == ACTIVE_MODE_NIGHTLIGHT + + if self._nightlight_brightness is not None: + return int(self._nightlight_brightness) > 0 + + return False + + @property + def is_color_flow_enabled(self) -> bool: + """Return true / false if color flow is currently running.""" + return self._color_flow and int(self._color_flow) == ACTIVE_COLOR_FLOWING + + @property + def _active_mode(self): + return self.bulb.last_properties.get("active_mode") + + @property + def _color_flow(self): + return self.bulb.last_properties.get("flowing") + + @property + def _nightlight_brightness(self): + return self.bulb.last_properties.get("nl_br") + + @property + def type(self): + """Return bulb type.""" + if not self._device_type: + self._device_type = self.bulb.bulb_type + + return self._device_type + + async def _async_update_properties(self): + """Read new properties from the device.""" + try: + await self.bulb.async_get_properties(UPDATE_REQUEST_PROPERTIES) + self._available = True + if not self._initialized: + self._initialized = True + except OSError as ex: + if self._available: # just inform once + _LOGGER.error( + "Unable to update device %s, %s: %s", self._host, self.name, ex + ) + self._available = False + except asyncio.TimeoutError as ex: + _LOGGER.debug( + "timed out while trying to update device %s, %s: %s", + self._host, + self.name, + ex, + ) + except BulbException as ex: + _LOGGER.debug( + "Unable to update device %s, %s: %s", self._host, self.name, ex + ) + + async def async_setup(self): + """Fetch capabilities and setup name if available.""" + scanner = YeelightScanner.async_get(self._hass) + self.capabilities = await scanner.async_get_capabilities(self._host) or {} + if self.capabilities: + self._bulb_device.set_capabilities(self.capabilities) + if name := self._config.get(CONF_NAME): + # Override default name when name is set in config + self._name = name + elif self.capabilities: + # Generate name from model and id when capabilities is available + self._name = _async_unique_name(self.capabilities) + elif self.model and (id_ := self._config.get(CONF_ID)): + self._name = f"Yeelight {async_format_model_id(self.model, id_)}" + else: + self._name = self._host # Default name is host + + async def async_update(self, force=False): + """Update device properties and send data updated signal.""" + if not force and self._initialized and self._available: + # No need to poll unless force, already connected + return + await self._async_update_properties() + async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) + + async def _async_forced_update(self, _now): + """Call a forced update.""" + await self.async_update(True) + + @callback + def async_update_callback(self, data): + """Update push from device.""" + was_available = self._available + self._available = data.get(KEY_CONNECTED, True) + if update_needs_bg_power_workaround(data) or ( + not was_available and self._available + ): + # On reconnect the properties may be out of sync + # + # If the device drops the connection right away, we do not want to + # do a property resync via async_update since its about + # to be called when async_setup_entry reaches the end of the + # function + # + async_call_later(self._hass, STATE_CHANGE_TIME, self._async_forced_update) + async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) diff --git a/homeassistant/components/yeelight/entity.py b/homeassistant/components/yeelight/entity.py new file mode 100644 index 00000000000..53211115dd6 --- /dev/null +++ b/homeassistant/components/yeelight/entity.py @@ -0,0 +1,40 @@ +"""Support for Xiaomi Yeelight WiFi color bulb.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN +from .device import YeelightDevice + + +class YeelightEntity(Entity): + """Represents single Yeelight entity.""" + + _attr_should_poll = False + + def __init__(self, device: YeelightDevice, entry: ConfigEntry) -> None: + """Initialize the entity.""" + self._device = device + self._unique_id = entry.unique_id or entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._unique_id)}, + name=self._device.name, + manufacturer="Yeelight", + model=self._device.model, + sw_version=self._device.fw_version, + ) + + @property + def unique_id(self) -> str: + """Return the unique ID.""" + return self._unique_id + + @property + def available(self) -> bool: + """Return if bulb is available.""" + return self._device.available + + async def async_update(self) -> None: + """Update the entity.""" + await self._device.async_update() diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 3d84a30f44e..75735beed34 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -47,7 +47,8 @@ from homeassistant.util.color import ( color_temperature_mired_to_kelvin as mired_to_kelvin, ) -from . import ( +from . import YEELIGHT_FLOW_TRANSITION_SCHEMA +from .const import ( ACTION_RECOVER, ATTR_ACTION, ATTR_COUNT, @@ -65,9 +66,8 @@ from . import ( DOMAIN, MODELS_WITH_DELAYED_ON_TRANSITION, POWER_STATE_CHANGE_TIME, - YEELIGHT_FLOW_TRANSITION_SCHEMA, - YeelightEntity, ) +from .entity import YeelightEntity _LOGGER = logging.getLogger(__name__) @@ -322,7 +322,7 @@ async def async_setup_entry( device.name, ) - async_add_entities(lights, True) + async_add_entities(lights) _async_setup_services(hass) @@ -411,6 +411,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): _attr_color_mode = COLOR_MODE_BRIGHTNESS _attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS} + _attr_should_poll = False def __init__(self, device, entry, custom_effects=None): """Initialize the Yeelight light.""" @@ -465,8 +466,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): @property def color_temp(self) -> int: """Return the color temperature.""" - temp_in_k = self._get_property("ct") - if temp_in_k: + if temp_in_k := self._get_property("ct"): self._color_temp = kelvin_to_mired(int(temp_in_k)) return self._color_temp @@ -530,9 +530,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): @property def rgb_color(self) -> tuple: """Return the color property.""" - rgb = self._get_property("rgb") - - if rgb is None: + if (rgb := self._get_property("rgb")) is None: return None rgb = int(rgb) @@ -594,7 +592,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): async def async_update(self): """Update light properties.""" - await self.device.async_update() + await self.device.async_update(True) async def async_set_music_mode(self, music_mode) -> None: """Set the music mode on or off.""" @@ -845,6 +843,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): async def async_set_mode(self, mode: str): """Set a power mode.""" await self._bulb.async_set_power_mode(PowerMode[mode.upper()]) + self._async_schedule_state_check(True) @_async_cmd async def async_start_flow(self, transitions, count=0, action=ACTION_RECOVER): diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 0b7718074a7..60098514125 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.8", "async-upnp-client==0.22.10"], + "requirements": ["yeelight==0.7.8", "async-upnp-client==0.22.12"], "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "dependencies": ["network"], @@ -16,5 +16,6 @@ "zeroconf": [{ "type": "_miio._udp.local.", "name": "yeelink-*" }], "homekit": { "models": ["YL*"] - } + }, + "after_dependencies": ["ssdp"] } diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py new file mode 100644 index 00000000000..1756fbe865c --- /dev/null +++ b/homeassistant/components/yeelight/scanner.py @@ -0,0 +1,190 @@ +"""Support for Xiaomi Yeelight WiFi color bulb.""" +from __future__ import annotations + +import asyncio +import contextlib +from ipaddress import IPv4Address, IPv6Address +import logging +from urllib.parse import urlparse + +from async_upnp_client.search import SsdpHeaders, SsdpSearchListener + +from homeassistant import config_entries +from homeassistant.components import network, ssdp +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_call_later, async_track_time_interval + +from .const import ( + DISCOVERY_ATTEMPTS, + DISCOVERY_INTERVAL, + DISCOVERY_SEARCH_INTERVAL, + DISCOVERY_TIMEOUT, + DOMAIN, + SSDP_ST, + SSDP_TARGET, +) + +_LOGGER = logging.getLogger(__name__) + + +class YeelightScanner: + """Scan for Yeelight devices.""" + + _scanner = None + + @classmethod + @callback + def async_get(cls, hass: HomeAssistant): + """Get scanner instance.""" + if cls._scanner is None: + cls._scanner = cls(hass) + return cls._scanner + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize class.""" + self._hass = hass + self._host_discovered_events = {} + self._unique_id_capabilities = {} + self._host_capabilities = {} + self._track_interval = None + self._listeners = [] + self._connected_events = [] + + async def async_setup(self): + """Set up the scanner.""" + if self._connected_events: + await self._async_wait_connected() + return + + for idx, source_ip in enumerate(await self._async_build_source_set()): + self._connected_events.append(asyncio.Event()) + + def _wrap_async_connected_idx(idx): + """Create a function to capture the idx cell variable.""" + + async def _async_connected(): + self._connected_events[idx].set() + + return _async_connected + + self._listeners.append( + SsdpSearchListener( + async_callback=self._async_process_entry, + service_type=SSDP_ST, + target=SSDP_TARGET, + source_ip=source_ip, + async_connect_callback=_wrap_async_connected_idx(idx), + ) + ) + + results = await asyncio.gather( + *(listener.async_start() for listener in self._listeners), + return_exceptions=True, + ) + failed_listeners = [] + for idx, result in enumerate(results): + if not isinstance(result, Exception): + continue + _LOGGER.warning( + "Failed to setup listener for %s: %s", + self._listeners[idx].source_ip, + result, + ) + failed_listeners.append(self._listeners[idx]) + self._connected_events[idx].set() + + for listener in failed_listeners: + self._listeners.remove(listener) + + await self._async_wait_connected() + self._track_interval = async_track_time_interval( + self._hass, self.async_scan, DISCOVERY_INTERVAL + ) + self.async_scan() + + async def _async_wait_connected(self): + """Wait for the listeners to be up and connected.""" + await asyncio.gather(*(event.wait() for event in self._connected_events)) + + async def _async_build_source_set(self) -> set[IPv4Address]: + """Build the list of ssdp sources.""" + adapters = await network.async_get_adapters(self._hass) + sources: set[IPv4Address] = set() + if network.async_only_default_interface_enabled(adapters): + sources.add(IPv4Address("0.0.0.0")) + return 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 isinstance(source_ip, IPv6Address) + } + + async def async_discover(self): + """Discover bulbs.""" + _LOGGER.debug("Yeelight discover with interval %s", DISCOVERY_SEARCH_INTERVAL) + await self.async_setup() + for _ in range(DISCOVERY_ATTEMPTS): + self.async_scan() + await asyncio.sleep(DISCOVERY_SEARCH_INTERVAL.total_seconds()) + return self._unique_id_capabilities.values() + + @callback + def async_scan(self, *_): + """Send discovery packets.""" + _LOGGER.debug("Yeelight scanning") + for listener in self._listeners: + listener.async_search() + + async def async_get_capabilities(self, host): + """Get capabilities via SSDP.""" + if host in self._host_capabilities: + return self._host_capabilities[host] + + host_event = asyncio.Event() + self._host_discovered_events.setdefault(host, []).append(host_event) + await self.async_setup() + + for listener in self._listeners: + listener.async_search((host, SSDP_TARGET[1])) + + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(host_event.wait(), timeout=DISCOVERY_TIMEOUT) + + self._host_discovered_events[host].remove(host_event) + return self._host_capabilities.get(host) + + def _async_discovered_by_ssdp(self, response): + @callback + def _async_start_flow(*_): + asyncio.create_task( + self._hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=ssdp.SsdpServiceInfo( + ssdp_usn="", + ssdp_st=SSDP_ST, + ssdp_headers=response, + upnp={}, + ), + ) + ) + + # Delay starting the flow in case the discovery is the result + # of another discovery + async_call_later(self._hass, 1, _async_start_flow) + + async def _async_process_entry(self, headers: SsdpHeaders): + """Process a discovery.""" + _LOGGER.debug("Discovered via SSDP: %s", headers) + unique_id = headers["id"] + host = urlparse(headers["location"]).hostname + current_entry = self._unique_id_capabilities.get(unique_id) + # Make sure we handle ip changes + if not current_entry or host != urlparse(current_entry["location"]).hostname: + _LOGGER.debug("Yeelight discovered with %s", headers) + self._async_discovered_by_ssdp(headers) + self._host_capabilities[host] = headers + self._unique_id_capabilities[unique_id] = headers + for event in self._host_discovered_events.get(host, []): + event.set() diff --git a/homeassistant/components/yeelight/translations/bg.json b/homeassistant/components/yeelight/translations/bg.json index d68ec8b933c..a53214e40e4 100644 --- a/homeassistant/components/yeelight/translations/bg.json +++ b/homeassistant/components/yeelight/translations/bg.json @@ -7,6 +7,7 @@ "error": { "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" }, + "flow_title": "{model} {id} ({host})", "step": { "pick_device": { "data": { diff --git a/homeassistant/components/yeelight/translations/ca.json b/homeassistant/components/yeelight/translations/ca.json index 2732806de85..0b62bebf1be 100644 --- a/homeassistant/components/yeelight/translations/ca.json +++ b/homeassistant/components/yeelight/translations/ca.json @@ -29,7 +29,7 @@ "step": { "init": { "data": { - "model": "Model (opcional)", + "model": "Model", "nightlight_switch": "Utilitza l'interruptor NightLight", "save_on_change": "Desa l'estat en canviar", "transition": "Temps de transici\u00f3 (ms)", diff --git a/homeassistant/components/yeelight/translations/he.json b/homeassistant/components/yeelight/translations/he.json index 535aa833a7f..0fe8dd0f06e 100644 --- a/homeassistant/components/yeelight/translations/he.json +++ b/homeassistant/components/yeelight/translations/he.json @@ -29,7 +29,7 @@ "step": { "init": { "data": { - "model": "\u05d3\u05d2\u05dd (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", + "model": "\u05d3\u05d2\u05dd", "nightlight_switch": "\u05d4\u05e9\u05ea\u05de\u05e9 \u05d1\u05de\u05ea\u05d2 \u05ea\u05d0\u05d5\u05e8\u05ea \u05dc\u05d9\u05dc\u05d4", "save_on_change": "\u05e9\u05de\u05d5\u05e8 \u05e1\u05d8\u05d8\u05d5\u05e1 \u05d1\u05e9\u05d9\u05e0\u05d5\u05d9", "transition": "\u05d6\u05de\u05df \u05de\u05e2\u05d1\u05e8 (\u05d0\u05dc\u05e4\u05d9\u05d5\u05ea \u05e9\u05e0\u05d9\u05d4)", diff --git a/homeassistant/components/yeelight/translations/id.json b/homeassistant/components/yeelight/translations/id.json index d9795662689..19537d658a1 100644 --- a/homeassistant/components/yeelight/translations/id.json +++ b/homeassistant/components/yeelight/translations/id.json @@ -29,7 +29,7 @@ "step": { "init": { "data": { - "model": "Model (opsional)", + "model": "Model", "nightlight_switch": "Gunakan Sakelar Lampu Malam", "save_on_change": "Simpan Status Saat Berubah", "transition": "Waktu Transisi (milidetik)", diff --git a/homeassistant/components/yeelight/translations/it.json b/homeassistant/components/yeelight/translations/it.json index 4036b6d6338..ce34523bb61 100644 --- a/homeassistant/components/yeelight/translations/it.json +++ b/homeassistant/components/yeelight/translations/it.json @@ -29,7 +29,7 @@ "step": { "init": { "data": { - "model": "Modello (opzionale)", + "model": "Modello", "nightlight_switch": "Usa l'interruttore luce notturna", "save_on_change": "Salva stato su modifica", "transition": "Tempo di transizione (ms)", diff --git a/homeassistant/components/yeelight/translations/ja.json b/homeassistant/components/yeelight/translations/ja.json new file mode 100644 index 00000000000..e032979dcee --- /dev/null +++ b/homeassistant/components/yeelight/translations/ja.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{model} {id} ({host})", + "step": { + "discovery_confirm": { + "description": "{model} ({host}) \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "pick_device": { + "data": { + "device": "\u30c7\u30d0\u30a4\u30b9" + } + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + }, + "description": "\u30db\u30b9\u30c8\u3092\u7a7a\u306b\u3057\u3066\u304a\u304f\u3068\u3001\u30c7\u30a3\u30b9\u30ab\u30d0\u30ea\u30fc\u3092\u4f7f\u3063\u3066\u30c7\u30d0\u30a4\u30b9\u3092\u691c\u7d22\u3057\u307e\u3059\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "model": "\u30e2\u30c7\u30eb", + "nightlight_switch": "\u5e38\u591c\u706f\u30b9\u30a4\u30c3\u30c1\u3092\u4f7f\u7528\u3059\u308b", + "save_on_change": "\u5909\u66f4\u6642\u306b\u30b9\u30c6\u30fc\u30bf\u30b9\u3092\u4fdd\u5b58", + "transition": "\u9077\u79fb\u6642\u9593(Transition Time)(ms)", + "use_music_mode": "\u97f3\u697d\u30e2\u30fc\u30c9\u3092\u6709\u52b9\u306b\u3059\u308b" + }, + "description": "\u30e2\u30c7\u30eb\u3092\u7a7a\u306b\u3057\u3066\u304a\u304f\u3068\u3001\u81ea\u52d5\u7684\u306b\u691c\u51fa\u3055\u308c\u307e\u3059\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yeelight/translations/nl.json b/homeassistant/components/yeelight/translations/nl.json index 08a971f0225..a83ef72695c 100644 --- a/homeassistant/components/yeelight/translations/nl.json +++ b/homeassistant/components/yeelight/translations/nl.json @@ -29,7 +29,7 @@ "step": { "init": { "data": { - "model": "Model (optioneel)", + "model": "Model", "nightlight_switch": "Gebruik Nachtlichtschakelaar", "save_on_change": "Bewaar status bij wijziging", "transition": "Overgangstijd (ms)", diff --git a/homeassistant/components/yeelight/translations/no.json b/homeassistant/components/yeelight/translations/no.json index 6814ec518cb..ea4436d7769 100644 --- a/homeassistant/components/yeelight/translations/no.json +++ b/homeassistant/components/yeelight/translations/no.json @@ -29,7 +29,7 @@ "step": { "init": { "data": { - "model": "Modell (valgfritt)", + "model": "Modell", "nightlight_switch": "Bruk nattlysbryter", "save_on_change": "Lagre status ved endring", "transition": "Overgangstid (ms)", diff --git a/homeassistant/components/yeelight/translations/pl.json b/homeassistant/components/yeelight/translations/pl.json index 2827e926a0d..818ff0946c4 100644 --- a/homeassistant/components/yeelight/translations/pl.json +++ b/homeassistant/components/yeelight/translations/pl.json @@ -21,7 +21,7 @@ "data": { "host": "Nazwa hosta lub adres IP" }, - "description": "Je\u015bli nie podasz IP lub nazwy hosta, wykrywanie zostanie u\u017cyte do odnalezienia urz\u0105dze\u0144." + "description": "Je\u015bli nie podasz IP lub nazwy hosta, zostanie u\u017cyte wykrywanie do odnalezienia urz\u0105dze\u0144." } } }, @@ -29,7 +29,7 @@ "step": { "init": { "data": { - "model": "Model (opcjonalnie)", + "model": "Model", "nightlight_switch": "U\u017cyj prze\u0142\u0105cznika Nocnego \u015bwiat\u0142a", "save_on_change": "Zachowaj status po zmianie", "transition": "Czas przej\u015bcia (ms)", diff --git a/homeassistant/components/yeelight/translations/ru.json b/homeassistant/components/yeelight/translations/ru.json index 34e3c4d2c8a..70694147baa 100644 --- a/homeassistant/components/yeelight/translations/ru.json +++ b/homeassistant/components/yeelight/translations/ru.json @@ -29,7 +29,7 @@ "step": { "init": { "data": { - "model": "\u041c\u043e\u0434\u0435\u043b\u044c (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", + "model": "\u041c\u043e\u0434\u0435\u043b\u044c", "nightlight_switch": "\u041f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0442\u0435\u043b\u044c \u0434\u043b\u044f \u043d\u043e\u0447\u043d\u0438\u043a\u0430", "save_on_change": "\u0421\u043e\u0445\u0440\u0430\u043d\u044f\u0442\u044c \u0441\u0442\u0430\u0442\u0443\u0441 \u043f\u0440\u0438 \u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0438", "transition": "\u0412\u0440\u0435\u043c\u044f \u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0430 (\u0432 \u043c\u0438\u043b\u043b\u0438\u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)", diff --git a/homeassistant/components/yeelight/translations/tr.json b/homeassistant/components/yeelight/translations/tr.json index 322f13f47b0..4eed4f477b4 100644 --- a/homeassistant/components/yeelight/translations/tr.json +++ b/homeassistant/components/yeelight/translations/tr.json @@ -1,12 +1,17 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "no_devices_found": "A\u011fda cihaz bulunamad\u0131" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, + "flow_title": "{model} {id} ({host})", "step": { + "discovery_confirm": { + "description": "{model} ( {host} ) kurulumu yapmak istiyor musunuz?" + }, "pick_device": { "data": { "device": "Cihaz" @@ -15,7 +20,8 @@ "user": { "data": { "host": "Ana Bilgisayar" - } + }, + "description": "Ana bilgisayar\u0131 bo\u015f b\u0131rak\u0131rsan\u0131z, cihazlar\u0131 bulmak i\u00e7in ke\u015fif kullan\u0131lacakt\u0131r." } } }, @@ -23,11 +29,13 @@ "step": { "init": { "data": { - "model": "Model (Opsiyonel)", + "model": "Model", + "nightlight_switch": "Gece I\u015f\u0131\u011f\u0131 Anahtar\u0131n\u0131 Kullan", "save_on_change": "De\u011fi\u015fiklikte Durumu Kaydet", "transition": "Ge\u00e7i\u015f S\u00fcresi (ms)", "use_music_mode": "M\u00fczik Modunu Etkinle\u015ftir" - } + }, + "description": "Modeli bo\u015f b\u0131rak\u0131rsan\u0131z, otomatik olarak alg\u0131lanacakt\u0131r." } } } diff --git a/homeassistant/components/yeelight/translations/zh-Hant.json b/homeassistant/components/yeelight/translations/zh-Hant.json index c0c83c213b0..785584a6f2a 100644 --- a/homeassistant/components/yeelight/translations/zh-Hant.json +++ b/homeassistant/components/yeelight/translations/zh-Hant.json @@ -29,7 +29,7 @@ "step": { "init": { "data": { - "model": "\u578b\u865f\uff08\u9078\u9805\uff09", + "model": "\u578b\u865f", "nightlight_switch": "\u4f7f\u7528\u591c\u71c8\u958b\u95dc", "save_on_change": "\u65bc\u8b8a\u66f4\u6642\u5132\u5b58\u72c0\u614b", "transition": "\u8f49\u63db\u6642\u9593\uff08\u6beb\u79d2\uff09", diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 16ed918914d..355d4f83127 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -21,6 +21,7 @@ from homeassistant.const import ( VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( @@ -71,12 +72,12 @@ class YoulessBaseSensor(CoordinatorEntity, SensorEntity): self._sensor_id = sensor_id self._attr_unique_id = f"{DOMAIN}_{device}_{sensor_id}" - self._attr_device_info = { - "identifiers": {(DOMAIN, f"{device}_{device_group}")}, - "name": friendly_name, - "manufacturer": "YouLess", - "model": self.coordinator.data.model, - } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{device}_{device_group}")}, + manufacturer="YouLess", + model=self.coordinator.data.model, + name=friendly_name, + ) @property def get_sensor(self) -> YoulessSensor | None: diff --git a/homeassistant/components/youless/translations/ja.json b/homeassistant/components/youless/translations/ja.json new file mode 100644 index 00000000000..c09398e1f70 --- /dev/null +++ b/homeassistant/components/youless/translations/ja.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/tr.json b/homeassistant/components/youless/translations/tr.json new file mode 100644 index 00000000000..afa9c9323f2 --- /dev/null +++ b/homeassistant/components/youless/translations/tr.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "step": { + "user": { + "data": { + "host": "Ana bilgisayar", + "name": "Ad" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zabbix/sensor.py b/homeassistant/components/zabbix/sensor.py index 1b3cf40eedd..36207842285 100644 --- a/homeassistant/components/zabbix/sensor.py +++ b/homeassistant/components/zabbix/sensor.py @@ -79,10 +79,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ZabbixTriggerCountSensor(SensorEntity): """Get the active trigger count for all Zabbix monitored hosts.""" - def __init__(self, zApi, name="Zabbix"): + def __init__(self, zapi, name="Zabbix"): """Initialize Zabbix sensor.""" self._name = name - self._zapi = zApi + self._zapi = zapi self._state = None self._attributes = {} @@ -121,9 +121,9 @@ class ZabbixTriggerCountSensor(SensorEntity): class ZabbixSingleHostTriggerCountSensor(ZabbixTriggerCountSensor): """Get the active trigger count for a single Zabbix monitored host.""" - def __init__(self, zApi, hostid, name=None): + def __init__(self, zapi, hostid, name=None): """Initialize Zabbix sensor.""" - super().__init__(zApi, name) + super().__init__(zapi, name) self._hostid = hostid if not name: self._name = self._zapi.host.get(hostids=self._hostid, output="extend")[0][ @@ -145,9 +145,9 @@ class ZabbixSingleHostTriggerCountSensor(ZabbixTriggerCountSensor): class ZabbixMultipleHostTriggerCountSensor(ZabbixTriggerCountSensor): """Get the active trigger count for specified Zabbix monitored hosts.""" - def __init__(self, zApi, hostids, name=None): + def __init__(self, zapi, hostids, name=None): """Initialize Zabbix sensor.""" - super().__init__(zApi, name) + super().__init__(zapi, name) self._hostids = hostids if not name: host_names = self._zapi.host.get(hostids=self._hostids, output="extend") diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 8b845f303cd..50db346451f 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -3,12 +3,13 @@ from __future__ import annotations import asyncio from contextlib import suppress +from dataclasses import dataclass import fnmatch from ipaddress import IPv4Address, IPv6Address, ip_address import logging import socket import sys -from typing import Any, TypedDict, cast +from typing import Any, Final, cast import voluptuous as vol from zeroconf import InterfaceChoice, IPVersion, ServiceStateChange @@ -17,6 +18,7 @@ from zeroconf.asyncio import AsyncServiceInfo from homeassistant import config_entries from homeassistant.components import network from homeassistant.components.network import async_get_source_ip +from homeassistant.components.network.const import MDNS_TARGET_IP from homeassistant.components.network.models import Adapter from homeassistant.const import ( EVENT_HOMEASSISTANT_START, @@ -24,8 +26,10 @@ from homeassistant.const import ( __version__, ) from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import discovery_flow import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.frame import report from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_homekit, async_get_zeroconf, bind_hass @@ -44,6 +48,18 @@ HOMEKIT_TYPES = [ "_hap._udp.local.", ] +# Keys we support matching against in properties that are always matched in +# upper case. ex: ZeroconfServiceInfo.properties["macaddress"] +UPPER_MATCH_PROPS = {"macaddress"} +# Keys we support matching against in properties that are always matched in +# lower case. ex: ZeroconfServiceInfo.properties["model"] +LOWER_MATCH_PROPS = {"manufacturer", "model"} +# Top level keys we support matching against in properties that are always matched in +# lower case. ex: ZeroconfServiceInfo.name +LOWER_MATCH_ATTRS = {"name"} +# Everything we support matching +ALL_MATCHERS = UPPER_MATCH_PROPS | LOWER_MATCH_PROPS | LOWER_MATCH_ATTRS + CONF_DEFAULT_INTERFACE = "default_interface" CONF_IPV6 = "ipv6" DEFAULT_DEFAULT_INTERFACE = True @@ -52,8 +68,6 @@ DEFAULT_IPV6 = True HOMEKIT_PAIRED_STATUS_FLAG = "sf" HOMEKIT_MODEL = "md" -MDNS_TARGET_IP = "224.0.0.251" - # Property key=value has a max length of 255 # so we use 230 to leave space for key= MAX_PROPERTY_VALUE_LEN = 230 @@ -61,6 +75,10 @@ MAX_PROPERTY_VALUE_LEN = 230 # Dns label max length MAX_NAME_LEN = 63 +# Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] +ATTR_PROPERTIES_ID: Final = "id" + + CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( @@ -78,7 +96,8 @@ CONFIG_SCHEMA = vol.Schema( ) -class HaServiceInfo(TypedDict): +@dataclass +class ZeroconfServiceInfo(BaseServiceInfo): """Prepared info from mDNS entries.""" host: str @@ -88,6 +107,41 @@ class HaServiceInfo(TypedDict): name: str properties: dict[str, Any] + # Used to prevent log flooding. To be removed in 2022.6 + _warning_logged: bool = False + + def __getitem__(self, name: str) -> Any: + """ + Enable method for compatibility reason. + + Deprecated, and will be removed in version 2022.6. + """ + if not self._warning_logged: + report( + f"accessed discovery_info['{name}'] instead of discovery_info.{name}; this will fail in version 2022.6", + exclude_integrations={DOMAIN}, + error_if_core=False, + ) + self._warning_logged = True + return getattr(self, name) + + def get(self, name: str, default: Any = None) -> Any: + """ + Enable method for compatibility reason. + + Deprecated, and will be removed in version 2022.6. + """ + if not self._warning_logged: + report( + f"accessed discovery_info.get('{name}') instead of discovery_info.{name}; this will fail in version 2022.6", + exclude_integrations={DOMAIN}, + error_if_core=False, + ) + self._warning_logged = True + if hasattr(self, name): + return getattr(self, name) + return default + @bind_hass async def async_get_instance(hass: HomeAssistant) -> HaZeroconf: @@ -272,6 +326,18 @@ async def _async_register_hass_zc_service( await aio_zc.async_register_service(info, allow_name_change=True) +def _match_against_data(matcher: dict[str, str], match_data: dict[str, str]) -> bool: + """Check a matcher to ensure all values in match_data match.""" + return not any( + key + for key in ALL_MATCHERS + if key in matcher + and ( + key not in match_data or not fnmatch.fnmatch(match_data[key], matcher[key]) + ) + ) + + class ZeroconfDiscovery: """Discovery via zeroconf.""" @@ -346,10 +412,10 @@ class ZeroconfDiscovery: return _LOGGER.debug("Discovered new device %s %s", name, info) + props: dict[str, str] = info.properties # If we can handle it as a HomeKit discovery, we do that here. if service_type in HOMEKIT_TYPES: - props = info["properties"] if domain := async_get_homekit_discovery_domain(self.homekit_models, props): discovery_flow.async_create_flow( self.hass, domain, {"source": config_entries.SOURCE_HOMEKIT}, info @@ -371,44 +437,22 @@ class ZeroconfDiscovery: # likely bad homekit data return - if "name" in info: - lowercase_name: str | None = info["name"].lower() - else: - lowercase_name = None - - if "macaddress" in info["properties"]: - uppercase_mac: str | None = info["properties"]["macaddress"].upper() - else: - uppercase_mac = None - - if "manufacturer" in info["properties"]: - lowercase_manufacturer: str | None = info["properties"][ - "manufacturer" - ].lower() - else: - lowercase_manufacturer = None + match_data: dict[str, str] = {} + for key in LOWER_MATCH_ATTRS: + attr_value: str = getattr(info, key) + match_data[key] = attr_value.lower() + for key in UPPER_MATCH_PROPS: + if key in props: + match_data[key] = props[key].upper() + for key in LOWER_MATCH_PROPS: + if key in props: + match_data[key] = props[key].lower() # Not all homekit types are currently used for discovery # so not all service type exist in zeroconf_types for matcher in self.zeroconf_types.get(service_type, []): - if len(matcher) > 1: - if "macaddress" in matcher and ( - uppercase_mac is None - or not fnmatch.fnmatch(uppercase_mac, matcher["macaddress"]) - ): - continue - if "name" in matcher and ( - lowercase_name is None - or not fnmatch.fnmatch(lowercase_name, matcher["name"]) - ): - continue - if "manufacturer" in matcher and ( - lowercase_manufacturer is None - or not fnmatch.fnmatch( - lowercase_manufacturer, matcher["manufacturer"] - ) - ): - continue + if len(matcher) > 1 and not _match_against_data(matcher, match_data): + continue discovery_flow.async_create_flow( self.hass, @@ -447,7 +491,7 @@ def async_get_homekit_discovery_domain( return None -def info_from_service(service: AsyncServiceInfo) -> HaServiceInfo | None: +def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: """Return prepared info from mDNS entries.""" properties: dict[str, Any] = {"_raw": {}} @@ -469,21 +513,19 @@ def info_from_service(service: AsyncServiceInfo) -> HaServiceInfo | None: if isinstance(value, bytes): properties[key] = value.decode("utf-8") - addresses = service.addresses - - if not addresses: + if not (addresses := service.addresses): return None if (host := _first_non_link_local_or_v6_address(addresses)) is None: return None - return { - "host": str(host), - "port": service.port, - "hostname": service.server, - "type": service.type, - "name": service.name, - "properties": properties, - } + return ZeroconfServiceInfo( + host=str(host), + port=service.port, + hostname=service.server, + type=service.type, + name=service.name, + properties=properties, + ) def _first_non_link_local_or_v6_address(addresses: list[bytes]) -> str | None: diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 95f9407661b..338456ca576 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.36.13"], + "requirements": ["zeroconf==0.37.0"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/components/zeroconf/usage.py b/homeassistant/components/zeroconf/usage.py index f7689ab63a4..ab0a0eaf9a7 100644 --- a/homeassistant/components/zeroconf/usage.py +++ b/homeassistant/components/zeroconf/usage.py @@ -1,28 +1,22 @@ """Zeroconf usage utility to warn about multiple instances.""" -from contextlib import suppress -import logging from typing import Any import zeroconf -from homeassistant.helpers.frame import ( - MissingIntegrationFrame, - get_integration_frame, - report_integration, -) +from homeassistant.helpers.frame import report from .models import HaZeroconf -_LOGGER = logging.getLogger(__name__) - def install_multiple_zeroconf_catcher(hass_zc: HaZeroconf) -> None: """Wrap the Zeroconf class to return the shared instance if multiple instances are detected.""" def new_zeroconf_new(self: zeroconf.Zeroconf, *k: Any, **kw: Any) -> HaZeroconf: - _report( + report( "attempted to create another Zeroconf instance. Please use the shared Zeroconf via await homeassistant.components.zeroconf.async_get_instance(hass)", + exclude_integrations={"zeroconf"}, + error_if_core=False, ) return hass_zc @@ -31,22 +25,3 @@ def install_multiple_zeroconf_catcher(hass_zc: HaZeroconf) -> None: zeroconf.Zeroconf.__new__ = new_zeroconf_new # type: ignore zeroconf.Zeroconf.__init__ = new_zeroconf_init # type: ignore - - -def _report(what: str) -> None: - """Report incorrect usage. - - Async friendly. - """ - integration_frame = None - - with suppress(MissingIntegrationFrame): - integration_frame = get_integration_frame(exclude_integrations={"zeroconf"}) - - if not integration_frame: - _LOGGER.warning( - "Detected code that %s; Please report this issue", what, stack_info=True - ) - return - - report_integration(what, integration_frame) diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py index 3f0136f9b0d..bc9b3cae410 100644 --- a/homeassistant/components/zerproc/light.py +++ b/homeassistant/components/zerproc/light.py @@ -16,6 +16,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval import homeassistant.util.color as color_util @@ -117,13 +118,13 @@ class ZerprocLight(LightEntity): return self._light.address @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info for this light.""" - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "manufacturer": "Zerproc", - } + return DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + manufacturer="Zerproc", + name=self.name, + ) @property def icon(self) -> str | None: diff --git a/homeassistant/components/zerproc/translations/ja.json b/homeassistant/components/zerproc/translations/ja.json new file mode 100644 index 00000000000..d1234b69652 --- /dev/null +++ b/homeassistant/components/zerproc/translations/ja.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "step": { + "confirm": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index bac32563776..36e13a780d9 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -7,14 +7,12 @@ import voluptuous as vol import xmltodict 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 import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) _RESOURCE = "http://www.zillow.com/webservice/GetZestimate.htm" -ATTRIBUTION = "Data provided by Zillow.com" - CONF_ZPID = "zpid" DEFAULT_NAME = "Zestimate" @@ -58,6 +56,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ZestimateDataSensor(SensorEntity): """Implementation of a Zestimate sensor.""" + _attr_attribution = "Data provided by Zillow.com" + def __init__(self, name, params): """Initialize the sensor.""" self._name = name @@ -91,7 +91,6 @@ class ZestimateDataSensor(SensorEntity): if self.data is not None: attributes = self.data attributes["address"] = self.address - attributes[ATTR_ATTRIBUTION] = ATTRIBUTION return attributes @property diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 403af7c6612..48e70c86c1f 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -1145,10 +1145,8 @@ def async_load_api(hass): strobe = service.data.get(ATTR_WARNING_DEVICE_STROBE) level = service.data.get(ATTR_LEVEL) - zha_device = zha_gateway.get_device(ieee) - if zha_device is not None: - channel = _get_ias_wd_channel(zha_device) - if channel: + if (zha_device := zha_gateway.get_device(ieee)) is not None: + if channel := _get_ias_wd_channel(zha_device): await channel.issue_squawk(mode, strobe, level) else: _LOGGER.error( @@ -1189,10 +1187,8 @@ def async_load_api(hass): duty_mode = service.data.get(ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE) intensity = service.data.get(ATTR_WARNING_DEVICE_STROBE_INTENSITY) - zha_device = zha_gateway.get_device(ieee) - if zha_device is not None: - channel = _get_ias_wd_channel(zha_device) - if channel: + if (zha_device := zha_gateway.get_device(ieee)) is not None: + if channel := _get_ias_wd_channel(zha_device): await channel.issue_start_warning( mode, strobe, level, duration, duty_mode, intensity ) diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index b82cccd5324..f7a1d1815db 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -405,8 +405,7 @@ class Thermostat(ZhaEntity, ClimateEntity): # occupancy attribute is an unreportable attribute, but if we get # an attribute update for an "occupied" setpoint, there's a chance # occupancy has changed - occupancy = await self._thrm.get_occupancy() - if occupancy is True: + if await self._thrm.get_occupancy() is True: self._preset = PRESET_NONE self.debug("Attribute '%s' = %s update", record.attr_name, record.value) @@ -600,7 +599,7 @@ class ZenWithinThermostat(Thermostat): channel_names=CHANNEL_THERMOSTAT, aux_channels=CHANNEL_FAN, manufacturers="Centralite", - models="3157100", + models={"3157100", "3157100-E"}, stop_on_match=True, ) class CentralitePearl(ZenWithinThermostat): @@ -614,10 +613,13 @@ class CentralitePearl(ZenWithinThermostat): "_TZE200_ywdxldoj", "_TZE200_cwnjrr72", "_TZE200_b6wax7g0", + "_TZE200_2atgpdho", + "_TZE200_pvvbommb", + "_TZE200_4eeyebrt", "_TYST11_ckud7u2l", "_TYST11_ywdxldoj", "_TYST11_cwnjrr72", - "_TYST11_b6wax7g0", + "_TYST11_2atgpdho", }, ) class MoesThermostat(Thermostat): diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index a3eaffdf1ba..4d6b22a82e5 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -8,9 +8,9 @@ import voluptuous as vol from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from homeassistant import config_entries -from homeassistant.components import usb -from homeassistant.const import CONF_HOST, CONF_NAME -from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.components import usb, zeroconf +from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult from .core.const import ( CONF_BAUDRATE, @@ -94,14 +94,14 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema(schema), ) - async def async_step_usb(self, discovery_info: DiscoveryInfoType): + async def async_step_usb(self, discovery_info: usb.UsbServiceInfo) -> FlowResult: """Handle usb discovery.""" - vid = discovery_info["vid"] - pid = discovery_info["pid"] - serial_number = discovery_info["serial_number"] - device = discovery_info["device"] - manufacturer = discovery_info["manufacturer"] - description = discovery_info["description"] + vid = discovery_info.vid + pid = discovery_info.pid + serial_number = discovery_info.serial_number + device = discovery_info.device + manufacturer = discovery_info.manufacturer + description = discovery_info.description dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) unique_id = f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" if current_entry := await self.async_set_unique_id(unique_id): @@ -159,12 +159,14 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({}), ) - async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: """Handle zeroconf discovery.""" # Hostname is format: livingroom.local. - local_name = discovery_info["hostname"][:-1] + local_name = discovery_info.hostname[:-1] node_name = local_name[: -len(".local")] - host = discovery_info[CONF_HOST] + host = discovery_info.host device_path = f"socket://{host}:6638" if current_entry := await self.async_set_unique_id(node_name): diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py index 89a9d51395e..5c3ef3a0f6c 100644 --- a/homeassistant/components/zha/core/channels/homeautomation.py +++ b/homeassistant/components/zha/core/channels/homeautomation.py @@ -65,6 +65,7 @@ class ElectricalMeasurementChannel(ZigbeeChannel): REPORT_CONFIG = ( {"attr": "active_power", "config": REPORT_CONFIG_OP}, {"attr": "active_power_max", "config": REPORT_CONFIG_DEFAULT}, + {"attr": "apparent_power", "config": REPORT_CONFIG_OP}, {"attr": "rms_current", "config": REPORT_CONFIG_OP}, {"attr": "rms_current_max", "config": REPORT_CONFIG_DEFAULT}, {"attr": "rms_voltage", "config": REPORT_CONFIG_OP}, diff --git a/homeassistant/components/zha/core/channels/measurement.py b/homeassistant/components/zha/core/channels/measurement.py index 19ecc8a6335..093c04245c4 100644 --- a/homeassistant/components/zha/core/channels/measurement.py +++ b/homeassistant/components/zha/core/channels/measurement.py @@ -62,6 +62,30 @@ class RelativeHumidity(ZigbeeChannel): ] +@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.SoilMoisture.cluster_id) +class SoilMoisture(ZigbeeChannel): + """Soil Moisture measurement channel.""" + + REPORT_CONFIG = [ + { + "attr": "measured_value", + "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), + } + ] + + +@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.LeafWetness.cluster_id) +class LeafWetness(ZigbeeChannel): + """Leaf Wetness measurement channel.""" + + REPORT_CONFIG = [ + { + "attr": "measured_value", + "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), + } + ] + + @registries.ZIGBEE_CHANNEL_REGISTRY.register( measurement.TemperatureMeasurement.cluster_id ) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index dd6832e0d6b..813f268bbe5 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -22,6 +22,7 @@ from homeassistant.components.light import DOMAIN as LIGHT from homeassistant.components.lock import DOMAIN as LOCK from homeassistant.components.number import DOMAIN as NUMBER from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.components.siren import DOMAIN as SIREN from homeassistant.components.switch import DOMAIN as SWITCH import homeassistant.helpers.config_validation as cv @@ -84,6 +85,8 @@ CHANNEL_ELECTRICAL_MEASUREMENT = "electrical_measurement" CHANNEL_EVENT_RELAY = "event_relay" CHANNEL_FAN = "fan" CHANNEL_HUMIDITY = "humidity" +CHANNEL_SOIL_MOISTURE = "soil_moisture" +CHANNEL_LEAF_WETNESS = "leaf_wetness" CHANNEL_IAS_ACE = "ias_ace" CHANNEL_IAS_WD = "ias_wd" CHANNEL_IDENTIFY = "identify" @@ -118,6 +121,7 @@ PLATFORMS = ( LOCK, NUMBER, SENSOR, + SIREN, SWITCH, ) diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index df257dbbecc..1d9edb82980 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -25,6 +25,7 @@ from .. import ( # noqa: F401 pylint: disable=unused-import, lock, number, sensor, + siren, switch, ) from .channels import base diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 8b2c4d11fbf..f624ef9289d 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -21,6 +21,7 @@ from homeassistant.components.light import DOMAIN as LIGHT from homeassistant.components.lock import DOMAIN as LOCK from homeassistant.components.number import DOMAIN as NUMBER from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.components.siren import DOMAIN as SIREN from homeassistant.components.switch import DOMAIN as SWITCH # importing channels updates registries @@ -82,6 +83,8 @@ SINGLE_INPUT_CLUSTER_DEVICE_CLASS = { zcl.clusters.measurement.OccupancySensing.cluster_id: BINARY_SENSOR, zcl.clusters.measurement.PressureMeasurement.cluster_id: SENSOR, zcl.clusters.measurement.RelativeHumidity.cluster_id: SENSOR, + zcl.clusters.measurement.SoilMoisture.cluster_id: SENSOR, + zcl.clusters.measurement.LeafWetness.cluster_id: SENSOR, zcl.clusters.measurement.TemperatureMeasurement.cluster_id: SENSOR, zcl.clusters.security.IasZone.cluster_id: BINARY_SENSOR, } @@ -111,6 +114,7 @@ DEVICE_CLASS = { zigpy.profiles.zha.DeviceType.SHADE: COVER, zigpy.profiles.zha.DeviceType.SMART_PLUG: SWITCH, zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL: ALARM, + zigpy.profiles.zha.DeviceType.IAS_WARNING_DEVICE: SIREN, }, zigpy.profiles.zll.PROFILE_ID: { zigpy.profiles.zll.DeviceType.COLOR_LIGHT: LIGHT, diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 0ba75a99306..80697c704bf 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -206,8 +206,7 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): if not self.zha_device.is_mains_powered: # mains powered devices will get real time state - last_state = await self.async_get_last_state() - if last_state: + if last_state := await self.async_get_last_state(): self.async_restore_last_state(last_state) self.async_accept_signal( diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index a4cae4686bc..960bb55e004 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,20 +4,21 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows==0.28.0", + "bellows==0.29.0", "pyserial==3.5", "pyserial-asyncio==0.5", - "zha-quirks==0.0.63", - "zigpy-deconz==0.13.0", - "zigpy==0.39.0", + "zha-quirks==0.0.65", + "zigpy-deconz==0.14.0", + "zigpy==0.42.0", "zigpy-xbee==0.14.0", "zigpy-zigate==0.7.3", - "zigpy-znp==0.5.4" + "zigpy-znp==0.6.4" ], "usb": [ {"vid":"10C4","pid":"EA60","description":"*2652*","known_devices":["slae.sh cc2652rb stick"]}, {"vid":"10C4","pid":"EA60","description":"*tubeszb*","known_devices":["TubesZB Coordinator"]}, {"vid":"1A86","pid":"7523","description":"*tubeszb*","known_devices":["TubesZB Coordinator"]}, + {"vid":"1A86","pid":"7523","description":"*zigstar*","known_devices":["ZigStar Coordinators"]}, {"vid":"1CF1","pid":"0030","description":"*conbee*","known_devices":["Conbee II"]}, {"vid":"10C4","pid":"8A2A","description":"*zigbee*","known_devices":["Nortek HUSBZB-1"]}, {"vid":"10C4","pid":"8B34","description":"*bv 2010/10*","known_devices":["Bitron Video AV2010/10"]} diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 2281d5295bc..567d2a6065e 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -30,6 +30,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_ENERGY, ELECTRIC_CURRENT_AMPERE, @@ -38,6 +39,7 @@ from homeassistant.const import ( ENTITY_CATEGORY_DIAGNOSTIC, LIGHT_LUX, PERCENTAGE, + POWER_VOLT_AMPERE, POWER_WATT, PRESSURE_HPA, TEMP_CELSIUS, @@ -59,11 +61,14 @@ from .core import discovery from .core.const import ( CHANNEL_ANALOG_INPUT, CHANNEL_ELECTRICAL_MEASUREMENT, + CHANNEL_FAN, CHANNEL_HUMIDITY, CHANNEL_ILLUMINANCE, + CHANNEL_LEAF_WETNESS, CHANNEL_POWER_CONFIGURATION, CHANNEL_PRESSURE, CHANNEL_SMARTENERGY_METERING, + CHANNEL_SOIL_MOISTURE, CHANNEL_TEMPERATURE, CHANNEL_THERMOSTAT, DATA_ZHA, @@ -311,6 +316,23 @@ class ElectricalMeasurement(Sensor): await super().async_update() +@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) +class ElectricalMeasurementApparentPower( + ElectricalMeasurement, id_suffix="apparent_power" +): + """Apparent power measurement.""" + + SENSOR_ATTR = "apparent_power" + _device_class = DEVICE_CLASS_POWER + _unit = POWER_VOLT_AMPERE + _div_mul_prefix = "ac_power" + + @property + def should_poll(self) -> bool: + """Poll indirectly by ElectricalMeasurementSensor.""" + return False + + @MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_current"): """RMS current measurement.""" @@ -353,6 +375,28 @@ class Humidity(Sensor): _unit = PERCENTAGE +@STRICT_MATCH(channel_names=CHANNEL_SOIL_MOISTURE) +class SoilMoisture(Sensor): + """Soil Moisture sensor.""" + + SENSOR_ATTR = "measured_value" + _device_class = DEVICE_CLASS_HUMIDITY + _divisor = 100 + _state_class = STATE_CLASS_MEASUREMENT + _unit = PERCENTAGE + + +@STRICT_MATCH(channel_names=CHANNEL_LEAF_WETNESS) +class LeafWetness(Sensor): + """Leaf Wetness sensor.""" + + SENSOR_ATTR = "measured_value" + _device_class = DEVICE_CLASS_HUMIDITY + _divisor = 100 + _state_class = STATE_CLASS_MEASUREMENT + _unit = PERCENTAGE + + @STRICT_MATCH(channel_names=CHANNEL_ILLUMINANCE) class Illuminance(Sensor): """Illuminance Sensor.""" @@ -499,6 +543,16 @@ class VOCLevel(Sensor): _unit = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER +@STRICT_MATCH(channel_names="voc_level", models="lumi.airmonitor.acn01") +class PPBVOCLevel(Sensor): + """VOC Level sensor.""" + + SENSOR_ATTR = "measured_value" + _decimals = 0 + _multiplier = 1 + _unit = CONCENTRATION_PARTS_PER_BILLION + + @STRICT_MATCH(channel_names="formaldehyde_concentration") class FormaldehydeConcentration(Sensor): """Formaldehyde Concentration sensor.""" @@ -583,6 +637,13 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): self.async_write_ha_state() +@MULTI_MATCH( + channel_names=CHANNEL_THERMOSTAT, + aux_channels=CHANNEL_FAN, + manufacturers="Centralite", + models={"3157100", "3157100-E"}, + stop_on_match=True, +) @MULTI_MATCH( channel_names=CHANNEL_THERMOSTAT, manufacturers="Zen Within", diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py new file mode 100644 index 00000000000..75f527cfcf1 --- /dev/null +++ b/homeassistant/components/zha/siren.py @@ -0,0 +1,143 @@ +"""Support for ZHA sirens.""" + +from __future__ import annotations + +import functools +from typing import Any + +from homeassistant.components.siren import ( + ATTR_DURATION, + DOMAIN, + SUPPORT_DURATION, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SirenEntity, +) +from homeassistant.components.siren.const import ( + ATTR_TONE, + ATTR_VOLUME_LEVEL, + SUPPORT_TONES, + SUPPORT_VOLUME_SET, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later + +from .core import discovery +from .core.channels.security import IasWd +from .core.const import ( + CHANNEL_IAS_WD, + DATA_ZHA, + SIGNAL_ADD_ENTITIES, + WARNING_DEVICE_MODE_BURGLAR, + WARNING_DEVICE_MODE_EMERGENCY, + WARNING_DEVICE_MODE_EMERGENCY_PANIC, + WARNING_DEVICE_MODE_FIRE, + WARNING_DEVICE_MODE_FIRE_PANIC, + WARNING_DEVICE_MODE_POLICE_PANIC, + WARNING_DEVICE_MODE_STOP, + WARNING_DEVICE_SOUND_HIGH, + WARNING_DEVICE_STROBE_NO, +) +from .core.registries import ZHA_ENTITIES +from .core.typing import ChannelType, ZhaDeviceType +from .entity import ZhaEntity + +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) +DEFAULT_DURATION = 5 # seconds + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation siren from config entry.""" + entities_to_create = hass.data[DATA_ZHA][DOMAIN] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, + async_add_entities, + entities_to_create, + update_before_add=False, + ), + ) + config_entry.async_on_unload(unsub) + + +@STRICT_MATCH(channel_names=CHANNEL_IAS_WD) +class ZHASiren(ZhaEntity, SirenEntity): + """Representation of a ZHA siren.""" + + def __init__( + self, + unique_id: str, + zha_device: ZhaDeviceType, + channels: list[ChannelType], + **kwargs, + ) -> None: + """Init this siren.""" + self._attr_supported_features = ( + SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_DURATION + | SUPPORT_VOLUME_SET + | SUPPORT_TONES + ) + self._attr_available_tones: list[int | str] | dict[int, str] | None = { + WARNING_DEVICE_MODE_BURGLAR: "Burglar", + WARNING_DEVICE_MODE_FIRE: "Fire", + WARNING_DEVICE_MODE_EMERGENCY: "Emergency", + WARNING_DEVICE_MODE_POLICE_PANIC: "Police Panic", + WARNING_DEVICE_MODE_FIRE_PANIC: "Fire Panic", + WARNING_DEVICE_MODE_EMERGENCY_PANIC: "Emergency Panic", + } + super().__init__(unique_id, zha_device, channels, **kwargs) + self._channel: IasWd = channels[0] + self._attr_is_on: bool = False + self._off_listener = None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on siren.""" + if self._off_listener: + self._off_listener() + self._off_listener = None + siren_tone = WARNING_DEVICE_MODE_EMERGENCY + siren_duration = DEFAULT_DURATION + siren_level = WARNING_DEVICE_SOUND_HIGH + if (duration := kwargs.get(ATTR_DURATION)) is not None: + siren_duration = duration + if (tone := kwargs.get(ATTR_TONE)) is not None: + siren_tone = tone + if (level := kwargs.get(ATTR_VOLUME_LEVEL)) is not None: + siren_level = int(level) + await self._channel.issue_start_warning( + mode=siren_tone, warning_duration=siren_duration, siren_level=siren_level + ) + self._attr_is_on = True + self._off_listener = async_call_later( + self._zha_device.hass, siren_duration, self.async_set_off + ) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off siren.""" + await self._channel.issue_start_warning( + mode=WARNING_DEVICE_MODE_STOP, strobe=WARNING_DEVICE_STROBE_NO + ) + self._attr_is_on = False + self.async_write_ha_state() + + @callback + def async_set_off(self, _) -> None: + """Set is_on to False and write HA state.""" + self._attr_is_on = False + if self._off_listener: + self._off_listener() + self._off_listener = None + self.async_write_ha_state() diff --git a/homeassistant/components/zha/translations/ja.json b/homeassistant/components/zha/translations/ja.json new file mode 100644 index 00000000000..35e8933220a --- /dev/null +++ b/homeassistant/components/zha/translations/ja.json @@ -0,0 +1,97 @@ +{ + "config": { + "abort": { + "not_zha_device": "\u3053\u306e\u30c7\u30d0\u30a4\u30b9\u306fzha\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u3042\u308a\u307e\u305b\u3093", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002", + "usb_probe_failed": "USB\u30c7\u30d0\u30a4\u30b9\u3092\u63a2\u3057\u51fa\u3059\u3053\u3068\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "{name} \u3092\u8a2d\u5b9a\u3057\u307e\u3059\u304b\uff1f" + }, + "pick_radio": { + "data": { + "radio_type": "\u7121\u7dda\u30bf\u30a4\u30d7" + }, + "description": "Zigbee\u7121\u7dda\u6a5f\u306e\u30bf\u30a4\u30d7\u3092\u9078\u3076", + "title": "\u7121\u7dda\u30bf\u30a4\u30d7" + }, + "port_config": { + "data": { + "baudrate": "\u30dd\u30fc\u30c8\u901f\u5ea6", + "flow_control": "\u30c7\u30fc\u30bf\u30d5\u30ed\u30fc\u30b3\u30f3\u30c8\u30ed\u30fc\u30eb", + "path": "\u30b7\u30ea\u30a2\u30eb \u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "description": "\u30dd\u30fc\u30c8\u56fa\u6709\u306e\u8a2d\u5b9a\u3092\u5165\u529b", + "title": "\u8a2d\u5b9a" + }, + "user": { + "data": { + "path": "\u30b7\u30ea\u30a2\u30eb \u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "description": "Zigbee radio\u7528\u30b7\u30ea\u30a2\u30eb\u30dd\u30fc\u30c8\u3092\u9078\u629e", + "title": "ZHA" + } + } + }, + "config_panel": { + "zha_alarm_options": { + "alarm_arm_requires_code": "\u8b66\u6212\u30a2\u30af\u30b7\u30e7\u30f3\u306b\u5fc5\u8981\u306a\u30b3\u30fc\u30c9", + "alarm_failed_tries": "\u30a2\u30e9\u30fc\u30e0\u3092\u30c8\u30ea\u30ac\u30fc\u3055\u305b\u308b\u305f\u3081\u306b\u9023\u7d9a\u3057\u3066\u5931\u6557\u3057\u305f\u30b3\u30fc\u30c9 \u30a8\u30f3\u30c8\u30ea\u306e\u6570", + "alarm_master_code": "\u30a2\u30e9\u30fc\u30e0 \u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u30d1\u30cd\u30eb\u306e\u30de\u30b9\u30bf\u30fc\u30b3\u30fc\u30c9", + "title": "\u30a2\u30e9\u30fc\u30e0 \u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u30d1\u30cd\u30eb\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + }, + "zha_options": { + "default_light_transition": "\u30c7\u30d5\u30a9\u30eb\u30c8\u306e\u30e9\u30a4\u30c8\u9077\u79fb\u6642\u9593(\u79d2)", + "enable_identify_on_join": "\u30c7\u30d0\u30a4\u30b9\u304c\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u306b\u53c2\u52a0\u3059\u308b\u969b\u306b\u3001\u8b58\u5225\u52b9\u679c\u3092\u6709\u52b9\u306b\u3059\u308b", + "title": "\u30b0\u30ed\u30fc\u30d0\u30eb\u30aa\u30d7\u30b7\u30e7\u30f3" + } + }, + "device_automation": { + "action_type": { + "squawk": "\u30b9\u30b3\u30fc\u30af(Squawk)" + }, + "trigger_subtype": { + "both_buttons": "\u4e21\u65b9\u306e\u30dc\u30bf\u30f3", + "button_1": "1\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "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", + "close": "\u30af\u30ed\u30fc\u30ba", + "dim_down": "\u8584\u6697\u304f\u3059\u308b", + "dim_up": "\u5fae\u304b\u306b\u660e\u308b\u304f\u3059\u308b", + "left": "\u5de6", + "open": "\u30aa\u30fc\u30d7\u30f3", + "right": "\u53f3", + "turn_off": "\u30aa\u30d5\u306b\u3059\u308b", + "turn_on": "\u30aa\u30f3\u306b\u3059\u308b" + }, + "trigger_type": { + "device_flipped": "\u30c7\u30d0\u30a4\u30b9\u304c\u53cd\u8ee2\u3057\u307e\u3057\u305f \"{subtype}\"", + "device_knocked": "\u30c7\u30d0\u30a4\u30b9\u304c\u30ce\u30c3\u30af\u3055\u308c\u307e\u3057\u305f \"{subtype}\"", + "device_offline": "\u30c7\u30d0\u30a4\u30b9\u304c\u30aa\u30d5\u30e9\u30a4\u30f3", + "device_rotated": "\u30c7\u30d0\u30a4\u30b9\u304c\u56de\u8ee2\u3057\u307e\u3057\u305f \"{subtype}\"", + "device_shaken": "\u30c7\u30d0\u30a4\u30b9\u304c\u63fa\u308c\u308b", + "device_slid": "\u30c7\u30d0\u30a4\u30b9 \u30b9\u30e9\u30a4\u30c9 \"{subtype}\"", + "device_tilted": "\u30c7\u30d0\u30a4\u30b9\u304c\u50be\u3044\u3066\u3044\u308b", + "remote_button_alt_double_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u3092\u30c0\u30d6\u30eb\u30af\u30ea\u30c3\u30af(\u4ee3\u66ff\u30e2\u30fc\u30c9)", + "remote_button_alt_long_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u3092\u62bc\u3057\u7d9a\u3051\u308b(\u4ee3\u66ff\u30e2\u30fc\u30c9)", + "remote_button_alt_quadruple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30924\u56de(quadruple)\u30af\u30ea\u30c3\u30af(\u4ee3\u66ff\u30e2\u30fc\u30c9)", + "remote_button_alt_quintuple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30925\u56de(quintuple)\u30af\u30ea\u30c3\u30af(\u4ee3\u66ff\u30e2\u30fc\u30c9)", + "remote_button_alt_short_press": "\"{subtype}\" \u62bc\u3057\u7d9a\u3051\u308b(\u4ee3\u66ff\u30e2\u30fc\u30c9)", + "remote_button_alt_triple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30923\u56de\u30af\u30ea\u30c3\u30af(\u4ee3\u66ff\u30e2\u30fc\u30c9)", + "remote_button_double_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u3092\u30c0\u30d6\u30eb\u30af\u30ea\u30c3\u30af", + "remote_button_long_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u3092\u62bc\u3057\u7d9a\u3051\u308b", + "remote_button_quadruple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30924\u56de(quadruple)\u30af\u30ea\u30c3\u30af", + "remote_button_quintuple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30925\u56de(quintuple)\u30af\u30ea\u30c3\u30af", + "remote_button_short_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u304c\u62bc\u3055\u308c\u307e\u3057\u305f\u3002", + "remote_button_triple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30923\u56de\u30af\u30ea\u30c3\u30af" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/translations/tr.json b/homeassistant/components/zha/translations/tr.json index a74f56a2f4e..39eb68d8d53 100644 --- a/homeassistant/components/zha/translations/tr.json +++ b/homeassistant/components/zha/translations/tr.json @@ -1,26 +1,112 @@ { "config": { "abort": { - "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + "not_zha_device": "Bu cihaz bir zha cihaz\u0131 de\u011fil", + "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr.", + "usb_probe_failed": "USB ayg\u0131t\u0131 ara\u015ft\u0131r\u0131lamad\u0131" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" }, + "flow_title": "{name}", "step": { + "confirm": { + "description": "{name} kurulumunu yapmak istiyor musunuz?" + }, "pick_radio": { + "data": { + "radio_type": "Radyo Tipi" + }, + "description": "Zigbee radyonuzun bir t\u00fcr\u00fcn\u00fc se\u00e7in", "title": "Radyo Tipi" }, "port_config": { "data": { + "baudrate": "ba\u011flant\u0131 noktas\u0131 h\u0131z\u0131", + "flow_control": "veri ak\u0131\u015f\u0131 denetimi", "path": "Seri cihaz yolu" }, + "description": "Ba\u011flant\u0131 noktas\u0131na \u00f6zel ayarlar\u0131 girin", "title": "Ayarlar" + }, + "user": { + "data": { + "path": "Seri Cihaz Yolu" + }, + "description": "Zigbee radyo i\u00e7in seri ba\u011flant\u0131 noktas\u0131 se\u00e7in", + "title": "ZHA" } } }, + "config_panel": { + "zha_alarm_options": { + "alarm_arm_requires_code": "Kurma eylemleri i\u00e7in gerekli kod", + "alarm_failed_tries": "Bir alarm\u0131 tetiklemek i\u00e7in ard\u0131\u015f\u0131k ba\u015far\u0131s\u0131z kod giri\u015flerinin say\u0131s\u0131", + "alarm_master_code": "Alarm kontrol panel(ler)i i\u00e7in ana kod", + "title": "Alarm Kontrol Paneli Se\u00e7enekleri" + }, + "zha_options": { + "consider_unavailable_battery": "Pille \u00e7al\u0131\u015fan ayg\u0131tlar\u0131n kullan\u0131lamad\u0131\u011f\u0131n\u0131 g\u00f6z \u00f6n\u00fcnde bulundurun (saniye)", + "consider_unavailable_mains": "\u015eebekeyle \u00e7al\u0131\u015fan ayg\u0131tlar\u0131n kullan\u0131lamad\u0131\u011f\u0131n\u0131 g\u00f6z \u00f6n\u00fcnde bulundurun (saniye)", + "default_light_transition": "Varsay\u0131lan \u0131\u015f\u0131k ge\u00e7i\u015f s\u00fcresi (saniye)", + "enable_identify_on_join": "Cihazlar a\u011fa kat\u0131ld\u0131\u011f\u0131nda tan\u0131mlama efektini etkinle\u015ftir", + "title": "Genel Se\u00e7enekler" + } + }, "device_automation": { + "action_type": { + "squawk": "Squawk", + "warn": "Uyarmak" + }, + "trigger_subtype": { + "both_buttons": "\u00c7ift d\u00fc\u011fmeler", + "button_1": "\u0130lk d\u00fc\u011fme", + "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", + "close": "Kapat", + "dim_down": "K\u0131sma", + "dim_up": "A\u00e7ma", + "face_1": "y\u00fcz 1 etkinle\u015ftirilmi\u015f", + "face_2": "y\u00fcz 2 etkinle\u015ftirilmi\u015f", + "face_3": "y\u00fcz 3 etkinle\u015ftirilmi\u015f", + "face_4": "y\u00fcz 4 etkinle\u015ftirilmi\u015f", + "face_5": "y\u00fcz 5 etkinle\u015ftirilmi\u015f", + "face_6": "y\u00fcz 6 etkinle\u015ftirilmi\u015f", + "face_any": "Herhangi bir/belirtilen y\u00fcz(ler) etkinle\u015ftirildi\u011finde", + "left": "Sol", + "open": "A\u00e7\u0131k", + "right": "Sa\u011f", + "turn_off": "Kapat", + "turn_on": "A\u00e7\u0131n" + }, "trigger_type": { - "device_offline": "Cihaz \u00e7evrimd\u0131\u015f\u0131" + "device_dropped": "Cihaz d\u00fc\u015ft\u00fc", + "device_flipped": "Ayg\u0131t \u00e7evrilmi\u015f \"{subtype}\"", + "device_knocked": "Cihaz \" {subtype} \" \u00f6\u011fesini \u00e7ald\u0131", + "device_offline": "Cihaz \u00e7evrimd\u0131\u015f\u0131", + "device_rotated": "Cihaz d\u00f6nd\u00fcr\u00fcld\u00fc \" {subtype} \"", + "device_shaken": "Cihaz salland\u0131", + "device_slid": "Cihaz kayd\u0131rd\u0131 \" {subtype} \"", + "device_tilted": "Cihaz e\u011fik", + "remote_button_alt_double_press": "\" {subtype} \" d\u00fc\u011fmesine \u00e7ift t\u0131kland\u0131 (Alternatif mod)", + "remote_button_alt_long_press": "\" {subtype} \" d\u00fc\u011fmesi s\u00fcrekli bas\u0131l\u0131 (Alternatif mod)", + "remote_button_alt_long_release": "\" {subtype} \" d\u00fc\u011fmesi uzun bas\u0131ld\u0131ktan sonra b\u0131rak\u0131ld\u0131 (Alternatif mod)", + "remote_button_alt_quadruple_press": "\" {subtype} \" d\u00fc\u011fmesi d\u00f6rt kez t\u0131kland\u0131 (Alternatif mod)", + "remote_button_alt_quintuple_press": "\" {subtype} \" d\u00fc\u011fmesi be\u015f kez t\u0131kland\u0131 (Alternatif mod)", + "remote_button_alt_short_press": "\" {subtype} \" d\u00fc\u011fmesine bas\u0131ld\u0131 (Alternatif mod)", + "remote_button_alt_short_release": "\" {subtype} \" d\u00fc\u011fmesi b\u0131rak\u0131ld\u0131 (Alternatif mod)", + "remote_button_alt_triple_press": "\" {subtype} \" d\u00fc\u011fmesine \u00fc\u00e7 kez t\u0131kland\u0131 (Alternatif mod)", + "remote_button_double_press": "\" {subtype} \" d\u00fc\u011fmesine \u00e7ift t\u0131kland\u0131", + "remote_button_long_press": "\" {subtype} \" d\u00fc\u011fmesi s\u00fcrekli bas\u0131l\u0131", + "remote_button_long_release": "\" {subtype} \" d\u00fc\u011fmesi uzun bas\u0131ld\u0131ktan sonra b\u0131rak\u0131ld\u0131", + "remote_button_quadruple_press": "\" {subtype} \" d\u00fc\u011fmesi d\u00f6rt kez t\u0131kland\u0131", + "remote_button_quintuple_press": "\" {subtype} \" d\u00fc\u011fmesi be\u015f kez t\u0131kland\u0131", + "remote_button_short_press": "\" {subtype} \" d\u00fc\u011fmesine bas\u0131ld\u0131", + "remote_button_short_release": "\" {subtype} \" d\u00fc\u011fmesi b\u0131rak\u0131ld\u0131", + "remote_button_triple_press": "\" {subtype} \" d\u00fc\u011fmesine \u00fc\u00e7 kez t\u0131kland\u0131" } } } \ No newline at end of file diff --git a/homeassistant/components/zodiac/translations/sensor.ja.json b/homeassistant/components/zodiac/translations/sensor.ja.json new file mode 100644 index 00000000000..fc95414254a --- /dev/null +++ b/homeassistant/components/zodiac/translations/sensor.ja.json @@ -0,0 +1,18 @@ +{ + "state": { + "zodiac__sign": { + "aquarius": "\u6c34\u74f6\u5ea7", + "aries": "\u7261\u7f8a\u5ea7", + "cancer": "\u304b\u306b\u5ea7", + "capricorn": "\u5c71\u7f8a\u5ea7", + "gemini": "\u53cc\u5b50\u5ea7", + "leo": "\u7345\u5b50\u5ea7", + "libra": "\u5929\u79e4\u5ea7", + "pisces": "\u3046\u304a\u5ea7", + "sagittarius": "\u5c04\u624b\u5ea7", + "scorpio": "\u880d\u5ea7", + "taurus": "\u7261\u725b\u5ea7", + "virgo": "\u4e59\u5973\u5ea7" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/translations/tr.json b/homeassistant/components/zone/translations/tr.json index dad65ac92a7..64935f31ae0 100644 --- a/homeassistant/components/zone/translations/tr.json +++ b/homeassistant/components/zone/translations/tr.json @@ -1,12 +1,21 @@ { "config": { + "error": { + "name_exists": "Bu ad zaten var" + }, "step": { "init": { "data": { + "icon": "Simge", "latitude": "Enlem", - "longitude": "Boylam" - } + "longitude": "Boylam", + "name": "Ad", + "passive": "Pasif", + "radius": "Yar\u0131\u00e7ap" + }, + "title": "B\u00f6lge parametrelerini tan\u0131mlay\u0131n" } - } + }, + "title": "B\u00f6lge" } } \ No newline at end of file diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index ef054b39714..373727e3f4d 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -8,9 +8,15 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_ZONE, ) -from homeassistant.core import CALLBACK_TYPE, HassJob, callback -from homeassistant.helpers import condition, config_validation as cv, location +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, + location, +) from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.typing import ConfigType # mypy: allow-incomplete-defs, allow-untyped-defs # mypy: no-check-untyped-defs @@ -21,10 +27,10 @@ DEFAULT_EVENT = EVENT_ENTER _EVENT_DESCRIPTION = {EVENT_ENTER: "entering", EVENT_LEAVE: "leaving"} -TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( +_TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "zone", - vol.Required(CONF_ENTITY_ID): cv.entity_ids, + vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids, vol.Required(CONF_ZONE): cv.entity_id, vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any( EVENT_ENTER, EVENT_LEAVE @@ -33,6 +39,18 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( ) +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate trigger config.""" + config = _TRIGGER_SCHEMA(config) + registry = er.async_get(hass) + config[CONF_ENTITY_ID] = er.async_resolve_entity_ids( + registry, config[CONF_ENTITY_ID] + ) + return config + + async def async_attach_trigger( hass, config, action, automation_info, *, platform_type: str = "zone" ) -> CALLBACK_TYPE: diff --git a/homeassistant/components/zoneminder/camera.py b/homeassistant/components/zoneminder/camera.py index 6144fe11226..0f9f5e2f679 100644 --- a/homeassistant/components/zoneminder/camera.py +++ b/homeassistant/components/zoneminder/camera.py @@ -19,8 +19,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): filter_urllib3_logging() cameras = [] for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): - monitors = zm_client.get_monitors() - if not monitors: + if not (monitors := zm_client.get_monitors()): _LOGGER.warning("Could not fetch monitors from ZoneMinder host: %s") return diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py index 0eb3e9d63a2..90c5f8d78eb 100644 --- a/homeassistant/components/zoneminder/sensor.py +++ b/homeassistant/components/zoneminder/sensor.py @@ -66,8 +66,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): sensors = [] for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): - monitors = zm_client.get_monitors() - if not monitors: + if not (monitors := zm_client.get_monitors()): _LOGGER.warning("Could not fetch any monitors from ZoneMinder") for monitor in monitors: diff --git a/homeassistant/components/zoneminder/switch.py b/homeassistant/components/zoneminder/switch.py index 0428ddbf888..b7ba3f48f10 100644 --- a/homeassistant/components/zoneminder/switch.py +++ b/homeassistant/components/zoneminder/switch.py @@ -28,8 +28,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): switches = [] for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): - monitors = zm_client.get_monitors() - if not monitors: + if not (monitors := zm_client.get_monitors()): _LOGGER.warning("Could not fetch monitors from ZoneMinder") return diff --git a/homeassistant/components/zoneminder/translations/ja.json b/homeassistant/components/zoneminder/translations/ja.json new file mode 100644 index 00000000000..0eab2b65c6a --- /dev/null +++ b/homeassistant/components/zoneminder/translations/ja.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "auth_fail": "\u30e6\u30fc\u30b6\u30fc\u540d\u307e\u305f\u306f\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093\u3002", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "connection_error": "ZoneMinder\u30b5\u30fc\u30d0\u30fc\u3078\u306e\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "create_entry": { + "default": "ZoneMinder\u30b5\u30fc\u30d0\u30fc\u304c\u8ffd\u52a0\u3055\u308c\u307e\u3057\u305f\u3002" + }, + "error": { + "auth_fail": "\u30e6\u30fc\u30b6\u30fc\u540d\u307e\u305f\u306f\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u6b63\u3057\u304f\u3042\u308a\u307e\u305b\u3093\u3002", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "connection_error": "ZoneMinder\u30b5\u30fc\u30d0\u30fc\u3078\u306e\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" + }, + "flow_title": "ZoneMinder", + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8\u3068\u30dd\u30fc\u30c8(\u4f8b: 10.10.0.4:8010)", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "path": "ZM\u30d1\u30b9", + "path_zms": "ZMS\u30d1\u30b9", + "ssl": "SSL\u8a3c\u660e\u66f8\u3092\u4f7f\u7528\u3059\u308b", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + }, + "title": "ZoneMinder\u30b5\u30fc\u30d0\u30fc\u3092\u8ffd\u52a0\u3057\u307e\u3059\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zoneminder/translations/tr.json b/homeassistant/components/zoneminder/translations/tr.json index 971f8cc9bd7..0cbd791f831 100644 --- a/homeassistant/components/zoneminder/translations/tr.json +++ b/homeassistant/components/zoneminder/translations/tr.json @@ -3,19 +3,31 @@ "abort": { "auth_fail": "Kullan\u0131c\u0131 ad\u0131 veya \u015fifre yanl\u0131\u015f.", "cannot_connect": "Ba\u011flanma hatas\u0131", + "connection_error": "ZoneMinder sunucusuna ba\u011flan\u0131lamad\u0131.", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" }, + "create_entry": { + "default": "ZoneMinder sunucusu eklendi." + }, "error": { "auth_fail": "Kullan\u0131c\u0131 ad\u0131 veya \u015fifre yanl\u0131\u015f.", "cannot_connect": "Ba\u011flanma hatas\u0131", + "connection_error": "ZoneMinder sunucusuna ba\u011flan\u0131lamad\u0131.", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" }, + "flow_title": "ZoneMinder", "step": { "user": { "data": { + "host": "Ana Bilgisayar ve Ba\u011flant\u0131 Noktas\u0131 (\u00f6r. 10.10.0.4:8010)", "password": "Parola", - "username": "Kullan\u0131c\u0131 Ad\u0131" - } + "path": "ZM Yolu", + "path_zms": "ZMS Yolu", + "ssl": "SSL sertifikas\u0131 kullan\u0131r", + "username": "Kullan\u0131c\u0131 Ad\u0131", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" + }, + "title": "ZoneMinder Sunucusunu ekleyin." } } } diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index b5a77c82050..ef3ab223248 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -56,7 +56,7 @@ from .const import ( DOMAIN, ) from .discovery_schemas import DISCOVERY_SCHEMAS -from .migration import ( # noqa: F401 pylint: disable=unused-import +from .migration import ( # noqa: F401 async_add_migration_entity_value, async_get_migration_data, async_is_ozw_migrated, diff --git a/homeassistant/components/zwave/translations/ja.json b/homeassistant/components/zwave/translations/ja.json index 3106439dc92..ff0afe58c0e 100644 --- a/homeassistant/components/zwave/translations/ja.json +++ b/homeassistant/components/zwave/translations/ja.json @@ -1,13 +1,32 @@ { + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" + }, + "error": { + "option_error": "Z-Wave\u306e\u691c\u8a3c\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002USB\u30b9\u30c6\u30a3\u30c3\u30af\u3078\u306e\u30d1\u30b9\u306f\u6b63\u3057\u3044\u3067\u3059\u304b\uff1f" + }, + "step": { + "user": { + "data": { + "network_key": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30ad\u30fc(\u7a7a\u767d\u306b\u3059\u308b\u3068\u81ea\u52d5\u751f\u6210\u3055\u308c\u307e\u3059)", + "usb_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "description": "\u3053\u306e\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u30e1\u30f3\u30c6\u30ca\u30f3\u30b9\u306f\u7d42\u4e86\u3057\u307e\u3057\u305f\u3002\u65b0\u898f\u306b\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3059\u308b\u5834\u5408\u306f\u3001\u4ee3\u308f\u308a\u306bZ-Wave JS\u3092\u4f7f\u7528\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n\n\u69cb\u6210\u5909\u6570\u306e\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001https://www.home-assistant.io/docs/z-wave/installation/ \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + }, "state": { "_": { + "dead": "\u30c7\u30c3\u30c9", "initializing": "\u521d\u671f\u5316\u4e2d", "ready": "\u6e96\u5099\u5b8c\u4e86", "sleeping": "\u30b9\u30ea\u30fc\u30d7" }, "query_stage": { - "dead": " ({query_stage})", - "initializing": "\u521d\u671f\u5316\u4e2d ( {query_stage} )" + "dead": "\u30c7\u30c3\u30c9", + "initializing": "\u521d\u671f\u5316\u4e2d" } } } \ No newline at end of file diff --git a/homeassistant/components/zwave/translations/tr.json b/homeassistant/components/zwave/translations/tr.json index 383ccc6cc4f..b6afa368b6d 100644 --- a/homeassistant/components/zwave/translations/tr.json +++ b/homeassistant/components/zwave/translations/tr.json @@ -4,11 +4,16 @@ "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, + "error": { + "option_error": "Z-Wave do\u011frulamas\u0131 ba\u015far\u0131s\u0131z oldu. USB stickin yolu do\u011fru mu?" + }, "step": { "user": { "data": { - "network_key": "A\u011f Anajtar\u0131 (otomatik \u00fcretilmesi i\u00e7in bo\u015f b\u0131rak\u0131n\u0131z)" - } + "network_key": "A\u011f Anajtar\u0131 (otomatik \u00fcretilmesi i\u00e7in bo\u015f b\u0131rak\u0131n\u0131z)", + "usb_path": "USB Cihaz Yolu" + }, + "description": "Bu entegrasyon art\u0131k korunmuyor. Yeni kurulumlar i\u00e7in bunun yerine Z-Wave JS kullan\u0131n. \n\n Yap\u0131land\u0131rma de\u011fi\u015fkenleri hakk\u0131nda bilgi i\u00e7in https://www.home-assistant.io/docs/z-wave/installation/ adresine bak\u0131n." } } }, diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 38d5b99147d..de2662bfa27 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -15,7 +15,10 @@ from zwave_js_server.const import ( CommandClass, InclusionStrategy, LogLevel, + Protocols, + QRCodeVersion, SecurityClass, + ZwaveFeature, ) from zwave_js_server.exceptions import ( BaseZwaveJSServerError, @@ -25,7 +28,12 @@ from zwave_js_server.exceptions import ( SetValueFailed, ) from zwave_js_server.firmware import begin_firmware_update -from zwave_js_server.model.controller import ControllerStatistics, InclusionGrant +from zwave_js_server.model.controller import ( + ControllerStatistics, + InclusionGrant, + ProvisioningEntry, + QRProvisioningInformation, +) from zwave_js_server.model.firmware import ( FirmwareUpdateFinished, FirmwareUpdateProgress, @@ -33,12 +41,14 @@ from zwave_js_server.model.firmware import ( from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.log_message import LogMessage from zwave_js_server.model.node import Node, NodeStatistics +from zwave_js_server.model.utils import async_parse_qr_code_string from zwave_js_server.util.node import async_set_config_parameter from homeassistant.components import websocket_api from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.components.websocket_api.const import ( + ERR_INVALID_FORMAT, ERR_NOT_FOUND, ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR, @@ -80,8 +90,6 @@ TYPE = "type" PROPERTY = "property" PROPERTY_KEY = "property_key" VALUE = "value" -INCLUSION_STRATEGY = "inclusion_strategy" -PIN = "pin" # constants for log config commands CONFIG = "config" @@ -106,6 +114,113 @@ CLIENT_SIDE_AUTH = "client_side_auth" # constants for migration DRY_RUN = "dry_run" +# constants for inclusion +INCLUSION_STRATEGY = "inclusion_strategy" +PIN = "pin" +FORCE_SECURITY = "force_security" +PLANNED_PROVISIONING_ENTRY = "planned_provisioning_entry" +QR_PROVISIONING_INFORMATION = "qr_provisioning_information" +QR_CODE_STRING = "qr_code_string" + +DSK = "dsk" + +VERSION = "version" +GENERIC_DEVICE_CLASS = "generic_device_class" +SPECIFIC_DEVICE_CLASS = "specific_device_class" +INSTALLER_ICON_TYPE = "installer_icon_type" +MANUFACTURER_ID = "manufacturer_id" +PRODUCT_TYPE = "product_type" +PRODUCT_ID = "product_id" +APPLICATION_VERSION = "application_version" +MAX_INCLUSION_REQUEST_INTERVAL = "max_inclusion_request_interval" +UUID = "uuid" +SUPPORTED_PROTOCOLS = "supported_protocols" + +FEATURE = "feature" +UNPROVISION = "unprovision" + +# https://github.com/zwave-js/node-zwave-js/blob/master/packages/core/src/security/QR.ts#L41 +MINIMUM_QR_STRING_LENGTH = 52 + + +def convert_planned_provisioning_entry(info: dict) -> ProvisioningEntry: + """Handle provisioning entry dict to ProvisioningEntry.""" + info = ProvisioningEntry( + dsk=info[DSK], + security_classes=[SecurityClass(sec_cls) for sec_cls in info[SECURITY_CLASSES]], + additional_properties={ + k: v for k, v in info.items() if k not in (DSK, SECURITY_CLASSES) + }, + ) + return info + + +def convert_qr_provisioning_information(info: dict) -> QRProvisioningInformation: + """Convert QR provisioning information dict to QRProvisioningInformation.""" + protocols = [Protocols(proto) for proto in info.get(SUPPORTED_PROTOCOLS, [])] + info = QRProvisioningInformation( + version=QRCodeVersion(info[VERSION]), + security_classes=[SecurityClass(sec_cls) for sec_cls in info[SECURITY_CLASSES]], + dsk=info[DSK], + generic_device_class=info[GENERIC_DEVICE_CLASS], + specific_device_class=info[SPECIFIC_DEVICE_CLASS], + installer_icon_type=info[INSTALLER_ICON_TYPE], + manufacturer_id=info[MANUFACTURER_ID], + product_type=info[PRODUCT_TYPE], + product_id=info[PRODUCT_ID], + application_version=info[APPLICATION_VERSION], + max_inclusion_request_interval=info.get(MAX_INCLUSION_REQUEST_INTERVAL), + uuid=info.get(UUID), + supported_protocols=protocols if protocols else None, + ) + return info + + +# Helper schemas +PLANNED_PROVISIONING_ENTRY_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(DSK): str, + vol.Required(SECURITY_CLASSES): vol.All( + cv.ensure_list, + [vol.Coerce(SecurityClass)], + ), + }, + # Provisioning entries can have extra keys for SmartStart + extra=vol.ALLOW_EXTRA, + ), + convert_planned_provisioning_entry, +) + +QR_PROVISIONING_INFORMATION_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(VERSION): vol.Coerce(QRCodeVersion), + vol.Required(SECURITY_CLASSES): vol.All( + cv.ensure_list, + [vol.Coerce(SecurityClass)], + ), + vol.Required(DSK): str, + vol.Required(GENERIC_DEVICE_CLASS): int, + vol.Required(SPECIFIC_DEVICE_CLASS): int, + vol.Required(INSTALLER_ICON_TYPE): int, + vol.Required(MANUFACTURER_ID): int, + vol.Required(PRODUCT_TYPE): int, + vol.Required(PRODUCT_ID): int, + vol.Required(APPLICATION_VERSION): str, + vol.Optional(MAX_INCLUSION_REQUEST_INTERVAL): vol.Any(int, None), + vol.Optional(UUID): vol.Any(str, None), + vol.Optional(SUPPORTED_PROTOCOLS): vol.All( + cv.ensure_list, + [vol.Coerce(Protocols)], + ), + } + ), + convert_qr_provisioning_information, +) + +QR_CODE_STRING_SCHEMA = vol.All(str, vol.Length(min=MINIMUM_QR_STRING_LENGTH)) + def async_get_entry(orig_func: Callable) -> Callable: """Decorate async function to get entry.""" @@ -194,6 +309,11 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_add_node) websocket_api.async_register_command(hass, websocket_grant_security_classes) websocket_api.async_register_command(hass, websocket_validate_dsk_and_enter_pin) + websocket_api.async_register_command(hass, websocket_provision_smart_start_node) + websocket_api.async_register_command(hass, websocket_unprovision_smart_start_node) + websocket_api.async_register_command(hass, websocket_get_provisioning_entries) + websocket_api.async_register_command(hass, websocket_parse_qr_code_string) + websocket_api.async_register_command(hass, websocket_supports_feature) websocket_api.async_register_command(hass, websocket_stop_inclusion) websocket_api.async_register_command(hass, websocket_stop_exclusion) websocket_api.async_register_command(hass, websocket_remove_node) @@ -434,9 +554,24 @@ async def websocket_ping_node( { vol.Required(TYPE): "zwave_js/add_node", vol.Required(ENTRY_ID): str, - vol.Optional(INCLUSION_STRATEGY, default=InclusionStrategy.DEFAULT): vol.In( - [strategy.value for strategy in InclusionStrategy] + vol.Optional(INCLUSION_STRATEGY, default=InclusionStrategy.DEFAULT): vol.All( + vol.Coerce(int), + vol.In( + [ + strategy.value + for strategy in InclusionStrategy + if strategy != InclusionStrategy.SMART_START + ] + ), ), + vol.Optional(FORCE_SECURITY): bool, + vol.Exclusive( + PLANNED_PROVISIONING_ENTRY, "options" + ): PLANNED_PROVISIONING_ENTRY_SCHEMA, + vol.Exclusive( + QR_PROVISIONING_INFORMATION, "options" + ): QR_PROVISIONING_INFORMATION_SCHEMA, + vol.Exclusive(QR_CODE_STRING, "options"): QR_CODE_STRING_SCHEMA, } ) @websocket_api.async_response @@ -452,6 +587,12 @@ async def websocket_add_node( """Add a node to the Z-Wave network.""" controller = client.driver.controller inclusion_strategy = InclusionStrategy(msg[INCLUSION_STRATEGY]) + force_security = msg.get(FORCE_SECURITY) + provisioning = ( + msg.get(PLANNED_PROVISIONING_ENTRY) + or msg.get(QR_PROVISIONING_INFORMATION) + or msg.get(QR_CODE_STRING) + ) @callback def async_cleanup() -> None: @@ -542,7 +683,18 @@ async def websocket_add_node( ), ] - result = await controller.async_begin_inclusion(inclusion_strategy) + try: + result = await controller.async_begin_inclusion( + inclusion_strategy, force_security=force_security, provisioning=provisioning + ) + except ValueError as err: + connection.send_error( + msg[ID], + ERR_INVALID_FORMAT, + err.args[0], + ) + return + connection.send_result( msg[ID], result, @@ -554,9 +706,10 @@ async def websocket_add_node( { vol.Required(TYPE): "zwave_js/grant_security_classes", vol.Required(ENTRY_ID): str, - vol.Required(SECURITY_CLASSES): [ - vol.In([sec_cls.value for sec_cls in SecurityClass]) - ], + vol.Required(SECURITY_CLASSES): vol.All( + cv.ensure_list, + [vol.Coerce(SecurityClass)], + ), vol.Optional(CLIENT_SIDE_AUTH, default=False): bool, } ) @@ -570,7 +723,7 @@ async def websocket_grant_security_classes( entry: ConfigEntry, client: Client, ) -> None: - """Add a node to the Z-Wave network.""" + """Choose SecurityClass grants as part of S2 inclusion process.""" inclusion_grant = InclusionGrant( [SecurityClass(sec_cls) for sec_cls in msg[SECURITY_CLASSES]], msg[CLIENT_SIDE_AUTH], @@ -597,11 +750,179 @@ async def websocket_validate_dsk_and_enter_pin( entry: ConfigEntry, client: Client, ) -> None: - """Add a node to the Z-Wave network.""" + """Validate DSK and enter PIN as part of S2 inclusion process.""" await client.driver.controller.async_validate_dsk_and_enter_pin(msg[PIN]) connection.send_result(msg[ID]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/provision_smart_start_node", + vol.Required(ENTRY_ID): str, + vol.Exclusive( + PLANNED_PROVISIONING_ENTRY, "options" + ): PLANNED_PROVISIONING_ENTRY_SCHEMA, + vol.Exclusive( + QR_PROVISIONING_INFORMATION, "options" + ): QR_PROVISIONING_INFORMATION_SCHEMA, + vol.Exclusive(QR_CODE_STRING, "options"): QR_CODE_STRING_SCHEMA, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_provision_smart_start_node( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, +) -> None: + """Pre-provision a smart start node.""" + try: + cv.has_at_least_one_key( + PLANNED_PROVISIONING_ENTRY, QR_PROVISIONING_INFORMATION, QR_CODE_STRING + )(msg) + except vol.Invalid as err: + connection.send_error( + msg[ID], + ERR_INVALID_FORMAT, + err.args[0], + ) + return + + provisioning_info = ( + msg.get(PLANNED_PROVISIONING_ENTRY) + or msg.get(QR_PROVISIONING_INFORMATION) + or msg[QR_CODE_STRING] + ) + + if ( + QR_PROVISIONING_INFORMATION in msg + and provisioning_info.version == QRCodeVersion.S2 + ): + connection.send_error( + msg[ID], + ERR_INVALID_FORMAT, + "QR code version S2 is not supported for this command", + ) + return + await client.driver.controller.async_provision_smart_start_node(provisioning_info) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/unprovision_smart_start_node", + vol.Required(ENTRY_ID): str, + vol.Exclusive(DSK, "input"): str, + vol.Exclusive(NODE_ID, "input"): int, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_unprovision_smart_start_node( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, +) -> None: + """Unprovision a smart start node.""" + try: + cv.has_at_least_one_key(DSK, NODE_ID)(msg) + except vol.Invalid as err: + connection.send_error( + msg[ID], + ERR_INVALID_FORMAT, + err.args[0], + ) + return + dsk_or_node_id = msg.get(DSK) or msg[NODE_ID] + await client.driver.controller.async_unprovision_smart_start_node(dsk_or_node_id) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/get_provisioning_entries", + vol.Required(ENTRY_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_get_provisioning_entries( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, +) -> None: + """Get provisioning entries (entries that have been pre-provisioned).""" + provisioning_entries = ( + await client.driver.controller.async_get_provisioning_entries() + ) + connection.send_result( + msg[ID], [dataclasses.asdict(entry) for entry in provisioning_entries] + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/parse_qr_code_string", + vol.Required(ENTRY_ID): str, + vol.Required(QR_CODE_STRING): QR_CODE_STRING_SCHEMA, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_parse_qr_code_string( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, +) -> None: + """Parse a QR Code String and return QRProvisioningInformation dict.""" + qr_provisioning_information = await async_parse_qr_code_string( + client, msg[QR_CODE_STRING] + ) + connection.send_result(msg[ID], dataclasses.asdict(qr_provisioning_information)) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/supports_feature", + vol.Required(ENTRY_ID): str, + vol.Required(FEATURE): vol.Coerce(ZwaveFeature), + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_supports_feature( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, +) -> None: + """Check if controller supports a particular feature.""" + supported = await client.driver.controller.async_supports_feature(msg[FEATURE]) + connection.send_result( + msg[ID], + {"supported": supported}, + ) + + @websocket_api.require_admin @websocket_api.websocket_command( { @@ -659,6 +980,7 @@ async def websocket_stop_exclusion( { vol.Required(TYPE): "zwave_js/remove_node", vol.Required(ENTRY_ID): str, + vol.Optional(UNPROVISION): bool, } ) @websocket_api.async_response @@ -707,7 +1029,7 @@ async def websocket_remove_node( controller.on("node removed", node_removed), ] - result = await controller.async_begin_exclusion() + result = await controller.async_begin_exclusion(msg.get(UNPROVISION)) connection.send_result( msg[ID], result, @@ -720,9 +1042,24 @@ async def websocket_remove_node( vol.Required(TYPE): "zwave_js/replace_failed_node", vol.Required(ENTRY_ID): str, vol.Required(NODE_ID): int, - vol.Optional(INCLUSION_STRATEGY, default=InclusionStrategy.DEFAULT): vol.In( - [strategy.value for strategy in InclusionStrategy] + vol.Optional(INCLUSION_STRATEGY, default=InclusionStrategy.DEFAULT): vol.All( + vol.Coerce(int), + vol.In( + [ + strategy.value + for strategy in InclusionStrategy + if strategy != InclusionStrategy.SMART_START + ] + ), ), + vol.Optional(FORCE_SECURITY): bool, + vol.Exclusive( + PLANNED_PROVISIONING_ENTRY, "options" + ): PLANNED_PROVISIONING_ENTRY_SCHEMA, + vol.Exclusive( + QR_PROVISIONING_INFORMATION, "options" + ): QR_PROVISIONING_INFORMATION_SCHEMA, + vol.Exclusive(QR_CODE_STRING, "options"): QR_CODE_STRING_SCHEMA, } ) @websocket_api.async_response @@ -739,6 +1076,12 @@ async def websocket_replace_failed_node( controller = client.driver.controller node_id = msg[NODE_ID] inclusion_strategy = InclusionStrategy(msg[INCLUSION_STRATEGY]) + force_security = msg.get(FORCE_SECURITY) + provisioning = ( + msg.get(PLANNED_PROVISIONING_ENTRY) + or msg.get(QR_PROVISIONING_INFORMATION) + or msg.get(QR_CODE_STRING) + ) @callback def async_cleanup() -> None: @@ -842,7 +1185,21 @@ async def websocket_replace_failed_node( ), ] - result = await controller.async_replace_failed_node(node_id, inclusion_strategy) + try: + result = await controller.async_replace_failed_node( + node_id, + inclusion_strategy, + force_security=force_security, + provisioning=provisioning, + ) + except ValueError as err: + connection.send_error( + msg[ID], + ERR_INVALID_FORMAT, + err.args[0], + ) + return + connection.send_result( msg[ID], result, @@ -1309,13 +1666,12 @@ async def websocket_subscribe_log_updates( { vol.Optional(ENABLED): cv.boolean, vol.Optional(LEVEL): vol.All( - cv.string, + str, vol.Lower, - vol.In([log_level.value for log_level in LogLevel]), - lambda val: LogLevel(val), # pylint: disable=unnecessary-lambda + vol.Coerce(LogLevel), ), vol.Optional(LOG_TO_FILE): cv.boolean, - vol.Optional(FILENAME): cv.string, + vol.Optional(FILENAME): str, vol.Optional(FORCE_CONSOLE): cv.boolean, } ), diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 4007064109d..5d91e9b8d93 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -1,8 +1,8 @@ """Representation of Z-Wave binary sensors.""" from __future__ import annotations +from dataclasses import dataclass import logging -from typing import TypedDict from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass @@ -19,14 +19,18 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_LOCK, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_MOTION, + DEVICE_CLASS_PLUG, DEVICE_CLASS_PROBLEM, DEVICE_CLASS_SAFETY, DEVICE_CLASS_SMOKE, DEVICE_CLASS_SOUND, + DEVICE_CLASS_TAMPER, DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -38,186 +42,228 @@ from .entity import ZWaveBaseEntity LOGGER = logging.getLogger(__name__) -NOTIFICATION_SMOKE_ALARM = 1 -NOTIFICATION_CARBON_MONOOXIDE = 2 -NOTIFICATION_CARBON_DIOXIDE = 3 -NOTIFICATION_HEAT = 4 -NOTIFICATION_WATER = 5 -NOTIFICATION_ACCESS_CONTROL = 6 -NOTIFICATION_HOME_SECURITY = 7 -NOTIFICATION_POWER_MANAGEMENT = 8 -NOTIFICATION_SYSTEM = 9 -NOTIFICATION_EMERGENCY = 10 -NOTIFICATION_CLOCK = 11 -NOTIFICATION_APPLIANCE = 12 -NOTIFICATION_HOME_HEALTH = 13 -NOTIFICATION_SIREN = 14 -NOTIFICATION_WATER_VALVE = 15 -NOTIFICATION_WEATHER = 16 -NOTIFICATION_IRRIGATION = 17 -NOTIFICATION_GAS = 18 +NOTIFICATION_SMOKE_ALARM = "1" +NOTIFICATION_CARBON_MONOOXIDE = "2" +NOTIFICATION_CARBON_DIOXIDE = "3" +NOTIFICATION_HEAT = "4" +NOTIFICATION_WATER = "5" +NOTIFICATION_ACCESS_CONTROL = "6" +NOTIFICATION_HOME_SECURITY = "7" +NOTIFICATION_POWER_MANAGEMENT = "8" +NOTIFICATION_SYSTEM = "9" +NOTIFICATION_EMERGENCY = "10" +NOTIFICATION_CLOCK = "11" +NOTIFICATION_APPLIANCE = "12" +NOTIFICATION_HOME_HEALTH = "13" +NOTIFICATION_SIREN = "14" +NOTIFICATION_WATER_VALVE = "15" +NOTIFICATION_WEATHER = "16" +NOTIFICATION_IRRIGATION = "17" +NOTIFICATION_GAS = "18" -class NotificationSensorMapping(TypedDict, total=False): - """Represent a notification sensor mapping dict type.""" +@dataclass +class NotificationZWaveJSEntityDescription(BinarySensorEntityDescription): + """Represent a Z-Wave JS binary sensor entity description.""" - type: int # required - states: list[str] - device_class: str - enabled: bool + off_state: str = "0" + states: tuple[str, ...] | None = None + + +@dataclass +class PropertyZWaveJSMixin: + """Represent the mixin for property sensor descriptions.""" + + on_states: tuple[str, ...] + + +@dataclass +class PropertyZWaveJSEntityDescription( + BinarySensorEntityDescription, PropertyZWaveJSMixin +): + """Represent the entity description for property name sensors.""" # Mappings for Notification sensors # https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/notifications.json -NOTIFICATION_SENSOR_MAPPINGS: list[NotificationSensorMapping] = [ - { +NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = ( + NotificationZWaveJSEntityDescription( # NotificationType 1: Smoke Alarm - State Id's 1 and 2 - Smoke detected - "type": NOTIFICATION_SMOKE_ALARM, - "states": ["1", "2"], - "device_class": DEVICE_CLASS_SMOKE, - }, - { + key=NOTIFICATION_SMOKE_ALARM, + states=("1", "2"), + device_class=DEVICE_CLASS_SMOKE, + ), + NotificationZWaveJSEntityDescription( # NotificationType 1: Smoke Alarm - All other State Id's - "type": NOTIFICATION_SMOKE_ALARM, - "device_class": DEVICE_CLASS_PROBLEM, - }, - { + key=NOTIFICATION_SMOKE_ALARM, + device_class=DEVICE_CLASS_PROBLEM, + ), + NotificationZWaveJSEntityDescription( # NotificationType 2: Carbon Monoxide - State Id's 1 and 2 - "type": NOTIFICATION_CARBON_MONOOXIDE, - "states": ["1", "2"], - "device_class": DEVICE_CLASS_GAS, - }, - { + key=NOTIFICATION_CARBON_MONOOXIDE, + states=("1", "2"), + device_class=DEVICE_CLASS_GAS, + ), + NotificationZWaveJSEntityDescription( # NotificationType 2: Carbon Monoxide - All other State Id's - "type": NOTIFICATION_CARBON_MONOOXIDE, - "device_class": DEVICE_CLASS_PROBLEM, - }, - { + key=NOTIFICATION_CARBON_MONOOXIDE, + device_class=DEVICE_CLASS_PROBLEM, + ), + NotificationZWaveJSEntityDescription( # NotificationType 3: Carbon Dioxide - State Id's 1 and 2 - "type": NOTIFICATION_CARBON_DIOXIDE, - "states": ["1", "2"], - "device_class": DEVICE_CLASS_GAS, - }, - { + key=NOTIFICATION_CARBON_DIOXIDE, + states=("1", "2"), + device_class=DEVICE_CLASS_GAS, + ), + NotificationZWaveJSEntityDescription( # NotificationType 3: Carbon Dioxide - All other State Id's - "type": NOTIFICATION_CARBON_DIOXIDE, - "device_class": DEVICE_CLASS_PROBLEM, - }, - { + key=NOTIFICATION_CARBON_DIOXIDE, + device_class=DEVICE_CLASS_PROBLEM, + ), + NotificationZWaveJSEntityDescription( # NotificationType 4: Heat - State Id's 1, 2, 5, 6 (heat/underheat) - "type": NOTIFICATION_HEAT, - "states": ["1", "2", "5", "6"], - "device_class": DEVICE_CLASS_HEAT, - }, - { + key=NOTIFICATION_HEAT, + states=("1", "2", "5", "6"), + device_class=DEVICE_CLASS_HEAT, + ), + NotificationZWaveJSEntityDescription( # NotificationType 4: Heat - All other State Id's - "type": NOTIFICATION_HEAT, - "device_class": DEVICE_CLASS_PROBLEM, - }, - { + key=NOTIFICATION_HEAT, + device_class=DEVICE_CLASS_PROBLEM, + ), + NotificationZWaveJSEntityDescription( # NotificationType 5: Water - State Id's 1, 2, 3, 4 - "type": NOTIFICATION_WATER, - "states": ["1", "2", "3", "4"], - "device_class": DEVICE_CLASS_MOISTURE, - }, - { + key=NOTIFICATION_WATER, + states=("1", "2", "3", "4"), + device_class=DEVICE_CLASS_MOISTURE, + ), + NotificationZWaveJSEntityDescription( # NotificationType 5: Water - All other State Id's - "type": NOTIFICATION_WATER, - "device_class": DEVICE_CLASS_PROBLEM, - }, - { + key=NOTIFICATION_WATER, + device_class=DEVICE_CLASS_PROBLEM, + ), + NotificationZWaveJSEntityDescription( # NotificationType 6: Access Control - State Id's 1, 2, 3, 4 (Lock) - "type": NOTIFICATION_ACCESS_CONTROL, - "states": ["1", "2", "3", "4"], - "device_class": DEVICE_CLASS_LOCK, - }, - { - # NotificationType 6: Access Control - State Id 16 (door/window open) - "type": NOTIFICATION_ACCESS_CONTROL, - "states": ["22"], - "device_class": DEVICE_CLASS_DOOR, - }, - { - # NotificationType 6: Access Control - State Id 17 (door/window closed) - "type": NOTIFICATION_ACCESS_CONTROL, - "states": ["23"], - "enabled": False, - }, - { + key=NOTIFICATION_ACCESS_CONTROL, + states=("1", "2", "3", "4"), + device_class=DEVICE_CLASS_LOCK, + ), + NotificationZWaveJSEntityDescription( + # NotificationType 6: Access Control - State Id's 11 (Lock jammed) + key=NOTIFICATION_ACCESS_CONTROL, + states=("11",), + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + NotificationZWaveJSEntityDescription( + # NotificationType 6: Access Control - State Id 22 (door/window open) + key=NOTIFICATION_ACCESS_CONTROL, + off_state="23", + states=("22", "23"), + device_class=DEVICE_CLASS_DOOR, + ), + NotificationZWaveJSEntityDescription( # NotificationType 7: Home Security - State Id's 1, 2 (intrusion) - "type": NOTIFICATION_HOME_SECURITY, - "states": ["1", "2"], - "device_class": DEVICE_CLASS_SAFETY, - }, - { + key=NOTIFICATION_HOME_SECURITY, + states=("1", "2"), + device_class=DEVICE_CLASS_SAFETY, + ), + NotificationZWaveJSEntityDescription( # NotificationType 7: Home Security - State Id's 3, 4, 9 (tampering) - "type": NOTIFICATION_HOME_SECURITY, - "states": ["3", "4", "9"], - "device_class": DEVICE_CLASS_SAFETY, - }, - { + key=NOTIFICATION_HOME_SECURITY, + states=("3", "4", "9"), + device_class=DEVICE_CLASS_TAMPER, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + NotificationZWaveJSEntityDescription( # NotificationType 7: Home Security - State Id's 5, 6 (glass breakage) - "type": NOTIFICATION_HOME_SECURITY, - "states": ["5", "6"], - "device_class": DEVICE_CLASS_SAFETY, - }, - { + key=NOTIFICATION_HOME_SECURITY, + states=("5", "6"), + device_class=DEVICE_CLASS_SAFETY, + ), + NotificationZWaveJSEntityDescription( # NotificationType 7: Home Security - State Id's 7, 8 (motion) - "type": NOTIFICATION_HOME_SECURITY, - "states": ["7", "8"], - "device_class": DEVICE_CLASS_MOTION, - }, - { - # NotificationType 9: System - State Id's 1, 2, 6, 7 - "type": NOTIFICATION_SYSTEM, - "states": ["1", "2", "6", "7"], - "device_class": DEVICE_CLASS_PROBLEM, - }, - { + key=NOTIFICATION_HOME_SECURITY, + states=("7", "8"), + device_class=DEVICE_CLASS_MOTION, + ), + NotificationZWaveJSEntityDescription( + # NotificationType 8: Power Management - + # State Id's 2, 3 (Mains status) + key=NOTIFICATION_POWER_MANAGEMENT, + off_state="2", + states=("2", "3"), + device_class=DEVICE_CLASS_PLUG, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + NotificationZWaveJSEntityDescription( + # NotificationType 8: Power Management - + # State Id's 6, 7, 8, 9 (power status) + key=NOTIFICATION_POWER_MANAGEMENT, + states=("6", "7", "8", "9"), + device_class=DEVICE_CLASS_SAFETY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + NotificationZWaveJSEntityDescription( + # NotificationType 8: Power Management - + # State Id's 10, 11, 17 (Battery maintenance status) + key=NOTIFICATION_POWER_MANAGEMENT, + states=("10", "11", "17"), + device_class=DEVICE_CLASS_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + NotificationZWaveJSEntityDescription( + # NotificationType 9: System - State Id's 1, 2, 3, 4, 6, 7 + key=NOTIFICATION_SYSTEM, + states=("1", "2", "3", "4", "6", "7"), + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + NotificationZWaveJSEntityDescription( # NotificationType 10: Emergency - State Id's 1, 2, 3 - "type": NOTIFICATION_EMERGENCY, - "states": ["1", "2", "3"], - "device_class": DEVICE_CLASS_PROBLEM, - }, - { + key=NOTIFICATION_EMERGENCY, + states=("1", "2", "3"), + device_class=DEVICE_CLASS_PROBLEM, + ), + NotificationZWaveJSEntityDescription( # NotificationType 14: Siren - "type": NOTIFICATION_SIREN, - "states": ["1"], - "device_class": DEVICE_CLASS_SOUND, - }, - { + key=NOTIFICATION_SIREN, + states=("1",), + device_class=DEVICE_CLASS_SOUND, + ), + NotificationZWaveJSEntityDescription( # NotificationType 18: Gas - "type": NOTIFICATION_GAS, - "states": ["1", "2", "3", "4"], - "device_class": DEVICE_CLASS_GAS, - }, - { + key=NOTIFICATION_GAS, + states=("1", "2", "3", "4"), + device_class=DEVICE_CLASS_GAS, + ), + NotificationZWaveJSEntityDescription( # NotificationType 18: Gas - "type": NOTIFICATION_GAS, - "states": ["6"], - "device_class": DEVICE_CLASS_PROBLEM, - }, -] - - -class PropertySensorMapping(TypedDict, total=False): - """Represent a property sensor mapping dict type.""" - - property_name: str # required - on_states: list[str] # required - device_class: str - enabled: bool + key=NOTIFICATION_GAS, + states=("6",), + device_class=DEVICE_CLASS_PROBLEM, + ), +) # Mappings for property sensors -PROPERTY_SENSOR_MAPPINGS: list[PropertySensorMapping] = [ - { - "property_name": DOOR_STATUS_PROPERTY, - "on_states": ["open"], - "device_class": DEVICE_CLASS_DOOR, - "enabled": True, - }, -] +PROPERTY_SENSOR_MAPPINGS: dict[str, PropertyZWaveJSEntityDescription] = { + DOOR_STATUS_PROPERTY: PropertyZWaveJSEntityDescription( + key=DOOR_STATUS_PROPERTY, + on_states=("open",), + device_class=DEVICE_CLASS_DOOR, + ), +} + + +# Mappings for boolean sensors +BOOLEAN_SENSOR_MAPPINGS: dict[str, BinarySensorEntityDescription] = { + CommandClass.BATTERY: BinarySensorEntityDescription( + key=str(CommandClass.BATTERY), + device_class=DEVICE_CLASS_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} async def async_setup_entry( @@ -239,11 +285,42 @@ async def async_setup_entry( # ignore idle key (0) if state_key == "0": continue - entities.append( - ZWaveNotificationBinarySensor(config_entry, client, info, state_key) + + notification_description: NotificationZWaveJSEntityDescription | None = ( + None ) - elif info.platform_hint == "property": - entities.append(ZWavePropertyBinarySensor(config_entry, client, info)) + + for description in NOTIFICATION_SENSOR_MAPPINGS: + if ( + int(description.key) + == info.primary_value.metadata.cc_specific[ + CC_SPECIFIC_NOTIFICATION_TYPE + ] + ) and (not description.states or state_key in description.states): + notification_description = description + break + + if ( + notification_description + and notification_description.off_state == state_key + ): + continue + + entities.append( + ZWaveNotificationBinarySensor( + config_entry, client, info, state_key, notification_description + ) + ) + elif info.platform_hint == "property" and ( + property_description := PROPERTY_SENSOR_MAPPINGS.get( + info.primary_value.property_name + ) + ): + entities.append( + ZWavePropertyBinarySensor( + config_entry, client, info, property_description + ) + ) else: # boolean sensor entities.append(ZWaveBooleanBinarySensor(config_entry, client, info)) @@ -273,11 +350,10 @@ class ZWaveBooleanBinarySensor(ZWaveBaseEntity, BinarySensorEntity): # Entity class attributes self._attr_name = self.generate_name(include_value_name=True) - self._attr_device_class = ( - DEVICE_CLASS_BATTERY - if self.info.primary_value.command_class == CommandClass.BATTERY - else None - ) + if description := BOOLEAN_SENSOR_MAPPINGS.get( + self.info.primary_value.command_class + ): + self.entity_description = description @property def is_on(self) -> bool | None: @@ -296,12 +372,13 @@ class ZWaveNotificationBinarySensor(ZWaveBaseEntity, BinarySensorEntity): client: ZwaveClient, info: ZwaveDiscoveryInfo, state_key: str, + description: NotificationZWaveJSEntityDescription | None = None, ) -> None: """Initialize a ZWaveNotificationBinarySensor entity.""" super().__init__(config_entry, client, info) self.state_key = state_key - # check if we have a custom mapping for this value - self._mapping_info = self._get_sensor_mapping() + if description: + self.entity_description = description # Entity class attributes self._attr_name = self.generate_name( @@ -309,11 +386,7 @@ class ZWaveNotificationBinarySensor(ZWaveBaseEntity, BinarySensorEntity): alternate_value_name=self.info.primary_value.property_name, additional_info=[self.info.primary_value.metadata.states[self.state_key]], ) - self._attr_device_class = self._mapping_info.get("device_class") self._attr_unique_id = f"{self._attr_unique_id}.{self.state_key}" - self._attr_entity_registry_enabled_default = ( - True if not self._mapping_info else self._mapping_info.get("enabled", True) - ) @property def is_on(self) -> bool | None: @@ -322,57 +395,27 @@ class ZWaveNotificationBinarySensor(ZWaveBaseEntity, BinarySensorEntity): return None return int(self.info.primary_value.value) == int(self.state_key) - @callback - def _get_sensor_mapping(self) -> NotificationSensorMapping: - """Try to get a device specific mapping for this sensor.""" - for mapping in NOTIFICATION_SENSOR_MAPPINGS: - if ( - mapping["type"] - != self.info.primary_value.metadata.cc_specific[ - CC_SPECIFIC_NOTIFICATION_TYPE - ] - ): - continue - if not mapping.get("states") or self.state_key in mapping["states"]: - # match found - return mapping - return {} - class ZWavePropertyBinarySensor(ZWaveBaseEntity, BinarySensorEntity): """Representation of a Z-Wave binary_sensor from a property.""" + entity_description: PropertyZWaveJSEntityDescription + def __init__( - self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + self, + config_entry: ConfigEntry, + client: ZwaveClient, + info: ZwaveDiscoveryInfo, + description: PropertyZWaveJSEntityDescription, ) -> None: """Initialize a ZWavePropertyBinarySensor entity.""" super().__init__(config_entry, client, info) - # check if we have a custom mapping for this value - self._mapping_info = self._get_sensor_mapping() - - # Entity class attributes + self.entity_description = description self._attr_name = self.generate_name(include_value_name=True) - self._attr_device_class = self._mapping_info.get("device_class") - # We hide some more advanced sensors by default to not overwhelm users - # unless explicitly stated in a mapping, assume deisabled by default - self._attr_entity_registry_enabled_default = self._mapping_info.get( - "enabled", False - ) @property def is_on(self) -> bool | None: """Return if the sensor is on or off.""" if self.info.primary_value.value is None: return None - return self.info.primary_value.value in self._mapping_info["on_states"] - - @callback - def _get_sensor_mapping(self) -> PropertySensorMapping: - """Try to get a device specific mapping for this sensor.""" - mapping_info = PropertySensorMapping() - for mapping in PROPERTY_SENSOR_MAPPINGS: - if mapping["property_name"] == self.info.primary_value.property_name: - mapping_info = mapping.copy() - break - - return mapping_info + return self.info.primary_value.value in self.entity_description.on_states diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 37e6b7c9320..32f406d7476 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -13,7 +13,7 @@ from zwave_js_server.version import VersionInfo, get_server_version from homeassistant import config_entries, exceptions from homeassistant.components import usb -from homeassistant.components.hassio import is_hassio +from homeassistant.components.hassio import HassioServiceInfo, is_hassio from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import ( @@ -301,6 +301,7 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): super().__init__() self.use_addon = False self._title: str | None = None + self._usb_discovery = False @property def flow_manager(self) -> config_entries.ConfigEntriesFlowManager: @@ -336,7 +337,7 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_manual() - async def async_step_usb(self, discovery_info: dict[str, str]) -> FlowResult: + async def async_step_usb(self, discovery_info: usb.UsbServiceInfo) -> FlowResult: """Handle USB Discovery.""" if not is_hassio(self.hass): return self.async_abort(reason="discovery_requires_supervisor") @@ -345,14 +346,14 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): if self._async_in_progress(): return self.async_abort(reason="already_in_progress") - vid = discovery_info["vid"] - pid = discovery_info["pid"] - serial_number = discovery_info["serial_number"] - device = discovery_info["device"] - manufacturer = discovery_info["manufacturer"] - description = discovery_info["description"] + vid = discovery_info.vid + pid = discovery_info.pid + serial_number = discovery_info.serial_number + device = discovery_info.device + manufacturer = discovery_info.manufacturer + description = discovery_info.description # Zooz uses this vid/pid, but so do 2652 sticks - if vid == "10C4" and pid == "EA60" and "2652" in description: + if vid == "10C4" and pid == "EA60" and description and "2652" in description: return self.async_abort(reason="not_zwave_device") addon_info = await self._async_get_addon_info() @@ -387,6 +388,8 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({}), ) + self._usb_discovery = True + return await self.async_step_on_supervisor({CONF_USE_ADDON: True}) async def async_step_manual( @@ -427,7 +430,7 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): step_id="manual", data_schema=get_manual_schema(user_input), errors=errors ) - async def async_step_hassio(self, discovery_info: dict[str, Any]) -> FlowResult: + async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Receive configuration from add-on discovery info. This flow is triggered by the Z-Wave JS add-on. @@ -435,7 +438,9 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): if self._async_in_progress(): return self.async_abort(reason="already_in_progress") - self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" + self.ws_address = ( + f"ws://{discovery_info.config['host']}:{discovery_info.config['port']}" + ) try: version_info = await async_get_version_info(self.hass, self.ws_address) except CannotConnect: @@ -504,7 +509,8 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): self.s2_access_control_key = user_input[CONF_S2_ACCESS_CONTROL_KEY] self.s2_authenticated_key = user_input[CONF_S2_AUTHENTICATED_KEY] self.s2_unauthenticated_key = user_input[CONF_S2_UNAUTHENTICATED_KEY] - self.usb_path = user_input[CONF_USB_PATH] + if not self._usb_discovery: + self.usb_path = user_input[CONF_USB_PATH] new_addon_config = { **addon_config, @@ -534,21 +540,21 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): CONF_ADDON_S2_UNAUTHENTICATED_KEY, self.s2_unauthenticated_key or "" ) - data_schema = vol.Schema( - { - vol.Required(CONF_USB_PATH, default=usb_path): str, - vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str, - vol.Optional( - CONF_S2_ACCESS_CONTROL_KEY, default=s2_access_control_key - ): str, - vol.Optional( - CONF_S2_AUTHENTICATED_KEY, default=s2_authenticated_key - ): str, - vol.Optional( - CONF_S2_UNAUTHENTICATED_KEY, default=s2_unauthenticated_key - ): str, - } - ) + schema = { + vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str, + vol.Optional( + CONF_S2_ACCESS_CONTROL_KEY, default=s2_access_control_key + ): str, + vol.Optional(CONF_S2_AUTHENTICATED_KEY, default=s2_authenticated_key): str, + vol.Optional( + CONF_S2_UNAUTHENTICATED_KEY, default=s2_unauthenticated_key + ): str, + } + + if not self._usb_discovery: + schema = {vol.Required(CONF_USB_PATH, default=usb_path): str, **schema} + + data_schema = vol.Schema(schema) return self.async_show_form(step_id="configure_addon", data_schema=data_schema) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index e484d01fccb..2d16c0113c9 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -109,7 +109,6 @@ ENTITY_DESC_KEY_PRESSURE = "pressure" ENTITY_DESC_KEY_SIGNAL_STRENGTH = "signal_strength" ENTITY_DESC_KEY_TEMPERATURE = "temperature" ENTITY_DESC_KEY_TARGET_TEMPERATURE = "target_temperature" -ENTITY_DESC_KEY_TIMESTAMP = "timestamp" ENTITY_DESC_KEY_MEASUREMENT = "measurement" ENTITY_DESC_KEY_TOTAL_INCREASING = "total_increasing" @@ -133,4 +132,5 @@ VALUE_SCHEMA = vol.Any( vol.Coerce(float), BITMASK_SCHEMA, cv.string, + dict, ) diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index e9759dbb171..c94c54e1948 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import TARGET_STATE_PROPERTY, TARGET_VALUE_PROPERTY @@ -19,13 +19,19 @@ from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.components.cover import ( ATTR_POSITION, + ATTR_TILT_POSITION, DEVICE_CLASS_BLIND, DEVICE_CLASS_GARAGE, DEVICE_CLASS_SHUTTER, DEVICE_CLASS_WINDOW, DOMAIN as COVER_DOMAIN, SUPPORT_CLOSE, + SUPPORT_CLOSE_TILT, SUPPORT_OPEN, + SUPPORT_OPEN_TILT, + SUPPORT_SET_POSITION, + SUPPORT_SET_TILT_POSITION, + SUPPORT_STOP, CoverEntity, ) from homeassistant.config_entries import ConfigEntry @@ -35,6 +41,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo +from .discovery_data_template import CoverTiltDataTemplate from .entity import ZWaveBaseEntity LOGGER = logging.getLogger(__name__) @@ -54,6 +61,8 @@ async def async_setup_entry( entities: list[ZWaveBaseEntity] = [] if info.platform_hint == "motorized_barrier": entities.append(ZwaveMotorizedBarrier(config_entry, client, info)) + elif info.platform_hint == "window_shutter_tilt": + entities.append(ZWaveTiltCover(config_entry, client, info)) else: entities.append(ZWaveCover(config_entry, client, info)) async_add_entities(entities) @@ -77,6 +86,26 @@ def percent_to_zwave_position(value: int) -> int: return 0 +def percent_to_zwave_tilt(value: int) -> int: + """Convert position in 0-100 scale to 0-99 scale. + + `value` -- (int) Position byte value from 0-100. + """ + if value > 0: + return round((value / 100) * 99) + return 0 + + +def zwave_tilt_to_percent(value: int) -> int: + """Convert 0-99 scale to position in 0-100 scale. + + `value` -- (int) Position byte value from 0-99. + """ + if value > 0: + return round((value / 99) * 100) + return 0 + + class ZWaveCover(ZWaveBaseEntity, CoverEntity): """Representation of a Z-Wave Cover device.""" @@ -91,7 +120,7 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): # Entity class attributes self._attr_device_class = DEVICE_CLASS_WINDOW - if self.info.platform_hint == "window_shutter": + if self.info.platform_hint in ("window_shutter", "window_shutter_tilt"): self._attr_device_class = DEVICE_CLASS_SHUTTER if self.info.platform_hint == "window_blind": self._attr_device_class = DEVICE_CLASS_BLIND @@ -150,6 +179,58 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): await self.info.node.async_set_value(close_value, False) +class ZWaveTiltCover(ZWaveCover): + """Representation of a Fibaro Z-Wave cover device.""" + + _attr_supported_features = ( + SUPPORT_OPEN + | SUPPORT_CLOSE + | SUPPORT_STOP + | SUPPORT_SET_POSITION + | SUPPORT_OPEN_TILT + | SUPPORT_CLOSE_TILT + | SUPPORT_SET_TILT_POSITION + ) + + def __init__( + self, + config_entry: ConfigEntry, + client: ZwaveClient, + info: ZwaveDiscoveryInfo, + ) -> None: + """Initialize a ZWaveCover entity.""" + super().__init__(config_entry, client, info) + self.data_template = cast( + CoverTiltDataTemplate, self.info.platform_data_template + ) + + @property + def current_cover_tilt_position(self) -> int | None: + """Return current position of cover tilt. + + None is unknown, 0 is closed, 100 is fully open. + """ + value = self.data_template.current_tilt_value(self.info.platform_data) + return zwave_tilt_to_percent(value.value) if value else None + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover tilt to a specific position.""" + tilt_value = self.data_template.current_tilt_value(self.info.platform_data) + if tilt_value: + await self.info.node.async_set_value( + tilt_value, + percent_to_zwave_tilt(kwargs[ATTR_TILT_POSITION]), + ) + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + await self.async_set_cover_tilt_position(tilt_position=100) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + await self.async_set_cover_tilt_position(tilt_position=0) + + class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity): """Representation of a Z-Wave motorized barrier device.""" diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py index 6694d88a135..4b1843782e2 100644 --- a/homeassistant/components/zwave_js/device_condition.py +++ b/homeassistant/components/zwave_js/device_condition.py @@ -148,12 +148,9 @@ async def async_get_conditions( @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" - if config_validation: - config = CONDITION_SCHEMA(config) - condition_type = config[CONF_TYPE] device_id = config[CONF_DEVICE_ID] diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index 368226d36a5..481fc429cb0 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -415,7 +415,7 @@ async def async_attach_trigger( else: raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") - state_config = state.TRIGGER_SCHEMA(state_config) + state_config = await state.async_validate_trigger_config(hass, state_config) return await state.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 23053804aae..6f2d83f99c3 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -44,7 +44,10 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import LOGGER from .discovery_data_template import ( BaseDiscoverySchemaDataTemplate, + ConfigurableFanSpeedDataTemplate, + CoverTiltDataTemplate, DynamicCurrentTempClimateDataTemplate, + FixedFanSpeedDataTemplate, NumericSensorDataTemplate, ZwaveValueID, ) @@ -228,11 +231,35 @@ DISCOVERY_SCHEMAS = [ product_type={0x4944}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, ), - # GE/Jasco fan controllers using switch multilevel CC + # GE/Jasco - In-Wall Smart Fan Control - 12730 / ZW4002 + ZWaveDiscoverySchema( + platform="fan", + hint="configured_fan_speed", + manufacturer_id={0x0063}, + product_id={0x3034}, + product_type={0x4944}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + data_template=FixedFanSpeedDataTemplate( + speeds=[33, 67, 99], + ), + ), + # GE/Jasco - In-Wall Smart Fan Control - 14287 / ZW4002 + ZWaveDiscoverySchema( + platform="fan", + hint="configured_fan_speed", + manufacturer_id={0x0063}, + product_id={0x3131}, + product_type={0x4944}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + data_template=FixedFanSpeedDataTemplate( + speeds=[32, 66, 99], + ), + ), + # GE/Jasco - In-Wall Smart Fan Control - 14314 / ZW4002 ZWaveDiscoverySchema( platform="fan", manufacturer_id={0x0063}, - product_id={0x3034, 0x3131, 0x3138}, + product_id={0x3138}, product_type={0x4944}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, ), @@ -258,14 +285,44 @@ DISCOVERY_SCHEMAS = [ type={"number"}, ), ), - # Fibaro Shutter Fibaro FGS222 + # HomeSeer HS-FC200+ + ZWaveDiscoverySchema( + platform="fan", + hint="configured_fan_speed", + manufacturer_id={0x000C}, + product_id={0x0001}, + product_type={0x0203}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + data_template=ConfigurableFanSpeedDataTemplate( + configuration_option=ZwaveValueID( + 5, CommandClass.CONFIGURATION, endpoint=0 + ), + configuration_value_to_speeds={0: [33, 66, 99], 1: [24, 49, 74, 99]}, + ), + ), + # Fibaro Shutter Fibaro FGR222 ZWaveDiscoverySchema( platform="cover", - hint="window_shutter", + hint="window_shutter_tilt", manufacturer_id={0x010F}, - product_id={0x1000}, - product_type={0x0302}, + product_id={0x1000, 0x1001}, + product_type={0x0301, 0x0302}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + data_template=CoverTiltDataTemplate( + tilt_value_id=ZwaveValueID( + "fibaro", + CommandClass.MANUFACTURER_PROPRIETARY, + endpoint=0, + property_key="venetianBlindsTilt", + ) + ), + required_values=[ + ZWaveValueDiscoverySchema( + command_class={CommandClass.MANUFACTURER_PROPRIETARY}, + property={"fibaro"}, + property_key={"venetianBlindsTilt"}, + ) + ], ), # Qubino flush shutter ZWaveDiscoverySchema( diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 7b76465d60e..3e7db7cdcd9 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Iterable from dataclasses import dataclass, field +import logging from typing import Any from zwave_js_server.const import CommandClass @@ -11,6 +12,13 @@ from zwave_js_server.const.command_class.meter import ( ENERGY_TOTAL_INCREASING_METER_TYPES, POWER_FACTOR_METER_TYPES, POWER_METER_TYPES, + UNIT_AMPERE as METER_UNIT_AMPERE, + UNIT_CUBIC_FEET, + UNIT_CUBIC_METER as METER_UNIT_CUBIC_METER, + UNIT_KILOWATT_HOUR, + UNIT_US_GALLON, + UNIT_VOLT as METER_UNIT_VOLT, + UNIT_WATT as METER_UNIT_WATT, VOLTAGE_METER_TYPES, ElectricScale, MeterScaleType, @@ -26,17 +34,101 @@ from zwave_js_server.const.command_class.multilevel_sensor import ( PRESSURE_SENSORS, SIGNAL_STRENGTH_SENSORS, TEMPERATURE_SENSORS, - TIMESTAMP_SENSORS, + UNIT_AMPERE as SENSOR_UNIT_AMPERE, + UNIT_BTU_H, + UNIT_CELSIUS, + UNIT_CENTIMETER, + UNIT_CUBIC_FEET_PER_MINUTE, + UNIT_CUBIC_METER as SENSOR_UNIT_CUBIC_METER, + UNIT_CUBIC_METER_PER_HOUR, + UNIT_DECIBEL, + UNIT_DEGREES, + UNIT_DENSITY, + UNIT_FAHRENHEIT, + UNIT_FEET, + UNIT_GALLONS, + UNIT_HERTZ, + UNIT_INCHES_OF_MERCURY, + UNIT_INCHES_PER_HOUR, + UNIT_KILOGRAM, + UNIT_KILOHERTZ, + UNIT_LITER, + UNIT_LUX, + UNIT_M_S, + UNIT_METER, + UNIT_MICROGRAM_PER_CUBIC_METER, + UNIT_MILLIAMPERE, + UNIT_MILLIMETER_HOUR, + UNIT_MILLIVOLT, + UNIT_MPH, + UNIT_PARTS_MILLION, + UNIT_PERCENTAGE_VALUE, + UNIT_POUND_PER_SQUARE_INCH, + UNIT_POUNDS, + UNIT_POWER_LEVEL, + UNIT_RSSI, + UNIT_SECOND, + UNIT_SYSTOLIC, + UNIT_VOLT as SENSOR_UNIT_VOLT, + UNIT_WATT as SENSOR_UNIT_WATT, + UNIT_WATT_PER_SQUARE_METER, VOLTAGE_SENSORS, + MultilevelSensorScaleType, MultilevelSensorType, ) from zwave_js_server.model.node import Node as ZwaveNode -from zwave_js_server.model.value import Value as ZwaveValue, get_value_id +from zwave_js_server.model.value import ( + ConfigurationValue as ZwaveConfigurationValue, + Value as ZwaveValue, + get_value_id, +) from zwave_js_server.util.command_class.meter import get_meter_scale_type from zwave_js_server.util.command_class.multilevel_sensor import ( + get_multilevel_sensor_scale_type, get_multilevel_sensor_type, ) +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + DEGREE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_CURRENT_MILLIAMPERE, + ELECTRIC_POTENTIAL_MILLIVOLT, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + FREQUENCY_HERTZ, + FREQUENCY_KILOHERTZ, + IRRADIATION_WATTS_PER_SQUARE_METER, + LENGTH_CENTIMETERS, + LENGTH_FEET, + LENGTH_METERS, + LIGHT_LUX, + MASS_KILOGRAMS, + MASS_POUNDS, + PERCENTAGE, + POWER_BTU_PER_HOUR, + POWER_WATT, + PRECIPITATION_INCHES_PER_HOUR, + PRECIPITATION_MILLIMETERS_PER_HOUR, + PRESSURE_INHG, + PRESSURE_MMHG, + PRESSURE_PSI, + SIGNAL_STRENGTH_DECIBELS, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TIME_SECONDS, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, + VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE, + VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, + VOLUME_GALLONS, + VOLUME_LITERS, +) + from .const import ( ENTITY_DESC_KEY_BATTERY, ENTITY_DESC_KEY_CO, @@ -53,7 +145,6 @@ from .const import ( ENTITY_DESC_KEY_SIGNAL_STRENGTH, ENTITY_DESC_KEY_TARGET_TEMPERATURE, ENTITY_DESC_KEY_TEMPERATURE, - ENTITY_DESC_KEY_TIMESTAMP, ENTITY_DESC_KEY_TOTAL_INCREASING, ENTITY_DESC_KEY_VOLTAGE, ) @@ -77,10 +168,63 @@ MULTILEVEL_SENSOR_DEVICE_CLASS_MAP: dict[str, set[MultilevelSensorType]] = { ENTITY_DESC_KEY_PRESSURE: PRESSURE_SENSORS, ENTITY_DESC_KEY_SIGNAL_STRENGTH: SIGNAL_STRENGTH_SENSORS, ENTITY_DESC_KEY_TEMPERATURE: TEMPERATURE_SENSORS, - ENTITY_DESC_KEY_TIMESTAMP: TIMESTAMP_SENSORS, ENTITY_DESC_KEY_VOLTAGE: VOLTAGE_SENSORS, } +METER_UNIT_MAP: dict[str, set[MeterScaleType]] = { + ELECTRIC_CURRENT_AMPERE: METER_UNIT_AMPERE, + VOLUME_CUBIC_FEET: UNIT_CUBIC_FEET, + VOLUME_CUBIC_METERS: METER_UNIT_CUBIC_METER, + VOLUME_GALLONS: UNIT_US_GALLON, + ENERGY_KILO_WATT_HOUR: UNIT_KILOWATT_HOUR, + ELECTRIC_POTENTIAL_VOLT: METER_UNIT_VOLT, + POWER_WATT: METER_UNIT_WATT, +} + +MULTILEVEL_SENSOR_UNIT_MAP: dict[str, set[MultilevelSensorScaleType]] = { + ELECTRIC_CURRENT_AMPERE: SENSOR_UNIT_AMPERE, + POWER_BTU_PER_HOUR: UNIT_BTU_H, + TEMP_CELSIUS: UNIT_CELSIUS, + LENGTH_CENTIMETERS: UNIT_CENTIMETER, + VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE: UNIT_CUBIC_FEET_PER_MINUTE, + VOLUME_CUBIC_METERS: SENSOR_UNIT_CUBIC_METER, + VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR: UNIT_CUBIC_METER_PER_HOUR, + SIGNAL_STRENGTH_DECIBELS: UNIT_DECIBEL, + DEGREE: UNIT_DEGREES, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: { + *UNIT_DENSITY, + *UNIT_MICROGRAM_PER_CUBIC_METER, + }, + TEMP_FAHRENHEIT: UNIT_FAHRENHEIT, + LENGTH_FEET: UNIT_FEET, + VOLUME_GALLONS: UNIT_GALLONS, + FREQUENCY_HERTZ: UNIT_HERTZ, + PRESSURE_INHG: UNIT_INCHES_OF_MERCURY, + PRECIPITATION_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, + ELECTRIC_POTENTIAL_MILLIVOLT: UNIT_MILLIVOLT, + SPEED_MILES_PER_HOUR: UNIT_MPH, + SPEED_METERS_PER_SECOND: UNIT_M_S, + CONCENTRATION_PARTS_PER_MILLION: UNIT_PARTS_MILLION, + PERCENTAGE: {*UNIT_PERCENTAGE_VALUE, *UNIT_RSSI}, + MASS_POUNDS: UNIT_POUNDS, + PRESSURE_PSI: UNIT_POUND_PER_SQUARE_INCH, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT: UNIT_POWER_LEVEL, + TIME_SECONDS: UNIT_SECOND, + PRESSURE_MMHG: UNIT_SYSTOLIC, + ELECTRIC_POTENTIAL_VOLT: SENSOR_UNIT_VOLT, + POWER_WATT: SENSOR_UNIT_WATT, + IRRADIATION_WATTS_PER_SQUARE_METER: UNIT_WATT_PER_SQUARE_METER, +} + +_LOGGER = logging.getLogger(__name__) + @dataclass class ZwaveValueID: @@ -157,10 +301,8 @@ class DynamicCurrentTempClimateDataTemplate(BaseDiscoverySchemaDataTemplate): value.node, self.dependent_value ), } - for key in self.lookup_table: - data["lookup_table"][key] = self._get_value_from_id( - value.node, self.lookup_table[key] - ) + for key, value_id in self.lookup_table.items(): + data["lookup_table"][key] = self._get_value_from_id(value.node, value_id) return data @@ -186,17 +328,45 @@ class DynamicCurrentTempClimateDataTemplate(BaseDiscoverySchemaDataTemplate): return None +@dataclass +class NumericSensorDataTemplateData: + """Class to represent returned data from NumericSensorDataTemplate.""" + + entity_description_key: str | None = None + unit_of_measurement: str | None = None + + class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): """Data template class for Z-Wave Sensor entities.""" - def resolve_data(self, value: ZwaveValue) -> str | None: + @staticmethod + def find_key_from_matching_set( + enum_value: MultilevelSensorType | MultilevelSensorScaleType | MeterScaleType, + set_map: dict[ + str, set[MultilevelSensorType | MultilevelSensorScaleType | MeterScaleType] + ], + ) -> str | None: + """Find a key in a set map that matches a given enum value.""" + for key, value_set in set_map.items(): + for value_in_set in value_set: + # Since these are IntEnums and the different classes reuse the same + # values, we need to match the class as well + if ( + value_in_set.__class__ == enum_value.__class__ + and value_in_set == enum_value + ): + return key + return None + + def resolve_data(self, value: ZwaveValue) -> NumericSensorDataTemplateData: """Resolve helper class data for a discovered value.""" if value.command_class == CommandClass.BATTERY: - return ENTITY_DESC_KEY_BATTERY + return NumericSensorDataTemplateData(ENTITY_DESC_KEY_BATTERY, PERCENTAGE) if value.command_class == CommandClass.METER: scale_type = get_meter_scale_type(value) + unit = self.find_key_from_matching_set(scale_type, METER_UNIT_MAP) # We do this because even though these are energy scales, they don't meet # the unit requirements for the energy device class. if scale_type in ( @@ -204,25 +374,197 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): ElectricScale.KILOVOLT_AMPERE_HOUR, ElectricScale.KILOVOLT_AMPERE_REACTIVE_HOUR, ): - return ENTITY_DESC_KEY_TOTAL_INCREASING + return NumericSensorDataTemplateData( + ENTITY_DESC_KEY_TOTAL_INCREASING, unit + ) # We do this because even though these are power scales, they don't meet # the unit requirements for the power device class. if scale_type == ElectricScale.KILOVOLT_AMPERE_REACTIVE: - return ENTITY_DESC_KEY_MEASUREMENT + return NumericSensorDataTemplateData(ENTITY_DESC_KEY_MEASUREMENT, unit) - for key, scale_type_set in METER_DEVICE_CLASS_MAP.items(): - if scale_type in scale_type_set: - return key + return NumericSensorDataTemplateData( + self.find_key_from_matching_set(scale_type, METER_DEVICE_CLASS_MAP), + unit, + ) if value.command_class == CommandClass.SENSOR_MULTILEVEL: sensor_type = get_multilevel_sensor_type(value) + scale_type = get_multilevel_sensor_scale_type(value) + unit = self.find_key_from_matching_set( + scale_type, MULTILEVEL_SENSOR_UNIT_MAP + ) if sensor_type == MultilevelSensorType.TARGET_TEMPERATURE: - return ENTITY_DESC_KEY_TARGET_TEMPERATURE - for ( - key, - sensor_type_set, - ) in MULTILEVEL_SENSOR_DEVICE_CLASS_MAP.items(): - if sensor_type in sensor_type_set: - return key + return NumericSensorDataTemplateData( + ENTITY_DESC_KEY_TARGET_TEMPERATURE, unit + ) + key = self.find_key_from_matching_set( + sensor_type, MULTILEVEL_SENSOR_DEVICE_CLASS_MAP + ) + if key: + return NumericSensorDataTemplateData(key, unit) - return None + return NumericSensorDataTemplateData() + + +@dataclass +class TiltValueMix: + """Mixin data class for the tilt_value.""" + + tilt_value_id: ZwaveValueID + + +@dataclass +class CoverTiltDataTemplate(BaseDiscoverySchemaDataTemplate, TiltValueMix): + """Tilt data template class for Z-Wave Cover entities.""" + + def resolve_data(self, value: ZwaveValue) -> dict[str, Any]: + """Resolve helper class data for a discovered value.""" + return {"tilt_value": self._get_value_from_id(value.node, self.tilt_value_id)} + + def values_to_watch(self, resolved_data: dict[str, Any]) -> Iterable[ZwaveValue]: + """Return list of all ZwaveValues resolved by helper that should be watched.""" + return [resolved_data["tilt_value"]] + + @staticmethod + def current_tilt_value(resolved_data: dict[str, Any]) -> ZwaveValue | None: + """Get current tilt ZwaveValue from resolved data.""" + return resolved_data["tilt_value"] + + +@dataclass +class FanSpeedDataTemplate: + """Mixin to define get_speed_config.""" + + def get_speed_config(self, resolved_data: dict[str, Any]) -> list[int] | None: + """ + Get the fan speed configuration for this device. + + Values should indicate the highest allowed device setting for each + actual speed, and should be sorted in ascending order. + + Empty lists are not permissible. + """ + raise NotImplementedError + + +@dataclass +class ConfigurableFanSpeedValueMix: + """Mixin data class for defining configurable fan speeds.""" + + configuration_option: ZwaveValueID + configuration_value_to_speeds: dict[int, list[int]] + + def __post_init__(self) -> None: + """ + Validate inputs. + + These inputs are hardcoded in `discovery.py`, so these checks should + only fail due to developer error. + """ + for speeds in self.configuration_value_to_speeds.values(): + assert len(speeds) > 0 + assert sorted(speeds) == speeds + + +@dataclass +class ConfigurableFanSpeedDataTemplate( + BaseDiscoverySchemaDataTemplate, FanSpeedDataTemplate, ConfigurableFanSpeedValueMix +): + """ + Gets fan speeds based on a configuration value. + + Example: + ZWaveDiscoverySchema( + platform="fan", + hint="configured_fan_speed", + ... + data_template=ConfigurableFanSpeedDataTemplate( + configuration_option=ZwaveValueID( + 5, CommandClass.CONFIGURATION, endpoint=0 + ), + configuration_value_to_speeds={0: [32, 65, 99], 1: [24, 49, 74, 99]}, + ), + ), + + `configuration_option` is a reference to the setting that determines how + many speeds are supported. + + `configuration_value_to_speeds` maps the values from `configuration_option` + to a list of speeds. The specified speeds indicate the maximum setting on + the underlying switch for each actual speed. + """ + + def resolve_data(self, value: ZwaveValue) -> dict[str, ZwaveConfigurationValue]: + """Resolve helper class data for a discovered value.""" + zwave_value: ZwaveValue = self._get_value_from_id( + value.node, self.configuration_option + ) + return {"configuration_value": zwave_value} + + def values_to_watch(self, resolved_data: dict[str, Any]) -> Iterable[ZwaveValue]: + """Return list of all ZwaveValues that should be watched.""" + return [ + resolved_data["configuration_value"], + ] + + def get_speed_config( + self, resolved_data: dict[str, ZwaveConfigurationValue] + ) -> list[int] | None: + """Get current speed configuration from resolved data.""" + zwave_value: ZwaveValue = resolved_data["configuration_value"] + + if zwave_value.value is None: + _LOGGER.warning("Unable to read fan speed configuration value") + return None + + speed_config = self.configuration_value_to_speeds.get(zwave_value.value) + if speed_config is None: + _LOGGER.warning("Unrecognized speed configuration value") + return None + + return speed_config + + +@dataclass +class FixedFanSpeedValueMix: + """Mixin data class for defining supported fan speeds.""" + + speeds: list[int] + + def __post_init__(self) -> None: + """ + Validate inputs. + + These inputs are hardcoded in `discovery.py`, so these checks should + only fail due to developer error. + """ + assert len(self.speeds) > 0 + assert sorted(self.speeds) == self.speeds + + +@dataclass +class FixedFanSpeedDataTemplate( + BaseDiscoverySchemaDataTemplate, FanSpeedDataTemplate, FixedFanSpeedValueMix +): + """ + Specifies a fixed set of fan speeds. + + Example: + ZWaveDiscoverySchema( + platform="fan", + hint="configured_fan_speed", + ... + data_template=FixedFanSpeedDataTemplate( + speeds=[32,65,99] + ), + ), + + `speeds` indicates the maximum setting on the underlying fan controller + for each actual speed. + """ + + def get_speed_config( + self, resolved_data: dict[str, ZwaveConfigurationValue] + ) -> list[int]: + """Get the fan speed configuration for this device.""" + return self.speeds diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index aa915cd5822..cf15f32932b 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -10,7 +10,7 @@ from zwave_js_server.model.value import Value as ZwaveValue, get_value_id from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo @@ -54,9 +54,9 @@ class ZWaveBaseEntity(Entity): ) self._attr_assumed_state = self.info.assumed_state # device is precreated in main handler - self._attr_device_info = { - "identifiers": {get_device_id(self.client, self.info.node)}, - } + self._attr_device_info = DeviceInfo( + identifiers={get_device_id(self.client, self.info.node)}, + ) @callback def on_value_update(self) -> None: diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 4b4f23a85d2..df9b1a46683 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -2,7 +2,7 @@ from __future__ import annotations import math -from typing import Any +from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import TARGET_VALUE_PROPERTY @@ -24,11 +24,12 @@ from homeassistant.util.percentage import ( from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo +from .discovery_data_template import FanSpeedDataTemplate from .entity import ZWaveBaseEntity SUPPORTED_FEATURES = SUPPORT_SET_SPEED -SPEED_RANGE = (1, 99) # off is not included +DEFAULT_SPEED_RANGE = (1, 99) # off is not included async def async_setup_entry( @@ -43,7 +44,11 @@ async def async_setup_entry( def async_add_fan(info: ZwaveDiscoveryInfo) -> None: """Add Z-Wave fan.""" entities: list[ZWaveBaseEntity] = [] - entities.append(ZwaveFan(config_entry, client, info)) + if info.platform_hint == "configured_fan_speed": + entities.append(ConfiguredSpeedRangeZwaveFan(config_entry, client, info)) + else: + entities.append(ZwaveFan(config_entry, client, info)) + async_add_entities(entities) config_entry.async_on_unload( @@ -58,19 +63,23 @@ async def async_setup_entry( class ZwaveFan(ZWaveBaseEntity, FanEntity): """Representation of a Z-Wave fan.""" - async def async_set_percentage(self, percentage: int | None) -> None: - """Set the speed percentage of the fan.""" - target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize the fan.""" + super().__init__(config_entry, client, info) + self._target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) - if percentage is None: - # Value 255 tells device to return to previous value - zwave_speed = 255 - elif percentage == 0: + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + if percentage == 0: zwave_speed = 0 else: - zwave_speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + zwave_speed = math.ceil( + percentage_to_ranged_value(DEFAULT_SPEED_RANGE, percentage) + ) - await self.info.node.async_set_value(target_value, zwave_speed) + await self.info.node.async_set_value(self._target_value, zwave_speed) async def async_turn_on( self, @@ -80,12 +89,15 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): **kwargs: Any, ) -> None: """Turn the device on.""" - await self.async_set_percentage(percentage) + if percentage is None: + # Value 255 tells device to return to previous value + await self.info.node.async_set_value(self._target_value, 255) + else: + await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) - await self.info.node.async_set_value(target_value, 0) + await self.info.node.async_set_value(self._target_value, 0) @property def is_on(self) -> bool | None: # type: ignore @@ -101,7 +113,9 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): if self.info.primary_value.value is None: # guard missing value return None - return ranged_value_to_percentage(SPEED_RANGE, self.info.primary_value.value) + return ranged_value_to_percentage( + DEFAULT_SPEED_RANGE, self.info.primary_value.value + ) @property def percentage_step(self) -> float: @@ -111,9 +125,103 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" - return int_states_in_range(SPEED_RANGE) + return int_states_in_range(DEFAULT_SPEED_RANGE) @property def supported_features(self) -> int: """Flag supported features.""" return SUPPORTED_FEATURES + + +class ConfiguredSpeedRangeZwaveFan(ZwaveFan): + """A Zwave fan with a configured speed range (e.g., 1-24 is low).""" + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize the fan.""" + super().__init__(config_entry, client, info) + self.data_template = cast( + FanSpeedDataTemplate, self.info.platform_data_template + ) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + zwave_speed = self.percentage_to_zwave_speed(percentage) + await self.info.node.async_set_value(self._target_value, zwave_speed) + + @property + def available(self) -> bool: + """Return whether the entity is available.""" + return super().available and self.has_speed_configuration + + @property + def percentage(self) -> int | None: + """Return the current speed percentage.""" + if self.info.primary_value.value is None: + # guard missing value + return None + + return self.zwave_speed_to_percentage(self.info.primary_value.value) + + @property + def percentage_step(self) -> float: + """Return the step size for percentage.""" + # This is the same implementation as the base fan type, but + # it needs to be overridden here because the ZwaveFan does + # something different for fans with unknown speeds. + return 100 / self.speed_count + + @property + def has_speed_configuration(self) -> bool: + """Check if the speed configuration is valid.""" + return self.data_template.get_speed_config(self.info.platform_data) is not None + + @property + def speed_configuration(self) -> list[int]: + """Return the speed configuration for this fan.""" + speed_configuration = self.data_template.get_speed_config( + self.info.platform_data + ) + + # Entity should be unavailable if this isn't set + assert speed_configuration is not None + + return speed_configuration + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return len(self.speed_configuration) + + def percentage_to_zwave_speed(self, percentage: int) -> int: + """Map a percentage to a ZWave speed.""" + if percentage == 0: + return 0 + + # Since the percentage steps are computed with rounding, we have to + # search to find the appropriate speed. + for speed_limit in self.speed_configuration: + step_percentage = self.zwave_speed_to_percentage(speed_limit) + if percentage <= step_percentage: + return speed_limit + + # This shouldn't actually happen; the last entry in + # `self.speed_configuration` should map to 100%. + return self.speed_configuration[-1] + + def zwave_speed_to_percentage(self, zwave_speed: int) -> int: + """Convert a Zwave speed to a percentage.""" + if zwave_speed == 0: + return 0 + + percentage = 0.0 + for speed_limit in self.speed_configuration: + percentage += self.percentage_step + if zwave_speed <= speed_limit: + break + + # This choice of rounding function is to provide consistency with how + # the UI handles steps e.g., for a 3-speed fan, you get steps at 33, + # 67, and 100. + return round(percentage) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 50e0a039488..4e65a9fe093 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.31.3"], + "requirements": ["zwave-js-server-python==0.33.0"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 15223419ced..08f9059e125 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -9,6 +9,7 @@ from zwave_js_server.const.command_class.sound_switch import ToneID from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_CONFIG from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -52,6 +53,8 @@ async def async_setup_entry( class ZwaveSelectEntity(ZWaveBaseEntity, SelectEntity): """Representation of a Z-Wave select entity.""" + _attr_entity_category = ENTITY_CATEGORY_CONFIG + def __init__( self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo ) -> None: @@ -86,6 +89,8 @@ class ZwaveSelectEntity(ZWaveBaseEntity, SelectEntity): class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity): """Representation of a Z-Wave default tone select entity.""" + _attr_entity_category = ENTITY_CATEGORY_CONFIG + def __init__( self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo ) -> None: diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 715affe351e..549df9f6264 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass import logging from typing import cast @@ -25,6 +24,9 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) +from homeassistant.components.zwave_js.discovery_data_template import ( + NumericSensorDataTemplateData, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_BATTERY, @@ -38,15 +40,13 @@ from homeassistant.const import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, ENTITY_CATEGORY_DIAGNOSTIC, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant, callback 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 .const import ( @@ -70,7 +70,6 @@ from .const import ( ENTITY_DESC_KEY_SIGNAL_STRENGTH, ENTITY_DESC_KEY_TARGET_TEMPERATURE, ENTITY_DESC_KEY_TEMPERATURE, - ENTITY_DESC_KEY_TIMESTAMP, ENTITY_DESC_KEY_TOTAL_INCREASING, ENTITY_DESC_KEY_VOLTAGE, SERVICE_RESET_METER, @@ -90,103 +89,91 @@ STATUS_ICON: dict[NodeStatus, str] = { } -@dataclass -class ZwaveSensorEntityDescription(SensorEntityDescription): - """Base description of a Zwave Sensor entity.""" - - info: ZwaveDiscoveryInfo | None = None - - -ENTITY_DESCRIPTION_KEY_MAP: dict[str, ZwaveSensorEntityDescription] = { - ENTITY_DESC_KEY_BATTERY: ZwaveSensorEntityDescription( +ENTITY_DESCRIPTION_KEY_MAP: dict[str, SensorEntityDescription] = { + ENTITY_DESC_KEY_BATTERY: SensorEntityDescription( ENTITY_DESC_KEY_BATTERY, device_class=DEVICE_CLASS_BATTERY, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, state_class=STATE_CLASS_MEASUREMENT, ), - ENTITY_DESC_KEY_CURRENT: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_CURRENT: SensorEntityDescription( ENTITY_DESC_KEY_CURRENT, device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, ), - ENTITY_DESC_KEY_VOLTAGE: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_VOLTAGE: SensorEntityDescription( ENTITY_DESC_KEY_VOLTAGE, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, ), - ENTITY_DESC_KEY_ENERGY_MEASUREMENT: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_ENERGY_MEASUREMENT: SensorEntityDescription( ENTITY_DESC_KEY_ENERGY_MEASUREMENT, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_MEASUREMENT, ), - ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING: SensorEntityDescription( ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), - ENTITY_DESC_KEY_POWER: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_POWER: SensorEntityDescription( ENTITY_DESC_KEY_POWER, device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), - ENTITY_DESC_KEY_POWER_FACTOR: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_POWER_FACTOR: SensorEntityDescription( ENTITY_DESC_KEY_POWER_FACTOR, device_class=DEVICE_CLASS_POWER_FACTOR, state_class=STATE_CLASS_MEASUREMENT, ), - ENTITY_DESC_KEY_CO: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_CO: SensorEntityDescription( ENTITY_DESC_KEY_CO, device_class=DEVICE_CLASS_CO, state_class=STATE_CLASS_MEASUREMENT, ), - ENTITY_DESC_KEY_CO2: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_CO2: SensorEntityDescription( ENTITY_DESC_KEY_CO2, device_class=DEVICE_CLASS_CO2, state_class=STATE_CLASS_MEASUREMENT, ), - ENTITY_DESC_KEY_HUMIDITY: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_HUMIDITY: SensorEntityDescription( ENTITY_DESC_KEY_HUMIDITY, device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), - ENTITY_DESC_KEY_ILLUMINANCE: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_ILLUMINANCE: SensorEntityDescription( ENTITY_DESC_KEY_ILLUMINANCE, device_class=DEVICE_CLASS_ILLUMINANCE, state_class=STATE_CLASS_MEASUREMENT, ), - ENTITY_DESC_KEY_PRESSURE: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_PRESSURE: SensorEntityDescription( ENTITY_DESC_KEY_PRESSURE, device_class=DEVICE_CLASS_PRESSURE, state_class=STATE_CLASS_MEASUREMENT, ), - ENTITY_DESC_KEY_SIGNAL_STRENGTH: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_SIGNAL_STRENGTH: SensorEntityDescription( ENTITY_DESC_KEY_SIGNAL_STRENGTH, device_class=DEVICE_CLASS_SIGNAL_STRENGTH, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, entity_registry_enabled_default=False, state_class=STATE_CLASS_MEASUREMENT, ), - ENTITY_DESC_KEY_TEMPERATURE: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_TEMPERATURE: SensorEntityDescription( ENTITY_DESC_KEY_TEMPERATURE, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), - ENTITY_DESC_KEY_TIMESTAMP: ZwaveSensorEntityDescription( - ENTITY_DESC_KEY_TIMESTAMP, - device_class=DEVICE_CLASS_TIMESTAMP, - state_class=STATE_CLASS_MEASUREMENT, - ), - ENTITY_DESC_KEY_TARGET_TEMPERATURE: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_TARGET_TEMPERATURE: SensorEntityDescription( ENTITY_DESC_KEY_TARGET_TEMPERATURE, device_class=DEVICE_CLASS_TEMPERATURE, state_class=None, ), - ENTITY_DESC_KEY_MEASUREMENT: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_MEASUREMENT: SensorEntityDescription( ENTITY_DESC_KEY_MEASUREMENT, device_class=None, state_class=STATE_CLASS_MEASUREMENT, ), - ENTITY_DESC_KEY_TOTAL_INCREASING: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_TOTAL_INCREASING: SensorEntityDescription( ENTITY_DESC_KEY_TOTAL_INCREASING, device_class=None, state_class=STATE_CLASS_TOTAL_INCREASING, @@ -207,25 +194,42 @@ async def async_setup_entry( """Add Z-Wave Sensor.""" entities: list[ZWaveBaseEntity] = [] + if info.platform_data: + data: NumericSensorDataTemplateData = info.platform_data + else: + data = NumericSensorDataTemplateData() entity_description = ENTITY_DESCRIPTION_KEY_MAP.get( - info.platform_data - ) or ZwaveSensorEntityDescription("base_sensor") - entity_description.info = info + data.entity_description_key or "", SensorEntityDescription("base_sensor") + ) if info.platform_hint == "string_sensor": - entities.append(ZWaveStringSensor(config_entry, client, entity_description)) + entities.append( + ZWaveStringSensor(config_entry, client, info, entity_description) + ) elif info.platform_hint == "numeric_sensor": entities.append( - ZWaveNumericSensor(config_entry, client, entity_description) + ZWaveNumericSensor( + config_entry, + client, + info, + entity_description, + data.unit_of_measurement, + ) ) elif info.platform_hint == "list_sensor": - entities.append(ZWaveListSensor(config_entry, client, entity_description)) + entities.append( + ZWaveListSensor(config_entry, client, info, entity_description) + ) elif info.platform_hint == "config_parameter": entities.append( - ZWaveConfigParameterSensor(config_entry, client, entity_description) + ZWaveConfigParameterSensor( + config_entry, client, info, entity_description + ) ) elif info.platform_hint == "meter": - entities.append(ZWaveMeterSensor(config_entry, client, entity_description)) + entities.append( + ZWaveMeterSensor(config_entry, client, info, entity_description) + ) else: LOGGER.warning( "Sensor not implemented for %s/%s", @@ -275,12 +279,14 @@ class ZwaveSensorBase(ZWaveBaseEntity, SensorEntity): self, config_entry: ConfigEntry, client: ZwaveClient, - entity_description: ZwaveSensorEntityDescription, + info: ZwaveDiscoveryInfo, + entity_description: SensorEntityDescription, + unit_of_measurement: str | None = None, ) -> None: """Initialize a ZWaveSensorBase entity.""" - assert entity_description.info - super().__init__(config_entry, client, entity_description.info) + super().__init__(config_entry, client, info) self.entity_description = entity_description + self._attr_native_unit_of_measurement = unit_of_measurement # Entity class attributes self._attr_force_update = True @@ -318,12 +324,10 @@ class ZWaveNumericSensor(ZwaveSensorBase): @property def native_unit_of_measurement(self) -> str | None: """Return unit of measurement the value is expressed in.""" + if self._attr_native_unit_of_measurement is not None: + return self._attr_native_unit_of_measurement if self.info.primary_value.metadata.unit is None: return None - if self.info.primary_value.metadata.unit == "C": - return TEMP_CELSIUS - if self.info.primary_value.metadata.unit == "F": - return TEMP_FAHRENHEIT return str(self.info.primary_value.metadata.unit) @@ -334,8 +338,7 @@ class ZWaveMeterSensor(ZWaveNumericSensor): @property def extra_state_attributes(self) -> Mapping[str, int | str] | None: """Return extra state attributes.""" - meter_type = get_meter_type(self.info.primary_value) - if meter_type: + if meter_type := get_meter_type(self.info.primary_value): return { ATTR_METER_TYPE: meter_type.value, ATTR_METER_TYPE_NAME: meter_type.name, @@ -372,10 +375,14 @@ class ZWaveListSensor(ZwaveSensorBase): self, config_entry: ConfigEntry, client: ZwaveClient, - entity_description: ZwaveSensorEntityDescription, + info: ZwaveDiscoveryInfo, + entity_description: SensorEntityDescription, + unit_of_measurement: str | None = None, ) -> None: """Initialize a ZWaveListSensor entity.""" - super().__init__(config_entry, client, entity_description) + super().__init__( + config_entry, client, info, entity_description, unit_of_measurement + ) # Entity class attributes self._attr_name = self.generate_name( @@ -412,10 +419,14 @@ class ZWaveConfigParameterSensor(ZwaveSensorBase): self, config_entry: ConfigEntry, client: ZwaveClient, - entity_description: ZwaveSensorEntityDescription, + info: ZwaveDiscoveryInfo, + entity_description: SensorEntityDescription, + unit_of_measurement: str | None = None, ) -> None: """Initialize a ZWaveConfigParameterSensor entity.""" - super().__init__(config_entry, client, entity_description) + super().__init__( + config_entry, client, info, entity_description, unit_of_measurement + ) self._primary_value = cast(ConfigurationValue, self.info.primary_value) # Entity class attributes @@ -476,9 +487,9 @@ class ZWaveNodeStatusSensor(SensorEntity): f"{self.client.driver.controller.home_id}.{node.node_id}.node_status" ) # device is precreated in main handler - self._attr_device_info = { - "identifiers": {get_device_id(self.client, self.node)}, - } + self._attr_device_info = DeviceInfo( + identifiers={get_device_id(self.client, self.node)}, + ) self._attr_native_value: str = node.status.name.lower() async def async_poll_value(self, _: bool) -> None: diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 1446c1fc7aa..13f65921cdb 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -22,6 +22,7 @@ }, "configure_addon": { "title": "Enter the Z-Wave JS add-on configuration", + "description": "The add-on will generate security keys if those fields are left empty.", "data": { "usb_path": "[%key:common::config_flow::data::usb_path%]", "s0_legacy_key": "S0 Key (Legacy)", @@ -79,6 +80,7 @@ }, "configure_addon": { "title": "Enter the Z-Wave JS add-on configuration", + "description": "The add-on will generate security keys if those fields are left empty.", "data": { "usb_path": "[%key:common::config_flow::data::usb_path%]", "s0_legacy_key": "S0 Key (Legacy)", diff --git a/homeassistant/components/zwave_js/translations/bg.json b/homeassistant/components/zwave_js/translations/bg.json index afde8dc1336..bdae86569ac 100644 --- a/homeassistant/components/zwave_js/translations/bg.json +++ b/homeassistant/components/zwave_js/translations/bg.json @@ -23,15 +23,16 @@ "options": { "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" + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" }, "error": { - "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "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": { "configure_addon": { "data": { + "network_key": "\u041c\u0440\u0435\u0436\u043e\u0432 \u043a\u043b\u044e\u0447", "s0_legacy_key": "S0 \u043a\u043b\u044e\u0447 (\u043d\u0430\u0441\u043b\u0435\u0434\u0435\u043d)", "s2_access_control_key": "S2 \u043a\u043b\u044e\u0447 \u0437\u0430 \u043a\u043e\u043d\u0442\u0440\u043e\u043b \u043d\u0430 \u0434\u043e\u0441\u0442\u044a\u043f\u0430", "s2_authenticated_key": "S2 \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u0435\u043d \u043a\u043b\u044e\u0447", diff --git a/homeassistant/components/zwave_js/translations/id.json b/homeassistant/components/zwave_js/translations/id.json index 2004be7238f..19f94f0ab1f 100644 --- a/homeassistant/components/zwave_js/translations/id.json +++ b/homeassistant/components/zwave_js/translations/id.json @@ -27,6 +27,10 @@ "configure_addon": { "data": { "network_key": "Kunci Jaringan", + "s0_legacy_key": "Kunci S0 (Warisan)", + "s2_access_control_key": "Kunci Kontrol Akses S2", + "s2_authenticated_key": "Kunci Autentikasi S2", + "s2_unauthenticated_key": "Kunci S2 Tidak Diautentikasi", "usb_path": "Jalur Perangkat USB" }, "title": "Masukkan konfigurasi add-on Z-Wave JS" @@ -51,17 +55,36 @@ }, "start_addon": { "title": "Add-on Z-Wave JS sedang dimulai." + }, + "usb_confirm": { + "description": "Ingin menyiapkan {name} dengan add-on Z-Wave JS?" } } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Hapus usercode pada {entity_name}", + "ping": "Ping perangkat", + "refresh_value": "Segarkan nilai untuk {entity_name}", + "reset_meter": "Setel ulang pengukur di {subtype}", + "set_config_parameter": "Tetapkan nilai parameter konfigurasi {subtype}", + "set_lock_usercode": "Setel usercode pada {entity_name}", + "set_value": "Setel nilai Nilai Z-Wave" + }, "condition_type": { "config_parameter": "Nilai parameter konfigurasi {subtype}", "node_status": "Status node", "value": "Nilai saat ini dari Nilai Z-Wave" }, "trigger_type": { - "state.node_status": "Status node berubah" + "event.notification.entry_control": "Mengirim notifikasi Entry Control", + "event.notification.notification": "Mengirim notifikasi", + "event.value_notification.basic": "Peristiwa Basic CC pada {subtype}", + "event.value_notification.central_scene": "Aksi Central Scene pada {subtype}", + "event.value_notification.scene_activation": "Aktivasi Skenario di {subtype}", + "state.node_status": "Status node berubah", + "zwave_js.value_updated.config_parameter": "Perubahan nilai pada parameter konfigurasi {subtype}", + "zwave_js.value_updated.value": "Perubahan nilai pada Nilai Z-Wave JS" } }, "options": { @@ -90,6 +113,10 @@ "emulate_hardware": "Emulasikan Perangkat Keras", "log_level": "Tingkat log", "network_key": "Kunci Jaringan", + "s0_legacy_key": "Kunci S0 (Warisan)", + "s2_access_control_key": "Kunci Kontrol Akses S2", + "s2_authenticated_key": "Kunci Autentikasi S2", + "s2_unauthenticated_key": "Kunci S2 Tidak Diautentikasi", "usb_path": "Jalur Perangkat USB" }, "title": "Masukkan konfigurasi add-on Z-Wave JS" diff --git a/homeassistant/components/zwave_js/translations/ja.json b/homeassistant/components/zwave_js/translations/ja.json index 78714cd5a7c..248c8d001e6 100644 --- a/homeassistant/components/zwave_js/translations/ja.json +++ b/homeassistant/components/zwave_js/translations/ja.json @@ -1,16 +1,134 @@ { - "options": { + "config": { + "abort": { + "addon_get_discovery_info_failed": "Z-Wave JS\u30a2\u30c9\u30aa\u30f3\u306e\u691c\u51fa\u60c5\u5831\u306e\u53d6\u5f97\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "addon_info_failed": "Z-Wave JS\u306e\u30a2\u30c9\u30aa\u30f3\u60c5\u5831\u306e\u53d6\u5f97\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "addon_install_failed": "Z-Wave JS\u30a2\u30c9\u30aa\u30f3\u306e\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "addon_set_config_failed": "Z-Wave JS\u306e\u8a2d\u5b9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "addon_start_failed": "Z-Wave JS\u30a2\u30c9\u30aa\u30f3\u306e\u8d77\u52d5\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "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", + "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" + }, + "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", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_ws_url": "\u7121\u52b9\u306aWebSocket URL", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name}", + "progress": { + "install_addon": "Z-Wave JS\u30a2\u30c9\u30aa\u30f3\u306e\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u304c\u5b8c\u4e86\u3059\u308b\u307e\u3067\u304a\u5f85\u3061\u304f\u3060\u3055\u3044\u3002\u3053\u308c\u306b\u306f\u6570\u5206\u304b\u304b\u308b\u5834\u5408\u304c\u3042\u308a\u307e\u3059\u3002", + "start_addon": "Z-Wave JS\u30a2\u30c9\u30aa\u30f3\u306e\u8d77\u52d5\u304c\u5b8c\u4e86\u3059\u308b\u307e\u3067\u304a\u5f85\u3061\u304f\u3060\u3055\u3044\u3002\u3053\u308c\u306b\u306f\u6570\u79d2\u304b\u304b\u308b\u5834\u5408\u304c\u3042\u308a\u307e\u3059\u3002" + }, "step": { "configure_addon": { "data": { + "network_key": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u30ad\u30fc", + "s0_legacy_key": "S0\u30ad\u30fc (\u30ec\u30ac\u30b7\u30fc)", + "s2_access_control_key": "S2\u30a2\u30af\u30bb\u30b9\u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u30ad\u30fc", + "s2_authenticated_key": "S2\u8a8d\u8a3c\u6e08\u307f\u306a\u30ad\u30fc", + "s2_unauthenticated_key": "S2\u8a8d\u8a3c\u3055\u308c\u3066\u3044\u306a\u3044\u30ad\u30fc", + "usb_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" + }, + "title": "Z-Wave JS\u30a2\u30c9\u30aa\u30f3\u306e\u8a2d\u5b9a\u3092\u5165\u529b" + }, + "hassio_confirm": { + "title": "Z-Wave JS\u30a2\u30c9\u30aa\u30f3\u3068Z-Wave JS\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" + }, + "install_addon": { + "title": "Z-Wave JS\u30a2\u30c9\u30aa\u30f3\u306e\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u304c\u958b\u59cb\u3055\u308c\u307e\u3057\u305f\u3002" + }, + "manual": { + "data": { + "url": "URL" + } + }, + "on_supervisor": { + "data": { + "use_addon": "\u30a2\u30c9\u30aa\u30f3 Z-Wave JS Supervisor\u3092\u4f7f\u7528" + }, + "description": "Z-Wave JS Supervisor\u30a2\u30c9\u30aa\u30f3\u3092\u4f7f\u7528\u3057\u307e\u3059\u304b\uff1f", + "title": "\u63a5\u7d9a\u65b9\u6cd5\u306e\u9078\u629e" + }, + "start_addon": { + "title": "Z-Wave JS \u30a2\u30c9\u30aa\u30f3\u3092\u8d77\u52d5\u3057\u3066\u3044\u307e\u3059\u3002" + }, + "usb_confirm": { + "description": "Z-Wave JS\u30a2\u30c9\u30aa\u30f3\u3067 {name} \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + } + } + }, + "device_automation": { + "action_type": { + "clear_lock_usercode": "{entity_name} \u30e6\u30fc\u30b6\u30fc\u30b3\u30fc\u30c9\u3092\u30af\u30ea\u30a2", + "ping": "Ping\u30c7\u30d0\u30a4\u30b9", + "refresh_value": "{entity_name} \u306e\u5024\u3092\u66f4\u65b0", + "reset_meter": "{subtype} \u306e\u30e1\u30fc\u30bf\u30fc\u3092\u30ea\u30bb\u30c3\u30c8", + "set_config_parameter": "\u8a2d\u5b9a\u30d1\u30e9\u30e1\u30fc\u30bf {subtype} \u306e\u5024\u3092\u8a2d\u5b9a", + "set_lock_usercode": "{entity_name} \u306b\u30e6\u30fc\u30b6\u30fc\u30b3\u30fc\u30c9\u3092\u8a2d\u5b9a", + "set_value": "Z-Wave\u5024\u306e\u8a2d\u5b9a\u5024" + }, + "condition_type": { + "config_parameter": "\u30b3\u30f3\u30d5\u30a3\u30b0\u30d1\u30e9\u30e1\u30fc\u30bf {subtype} \u306e\u5024", + "node_status": "\u30ce\u30fc\u30c9\u30b9\u30c6\u30fc\u30bf\u30b9", + "value": "Z-Wave\u5024\u306e\u73fe\u5728\u306e\u5024" + }, + "trigger_type": { + "event.notification.entry_control": "\u30a8\u30f3\u30c8\u30ea\u30fc\u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u901a\u77e5\u3092\u9001\u4fe1\u3057\u307e\u3057\u305f", + "event.notification.notification": "\u901a\u77e5\u3092\u9001\u4fe1\u3057\u307e\u3057\u305f", + "event.value_notification.basic": "{subtype} \u306a\u30d9\u30fc\u30b7\u30c3\u30af CC \u30a4\u30d9\u30f3\u30c8", + "event.value_notification.central_scene": "{subtype} \u306e\u30bb\u30f3\u30c8\u30e9\u30eb \u30b7\u30fc\u30f3 \u30a2\u30af\u30b7\u30e7\u30f3", + "event.value_notification.scene_activation": "{subtype} \u3067\u306e\u30b7\u30fc\u30f3\u306e\u30a2\u30af\u30c6\u30a3\u30d6\u5316", + "state.node_status": "\u30ce\u30fc\u30c9\u30b9\u30c6\u30fc\u30bf\u30b9\u304c\u5909\u5316\u3057\u307e\u3057\u305f", + "zwave_js.value_updated.config_parameter": "\u30b3\u30f3\u30d5\u30a3\u30b0\u30d1\u30e9\u30e1\u30fc\u30bf {subtype} \u306e\u5024\u306e\u5909\u66f4", + "zwave_js.value_updated.value": "Z-Wave JS\u5024\u306e\u5024\u306e\u5909\u66f4" + } + }, + "options": { + "abort": { + "addon_get_discovery_info_failed": "Z-Wave JS\u30a2\u30c9\u30aa\u30f3\u306e\u691c\u51fa\u60c5\u5831\u306e\u53d6\u5f97\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "addon_info_failed": "Z-Wave JS\u306e\u30a2\u30c9\u30aa\u30f3\u60c5\u5831\u306e\u53d6\u5f97\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "addon_install_failed": "Z-Wave JS\u30a2\u30c9\u30aa\u30f3\u306e\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "addon_set_config_failed": "Z-Wave JS\u306e\u8a2d\u5b9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "addon_start_failed": "Z-Wave JS\u30a2\u30c9\u30aa\u30f3\u306e\u8d77\u52d5\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002", + "already_configured": "\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", + "different_device": "\u63a5\u7d9a\u3055\u308c\u3066\u3044\u308bUSB\u30c7\u30d0\u30a4\u30b9\u306f\u3001\u3053\u306e\u69cb\u6210\u30a8\u30f3\u30c8\u30ea\u30fc\u3067\u4ee5\u524d\u306b\u69cb\u6210\u3057\u305f\u3082\u306e\u3068\u306f\u7570\u306a\u308a\u307e\u3059\u3002\u4ee3\u308f\u308a\u306b\u3001\u65b0\u3057\u3044\u30c7\u30d0\u30a4\u30b9\u7528\u306b\u65b0\u3057\u3044\u69cb\u6210\u30a8\u30f3\u30c8\u30ea\u30fc\u3092\u4f5c\u6210\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_ws_url": "\u7121\u52b9\u306aWebSocket URL", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "progress": { + "install_addon": "Z-Wave JS\u30a2\u30c9\u30aa\u30f3\u306e\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u304c\u5b8c\u4e86\u3059\u308b\u307e\u3067\u304a\u5f85\u3061\u304f\u3060\u3055\u3044\u3002\u3053\u308c\u306b\u306f\u6570\u5206\u304b\u304b\u308b\u5834\u5408\u304c\u3042\u308a\u307e\u3059\u3002", + "start_addon": "Z-Wave JS\u30a2\u30c9\u30aa\u30f3\u306e\u8d77\u52d5\u304c\u5b8c\u4e86\u3059\u308b\u307e\u3067\u304a\u5f85\u3061\u304f\u3060\u3055\u3044\u3002\u3053\u308c\u306b\u306f\u6570\u79d2\u304b\u304b\u308b\u5834\u5408\u304c\u3042\u308a\u307e\u3059\u3002" + }, + "step": { + "configure_addon": { + "data": { + "emulate_hardware": "\u30cf\u30fc\u30c9\u30a6\u30a7\u30a2\u306e\u30a8\u30df\u30e5\u30ec\u30fc\u30b7\u30e7\u30f3", "log_level": "\u30ed\u30b0\u30ec\u30d9\u30eb", - "network_key": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af" + "network_key": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af", + "s0_legacy_key": "S0\u30ad\u30fc (\u30ec\u30ac\u30b7\u30fc)", + "s2_access_control_key": "S2\u30a2\u30af\u30bb\u30b9\u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u30ad\u30fc", + "s2_authenticated_key": "S2\u8a8d\u8a3c\u6e08\u307f\u306a\u30ad\u30fc", + "s2_unauthenticated_key": "S2\u8a8d\u8a3c\u3055\u308c\u3066\u3044\u306a\u3044\u30ad\u30fc", + "usb_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" }, "title": "Z-Wave JS\u306e\u30a2\u30c9\u30aa\u30f3\u304c\u59cb\u307e\u308a\u307e\u3059\u3002" }, "install_addon": { "title": "Z-Wave JS\u30a2\u30c9\u30aa\u30f3\u306e\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u304c\u958b\u59cb\u3055\u308c\u307e\u3057\u305f\u3002" }, + "manual": { + "data": { + "url": "URL" + } + }, "on_supervisor": { "data": { "use_addon": "\u30a2\u30c9\u30aa\u30f3\u300cZ-Wave JS Supervisor\u300d\u306e\u4f7f\u7528" @@ -22,5 +140,6 @@ "title": "Z-Wave JS \u30a2\u30c9\u30aa\u30f3\u304c\u8d77\u52d5\u3057\u3066\u3044\u307e\u3059\u3002" } } - } + }, + "title": "Z-Wave JS" } \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/pl.json b/homeassistant/components/zwave_js/translations/pl.json index 0ae905a0854..812d00b8e20 100644 --- a/homeassistant/components/zwave_js/translations/pl.json +++ b/homeassistant/components/zwave_js/translations/pl.json @@ -27,6 +27,10 @@ "configure_addon": { "data": { "network_key": "Klucz sieci", + "s0_legacy_key": "Klucz S0 (Legacy)", + "s2_access_control_key": "Klucz kontroli dost\u0119pu S2", + "s2_authenticated_key": "Klucz uwierzytelniony S2", + "s2_unauthenticated_key": "Klucz nieuwierzytelniony S2", "usb_path": "\u015acie\u017cka urz\u0105dzenia USB" }, "title": "Wprowad\u017a konfiguracj\u0119 dodatku Z-Wave JS" @@ -58,6 +62,15 @@ } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "wyczy\u015b\u0107 kod u\u017cytkownika w {entity_name}", + "ping": "pinguj urz\u0105dzenie", + "refresh_value": "od\u015bwie\u017c warto\u015bci dla {entity_name}", + "reset_meter": "zresetuj liczniki na {subtype}", + "set_config_parameter": "ustaw warto\u015b\u0107 parametru konfiguracji {subtype}", + "set_lock_usercode": "ustaw kod u\u017cytkownika w {entity_name}", + "set_value": "ustaw warto\u015b\u0107 Z-Wave" + }, "condition_type": { "config_parameter": "warto\u015b\u0107 parametru jest {subtype}", "node_status": "stan w\u0119z\u0142a", @@ -100,6 +113,10 @@ "emulate_hardware": "Emulacja sprz\u0119tu", "log_level": "Poziom loga", "network_key": "Klucz sieci", + "s0_legacy_key": "Klucz S0 (Legacy)", + "s2_access_control_key": "Klucz kontroli dost\u0119pu S2", + "s2_authenticated_key": "Klucz uwierzytelniony S2", + "s2_unauthenticated_key": "Klucz nieuwierzytelniony S2", "usb_path": "\u015acie\u017cka urz\u0105dzenia USB" }, "title": "Wprowad\u017a konfiguracj\u0119 dodatku Z-Wave JS" diff --git a/homeassistant/components/zwave_js/translations/tr.json b/homeassistant/components/zwave_js/translations/tr.json index 5fe4f92b857..f77b7f1f0f4 100644 --- a/homeassistant/components/zwave_js/translations/tr.json +++ b/homeassistant/components/zwave_js/translations/tr.json @@ -5,9 +5,12 @@ "addon_info_failed": "Z-Wave JS eklenti bilgileri al\u0131namad\u0131.", "addon_install_failed": "Z-Wave JS eklentisi y\u00fcklenemedi.", "addon_set_config_failed": "Z-Wave JS yap\u0131land\u0131rmas\u0131 ayarlanamad\u0131.", + "addon_start_failed": "Z-Wave JS eklentisi ba\u015flat\u0131lamad\u0131.", "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" + "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." }, "error": { "addon_start_failed": "Z-Wave JS eklentisi ba\u015flat\u0131lamad\u0131. Yap\u0131land\u0131rmay\u0131 kontrol edin.", @@ -15,13 +18,19 @@ "invalid_ws_url": "Ge\u00e7ersiz websocket URL'si", "unknown": "Beklenmeyen hata" }, + "flow_title": "{name}", "progress": { - "install_addon": "L\u00fctfen Z-Wave JS eklenti kurulumu bitene kadar bekleyin. Bu birka\u00e7 dakika s\u00fcrebilir." + "install_addon": "L\u00fctfen Z-Wave JS eklenti kurulumu bitene kadar bekleyin. Bu birka\u00e7 dakika s\u00fcrebilir.", + "start_addon": "Z-Wave JS eklenti ba\u015flatma i\u015flemi tamamlanana kadar l\u00fctfen bekleyin. Bu birka\u00e7 saniye s\u00fcrebilir." }, "step": { "configure_addon": { "data": { "network_key": "A\u011f Anahtar\u0131", + "s0_legacy_key": "S0 Anahtar\u0131 (Eski)", + "s2_access_control_key": "S2 Eri\u015fim Kontrol Anahtar\u0131", + "s2_authenticated_key": "S2 Kimli\u011fi Do\u011frulanm\u0131\u015f Anahtar", + "s2_unauthenticated_key": "S2 Kimli\u011fi Do\u011frulanmam\u0131\u015f Anahtar", "usb_path": "USB Ayg\u0131t Yolu" }, "title": "Z-Wave JS eklenti yap\u0131land\u0131rmas\u0131na girin" @@ -43,12 +52,93 @@ }, "description": "Z-Wave JS Supervisor eklentisini kullanmak istiyor musunuz?", "title": "Ba\u011flant\u0131 y\u00f6ntemini se\u00e7in" + }, + "start_addon": { + "title": "Z-Wave JS eklentisi ba\u015fl\u0131yor." + }, + "usb_confirm": { + "description": "{name} Z-Wave JS eklentisiyle kurmak istiyor musunuz?" } } }, "device_automation": { "action_type": { - "ping": "ping" + "clear_lock_usercode": "{entity_name} \u00fczerindeki kullan\u0131c\u0131 kodunu temizle", + "ping": "ping", + "refresh_value": "{entity_name} i\u00e7in de\u011ferleri yenileme", + "reset_meter": "{subtype} \u00fczerindeki saya\u00e7lar\u0131 s\u0131f\u0131rla", + "set_config_parameter": "{subtype} yap\u0131land\u0131rma parametresinin de\u011ferini ayarlama", + "set_lock_usercode": "{entity_name} \u00fczerinde kullan\u0131c\u0131 kodu ayarlama", + "set_value": "Z-Wave De\u011ferini Ayarla" + }, + "condition_type": { + "config_parameter": "Yap\u0131land\u0131rma parametresi {subtype} de\u011feri", + "node_status": "D\u00fc\u011f\u00fcm durumu", + "value": "Z-Wave De\u011ferinin mevcut ayar\u0131" + }, + "trigger_type": { + "event.notification.entry_control": "Giri\u015f Kontrol\u00fc bildirimi g\u00f6nderdi", + "event.notification.notification": "Bildirim g\u00f6nderdi", + "event.value_notification.basic": "{subtype} \u00fczerinde temel CC etkinli\u011fi", + "event.value_notification.central_scene": "{subtype} \u00fczerinde Merkezi Sahne eylemi", + "event.value_notification.scene_activation": "{subtype} \u00fczerinde Sahne Aktivasyonu", + "state.node_status": "D\u00fc\u011f\u00fcm durumu de\u011fi\u015fti", + "zwave_js.value_updated.config_parameter": "{subtype} yap\u0131land\u0131rma parametresinde de\u011fer de\u011fi\u015fikli\u011fi", + "zwave_js.value_updated.value": "Z-Wave JS De\u011ferinde de\u011fer de\u011fi\u015fikli\u011fi" + } + }, + "options": { + "abort": { + "addon_get_discovery_info_failed": "Z-Wave JS eklenti ke\u015fif bilgileri al\u0131namad\u0131.", + "addon_info_failed": "Z-Wave JS eklenti bilgileri al\u0131namad\u0131.", + "addon_install_failed": "Z-Wave JS eklentisi y\u00fcklenemedi.", + "addon_set_config_failed": "Z-Wave JS yap\u0131land\u0131rmas\u0131 ayarlanamad\u0131.", + "addon_start_failed": "Z-Wave JS eklentisi ba\u015flat\u0131lamad\u0131.", + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "different_device": "Ba\u011fl\u0131 USB ayg\u0131t\u0131, bu yap\u0131land\u0131rma giri\u015fi i\u00e7in daha \u00f6nce yap\u0131land\u0131r\u0131lanla ayn\u0131 de\u011fil. L\u00fctfen bunun yerine yeni cihaz i\u00e7in yeni bir yap\u0131land\u0131rma giri\u015fi olu\u015fturun." + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_ws_url": "Ge\u00e7ersiz websocket URL'si", + "unknown": "Beklenmeyen hata" + }, + "progress": { + "install_addon": "L\u00fctfen Z-Wave JS eklenti kurulumu bitene kadar bekleyin. Bu birka\u00e7 dakika s\u00fcrebilir.", + "start_addon": "Z-Wave JS eklenti ba\u015flatma i\u015flemi tamamlanana kadar l\u00fctfen bekleyin. Bu birka\u00e7 saniye s\u00fcrebilir." + }, + "step": { + "configure_addon": { + "data": { + "emulate_hardware": "Donan\u0131m\u0131 Taklit Et", + "log_level": "G\u00fcnl\u00fck d\u00fczeyi", + "network_key": "A\u011f Anahtar\u0131", + "s0_legacy_key": "S0 Anahtar\u0131 (Eski)", + "s2_access_control_key": "S2 Eri\u015fim Kontrol Anahtar\u0131", + "s2_authenticated_key": "S2 Kimli\u011fi Do\u011frulanm\u0131\u015f Anahtar", + "s2_unauthenticated_key": "S2 Kimli\u011fi Do\u011frulanmam\u0131\u015f Anahtar", + "usb_path": "USB Cihaz Yolu" + }, + "title": "Z-Wave JS eklenti yap\u0131land\u0131rmas\u0131na girin" + }, + "install_addon": { + "title": "Z-Wave JS eklenti kurulumu ba\u015flad\u0131" + }, + "manual": { + "data": { + "url": "URL" + } + }, + "on_supervisor": { + "data": { + "use_addon": "Z-Wave JS Supervisor eklentisini kullan\u0131n" + }, + "description": "Z-Wave JS Supervisor eklentisini kullanmak istiyor musunuz?", + "title": "Ba\u011flant\u0131 y\u00f6ntemini se\u00e7in" + }, + "start_addon": { + "title": "Z-Wave JS eklentisi ba\u015fl\u0131yor." + } } }, "title": "Z-Wave JS" diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 17f8b1396ed..cdea9da2540 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -4,11 +4,12 @@ from __future__ import annotations import asyncio from collections.abc import Iterable, Mapping from contextvars import ContextVar +import dataclasses from enum import Enum import functools import logging from types import MappingProxyType, MethodType -from typing import Any, Callable, Optional, cast +from typing import TYPE_CHECKING, Any, Callable, Optional, cast import weakref from homeassistant import data_entry_flow, loader @@ -31,6 +32,14 @@ from homeassistant.setup import async_process_deps_reqs, async_setup_component from homeassistant.util.decorator import Registry import homeassistant.util.uuid as uuid_util +if TYPE_CHECKING: + from homeassistant.components.dhcp import DhcpServiceInfo + from homeassistant.components.hassio import HassioServiceInfo + from homeassistant.components.mqtt import MqttServiceInfo + from homeassistant.components.ssdp import SsdpServiceInfo + from homeassistant.components.usb import UsbServiceInfo + from homeassistant.components.zeroconf import ZeroconfServiceInfo + _LOGGER = logging.getLogger(__name__) SOURCE_DISCOVERY = "discovery" @@ -1154,6 +1163,12 @@ class ConfigFlow(data_entry_flow.FlowHandler): """Get the options flow for this handler.""" raise data_entry_flow.UnknownHandler + @classmethod + @callback + def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: + """Return options flow support for this handler.""" + return cls.async_get_options_flow is not ConfigFlow.async_get_options_flow + @callback def _async_abort_entries_match( self, match_dict: dict[str, Any] | None = None @@ -1345,46 +1360,46 @@ class ConfigFlow(data_entry_flow.FlowHandler): ) async def async_step_hassio( - self, discovery_info: DiscoveryInfoType + self, discovery_info: HassioServiceInfo ) -> data_entry_flow.FlowResult: """Handle a flow initialized by HASS IO discovery.""" - return await self.async_step_discovery(discovery_info) + return await self.async_step_discovery(discovery_info.config) async def async_step_homekit( - self, discovery_info: DiscoveryInfoType + self, discovery_info: ZeroconfServiceInfo ) -> data_entry_flow.FlowResult: """Handle a flow initialized by Homekit discovery.""" - return await self.async_step_discovery(discovery_info) + return await self.async_step_discovery(dataclasses.asdict(discovery_info)) async def async_step_mqtt( - self, discovery_info: DiscoveryInfoType + self, discovery_info: MqttServiceInfo ) -> data_entry_flow.FlowResult: """Handle a flow initialized by MQTT discovery.""" - return await self.async_step_discovery(discovery_info) + return await self.async_step_discovery(dataclasses.asdict(discovery_info)) async def async_step_ssdp( - self, discovery_info: DiscoveryInfoType + self, discovery_info: SsdpServiceInfo ) -> data_entry_flow.FlowResult: """Handle a flow initialized by SSDP discovery.""" - return await self.async_step_discovery(discovery_info) + return await self.async_step_discovery(dataclasses.asdict(discovery_info)) async def async_step_zeroconf( - self, discovery_info: DiscoveryInfoType + self, discovery_info: ZeroconfServiceInfo ) -> data_entry_flow.FlowResult: """Handle a flow initialized by Zeroconf discovery.""" - return await self.async_step_discovery(discovery_info) + return await self.async_step_discovery(dataclasses.asdict(discovery_info)) async def async_step_dhcp( - self, discovery_info: DiscoveryInfoType + self, discovery_info: DhcpServiceInfo ) -> data_entry_flow.FlowResult: """Handle a flow initialized by DHCP discovery.""" - return await self.async_step_discovery(discovery_info) + return await self.async_step_discovery(dataclasses.asdict(discovery_info)) async def async_step_usb( - self, discovery_info: DiscoveryInfoType + self, discovery_info: UsbServiceInfo ) -> data_entry_flow.FlowResult: """Handle a flow initialized by USB discovery.""" - return await self.async_step_discovery(discovery_info) + return await self.async_step_discovery(dataclasses.asdict(discovery_info)) @callback def async_create_entry( # pylint: disable=arguments-differ diff --git a/homeassistant/const.py b/homeassistant/const.py index 910b9fe9ec8..f0808c28aaf 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -3,9 +3,11 @@ from __future__ import annotations from typing import Final +from homeassistant.backports.enum import StrEnum + MAJOR_VERSION: Final = 2021 -MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "5" +MINOR_VERSION: Final = 12 +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, 8, 0) @@ -16,6 +18,42 @@ REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2022.1" # Format for platform files PLATFORM_FORMAT: Final = "{platform}.{domain}" + +class Platform(StrEnum): + """Available entity platforms.""" + + AIR_QUALITY = "air_quality" + ALARM_CONTROL_PANEL = "alarm_control_panel" + BINARY_SENSOR = "binary_sensor" + BUTTON = "button" + CALENDAR = "calendar" + CAMERA = "camera" + CLIMATE = "climate" + COVER = "cover" + DEVICE_TRACKER = "device_tracker" + FAN = "fan" + GEO_LOCATION = "geo_location" + HUMIDIFIER = "humidifier" + IMAGE_PROCESSING = "image_processing" + LIGHT = "light" + LOCK = "lock" + MAILBOX = "mailbox" + MEDIA_PLAYER = "media_player" + NOTIFY = "notify" + NUMBER = "number" + REMOTE = "remote" + SCENE = "scene" + SELECT = "select" + SENSOR = "sensor" + SIREN = "siren" + STT = "stt" + SWITCH = "switch" + TTS = "tts" + VACUUM = "vacuum" + WATER_HEATER = "water_heater" + WEATHER = "weather" + + # Can be used to specify a catch all when registering state or event listeners. MATCH_ALL: Final = "*" @@ -233,6 +271,8 @@ EVENT_TIME_CHANGED: Final = "time_changed" # #### DEVICE CLASSES #### +# DEVICE_CLASS_* below are deprecated as of 2021.12 +# use the SensorDeviceClass enum instead. DEVICE_CLASS_AQI: Final = "aqi" DEVICE_CLASS_BATTERY: Final = "battery" DEVICE_CLASS_CO: Final = "carbon_monoxide" @@ -240,6 +280,8 @@ DEVICE_CLASS_CO2: Final = "carbon_dioxide" DEVICE_CLASS_CURRENT: Final = "current" DEVICE_CLASS_DATE: Final = "date" DEVICE_CLASS_ENERGY: Final = "energy" +DEVICE_CLASS_FREQUENCY: Final = "frequency" +DEVICE_CLASS_GAS: Final = "gas" DEVICE_CLASS_HUMIDITY: Final = "humidity" DEVICE_CLASS_ILLUMINANCE: Final = "illuminance" DEVICE_CLASS_MONETARY: Final = "monetary" @@ -247,19 +289,18 @@ DEVICE_CLASS_NITROGEN_DIOXIDE = "nitrogen_dioxide" DEVICE_CLASS_NITROGEN_MONOXIDE = "nitrogen_monoxide" DEVICE_CLASS_NITROUS_OXIDE = "nitrous_oxide" DEVICE_CLASS_OZONE: Final = "ozone" -DEVICE_CLASS_POWER_FACTOR: Final = "power_factor" -DEVICE_CLASS_POWER: Final = "power" -DEVICE_CLASS_PM25: Final = "pm25" DEVICE_CLASS_PM1: Final = "pm1" DEVICE_CLASS_PM10: Final = "pm10" +DEVICE_CLASS_PM25: Final = "pm25" +DEVICE_CLASS_POWER_FACTOR: Final = "power_factor" +DEVICE_CLASS_POWER: Final = "power" DEVICE_CLASS_PRESSURE: Final = "pressure" DEVICE_CLASS_SIGNAL_STRENGTH: Final = "signal_strength" DEVICE_CLASS_SULPHUR_DIOXIDE = "sulphur_dioxide" DEVICE_CLASS_TEMPERATURE: Final = "temperature" DEVICE_CLASS_TIMESTAMP: Final = "timestamp" -DEVICE_CLASS_VOLTAGE: Final = "voltage" DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" -DEVICE_CLASS_GAS: Final = "gas" +DEVICE_CLASS_VOLTAGE: Final = "voltage" # #### STATES #### STATE_ON: Final = "on" @@ -420,6 +461,7 @@ ATTR_TEMPERATURE: Final = "temperature" POWER_WATT: Final = "W" POWER_KILO_WATT: Final = "kW" POWER_VOLT_AMPERE: Final = "VA" +POWER_BTU_PER_HOUR: Final = "BTU/h" # Energy units ENERGY_WATT_HOUR: Final = "Wh" @@ -471,6 +513,7 @@ LENGTH_MILES: Final = "mi" # Frequency units FREQUENCY_HERTZ: Final = "Hz" +FREQUENCY_KILOHERTZ: Final = "kHz" FREQUENCY_MEGAHERTZ: Final = "MHz" FREQUENCY_GIGAHERTZ: Final = "GHz" @@ -479,7 +522,9 @@ PRESSURE_PA: Final = "Pa" PRESSURE_HPA: Final = "hPa" PRESSURE_KPA: Final = "kPa" PRESSURE_BAR: Final = "bar" +PRESSURE_CBAR: Final = "cbar" PRESSURE_MBAR: Final = "mbar" +PRESSURE_MMHG: Final = "mmHg" PRESSURE_INHG: Final = "inHg" PRESSURE_PSI: Final = "psi" @@ -660,21 +705,6 @@ URL_API_ERROR_LOG: Final = "/api/error_log" URL_API_LOG_OUT: Final = "/api/log_out" URL_API_TEMPLATE: Final = "/api/template" -HTTP_OK: Final = 200 -HTTP_CREATED: Final = 201 -HTTP_ACCEPTED: Final = 202 -HTTP_MOVED_PERMANENTLY: Final = 301 -HTTP_BAD_REQUEST: Final = 400 -HTTP_UNAUTHORIZED: Final = 401 -HTTP_FORBIDDEN: Final = 403 -HTTP_NOT_FOUND: Final = 404 -HTTP_METHOD_NOT_ALLOWED: Final = 405 -HTTP_UNPROCESSABLE_ENTITY: Final = 422 -HTTP_TOO_MANY_REQUESTS: Final = 429 -HTTP_INTERNAL_SERVER_ERROR: Final = 500 -HTTP_BAD_GATEWAY: Final = 502 -HTTP_SERVICE_UNAVAILABLE: Final = 503 - HTTP_BASIC_AUTHENTICATION: Final = "basic" HTTP_BEARER_AUTHENTICATION: Final = "bearer_token" HTTP_DIGEST_AUTHENTICATION: Final = "digest" @@ -695,8 +725,10 @@ MASS: Final = "mass" PRESSURE: Final = "pressure" VOLUME: Final = "volume" TEMPERATURE: Final = "temperature" -SPEED_MS: Final = "speed_ms" +SPEED: Final = "speed" +WIND_SPEED: Final = "wind_speed" ILLUMINANCE: Final = "illuminance" +ACCUMULATED_PRECIPITATION: Final = "accumulated_precipitation" WEEKDAYS: Final[list[str]] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] @@ -709,8 +741,21 @@ PRECISION_TENTHS: Final = 0.1 # cloud, alexa, or google_home components CLOUD_NEVER_EXPOSED_ENTITIES: Final[list[str]] = ["group.all_locks"] -# The ID of the Home Assistant Cast App -CAST_APP_ID_HOMEASSISTANT: Final = "B12CE3CA" - +# ENTITY_CATEGOR* below are deprecated as of 2021.12 +# use the EntityCategory enum instead. ENTITY_CATEGORY_CONFIG: Final = "config" ENTITY_CATEGORY_DIAGNOSTIC: Final = "diagnostic" +ENTITY_CATEGORY_SYSTEM: Final = "system" +ENTITY_CATEGORIES: Final[list[str]] = [ + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, + ENTITY_CATEGORY_SYSTEM, +] + +# The ID of the Home Assistant Media Player Cast App +CAST_APP_ID_HOMEASSISTANT_MEDIA: Final = "B45F4572" +# The ID of the Home Assistant Lovelace Cast App +CAST_APP_ID_HOMEASSISTANT_LOVELACE: Final = "A078F6B0" + +# User used by Supervisor +HASSIO_USER_NAME = "Supervisor" diff --git a/homeassistant/core.py b/homeassistant/core.py index 34b48e66953..2f5783de443 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -25,7 +25,7 @@ import attr import voluptuous as vol import yarl -from homeassistant import block_async_io, loader, util +from homeassistant import async_timeout_backcompat, block_async_io, loader, util from homeassistant.const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, @@ -82,7 +82,7 @@ STAGE_1_SHUTDOWN_TIMEOUT = 100 STAGE_2_SHUTDOWN_TIMEOUT = 60 STAGE_3_SHUTDOWN_TIMEOUT = 30 - +async_timeout_backcompat.enable() block_async_io.enable() T = TypeVar("T") @@ -1715,7 +1715,7 @@ class Config: async def async_load(self) -> None: """Load [homeassistant] core config.""" store = self.hass.helpers.storage.Store( - CORE_STORAGE_VERSION, CORE_STORAGE_KEY, private=True + CORE_STORAGE_VERSION, CORE_STORAGE_KEY, private=True, atomic_writes=True ) if not (data := await store.async_load()): @@ -1763,7 +1763,7 @@ class Config: } store = self.hass.helpers.storage.Store( - CORE_STORAGE_VERSION, CORE_STORAGE_KEY, private=True + CORE_STORAGE_VERSION, CORE_STORAGE_KEY, private=True, atomic_writes=True ) await store.async_save(data) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index c1f798fcc32..e84689ee269 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations import abc import asyncio from collections.abc import Iterable, Mapping +from dataclasses import dataclass from types import MappingProxyType from typing import Any, TypedDict import uuid @@ -25,6 +26,11 @@ RESULT_TYPE_SHOW_PROGRESS_DONE = "progress_done" EVENT_DATA_ENTRY_FLOW_PROGRESSED = "data_entry_flow_progressed" +@dataclass +class BaseServiceInfo: + """Base class for discovery ServiceInfo.""" + + class FlowError(HomeAssistantError): """Error while configuring an account.""" @@ -290,8 +296,7 @@ class FlowManager(abc.ABC): @callback def _async_remove_flow_progress(self, flow_id: str) -> None: """Remove a flow from in progress.""" - flow = self._progress.pop(flow_id, None) - if flow is None: + if (flow := self._progress.pop(flow_id, None)) is None: raise UnknownFlow handler = flow.handler self._handler_progress_index[handler].remove(flow.flow_id) @@ -302,7 +307,7 @@ class FlowManager(abc.ABC): self, flow: Any, step_id: str, - user_input: dict | None, + user_input: dict | BaseServiceInfo | None, step_done: asyncio.Future | None = None, ) -> FlowResult: """Handle a step of a flow.""" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index aef37105170..c2648ec04cd 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -35,6 +35,7 @@ FLOWS = [ "awair", "axis", "azure_devops", + "balboa", "blebox", "blink", "bmw_connected_drive", @@ -43,6 +44,7 @@ FLOWS = [ "braviatv", "broadlink", "brother", + "brunt", "bsblan", "buienradar", "canary", @@ -60,6 +62,7 @@ FLOWS = [ "deconz", "denonavr", "devolo_home_control", + "devolo_home_network", "dexcom", "dialogflow", "directv", @@ -81,6 +84,7 @@ FLOWS = [ "environment_canada", "epson", "esphome", + "evil_genius_labs", "ezviz", "faa_delays", "fireservicerota", @@ -99,6 +103,7 @@ FLOWS = [ "fritz", "fritzbox", "fritzbox_callmonitor", + "fronius", "garages_amsterdam", "gdacs", "geofency", @@ -145,9 +150,11 @@ FLOWS = [ "islamic_prayer_times", "isy994", "izone", + "jellyfin", "juicenet", "keenetic_ndms2", "kmtronic", + "knx", "kodi", "konnected", "kostal_plenticore", @@ -234,9 +241,11 @@ FLOWS = [ "rachio", "rainforest_eagle", "rainmachine", + "rdw", "recollect_waste", "renault", "rfxtrx", + "ridwell", "ring", "risco", "rituals_perfume_genie", @@ -286,16 +295,20 @@ FLOWS = [ "synology_dsm", "system_bridge", "tado", + "tailscale", "tasmota", "tellduslive", + "tesla_wall_connector", "tibber", "tile", + "tolo", "toon", "totalconnect", "tplink", "traccar", "tractive", "tradfri", + "trafikverket_weatherstation", "transmission", "tuya", "twentemilieu", @@ -311,6 +324,7 @@ FLOWS = [ "vera", "verisure", "vesync", + "vicare", "vilfo", "vizio", "vlc_telnet", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index ada826de8a8..4313aa3f486 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -91,6 +91,11 @@ DHCP = [ "macaddress": "B4E842*", "hostname": "[ba][lk]*" }, + { + "domain": "flux_led", + "macaddress": "F0FE6B*", + "hostname": "[ba][lk]*" + }, { "domain": "flux_led", "macaddress": "8CCE4E*", @@ -98,61 +103,17 @@ DHCP = [ }, { "domain": "flux_led", - "hostname": "zengge_0[6789b]_*" - }, - { - "domain": "flux_led", - "hostname": "zengge_1[06789abc]_*" - }, - { - "domain": "flux_led", - "hostname": "zengge_2[15]_*" - }, - { - "domain": "flux_led", - "hostname": "zengge_3[35]_*" - }, - { - "domain": "flux_led", - "hostname": "zengge_4[14]_*" - }, - { - "domain": "flux_led", - "hostname": "zengge_5[24]_*" - }, - { - "domain": "flux_led", - "hostname": "zengge_62_*" - }, - { - "domain": "flux_led", - "hostname": "zengge_81_*" - }, - { - "domain": "flux_led", - "hostname": "zengge_0[0e]_*" - }, - { - "domain": "flux_led", - "hostname": "zengge_9[34567]_*" - }, - { - "domain": "flux_led", - "hostname": "zengge_a[123]_*" - }, - { - "domain": "flux_led", - "hostname": "zengge_d1_*" - }, - { - "domain": "flux_led", - "hostname": "zengge_e[12]_*" + "hostname": "zengge_[0-9a-f][0-9a-f]_*" }, { "domain": "flux_led", "macaddress": "C82E47*", "hostname": "sta*" }, + { + "domain": "fronius", + "macaddress": "0003AC*" + }, { "domain": "goalzero", "hostname": "yeti*" @@ -166,6 +127,11 @@ DHCP = [ "hostname": "gvc*", "macaddress": "30AEA4*" }, + { + "domain": "guardian", + "hostname": "gvc*", + "macaddress": "B4E62D*" + }, { "domain": "guardian", "hostname": "guardian*", @@ -302,6 +268,11 @@ DHCP = [ "hostname": "sense-*", "macaddress": "A4D578*" }, + { + "domain": "simplisafe", + "hostname": "simplisafe*", + "macaddress": "30AEA4*" + }, { "domain": "smartthings", "hostname": "st*", @@ -346,11 +317,35 @@ DHCP = [ "domain": "tado", "hostname": "tado*" }, + { + "domain": "tesla_wall_connector", + "hostname": "teslawallconnector_*", + "macaddress": "DC44271*" + }, + { + "domain": "tesla_wall_connector", + "hostname": "teslawallconnector_*", + "macaddress": "98ED5C*" + }, + { + "domain": "tesla_wall_connector", + "hostname": "teslawallconnector_*", + "macaddress": "4CFCAA*" + }, + { + "domain": "tolo", + "hostname": "usr-tcp232-ed2" + }, { "domain": "toon", "hostname": "eneco-*", "macaddress": "74C63B*" }, + { + "domain": "tplink", + "hostname": "k[lp]*", + "macaddress": "60A4B7*" + }, { "domain": "tplink", "hostname": "k[lp]*", @@ -524,6 +519,10 @@ DHCP = [ "domain": "verisure", "macaddress": "0023C1*" }, + { + "domain": "vicare", + "macaddress": "B87424*" + }, { "domain": "yeelight", "hostname": "yeelink-*" diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 0fb3f52e1d7..57f2090fee2 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -29,6 +29,12 @@ USB = [ "pid": "7523", "description": "*tubeszb*" }, + { + "domain": "zha", + "vid": "1A86", + "pid": "7523", + "description": "*zigstar*" + }, { "domain": "zha", "vid": "1CF1", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 8ab7a7f8e31..aec93dd36c9 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -58,6 +58,9 @@ ZEROCONF = { "_dvl-deviceapi._tcp.local.": [ { "domain": "devolo_home_control" + }, + { + "domain": "devolo_home_network" } ], "_easylink._tcp.local.": [ diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 3e5496b4179..908a9d68ddf 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -123,7 +123,7 @@ async def async_aiohttp_proxy_web( ) -> web.StreamResponse | None: """Stream websession request to aiohttp web response.""" try: - with async_timeout.timeout(timeout): + async with async_timeout.timeout(timeout): req = await web_coro except asyncio.CancelledError: @@ -164,7 +164,7 @@ async def async_aiohttp_proxy_stream( # Suppressing something went wrong fetching data, closed connection with suppress(asyncio.TimeoutError, aiohttp.ClientError): while hass.is_running: - with async_timeout.timeout(timeout): + async with async_timeout.timeout(timeout): data = await stream.read(buffer_size) if not data: diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 0073ecfb44b..11b7e5a78bd 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -49,7 +49,9 @@ class AreaRegistry: """Initialize the area registry.""" self.hass = hass self.areas: MutableMapping[str, AreaEntry] = {} - self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._store = hass.helpers.storage.Store( + STORAGE_VERSION, STORAGE_KEY, atomic_writes=True + ) self._normalized_name_area_idx: dict[str, str] = {} @callback @@ -73,8 +75,7 @@ class AreaRegistry: @callback def async_get_or_create(self, name: str) -> AreaEntry: """Get or create an area.""" - area = self.async_get_area_by_name(name) - if area: + if area := self.async_get_area_by_name(name): return area return self.async_create(name) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index a7064640307..030e5dacfd5 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -51,7 +51,7 @@ from homeassistant.exceptions import ( HomeAssistantError, TemplateError, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.sun import get_astral_event_date from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -71,8 +71,9 @@ from .trace import ( # mypy: disallow-any-generics -FROM_CONFIG_FORMAT = "{}_from_config" ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config" +FROM_CONFIG_FORMAT = "{}_from_config" +VALIDATE_CONFIG_FORMAT = "{}_validate_config" _LOGGER = logging.getLogger(__name__) @@ -152,7 +153,6 @@ def trace_condition_function(condition: ConditionCheckerType) -> ConditionChecke async def async_from_config( hass: HomeAssistant, config: ConfigType | Template, - config_validation: bool = True, ) -> ConditionCheckerType: """Turn a condition configuration into a method. @@ -181,21 +181,15 @@ async def async_from_config( check_factory = check_factory.func if asyncio.iscoroutinefunction(check_factory): - return cast( - ConditionCheckerType, await factory(hass, config, config_validation) - ) - return cast(ConditionCheckerType, factory(config, config_validation)) + return cast(ConditionCheckerType, await factory(hass, config)) + return cast(ConditionCheckerType, factory(config)) async def async_and_from_config( - hass: HomeAssistant, config: ConfigType, config_validation: bool = True + hass: HomeAssistant, config: ConfigType ) -> ConditionCheckerType: """Create multi condition matcher using 'AND'.""" - if config_validation: - config = cv.AND_CONDITION_SCHEMA(config) - checks = [ - await async_from_config(hass, entry, False) for entry in config["conditions"] - ] + checks = [await async_from_config(hass, entry) for entry in config["conditions"]] @trace_condition_function def if_and_condition( @@ -223,14 +217,10 @@ async def async_and_from_config( async def async_or_from_config( - hass: HomeAssistant, config: ConfigType, config_validation: bool = True + hass: HomeAssistant, config: ConfigType ) -> ConditionCheckerType: """Create multi condition matcher using 'OR'.""" - if config_validation: - config = cv.OR_CONDITION_SCHEMA(config) - checks = [ - await async_from_config(hass, entry, False) for entry in config["conditions"] - ] + checks = [await async_from_config(hass, entry) for entry in config["conditions"]] @trace_condition_function def if_or_condition( @@ -258,14 +248,10 @@ async def async_or_from_config( async def async_not_from_config( - hass: HomeAssistant, config: ConfigType, config_validation: bool = True + hass: HomeAssistant, config: ConfigType ) -> ConditionCheckerType: """Create multi condition matcher using 'NOT'.""" - if config_validation: - config = cv.NOT_CONDITION_SCHEMA(config) - checks = [ - await async_from_config(hass, entry, False) for entry in config["conditions"] - ] + checks = [await async_from_config(hass, entry) for entry in config["conditions"]] @trace_condition_function def if_not_condition( @@ -335,10 +321,11 @@ def async_numeric_state( # noqa: C901 entity_id = entity.entity_id if attribute is not None and attribute not in entity.attributes: - raise ConditionErrorMessage( - "numeric_state", - f"attribute '{attribute}' (of entity {entity_id}) does not exist", + condition_trace_set_result( + False, + message=f"attribute '{attribute}' of entity {entity_id} does not exist", ) + return False value: Any = None if value_template is None: @@ -356,8 +343,12 @@ def async_numeric_state( # noqa: C901 "numeric_state", f"template error: {ex}" ) from ex - # Known states that never match the numeric condition - if value in (STATE_UNAVAILABLE, STATE_UNKNOWN): + # Known states or attribute values that never match the numeric condition + if value in (None, STATE_UNAVAILABLE, STATE_UNKNOWN): + condition_trace_set_result( + False, + message=f"value '{value}' is non-numeric and treated as False", + ) return False try: @@ -428,12 +419,8 @@ def async_numeric_state( # noqa: C901 return True -def async_numeric_state_from_config( - config: ConfigType, config_validation: bool = True -) -> ConditionCheckerType: +def async_numeric_state_from_config(config: ConfigType) -> ConditionCheckerType: """Wrap action method with state based condition.""" - if config_validation: - config = cv.NUMERIC_STATE_CONDITION_SCHEMA(config) entity_ids = config.get(CONF_ENTITY_ID, []) attribute = config.get(CONF_ATTRIBUTE) below = config.get(CONF_BELOW) @@ -501,9 +488,11 @@ def state( entity_id = entity.entity_id if attribute is not None and attribute not in entity.attributes: - raise ConditionErrorMessage( - "state", f"attribute '{attribute}' (of entity {entity_id}) does not exist" + condition_trace_set_result( + False, + message=f"attribute '{attribute}' of entity {entity_id} does not exist", ) + return False assert isinstance(entity, State) @@ -541,12 +530,8 @@ def state( return duration_ok -def state_from_config( - config: ConfigType, config_validation: bool = True -) -> ConditionCheckerType: +def state_from_config(config: ConfigType) -> ConditionCheckerType: """Wrap action method with state based condition.""" - if config_validation: - config = cv.STATE_CONDITION_SCHEMA(config) entity_ids = config.get(CONF_ENTITY_ID, []) req_states: str | list[str] = config.get(CONF_STATE, []) for_period = config.get("for") @@ -649,12 +634,8 @@ def sun( return True -def sun_from_config( - config: ConfigType, config_validation: bool = True -) -> ConditionCheckerType: +def sun_from_config(config: ConfigType) -> ConditionCheckerType: """Wrap action method with sun based condition.""" - if config_validation: - config = cv.SUN_CONDITION_SCHEMA(config) before = config.get("before") after = config.get("after") before_offset = config.get("before_offset") @@ -696,12 +677,8 @@ def async_template( return result -def async_template_from_config( - config: ConfigType, config_validation: bool = True -) -> ConditionCheckerType: +def async_template_from_config(config: ConfigType) -> ConditionCheckerType: """Wrap action method with state based condition.""" - if config_validation: - config = cv.TEMPLATE_CONDITION_SCHEMA(config) value_template = cast(Template, config.get(CONF_VALUE_TEMPLATE)) @trace_condition_function @@ -801,12 +778,8 @@ def time( return True -def time_from_config( - config: ConfigType, config_validation: bool = True -) -> ConditionCheckerType: +def time_from_config(config: ConfigType) -> ConditionCheckerType: """Wrap action method with time based condition.""" - if config_validation: - config = cv.TIME_CONDITION_SCHEMA(config) before = config.get(CONF_BEFORE) after = config.get(CONF_AFTER) weekday = config.get(CONF_WEEKDAY) @@ -866,12 +839,8 @@ def zone( ) -def zone_from_config( - config: ConfigType, config_validation: bool = True -) -> ConditionCheckerType: +def zone_from_config(config: ConfigType) -> ConditionCheckerType: """Wrap action method with zone based condition.""" - if config_validation: - config = cv.ZONE_CONDITION_SCHEMA(config) entity_ids = config.get(CONF_ENTITY_ID, []) zone_entity_ids = config.get(CONF_ZONE, []) @@ -908,28 +877,24 @@ def zone_from_config( async def async_device_from_config( - hass: HomeAssistant, config: ConfigType, config_validation: bool = True + hass: HomeAssistant, config: ConfigType ) -> ConditionCheckerType: """Test a device condition.""" - if config_validation: - config = cv.DEVICE_CONDITION_SCHEMA(config) platform = await async_get_device_automation_platform( hass, config[CONF_DOMAIN], "condition" ) return trace_condition_function( cast( ConditionCheckerType, - platform.async_condition_from_config(config, config_validation), # type: ignore + platform.async_condition_from_config(hass, config), # type: ignore ) ) async def async_trigger_from_config( - hass: HomeAssistant, config: ConfigType, config_validation: bool = True + hass: HomeAssistant, config: ConfigType ) -> ConditionCheckerType: """Test a trigger condition.""" - if config_validation: - config = cv.TRIGGER_CONDITION_SCHEMA(config) trigger_id = config[CONF_ID] @trace_condition_function @@ -944,6 +909,30 @@ async def async_trigger_from_config( return trigger_if +def numeric_state_validate_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate numeric_state condition config.""" + + registry = er.async_get(hass) + config = dict(config) + config[CONF_ENTITY_ID] = er.async_resolve_entity_ids( + registry, cv.entity_ids_or_uuids(config[CONF_ENTITY_ID]) + ) + return config + + +def state_validate_config(hass: HomeAssistant, config: ConfigType) -> ConfigType: + """Validate state condition config.""" + + registry = er.async_get(hass) + config = dict(config) + config[CONF_ENTITY_ID] = er.async_resolve_entity_ids( + registry, cv.entity_ids_or_uuids(config[CONF_ENTITY_ID]) + ) + return config + + async def async_validate_condition_config( hass: HomeAssistant, config: ConfigType | Template ) -> ConfigType | Template: @@ -969,9 +958,24 @@ async def async_validate_condition_config( return await platform.async_validate_condition_config(hass, config) # type: ignore return cast(ConfigType, platform.CONDITION_SCHEMA(config)) # type: ignore + if condition in ("numeric_state", "state"): + validator = getattr( + sys.modules[__name__], VALIDATE_CONFIG_FORMAT.format(condition) + ) + return validator(hass, config) # type: ignore + return config +async def async_validate_conditions_config( + hass: HomeAssistant, conditions: list[ConfigType | Template] +) -> list[ConfigType | Template]: + """Validate config.""" + return await asyncio.gather( + *(async_validate_condition_config(hass, cond) for cond in conditions) + ) + + @callback def async_extract_entities(config: ConfigType | Template) -> set[str]: """Extract entities from a condition.""" diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 2f9f0b52839..0a565b3b9eb 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -5,6 +5,7 @@ import logging from typing import Any, Awaitable, Callable, Union from homeassistant import config_entries +from homeassistant.components import dhcp, mqtt, ssdp, zeroconf from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.typing import UNDEFINED, DiscoveryInfoType, UndefinedType @@ -81,11 +82,54 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow): return await self.async_step_confirm() - async_step_zeroconf = async_step_discovery - async_step_ssdp = async_step_discovery - async_step_mqtt = async_step_discovery - async_step_homekit = async_step_discovery - async_step_dhcp = async_step_discovery + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Handle a flow initialized by dhcp discovery.""" + if self._async_in_progress() or self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + await self.async_set_unique_id(self._domain) + + return await self.async_step_confirm() + + async def async_step_homekit( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle a flow initialized by Homekit discovery.""" + if self._async_in_progress() or self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + await self.async_set_unique_id(self._domain) + + return await self.async_step_confirm() + + async def async_step_mqtt(self, discovery_info: mqtt.MqttServiceInfo) -> FlowResult: + """Handle a flow initialized by mqtt discovery.""" + if self._async_in_progress() or self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + await self.async_set_unique_id(self._domain) + + return await self.async_step_confirm() + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle a flow initialized by Zeroconf discovery.""" + if self._async_in_progress() or self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + await self.async_set_unique_id(self._domain) + + return await self.async_step_confirm() + + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: + """Handle a flow initialized by Ssdp discovery.""" + if self._async_in_progress() or self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + await self.async_set_unique_id(self._domain) + + return await self.async_step_confirm() async def async_step_import(self, _: dict[str, Any] | None) -> FlowResult: """Handle a flow initialized by import.""" diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 54f257cb781..3c987b1ea9e 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -270,7 +270,7 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): return self.async_external_step_done(next_step_id="creation") try: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): url = await self.flow_impl.async_generate_authorize_url(self.flow_id) except asyncio.TimeoutError: return self.async_abort(reason="authorize_url_timeout") @@ -356,8 +356,7 @@ async def async_get_implementations( registered = dict(registered) for provider_domain, get_impl in hass.data[DATA_PROVIDERS].items(): - implementation = await get_impl(hass, domain) - if implementation is not None: + if (implementation := await get_impl(hass, domain)) is not None: registered[provider_domain] = implementation return registered diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index f2ac86239f8..cbcfb551dad 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Hashable +import contextlib from datetime import ( date as date_sys, datetime as datetime_sys, @@ -78,7 +79,6 @@ from homeassistant.helpers import ( script_variables as script_variables_helper, template as template_helper, ) -from homeassistant.helpers.logging import KeywordStyleAdapter from homeassistant.util import raise_if_invalid_path, slugify as util_slugify import homeassistant.util.dt as dt_util @@ -263,14 +263,34 @@ def entity_id(value: Any) -> str: raise vol.Invalid(f"Entity ID {value} is an invalid entity ID") -def entity_ids(value: str | list) -> list[str]: - """Validate Entity IDs.""" +def entity_id_or_uuid(value: Any) -> str: + """Validate Entity specified by entity_id or uuid.""" + with contextlib.suppress(vol.Invalid): + return entity_id(value) + with contextlib.suppress(vol.Invalid): + return fake_uuid4_hex(value) + raise vol.Invalid(f"Entity {value} is neither a valid entity ID nor a valid UUID") + + +def _entity_ids(value: str | list, allow_uuid: bool) -> list[str]: + """Help validate entity IDs or UUIDs.""" if value is None: raise vol.Invalid("Entity IDs can not be None") if isinstance(value, str): value = [ent_id.strip() for ent_id in value.split(",")] - return [entity_id(ent_id) for ent_id in value] + validator = entity_id_or_uuid if allow_uuid else entity_id + return [validator(ent_id) for ent_id in value] + + +def entity_ids(value: str | list) -> list[str]: + """Validate Entity IDs.""" + return _entity_ids(value, False) + + +def entity_ids_or_uuids(value: str | list) -> list[str]: + """Validate entities specified by entity IDs or UUIDs.""" + return _entity_ids(value, True) comp_entity_ids = vol.Any( @@ -683,6 +703,16 @@ def uuid4_hex(value: Any) -> str: return result.hex +_FAKE_UUID_4_HEX = re.compile(r"^[0-9a-f]{32}$") + + +def fake_uuid4_hex(value: Any) -> str: + """Validate a fake v4 UUID generated by random_uuid_hex.""" + if not _FAKE_UUID_4_HEX.match(value): + raise vol.Invalid("Invalid UUID") + return cast(str, value) # Pattern.match throws if input is not a string + + def ensure_list_csv(value: Any) -> list: """Ensure that input is a list or make one from comma-separated string.""" if isinstance(value, str): @@ -709,23 +739,26 @@ class multi_select: return selected -def deprecated( +def _deprecated_or_removed( key: str, - replacement_key: str | None = None, - default: Any | None = None, + replacement_key: str | None, + default: Any | None, + raise_if_present: bool, + option_removed: bool, ) -> Callable[[dict], dict]: """ - Log key as deprecated and provide a replacement (if exists). + Log key as deprecated and provide a replacement (if exists) or fail. Expected behavior: - - Outputs the appropriate deprecation warning if key is detected + - Outputs or throws the appropriate deprecation warning if key is detected + - Outputs or throws the appropriate error if key is detected and removed from support - Processes schema moving the value from key to replacement_key - Processes schema changing nothing if only replacement_key provided - No warning if only replacement_key provided - No warning if neither key nor replacement_key are provided - Adds replacement_key with default value in this case """ - module = inspect.getmodule(inspect.stack(context=0)[1].frame) + module = inspect.getmodule(inspect.stack(context=0)[2].frame) if module is not None: module_name = module.__name__ else: @@ -733,36 +766,34 @@ def deprecated( # will be missing information, so let's guard. # https://github.com/home-assistant/core/issues/24982 module_name = __name__ - - if replacement_key: - warning = ( - "The '{key}' option is deprecated," - " please replace it with '{replacement_key}'" - ) + if option_removed: + logger_func = logging.getLogger(module_name).error + option_status = "has been removed" else: - warning = ( - "The '{key}' option is deprecated," - " please remove it from your configuration" - ) + logger_func = logging.getLogger(module_name).warning + option_status = "is deprecated" def validator(config: dict) -> dict: - """Check if key is in config and log warning.""" + """Check if key is in config and log warning or error.""" if key in config: try: - KeywordStyleAdapter(logging.getLogger(module_name)).warning( - warning.replace( - "'{key}' option", - f"'{key}' option near {config.__config_file__}:{config.__line__}", # type: ignore - ), - key=key, - replacement_key=replacement_key, - ) + near = f"near {config.__config_file__}:{config.__line__} " # type: ignore except AttributeError: - KeywordStyleAdapter(logging.getLogger(module_name)).warning( - warning, - key=key, - replacement_key=replacement_key, + near = "" + arguments: tuple[str, ...] + if replacement_key: + warning = "The '%s' option %s%s, please replace it with '%s'" + arguments = (key, near, option_status, replacement_key) + else: + warning = ( + "The '%s' option %s%s, please remove it from your configuration" ) + arguments = (key, near, option_status) + + if raise_if_present: + raise vol.Invalid(warning % arguments) + + logger_func(warning, *arguments) value = config[key] if replacement_key: config.pop(key) @@ -782,6 +813,52 @@ def deprecated( return validator +def deprecated( + key: str, + replacement_key: str | None = None, + default: Any | None = None, + raise_if_present: bool | None = False, +) -> Callable[[dict], dict]: + """ + Log key as deprecated and provide a replacement (if exists). + + Expected behavior: + - Outputs the appropriate deprecation warning if key is detected or raises an exception + - Processes schema moving the value from key to replacement_key + - Processes schema changing nothing if only replacement_key provided + - No warning if only replacement_key provided + - No warning if neither key nor replacement_key are provided + - Adds replacement_key with default value in this case + """ + return _deprecated_or_removed( + key, + replacement_key=replacement_key, + default=default, + raise_if_present=raise_if_present or False, + option_removed=False, + ) + + +def removed( + key: str, + default: Any | None = None, + raise_if_present: bool | None = True, +) -> Callable[[dict], dict]: + """ + Log key as deprecated and fail the config validation. + + Expected behavior: + - Outputs the appropriate error if key is detected and removed from support or raises an exception + """ + return _deprecated_or_removed( + key, + replacement_key=None, + default=default, + raise_if_present=raise_if_present or False, + option_removed=True, + ) + + def key_value_schemas( key: str, value_schemas: dict[Hashable, vol.Schema] ) -> Callable[[Any], dict[Hashable, Any]]: @@ -951,7 +1028,7 @@ NUMERIC_STATE_CONDITION_SCHEMA = vol.All( { **CONDITION_BASE_SCHEMA, vol.Required(CONF_CONDITION): "numeric_state", - vol.Required(CONF_ENTITY_ID): entity_ids, + vol.Required(CONF_ENTITY_ID): entity_ids_or_uuids, vol.Optional(CONF_ATTRIBUTE): str, CONF_BELOW: NUMERIC_STATE_THRESHOLD_SCHEMA, CONF_ABOVE: NUMERIC_STATE_THRESHOLD_SCHEMA, @@ -964,7 +1041,7 @@ NUMERIC_STATE_CONDITION_SCHEMA = vol.All( STATE_CONDITION_BASE_SCHEMA = { **CONDITION_BASE_SCHEMA, vol.Required(CONF_CONDITION): "state", - vol.Required(CONF_ENTITY_ID): entity_ids, + vol.Required(CONF_ENTITY_ID): entity_ids_or_uuids, vol.Optional(CONF_ATTRIBUTE): str, vol.Optional(CONF_FOR): positive_time_period, # To support use_trigger_value in automation diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index 787041700f4..061233c0e1a 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -110,8 +110,10 @@ class FlowManagerResourceView(_BaseFlowManagerView): result = await self._flow_mgr.async_configure(flow_id, data) except data_entry_flow.UnknownFlow: return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) - except vol.Invalid: - return self.json_message("User input malformed", HTTPStatus.BAD_REQUEST) + except vol.Invalid as ex: + return self.json_message( + f"User input malformed: {ex}", HTTPStatus.BAD_REQUEST + ) result = self._prepare_result_json(result) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index b41df3d6aa0..e31b77d3ae2 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -8,9 +8,12 @@ from typing import TYPE_CHECKING, Any, NamedTuple, cast import attr +from homeassistant.backports.enum import StrEnum from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import RequiredParameterMissing +from homeassistant.helpers import storage +from homeassistant.helpers.frame import report from homeassistant.loader import bind_hass import homeassistant.util.uuid as uuid_util @@ -29,7 +32,8 @@ _LOGGER = logging.getLogger(__name__) DATA_REGISTRY = "device_registry" EVENT_DEVICE_REGISTRY_UPDATED = "device_registry_updated" STORAGE_KEY = "core.device_registry" -STORAGE_VERSION = 1 +STORAGE_VERSION_MAJOR = 1 +STORAGE_VERSION_MINOR = 2 SAVE_DELAY = 10 CLEANUP_DELAY = 10 @@ -37,10 +41,6 @@ CONNECTION_NETWORK_MAC = "mac" CONNECTION_UPNP = "upnp" CONNECTION_ZIGBEE = "zigbee" -DISABLED_CONFIG_ENTRY = "config_entry" -DISABLED_INTEGRATION = "integration" -DISABLED_USER = "user" - ORPHANED_DEVICE_KEEP_SECONDS = 86400 * 30 @@ -49,6 +49,26 @@ class _DeviceIndex(NamedTuple): connections: dict[tuple[str, str], str] +class DeviceEntryDisabler(StrEnum): + """What disabled a device entry.""" + + CONFIG_ENTRY = "config_entry" + INTEGRATION = "integration" + USER = "user" + + +# DISABLED_* are deprecated, to be removed in 2022.3 +DISABLED_CONFIG_ENTRY = DeviceEntryDisabler.CONFIG_ENTRY.value +DISABLED_INTEGRATION = DeviceEntryDisabler.INTEGRATION.value +DISABLED_USER = DeviceEntryDisabler.USER.value + + +class DeviceEntryType(StrEnum): + """Device entry type.""" + + SERVICE = "service" + + @attr.s(slots=True, frozen=True) class DeviceEntry: """Device Registry Entry.""" @@ -57,18 +77,8 @@ class DeviceEntry: config_entries: set[str] = attr.ib(converter=set, factory=set) configuration_url: str | None = attr.ib(default=None) connections: set[tuple[str, str]] = attr.ib(converter=set, factory=set) - disabled_by: str | None = attr.ib( - default=None, - validator=attr.validators.in_( - ( - DISABLED_CONFIG_ENTRY, - DISABLED_INTEGRATION, - DISABLED_USER, - None, - ) - ), - ) - entry_type: str | None = attr.ib(default=None) + disabled_by: DeviceEntryDisabler | None = attr.ib(default=None) + entry_type: DeviceEntryType | None = attr.ib(default=None) id: str = attr.ib(factory=uuid_util.random_uuid_hex) identifiers: set[tuple[str, str]] = attr.ib(converter=set, factory=set) manufacturer: str | None = attr.ib(default=None) @@ -151,6 +161,45 @@ def _async_get_device_id_from_index( return None +class DeviceRegistryStore(storage.Store): + """Store entity registry data.""" + + 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.""" + if old_major_version < 2 and old_minor_version < 2: + # From version 1.1 + for device in old_data["devices"]: + # Introduced in 0.110 + try: + device["entry_type"] = DeviceEntryType(device.get("entry_type")) + except ValueError: + device["entry_type"] = None + + # Introduced in 0.79 + # renamed in 0.95 + device["via_device_id"] = device.get("via_device_id") or device.get( + "hub_device_id" + ) + # Introduced in 0.87 + device["area_id"] = device.get("area_id") + device["name_by_user"] = device.get("name_by_user") + # Introduced in 0.119 + device["disabled_by"] = device.get("disabled_by") + # Introduced in 2021.11 + device["configuration_url"] = device.get("configuration_url") + # Introduced in 0.111 + old_data["deleted_devices"] = old_data.get("deleted_devices", []) + for device in old_data["deleted_devices"]: + # Introduced in 2021.2 + device["orphaned_timestamp"] = device.get("orphaned_timestamp") + + if old_major_version > 1: + raise NotImplementedError + return old_data + + class DeviceRegistry: """Class to hold a registry of devices.""" @@ -162,7 +211,13 @@ class DeviceRegistry: def __init__(self, hass: HomeAssistant) -> None: """Initialize the device registry.""" self.hass = hass - self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._store = DeviceRegistryStore( + hass, + STORAGE_VERSION_MAJOR, + STORAGE_KEY, + atomic_writes=True, + minor_version=STORAGE_VERSION_MINOR, + ) self._clear_index() @callback @@ -251,8 +306,8 @@ class DeviceRegistry: default_model: str | None | UndefinedType = UNDEFINED, default_name: str | None | UndefinedType = UNDEFINED, # To disable a device if it gets created - disabled_by: str | None | UndefinedType = UNDEFINED, - entry_type: str | None | UndefinedType = UNDEFINED, + disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, + entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED, identifiers: set[tuple[str, str]] | None = None, manufacturer: str | None | UndefinedType = UNDEFINED, model: str | None | UndefinedType = UNDEFINED, @@ -301,6 +356,15 @@ class DeviceRegistry: else: via_device_id = UNDEFINED + if isinstance(entry_type, str) and not isinstance(entry_type, DeviceEntryType): + report( # type: ignore[unreachable] + "uses str for device registry entry_type. This is deprecated and will " + "stop working in Home Assistant 2022.3, it should be updated to use " + "DeviceEntryType instead", + error_if_core=False, + ) + entry_type = DeviceEntryType(entry_type) + device = self._async_update_device( device.id, add_config_entry_id=config_entry_id, @@ -330,7 +394,7 @@ class DeviceRegistry: add_config_entry_id: str | UndefinedType = UNDEFINED, area_id: str | None | UndefinedType = UNDEFINED, configuration_url: str | None | UndefinedType = UNDEFINED, - disabled_by: str | None | UndefinedType = UNDEFINED, + disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, manufacturer: str | None | UndefinedType = UNDEFINED, model: str | None | UndefinedType = UNDEFINED, name_by_user: str | None | UndefinedType = UNDEFINED, @@ -367,8 +431,8 @@ class DeviceRegistry: add_config_entry_id: str | UndefinedType = UNDEFINED, area_id: str | None | UndefinedType = UNDEFINED, configuration_url: str | None | UndefinedType = UNDEFINED, - disabled_by: str | None | UndefinedType = UNDEFINED, - entry_type: str | None | UndefinedType = UNDEFINED, + disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, + entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED, manufacturer: str | None | UndefinedType = UNDEFINED, merge_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, merge_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, @@ -388,6 +452,17 @@ class DeviceRegistry: config_entries = old.config_entries + if isinstance(disabled_by, str) and not isinstance( + disabled_by, DeviceEntryDisabler + ): + report( # type: ignore[unreachable] + "uses str for device registry disabled_by. This is deprecated and will " + "stop working in Home Assistant 2022.3, it should be updated to use " + "DeviceEntryDisabler instead", + error_if_core=False, + ) + disabled_by = DeviceEntryDisabler(disabled_by) + if ( suggested_area not in (UNDEFINED, None, "") and area_id is UNDEFINED @@ -483,6 +558,9 @@ class DeviceRegistry: orphaned_timestamp=None, ) ) + for other_device in list(self.devices.values()): + if other_device.via_device_id == device_id: + self._async_update_device(other_device.id, via_device_id=None) self.hass.bus.async_fire( EVENT_DEVICE_REGISTRY_UPDATED, {"action": "remove", "device_id": device_id} ) @@ -498,42 +576,36 @@ class DeviceRegistry: deleted_devices = OrderedDict() if data is not None: + data = cast("dict[str, Any]", data) for device in data["devices"]: devices[device["id"]] = DeviceEntry( + area_id=device["area_id"], config_entries=set(device["config_entries"]), + configuration_url=device["configuration_url"], # type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625 connections={tuple(conn) for conn in device["connections"]}, # type: ignore[misc] + disabled_by=device["disabled_by"], + entry_type=DeviceEntryType(device["entry_type"]) + if device["entry_type"] + else None, + id=device["id"], identifiers={tuple(iden) for iden in device["identifiers"]}, # type: ignore[misc] manufacturer=device["manufacturer"], model=device["model"], + name_by_user=device["name_by_user"], name=device["name"], sw_version=device["sw_version"], - # Introduced in 0.110 - entry_type=device.get("entry_type"), - id=device["id"], - # Introduced in 0.79 - # renamed in 0.95 - via_device_id=( - device.get("via_device_id") or device.get("hub_device_id") - ), - # Introduced in 0.87 - area_id=device.get("area_id"), - name_by_user=device.get("name_by_user"), - # Introduced in 0.119 - disabled_by=device.get("disabled_by"), - # Introduced in 2021.11 - configuration_url=device.get("configuration_url"), + via_device_id=device["via_device_id"], ) # Introduced in 0.111 - for device in data.get("deleted_devices", []): + for device in data["deleted_devices"]: deleted_devices[device["id"]] = DeletedDeviceEntry( config_entries=set(device["config_entries"]), # type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625 connections={tuple(conn) for conn in device["connections"]}, # type: ignore[misc] identifiers={tuple(iden) for iden in device["identifiers"]}, # type: ignore[misc] id=device["id"], - # Introduced in 2021.2 - orphaned_timestamp=device.get("orphaned_timestamp"), + orphaned_timestamp=device["orphaned_timestamp"], ) self.devices = devices @@ -681,7 +753,7 @@ def async_config_entry_disabled_by_changed( Disable devices in the registry that are associated with a config entry when the config entry is disabled, enable devices in the registry that are associated with a config entry when the config entry is enabled and the devices are marked - DISABLED_CONFIG_ENTRY. + DeviceEntryDisabler.CONFIG_ENTRY. Only disable a device if all associated config entries are disabled. """ @@ -689,7 +761,7 @@ def async_config_entry_disabled_by_changed( if not config_entry.disabled_by: for device in devices: - if device.disabled_by != DISABLED_CONFIG_ENTRY: + if device.disabled_by is not DeviceEntryDisabler.CONFIG_ENTRY: continue registry.async_update_device(device.id, disabled_by=None) return @@ -708,7 +780,9 @@ def async_config_entry_disabled_by_changed( enabled_config_entries ): continue - registry.async_update_device(device.id, disabled_by=DISABLED_CONFIG_ENTRY) + registry.async_update_device( + device.id, disabled_by=DeviceEntryDisabler.CONFIG_ENTRY + ) @callback diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index c76aefc3fa9..cd04f9db184 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -15,6 +15,7 @@ from typing import Any, Final, Literal, TypedDict, final import voluptuous as vol +from homeassistant.backports.enum import StrEnum from homeassistant.config import DATA_CUSTOMIZE from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -26,8 +27,7 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, DEVICE_DEFAULT_NAME, - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, + ENTITY_CATEGORIES, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -38,6 +38,7 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, NoEntitySpecifiedError from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.event import Event, async_track_entity_registry_updated_event from homeassistant.helpers.typing import StateType @@ -55,11 +56,6 @@ SOURCE_PLATFORM_CONFIG = "platform_config" FLOAT_PRECISION = abs(int(math.floor(math.log10(abs(sys.float_info.epsilon))))) - 1 -ENTITY_CATEGORIES: Final[list[str]] = [ - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, -] - ENTITY_CATEGORIES_SCHEMA: Final = vol.In(ENTITY_CATEGORIES) @@ -133,7 +129,7 @@ def get_device_class(hass: HomeAssistant, entity_id: str) -> str | None: if not (entry := entity_registry.async_get(entity_id)): raise HomeAssistantError(f"Unknown entity {entity_id}") - return entry.device_class + return entry.device_class or entry.original_device_class def get_supported_features(hass: HomeAssistant, entity_id: str) -> int: @@ -174,7 +170,7 @@ class DeviceInfo(TypedDict, total=False): default_manufacturer: str default_model: str default_name: str - entry_type: str | None + entry_type: DeviceEntryType | None identifiers: set[tuple[str, str]] manufacturer: str | None model: str | None @@ -184,6 +180,24 @@ class DeviceInfo(TypedDict, total=False): via_device: tuple[str, str] +class EntityCategory(StrEnum): + """Category of an entity. + + An entity with a category will: + - Not be exposed to cloud, Alexa, or Google Assistant components + - Not be included in indirect service calls to devices or areas + """ + + # Config: An entity which allows changing the configuration of a device + CONFIG = "config" + + # Diagnostic: An entity exposing some configuration parameter or diagnostics of a device + DIAGNOSTIC = "diagnostic" + + # System: An entity which is not useful for the user to interact with + SYSTEM = "system" + + @dataclass class EntityDescription: """A class that describes Home Assistant entities.""" @@ -192,7 +206,9 @@ class EntityDescription: key: str device_class: str | None = None - entity_category: Literal["config", "diagnostic"] | None = None + entity_category: EntityCategory | Literal[ + "config", "diagnostic", "system" + ] | None = None entity_registry_enabled_default: bool = True force_update: bool = False icon: str | None = None @@ -225,6 +241,9 @@ class Entity(ABC): # If we reported this entity is updated while disabled _disabled_reported = False + # If we reported this entity is using deprecated device_state_attributes + _deprecated_device_state_attributes_reported = False + # Protect for multiple updates _update_staged = False @@ -251,7 +270,7 @@ class Entity(ABC): _attr_context_recent_time: timedelta = timedelta(seconds=5) _attr_device_class: str | None _attr_device_info: DeviceInfo | None = None - _attr_entity_category: str | None + _attr_entity_category: EntityCategory | str | None _attr_entity_picture: str | None = None _attr_entity_registry_enabled_default: bool _attr_extra_state_attributes: MutableMapping[str, Any] @@ -419,7 +438,7 @@ class Entity(ABC): return self._attr_attribution @property - def entity_category(self) -> str | None: + def entity_category(self) -> EntityCategory | str | None: """Return the category of the entity, if any.""" if hasattr(self, "_attr_entity_category"): return self._attr_entity_category @@ -521,7 +540,19 @@ class Entity(ABC): attr.update(self.state_attributes or {}) extra_state_attributes = self.extra_state_attributes # Backwards compatibility for "device_state_attributes" deprecated in 2021.4 - # Add warning in 2021.6, remove in 2021.10 + # Warning added in 2021.12, will be removed in 2022.4 + if ( + self.device_state_attributes is not None + and not self._deprecated_device_state_attributes_reported + ): + report_issue = self._suggest_report_issue() + _LOGGER.warning( + "Entity %s (%s) implements device_state_attributes. Please %s", + self.entity_id, + type(self), + report_issue, + ) + self._deprecated_device_state_attributes_reported = True if extra_state_attributes is None: extra_state_attributes = self.device_state_attributes attr.update(extra_state_attributes or {}) @@ -531,28 +562,30 @@ class Entity(ABC): attr[ATTR_UNIT_OF_MEASUREMENT] = unit_of_measurement entry = self.registry_entry - # pylint: disable=consider-using-ternary - if (name := (entry and entry.name) or self.name) is not None: - attr[ATTR_FRIENDLY_NAME] = name - - if (icon := (entry and entry.icon) or self.icon) is not None: - attr[ATTR_ICON] = icon - - if (entity_picture := self.entity_picture) is not None: - attr[ATTR_ENTITY_PICTURE] = entity_picture if assumed_state := self.assumed_state: attr[ATTR_ASSUMED_STATE] = assumed_state - if (supported_features := self.supported_features) is not None: - attr[ATTR_SUPPORTED_FEATURES] = supported_features - - if (device_class := self.device_class) is not None: - attr[ATTR_DEVICE_CLASS] = str(device_class) - if (attribution := self.attribution) is not None: attr[ATTR_ATTRIBUTION] = attribution + if ( + device_class := (entry and entry.device_class) or self.device_class + ) is not None: + attr[ATTR_DEVICE_CLASS] = str(device_class) + + if (entity_picture := self.entity_picture) is not None: + attr[ATTR_ENTITY_PICTURE] = entity_picture + + if (icon := (entry and entry.icon) or self.icon) is not None: + attr[ATTR_ICON] = icon + + if (name := (entry and entry.name) or self.name) is not None: + attr[ATTR_FRIENDLY_NAME] = name + + if (supported_features := self.supported_features) is not None: + attr[ATTR_SUPPORTED_FEATURES] = supported_features + end = timer() if end - start > 0.4 and not self._slow_reported: diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index d65f485166b..c190fd5fc35 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -49,9 +49,7 @@ async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None: ) return - entity_obj = entity_comp.get_entity(entity_id) - - if entity_obj is None: + if (entity_obj := entity_comp.get_entity(entity_id)) is None: logging.getLogger(__name__).warning( "Forced update failed. Entity %s not found.", entity_id ) @@ -175,9 +173,7 @@ class EntityComponent: """Unload a config entry.""" key = config_entry.entry_id - platform = self._platforms.pop(key, None) - - if platform is None: + if (platform := self._platforms.pop(key, None)) is None: raise ValueError("Config entry was never loaded!") await platform.async_reset() diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index c44eb96026d..d8cb8477f11 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -42,7 +42,7 @@ from . import ( service, ) from .device_registry import DeviceRegistry -from .entity_registry import DISABLED_INTEGRATION, EntityRegistry +from .entity_registry import EntityRegistry, RegistryEntryDisabler from .event import async_call_later, async_track_time_interval from .typing import ConfigType, DiscoveryInfoType @@ -456,6 +456,7 @@ class EntityPlatform: device_info = entity.device_info device_id = None + device = None if config_entry_id is not None and device_info is not None: processed_dev_info: dict[str, str | None] = { @@ -501,28 +502,33 @@ class EntityPlatform: except RequiredParameterMissing: pass - disabled_by: str | None = None + disabled_by: RegistryEntryDisabler | None = None if not entity.entity_registry_enabled_default: - disabled_by = DISABLED_INTEGRATION + disabled_by = RegistryEntryDisabler.INTEGRATION entry = entity_registry.async_get_or_create( self.domain, self.platform_name, entity.unique_id, - suggested_object_id=suggested_object_id, + capabilities=entity.capability_attributes, config_entry=self.config_entry, device_id=device_id, - known_object_ids=self.entities.keys(), disabled_by=disabled_by, - capabilities=entity.capability_attributes, - supported_features=entity.supported_features, - device_class=entity.device_class, - unit_of_measurement=entity.unit_of_measurement, - original_name=entity.name, - original_icon=entity.icon, entity_category=entity.entity_category, + known_object_ids=self.entities.keys(), + original_device_class=entity.device_class, + original_icon=entity.icon, + original_name=entity.name, + suggested_object_id=suggested_object_id, + supported_features=entity.supported_features, + unit_of_measurement=entity.unit_of_measurement, ) + if device and device.disabled and not entry.disabled: + entry = entity_registry.async_update_entity( + entry.entity_id, disabled_by=RegistryEntryDisabler.DEVICE + ) + entity.registry_entry = entry entity.entity_id = entry.entity_id diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index b7b0eed2f32..b79e9af209e 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -9,13 +9,15 @@ timer. """ from __future__ import annotations -from collections import OrderedDict +from collections import UserDict from collections.abc import Callable, Iterable, Mapping import logging from typing import TYPE_CHECKING, Any, cast import attr +import voluptuous as vol +from homeassistant.backports.enum import StrEnum from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -36,10 +38,11 @@ from homeassistant.core import ( valid_entity_id, ) from homeassistant.exceptions import MaxLengthExceeded -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, storage from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED +from homeassistant.helpers.frame import report from homeassistant.loader import bind_hass -from homeassistant.util import slugify +from homeassistant.util import slugify, uuid as uuid_util from homeassistant.util.yaml import load_yaml from .typing import UNDEFINED, UndefinedType @@ -52,28 +55,42 @@ DATA_REGISTRY = "entity_registry" EVENT_ENTITY_REGISTRY_UPDATED = "entity_registry_updated" SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) -DISABLED_CONFIG_ENTRY = "config_entry" -DISABLED_DEVICE = "device" -DISABLED_HASS = "hass" -DISABLED_INTEGRATION = "integration" -DISABLED_USER = "user" -STORAGE_VERSION = 1 +STORAGE_VERSION_MAJOR = 1 +STORAGE_VERSION_MINOR = 4 STORAGE_KEY = "core.entity_registry" # Attributes relevant to describing entity # to external services. ENTITY_DESCRIBING_ATTRIBUTES = { + "capabilities", + "device_class", "entity_id", "name", "original_name", - "capabilities", "supported_features", - "device_class", "unit_of_measurement", } +class RegistryEntryDisabler(StrEnum): + """What disabled a registry entry.""" + + CONFIG_ENTRY = "config_entry" + DEVICE = "device" + HASS = "hass" + INTEGRATION = "integration" + USER = "user" + + +# DISABLED_* are deprecated, to be removed in 2022.3 +DISABLED_CONFIG_ENTRY = RegistryEntryDisabler.CONFIG_ENTRY.value +DISABLED_DEVICE = RegistryEntryDisabler.DEVICE.value +DISABLED_HASS = RegistryEntryDisabler.HASS.value +DISABLED_INTEGRATION = RegistryEntryDisabler.INTEGRATION.value +DISABLED_USER = RegistryEntryDisabler.USER.value + + @attr.s(slots=True, frozen=True) class RegistryEntry: """Entity Registry Entry.""" @@ -81,33 +98,23 @@ class RegistryEntry: entity_id: str = attr.ib() unique_id: str = attr.ib() platform: str = attr.ib() - name: str | None = attr.ib(default=None) - icon: str | None = attr.ib(default=None) - device_id: str | None = attr.ib(default=None) area_id: str | None = attr.ib(default=None) - config_entry_id: str | None = attr.ib(default=None) - disabled_by: str | None = attr.ib( - default=None, - validator=attr.validators.in_( - ( - DISABLED_CONFIG_ENTRY, - DISABLED_DEVICE, - DISABLED_HASS, - DISABLED_INTEGRATION, - DISABLED_USER, - None, - ) - ), - ) capabilities: Mapping[str, Any] | None = attr.ib(default=None) - supported_features: int = attr.ib(default=0) + config_entry_id: str | None = attr.ib(default=None) device_class: str | None = attr.ib(default=None) - unit_of_measurement: str | None = attr.ib(default=None) - # As set by integration - original_name: str | None = attr.ib(default=None) - original_icon: str | None = attr.ib(default=None) - entity_category: str | None = attr.ib(default=None) + device_id: str | None = attr.ib(default=None) domain: str = attr.ib(init=False, repr=False) + disabled_by: RegistryEntryDisabler | None = attr.ib(default=None) + entity_category: str | None = attr.ib(default=None) + icon: str | None = attr.ib(default=None) + id: str = attr.ib(factory=uuid_util.random_uuid_hex) + name: str | None = attr.ib(default=None) + # As set by integration + original_device_class: str | None = attr.ib(default=None) + original_icon: str | None = attr.ib(default=None) + original_name: str | None = attr.ib(default=None) + supported_features: int = attr.ib(default=0) + unit_of_measurement: str | None = attr.ib(default=None) @domain.default def _domain_default(self) -> str: @@ -127,35 +134,95 @@ class RegistryEntry: if self.capabilities is not None: attrs.update(self.capabilities) - if self.supported_features is not None: - attrs[ATTR_SUPPORTED_FEATURES] = self.supported_features - - if self.device_class is not None: - attrs[ATTR_DEVICE_CLASS] = self.device_class - - if self.unit_of_measurement is not None: - attrs[ATTR_UNIT_OF_MEASUREMENT] = self.unit_of_measurement - - name = self.name or self.original_name - if name is not None: - attrs[ATTR_FRIENDLY_NAME] = name + device_class = self.device_class or self.original_device_class + if device_class is not None: + attrs[ATTR_DEVICE_CLASS] = device_class icon = self.icon or self.original_icon if icon is not None: attrs[ATTR_ICON] = icon + name = self.name or self.original_name + if name is not None: + attrs[ATTR_FRIENDLY_NAME] = name + + if self.supported_features is not None: + attrs[ATTR_SUPPORTED_FEATURES] = self.supported_features + + if self.unit_of_measurement is not None: + attrs[ATTR_UNIT_OF_MEASUREMENT] = self.unit_of_measurement + hass.states.async_set(self.entity_id, STATE_UNAVAILABLE, attrs) +class EntityRegistryStore(storage.Store): + """Store entity registry data.""" + + async def _async_migrate_func( + self, old_major_version: int, old_minor_version: int, old_data: dict + ) -> dict: + """Migrate to the new version.""" + return await _async_migrate(old_major_version, old_minor_version, old_data) + + +class EntityRegistryItems(UserDict): + """Container for entity registry items, maps entity_id -> entry. + + Maintains two additional indexes: + - id -> entry + - (domain, platform, unique_id) -> entry + """ + + def __init__(self) -> None: + """Initialize the container.""" + super().__init__() + self._entry_ids: dict[str, RegistryEntry] = {} + self._index: dict[tuple[str, str, str], str] = {} + + def __setitem__(self, key: str, entry: RegistryEntry) -> None: + """Add an item.""" + if key in self: + old_entry = self[key] + del self._entry_ids[old_entry.id] + del self._index[(old_entry.domain, old_entry.platform, old_entry.unique_id)] + super().__setitem__(key, entry) + self._entry_ids.__setitem__(entry.id, entry) + self._index[(entry.domain, entry.platform, entry.unique_id)] = entry.entity_id + + def __delitem__(self, key: str) -> None: + """Remove an item.""" + entry = self[key] + self._entry_ids.__delitem__(entry.id) + self._index.__delitem__((entry.domain, entry.platform, entry.unique_id)) + super().__delitem__(key) + + def __getitem__(self, key: str) -> RegistryEntry: + """Get an item.""" + return cast(RegistryEntry, super().__getitem__(key)) + + def get_entity_id(self, key: tuple[str, str, str]) -> str | None: + """Get entity_id from (domain, platform, unique_id).""" + return self._index.get(key) + + def get_entry(self, key: str) -> RegistryEntry | None: + """Get entry from id.""" + return self._entry_ids.get(key) + + class EntityRegistry: """Class to hold a registry of entities.""" def __init__(self, hass: HomeAssistant) -> None: """Initialize the registry.""" self.hass = hass - self.entities: dict[str, RegistryEntry] - self._index: dict[tuple[str, str, str], str] = {} - self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self.entities: EntityRegistryItems + self._store = EntityRegistryStore( + hass, + STORAGE_VERSION_MAJOR, + STORAGE_KEY, + atomic_writes=True, + minor_version=STORAGE_VERSION_MINOR, + ) self.hass.bus.async_listen( EVENT_DEVICE_REGISTRY_UPDATED, self.async_device_modified ) @@ -173,7 +240,8 @@ class EntityRegistry: for entity in self.entities.values(): if not entity.device_id: continue - domain_device_class = (entity.domain, entity.device_class) + device_class = entity.device_class or entity.original_device_class + domain_device_class = (entity.domain, device_class) if domain_device_class not in domain_device_classes: continue if entity.device_id not in lookup: @@ -197,7 +265,7 @@ class EntityRegistry: self, domain: str, platform: str, unique_id: str ) -> str | None: """Check if an entity_id is currently registered.""" - return self._index.get((domain, platform, unique_id)) + return self.entities.get_entity_id((domain, platform, unique_id)) @callback def async_generate_entity_id( @@ -246,14 +314,14 @@ class EntityRegistry: known_object_ids: Iterable[str] | None = None, suggested_object_id: str | None = None, # To disable an entity if it gets created - disabled_by: str | None = None, + disabled_by: RegistryEntryDisabler | None = None, # Data that we want entry to have area_id: str | None = None, capabilities: Mapping[str, Any] | None = None, config_entry: ConfigEntry | None = None, - device_class: str | None = None, device_id: str | None = None, entity_category: str | None = None, + original_device_class: str | None = None, original_icon: str | None = None, original_name: str | None = None, supported_features: int | None = None, @@ -269,16 +337,16 @@ class EntityRegistry: if entity_id: return self._async_update_entity( entity_id, - config_entry_id=config_entry_id or UNDEFINED, - device_id=device_id or UNDEFINED, area_id=area_id or UNDEFINED, capabilities=capabilities or UNDEFINED, - supported_features=supported_features or UNDEFINED, - device_class=device_class or UNDEFINED, - unit_of_measurement=unit_of_measurement or UNDEFINED, - original_name=original_name or UNDEFINED, - original_icon=original_icon or UNDEFINED, + config_entry_id=config_entry_id or UNDEFINED, + device_id=device_id or UNDEFINED, entity_category=entity_category or UNDEFINED, + original_device_class=original_device_class or UNDEFINED, + original_icon=original_icon or UNDEFINED, + original_name=original_name or UNDEFINED, + supported_features=supported_features or UNDEFINED, + unit_of_measurement=unit_of_measurement or UNDEFINED, # When we changed our slugify algorithm, we invalidated some # stored entity IDs with either a __ or ending in _. # Fix introduced in 0.86 (Jan 23, 2019). Next line can be @@ -292,22 +360,32 @@ class EntityRegistry: domain, suggested_object_id or f"{platform}_{unique_id}", known_object_ids ) - if ( + if isinstance(disabled_by, str) and not isinstance( + disabled_by, RegistryEntryDisabler + ): + report( # type: ignore[unreachable] + "uses str for entity registry disabled_by. This is deprecated and will " + "stop working in Home Assistant 2022.3, it should be updated to use " + "RegistryEntryDisabler instead", + error_if_core=False, + ) + disabled_by = RegistryEntryDisabler(disabled_by) + elif ( disabled_by is None and config_entry and config_entry.pref_disable_new_entities ): - disabled_by = DISABLED_INTEGRATION + disabled_by = RegistryEntryDisabler.INTEGRATION - entity = RegistryEntry( + entry = RegistryEntry( area_id=area_id, capabilities=capabilities, config_entry_id=config_entry_id, - device_class=device_class, device_id=device_id, disabled_by=disabled_by, entity_category=entity_category, entity_id=entity_id, + original_device_class=original_device_class, original_icon=original_icon, original_name=original_name, platform=platform, @@ -315,7 +393,7 @@ class EntityRegistry: unique_id=unique_id, unit_of_measurement=unit_of_measurement, ) - self._register_entry(entity) + self.entities[entity_id] = entry _LOGGER.info("Registered new %s.%s entity: %s", domain, platform, entity_id) self.async_schedule_save() @@ -323,12 +401,12 @@ class EntityRegistry: EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "entity_id": entity_id} ) - return entity + return entry @callback def async_remove(self, entity_id: str) -> None: """Remove an entity from registry.""" - self._unregister_entry(self.entities[entity_id]) + self.entities.pop(entity_id) self.hass.bus.async_fire( EVENT_ENTITY_REGISTRY_UPDATED, {"action": "remove", "entity_id": entity_id} ) @@ -364,19 +442,21 @@ class EntityRegistry: self, event.data["device_id"], include_disabled_entities=True ) for entity in entities: - if entity.disabled_by != DISABLED_DEVICE: + if entity.disabled_by is not RegistryEntryDisabler.DEVICE: continue self.async_update_entity(entity.entity_id, disabled_by=None) return - if device.disabled_by == dr.DISABLED_CONFIG_ENTRY: + if device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY: # Handled by async_config_entry_disabled return # Fetch entities which are not already disabled entities = async_entries_for_device(self, event.data["device_id"]) for entity in entities: - self.async_update_entity(entity.entity_id, disabled_by=DISABLED_DEVICE) + self.async_update_entity( + entity.entity_id, disabled_by=RegistryEntryDisabler.DEVICE + ) @callback def async_update_entity( @@ -386,12 +466,13 @@ class EntityRegistry: area_id: str | None | UndefinedType = UNDEFINED, config_entry_id: str | None | UndefinedType = UNDEFINED, device_class: str | None | UndefinedType = UNDEFINED, - disabled_by: str | None | UndefinedType = UNDEFINED, + disabled_by: RegistryEntryDisabler | None | UndefinedType = UNDEFINED, entity_category: str | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, new_entity_id: str | UndefinedType = UNDEFINED, new_unique_id: str | UndefinedType = UNDEFINED, + original_device_class: str | None | UndefinedType = UNDEFINED, original_icon: str | None | UndefinedType = UNDEFINED, original_name: str | None | UndefinedType = UNDEFINED, unit_of_measurement: str | None | UndefinedType = UNDEFINED, @@ -408,6 +489,7 @@ class EntityRegistry: name=name, new_entity_id=new_entity_id, new_unique_id=new_unique_id, + original_device_class=original_device_class, original_icon=original_icon, original_name=original_name, unit_of_measurement=unit_of_measurement, @@ -423,12 +505,13 @@ class EntityRegistry: config_entry_id: str | None | UndefinedType = UNDEFINED, device_class: str | None | UndefinedType = UNDEFINED, device_id: str | None | UndefinedType = UNDEFINED, - disabled_by: str | None | UndefinedType = UNDEFINED, + disabled_by: RegistryEntryDisabler | None | UndefinedType = UNDEFINED, entity_category: str | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, new_entity_id: str | UndefinedType = UNDEFINED, new_unique_id: str | UndefinedType = UNDEFINED, + original_device_class: str | None | UndefinedType = UNDEFINED, original_icon: str | None | UndefinedType = UNDEFINED, original_name: str | None | UndefinedType = UNDEFINED, supported_features: int | UndefinedType = UNDEFINED, @@ -440,20 +523,32 @@ class EntityRegistry: new_values = {} # Dict with new key/value pairs old_values = {} # Dict with old key/value pairs + if isinstance(disabled_by, str) and not isinstance( + disabled_by, RegistryEntryDisabler + ): + report( # type: ignore[unreachable] + "uses str for entity registry disabled_by. This is deprecated and will " + "stop working in Home Assistant 2022.3, it should be updated to use " + "RegistryEntryDisabler instead", + error_if_core=False, + ) + disabled_by = RegistryEntryDisabler(disabled_by) + for attr_name, value in ( - ("name", name), - ("icon", icon), - ("config_entry_id", config_entry_id), - ("device_id", device_id), ("area_id", area_id), - ("disabled_by", disabled_by), ("capabilities", capabilities), - ("supported_features", supported_features), + ("config_entry_id", config_entry_id), ("device_class", device_class), - ("unit_of_measurement", unit_of_measurement), - ("original_name", original_name), - ("original_icon", original_icon), + ("device_id", device_id), + ("disabled_by", disabled_by), ("entity_category", entity_category), + ("icon", icon), + ("name", name), + ("original_device_class", original_device_class), + ("original_icon", original_icon), + ("original_name", original_name), + ("supported_features", supported_features), + ("unit_of_measurement", unit_of_measurement), ): if value is not UNDEFINED and value != getattr(old, attr_name): new_values[attr_name] = value @@ -488,9 +583,7 @@ class EntityRegistry: if not new_values: return old - self._remove_index(old) - new = attr.evolve(old, **new_values) - self._register_entry(new) + new = self.entities[entity_id] = attr.evolve(old, **new_values) self.async_schedule_save() @@ -507,13 +600,14 @@ class EntityRegistry: """Load the entity registry.""" async_setup_entity_restore(self.hass, self) - data = await self.hass.helpers.storage.async_migrator( + data = await storage.async_migrator( + self.hass, self.hass.config.path(PATH_REGISTRY), self._store, old_conf_load_func=load_yaml, - old_conf_migrate_func=_async_migrate, + old_conf_migrate_func=_async_migrate_yaml_to_json, ) - entities: dict[str, RegistryEntry] = OrderedDict() + entities = EntityRegistryItems() if data is not None: for entity in data["entities"]: @@ -524,26 +618,29 @@ class EntityRegistry: continue entities[entity["entity_id"]] = RegistryEntry( + area_id=entity["area_id"], + capabilities=entity["capabilities"], + config_entry_id=entity["config_entry_id"], + device_class=entity["device_class"], + device_id=entity["device_id"], + disabled_by=RegistryEntryDisabler(entity["disabled_by"]) + if entity["disabled_by"] + else None, + entity_category=entity["entity_category"], entity_id=entity["entity_id"], - config_entry_id=entity.get("config_entry_id"), - device_id=entity.get("device_id"), - area_id=entity.get("area_id"), - unique_id=entity["unique_id"], + icon=entity["icon"], + id=entity["id"], + name=entity["name"], + original_device_class=entity["original_device_class"], + original_icon=entity["original_icon"], + original_name=entity["original_name"], platform=entity["platform"], - name=entity.get("name"), - icon=entity.get("icon"), - disabled_by=entity.get("disabled_by"), - capabilities=entity.get("capabilities") or {}, - supported_features=entity.get("supported_features", 0), - device_class=entity.get("device_class"), - unit_of_measurement=entity.get("unit_of_measurement"), - original_name=entity.get("original_name"), - original_icon=entity.get("original_icon"), - entity_category=entity.get("entity_category"), + supported_features=entity["supported_features"], + unique_id=entity["unique_id"], + unit_of_measurement=entity["unit_of_measurement"], ) self.entities = entities - self._rebuild_index() @callback def async_schedule_save(self) -> None: @@ -557,22 +654,24 @@ class EntityRegistry: data["entities"] = [ { - "entity_id": entry.entity_id, - "config_entry_id": entry.config_entry_id, - "device_id": entry.device_id, "area_id": entry.area_id, - "unique_id": entry.unique_id, - "platform": entry.platform, - "name": entry.name, - "icon": entry.icon, - "disabled_by": entry.disabled_by, "capabilities": entry.capabilities, - "supported_features": entry.supported_features, + "config_entry_id": entry.config_entry_id, "device_class": entry.device_class, - "unit_of_measurement": entry.unit_of_measurement, - "original_name": entry.original_name, - "original_icon": entry.original_icon, + "device_id": entry.device_id, + "disabled_by": entry.disabled_by, "entity_category": entry.entity_category, + "entity_id": entry.entity_id, + "icon": entry.icon, + "id": entry.id, + "name": entry.name, + "original_device_class": entry.original_device_class, + "original_icon": entry.original_icon, + "original_name": entry.original_name, + "platform": entry.platform, + "supported_features": entry.supported_features, + "unique_id": entry.unique_id, + "unit_of_measurement": entry.unit_of_measurement, } for entry in self.entities.values() ] @@ -596,25 +695,6 @@ class EntityRegistry: if area_id == entry.area_id: self._async_update_entity(entity_id, area_id=None) - def _register_entry(self, entry: RegistryEntry) -> None: - self.entities[entry.entity_id] = entry - self._add_index(entry) - - def _add_index(self, entry: RegistryEntry) -> None: - self._index[(entry.domain, entry.platform, entry.unique_id)] = entry.entity_id - - def _unregister_entry(self, entry: RegistryEntry) -> None: - self._remove_index(entry) - del self.entities[entry.entity_id] - - def _remove_index(self, entry: RegistryEntry) -> None: - del self._index[(entry.domain, entry.platform, entry.unique_id)] - - def _rebuild_index(self) -> None: - self._index = {} - for entry in self.entities.values(): - self._add_index(entry) - @callback def async_get(hass: HomeAssistant) -> EntityRegistry: @@ -687,7 +767,7 @@ def async_config_entry_disabled_by_changed( if not config_entry.disabled_by: for entity in entities: - if entity.disabled_by != DISABLED_CONFIG_ENTRY: + if entity.disabled_by is not RegistryEntryDisabler.CONFIG_ENTRY: continue registry.async_update_entity(entity.entity_id, disabled_by=None) return @@ -697,17 +777,60 @@ def async_config_entry_disabled_by_changed( # Entity already disabled, do not overwrite continue registry.async_update_entity( - entity.entity_id, disabled_by=DISABLED_CONFIG_ENTRY + entity.entity_id, disabled_by=RegistryEntryDisabler.CONFIG_ENTRY ) -async def _async_migrate(entities: dict[str, Any]) -> dict[str, list[dict[str, Any]]]: +async def _async_migrate( + old_major_version: int, old_minor_version: int, data: dict +) -> dict: + """Migrate to the new version.""" + if old_major_version < 2 and old_minor_version < 2: + # From version 1.1 + for entity in data["entities"]: + # Populate all keys + entity["area_id"] = entity.get("area_id") + entity["capabilities"] = entity.get("capabilities") or {} + entity["config_entry_id"] = entity.get("config_entry_id") + entity["device_class"] = entity.get("device_class") + entity["device_id"] = entity.get("device_id") + entity["disabled_by"] = entity.get("disabled_by") + entity["entity_category"] = entity.get("entity_category") + entity["icon"] = entity.get("icon") + entity["name"] = entity.get("name") + entity["original_icon"] = entity.get("original_icon") + entity["original_name"] = entity.get("original_name") + entity["platform"] = entity["platform"] + entity["supported_features"] = entity.get("supported_features", 0) + entity["unit_of_measurement"] = entity.get("unit_of_measurement") + + if old_major_version < 2 and old_minor_version < 3: + # Version 1.3 adds original_device_class + for entity in data["entities"]: + # Move device_class to original_device_class + entity["original_device_class"] = entity["device_class"] + entity["device_class"] = None + + if old_major_version < 2 and old_minor_version < 4: + # Version 1.4 adds id + for entity in data["entities"]: + entity["id"] = uuid_util.random_uuid_hex() + + if old_major_version > 1: + raise NotImplementedError + return data + + +async def _async_migrate_yaml_to_json( + entities: dict[str, Any] +) -> dict[str, list[dict[str, Any]]]: """Migrate the YAML config file to storage helper format.""" - return { + entities_1_1 = { "entities": [ {"entity_id": entity_id, **info} for entity_id, info in entities.items() ] } + return await _async_migrate(1, 1, entities_1_1) @callback @@ -768,3 +891,25 @@ async def async_migrate_entries( if updates is not None: ent_reg.async_update_entity(entry.entity_id, **updates) + + +@callback +def async_resolve_entity_ids( + registry: EntityRegistry, entity_ids_or_uuids: list[str] +) -> list[str]: + """Resolve a list of entity ids or UUIDs to a list of entity ids.""" + + def resolve_entity(entity_id_or_uuid: str) -> str | None: + """Resolve an entity id or UUID to an entity id or None.""" + if valid_entity_id(entity_id_or_uuid): + return entity_id_or_uuid + if (entry := registry.entities.get_entry(entity_id_or_uuid)) is None: + raise vol.Invalid(f"Unknown entity registry entry {entity_id_or_uuid}") + return entry.entity_id + + tmp = [ + resolved_item + for item in entity_ids_or_uuids + if (resolved_item := resolve_entity(item)) is not None + ] + return tmp diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 64cf3bf20bb..1404b3730f1 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -789,7 +789,32 @@ class _TrackTemplateResultInfo: def async_setup(self, raise_on_template_error: bool, strict: bool = False) -> None: """Activation of template tracking.""" + block_render = False + super_template = self._track_templates[0] if self._has_super_template else None + + # Render the super template first + if super_template is not None: + template = super_template.template + variables = super_template.variables + self._info[template] = info = template.async_render_to_info( + variables, strict=strict + ) + + # If the super template did not render to True, don't update other templates + try: + super_result: str | TemplateError = info.result() + except TemplateError as ex: + super_result = ex + if ( + super_result is not None + and self._super_template_as_boolean(super_result) is not True + ): + block_render = True + + # Then update the remaining templates unless blocked by the super template for track_template_ in self._track_templates: + if block_render or track_template_ == super_template: + continue template = track_template_.template variables = track_template_.variables self._info[template] = info = template.async_render_to_info( @@ -810,9 +835,10 @@ class _TrackTemplateResultInfo: ) self._update_time_listeners() _LOGGER.debug( - "Template group %s listens for %s", + "Template group %s listens for %s, first render blocker by super template: %s", self._track_templates, self.listeners, + block_render, ) @property @@ -923,8 +949,8 @@ class _TrackTemplateResultInfo: last_result = self._last_result.get(template) - # Check to see if the result has changed - if result == last_result: + # Check to see if the result has changed or is new + if result == last_result and template in self._last_result: return True if isinstance(result, TemplateError) and isinstance(last_result, TemplateError): @@ -932,6 +958,14 @@ class _TrackTemplateResultInfo: return TrackTemplateResult(template, last_result, result) + @staticmethod + def _super_template_as_boolean(result: bool | str | TemplateError) -> bool: + """Return True if the result is truthy or a TemplateError.""" + if isinstance(result, TemplateError): + return True + + return result_as_boolean(result) + @callback def _refresh( self, @@ -974,13 +1008,6 @@ class _TrackTemplateResultInfo: track_templates = track_templates or self._track_templates - def _super_template_as_boolean(result: bool | str | TemplateError) -> bool: - """Return True if the result is truthy or a TemplateError.""" - if isinstance(result, TemplateError): - return True - - return result_as_boolean(result) - # Update the super template first if super_template is not None: update = self._render_template_if_ready(super_template, now, event) @@ -994,14 +1021,14 @@ class _TrackTemplateResultInfo: # If the super template did not render to True, don't update other templates if ( super_result is not None - and _super_template_as_boolean(super_result) is not True + and self._super_template_as_boolean(super_result) is not True ): block_updates = True if ( isinstance(update, TrackTemplateResult) - and _super_template_as_boolean(update.last_result) is not True - and _super_template_as_boolean(update.result) is True + and self._super_template_as_boolean(update.last_result) is not True + and self._super_template_as_boolean(update.result) is True ): # Super template changed from not True to True, force re-render # of all templates in the group @@ -1030,9 +1057,10 @@ class _TrackTemplateResultInfo: ) ) _LOGGER.debug( - "Template group %s listens for %s", + "Template group %s listens for %s, re-render blocker by super template: %s", self._track_templates, self.listeners, + block_updates, ) if not updates: @@ -1483,7 +1511,7 @@ def async_track_time_change( minute: Any | None = None, second: Any | None = None, ) -> CALLBACK_TYPE: - """Add a listener that will fire if UTC time matches a pattern.""" + """Add a listener that will fire if local time matches a pattern.""" return async_track_utc_time_change(hass, action, hour, minute, second, local=True) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 7d29d78dc54..13ffea48f81 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -12,6 +12,9 @@ from homeassistant.exceptions import HomeAssistantError _LOGGER = logging.getLogger(__name__) +# Keep track of integrations already reported to prevent flooding +_REPORTED_INTEGRATIONS: set[str] = set() + CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) # pylint: disable=invalid-name @@ -50,24 +53,34 @@ class MissingIntegrationFrame(HomeAssistantError): """Raised when no integration is found in the frame.""" -def report(what: str) -> None: +def report( + what: str, + exclude_integrations: set | None = None, + error_if_core: bool = True, + level: int = logging.WARNING, +) -> None: """Report incorrect usage. Async friendly. """ try: - integration_frame = get_integration_frame() + integration_frame = get_integration_frame( + exclude_integrations=exclude_integrations + ) except MissingIntegrationFrame as err: - # Did not source from an integration? Hard error. - raise RuntimeError( - f"Detected code that {what}. Please report this issue." - ) from err + msg = f"Detected code that {what}. Please report this issue." + if error_if_core: + raise RuntimeError(msg) from err + _LOGGER.warning(msg, stack_info=True) + return - report_integration(what, integration_frame) + report_integration(what, integration_frame, level) def report_integration( - what: str, integration_frame: tuple[FrameSummary, str, str] + what: str, + integration_frame: tuple[FrameSummary, str, str], + level: int = logging.WARNING, ) -> None: """Report incorrect usage in an integration. @@ -75,13 +88,20 @@ def report_integration( """ found_frame, integration, path = integration_frame + # Keep track of integrations already reported to prevent flooding + key = f"{found_frame.filename}:{found_frame.lineno}" + if key in _REPORTED_INTEGRATIONS: + return + _REPORTED_INTEGRATIONS.add(key) + index = found_frame.filename.index(path) if path == "custom_components/": extra = " to the custom component author" else: extra = "" - _LOGGER.warning( + _LOGGER.log( + level, "Detected integration that %s. " "Please report issue%s for %s using this method at %s, line %s: %s", what, diff --git a/homeassistant/helpers/instance_id.py b/homeassistant/helpers/instance_id.py index 5b6f645c55a..59a4cf39498 100644 --- a/homeassistant/helpers/instance_id.py +++ b/homeassistant/helpers/instance_id.py @@ -18,7 +18,7 @@ async def async_get(hass: HomeAssistant) -> str: """Get unique ID for the hass instance.""" store = storage.Store(hass, DATA_VERSION, DATA_KEY, True) - data: dict[str, str] | None = await storage.async_migrator( # type: ignore + data: dict[str, str] | None = await storage.async_migrator( hass, hass.config.path(LEGACY_UUID_FILE), store, diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index a153d994471..d3494c3f41b 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -168,8 +168,7 @@ def _fuzzymatch(name: str, items: Iterable[T], key: Callable[[T], str]) -> T | N pattern = ".*?".join(name) regex = re.compile(pattern, re.IGNORECASE) for idx, item in enumerate(items): - match = regex.search(key(item)) - if match: + if match := regex.search(key(item)): # Add key length so we prefer shorter keys with the same group and start. # Add index so we pick first match in case same group, start, and key length. matches.append( diff --git a/homeassistant/helpers/logging.py b/homeassistant/helpers/logging.py deleted file mode 100644 index e996e7cca10..00000000000 --- a/homeassistant/helpers/logging.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Helpers for logging allowing more advanced logging styles to be used.""" -from __future__ import annotations - -from collections.abc import Mapping, MutableMapping -import inspect -import logging -from typing import Any - - -class KeywordMessage: - """ - Represents a logging message with keyword arguments. - - Adapted from: https://stackoverflow.com/a/24683360/2267718 - """ - - def __init__(self, fmt: Any, args: Any, kwargs: Mapping[str, Any]) -> None: - """Initialize a new KeywordMessage object.""" - self._fmt = fmt - self._args = args - self._kwargs = kwargs - - def __str__(self) -> str: - """Convert the object to a string for logging.""" - return str(self._fmt).format(*self._args, **self._kwargs) - - -class KeywordStyleAdapter(logging.LoggerAdapter): - """Represents an adapter wrapping the logger allowing KeywordMessages.""" - - def __init__( - self, logger: logging.Logger, extra: Mapping[str, Any] | None = None - ) -> None: - """Initialize a new StyleAdapter for the provided logger.""" - super().__init__(logger, extra or {}) - - def log(self, level: int, msg: Any, *args: Any, **kwargs: Any) -> None: - """Log the message provided at the appropriate level.""" - if self.isEnabledFor(level): - msg, log_kwargs = self.process(msg, kwargs) - self.logger._log( # pylint: disable=protected-access - level, KeywordMessage(msg, args, kwargs), (), **log_kwargs - ) - - def process( - self, msg: Any, kwargs: MutableMapping[str, Any] - ) -> tuple[Any, MutableMapping[str, Any]]: - """Process the keyword args in preparation for logging.""" - return ( - msg, - { - k: kwargs[k] - for k in inspect.getfullargspec( - self.logger._log # pylint: disable=protected-access - ).args[1:] - if k in kwargs - }, - ) diff --git a/homeassistant/helpers/recorder.py b/homeassistant/helpers/recorder.py index e3ed3428a2a..a51d9de59e2 100644 --- a/homeassistant/helpers/recorder.py +++ b/homeassistant/helpers/recorder.py @@ -4,12 +4,11 @@ from homeassistant.core import HomeAssistant -async def async_migration_in_progress(hass: HomeAssistant) -> bool: +def async_migration_in_progress(hass: HomeAssistant) -> bool: """Check to see if a recorder migration is in progress.""" if "recorder" not in hass.config.components: return False - from homeassistant.components import ( # pylint: disable=import-outside-toplevel - recorder, - ) + # pylint: disable-next=import-outside-toplevel + from homeassistant.components import recorder - return await recorder.async_migration_in_progress(hass) + return recorder.util.async_migration_in_progress(hass) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index a79caad74b0..d4d37e1b4ac 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -262,11 +262,7 @@ async def async_validate_action_config( config = platform.ACTION_SCHEMA(config) # type: ignore elif action_type == cv.SCRIPT_ACTION_CHECK_CONDITION: - if config[CONF_CONDITION] == "device": - platform = await device_automation.async_get_device_automation_platform( - hass, config[CONF_DOMAIN], "condition" - ) - config = platform.CONDITION_SCHEMA(config) # type: ignore + config = await condition.async_validate_condition_config(hass, config) # type: ignore elif action_type == cv.SCRIPT_ACTION_WAIT_FOR_TRIGGER: config[CONF_WAIT_FOR_TRIGGER] = await async_validate_trigger_config( @@ -274,7 +270,17 @@ async def async_validate_action_config( ) elif action_type == cv.SCRIPT_ACTION_REPEAT: - config[CONF_SEQUENCE] = await async_validate_actions_config( + if CONF_UNTIL in config[CONF_REPEAT]: + conditions = await condition.async_validate_conditions_config( + hass, config[CONF_REPEAT][CONF_UNTIL] + ) + config[CONF_REPEAT][CONF_UNTIL] = conditions + if CONF_WHILE in config[CONF_REPEAT]: + conditions = await condition.async_validate_conditions_config( + hass, config[CONF_REPEAT][CONF_WHILE] + ) + config[CONF_REPEAT][CONF_WHILE] = conditions + config[CONF_REPEAT][CONF_SEQUENCE] = await async_validate_actions_config( hass, config[CONF_REPEAT][CONF_SEQUENCE] ) @@ -285,6 +291,10 @@ async def async_validate_action_config( ) for choose_conf in config[CONF_CHOOSE]: + conditions = await condition.async_validate_conditions_config( + hass, choose_conf[CONF_CONDITIONS] + ) + choose_conf[CONF_CONDITIONS] = conditions choose_conf[CONF_SEQUENCE] = await async_validate_actions_config( hass, choose_conf[CONF_SEQUENCE] ) @@ -476,7 +486,10 @@ class _ScriptRun: def async_script_wait(entity_id, from_s, to_s): """Handle script after template condition is true.""" wait_var = self._variables["wait"] - wait_var["remaining"] = to_context.remaining if to_context else timeout + if to_context and to_context.deadline: + wait_var["remaining"] = to_context.deadline - self._hass.loop.time() + else: + wait_var["remaining"] = timeout wait_var["completed"] = True done.set() @@ -777,7 +790,10 @@ class _ScriptRun: async def async_done(variables, context=None): wait_var = self._variables["wait"] - wait_var["remaining"] = to_context.remaining if to_context else timeout + if to_context and to_context.deadline: + wait_var["remaining"] = to_context.deadline - self._hass.loop.time() + else: + wait_var["remaining"] = timeout wait_var["trigger"] = variables["trigger"] done.set() @@ -1273,7 +1289,7 @@ class Script: else: config_cache_key = frozenset((k, str(v)) for k, v in config.items()) if not (cond := self._config_cache.get(config_cache_key)): - cond = await condition.async_from_config(self._hass, config, False) + cond = await condition.async_from_config(self._hass, config) self._config_cache[config_cache_key] = cond return cond diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index c2720c02f47..00369d43536 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -20,8 +20,7 @@ from homeassistant.const import ( CONF_SERVICE_DATA, CONF_SERVICE_TEMPLATE, CONF_TARGET, - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, + ENTITY_CATEGORIES, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE, ) @@ -367,10 +366,7 @@ def async_extract_referenced_entity_ids( for ent_entry in ent_reg.entities.values(): # Do not add config or diagnostic entities referenced by areas or devices - if ent_entry.entity_category in ( - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, - ): + if ent_entry.entity_category in ENTITY_CATEGORIES: continue if ( diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 9de54682d67..323737d5b98 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Callable from contextlib import suppress from copy import deepcopy +import inspect from json import JSONEncoder import logging import os @@ -12,7 +13,6 @@ from typing import Any from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback -from homeassistant.helpers.event import async_call_later from homeassistant.loader import MAX_LOAD_CONCURRENTLY, bind_hass from homeassistant.util import json as json_util @@ -27,21 +27,19 @@ STORAGE_SEMAPHORE = "storage_semaphore" @bind_hass async def async_migrator( - hass, - old_path, - store, + hass: HomeAssistant, + old_path: str, + store: Store, *, - old_conf_load_func=None, - old_conf_migrate_func=None, -): + old_conf_load_func: Callable | None = None, + old_conf_migrate_func: Callable | None = None, +) -> Any: """Migrate old data to a store and then load data. async def old_conf_migrate_func(old_data) """ - store_data = await store.async_load() - # If we already have store data we have already migrated in the past. - if store_data is not None: + if (store_data := await store.async_load()) is not None: return store_data def load_old_config(): @@ -78,10 +76,13 @@ class Store: key: str, private: bool = False, *, + atomic_writes: bool = False, encoder: type[JSONEncoder] | None = None, + minor_version: int = 1, ) -> None: """Initialize storage class.""" self.version = version + self.minor_version = minor_version self.key = key self.hass = hass self._private = private @@ -91,6 +92,7 @@ class Store: self._write_lock = asyncio.Lock() self._load_task: asyncio.Future | None = None self._encoder = encoder + self._atomic_writes = atomic_writes @property def path(self): @@ -100,8 +102,8 @@ class Store: async def async_load(self) -> dict | list | None: """Load data. - If the expected version does not match the given version, the migrate - function will be invoked with await migrate_func(version, config). + If the expected version and minor version do not match the given versions, the + migrate function will be invoked with migrate_func(version, minor_version, config). Will ensure that when a call comes in while another one is in progress, the second call will wait and return the result of the first call. @@ -142,22 +144,48 @@ class Store: if data == {}: return None - if data["version"] == self.version: + + # Add minor_version if not set + if "minor_version" not in data: + data["minor_version"] = 1 + + if ( + data["version"] == self.version + and data["minor_version"] == self.minor_version + ): stored = data["data"] else: _LOGGER.info( - "Migrating %s storage from %s to %s", + "Migrating %s storage from %s.%s to %s.%s", self.key, data["version"], + data["minor_version"], self.version, + self.minor_version, ) - stored = await self._async_migrate_func(data["version"], data["data"]) + if len(inspect.signature(self._async_migrate_func).parameters) == 2: + # pylint: disable-next=no-value-for-parameter + stored = await self._async_migrate_func(data["version"], data["data"]) + else: + try: + stored = await self._async_migrate_func( + data["version"], data["minor_version"], data["data"] + ) + except NotImplementedError: + if data["version"] != self.version: + raise + stored = data["data"] return stored async def async_save(self, data: dict | list) -> None: """Save data.""" - self._data = {"version": self.version, "key": self.key, "data": data} + self._data = { + "version": self.version, + "minor_version": self.minor_version, + "key": self.key, + "data": data, + } if self.hass.state == CoreState.stopping: self._async_ensure_final_write_listener() @@ -168,7 +196,15 @@ class Store: @callback def async_delay_save(self, data_func: Callable[[], dict], delay: float = 0) -> None: """Save data with an optional delay.""" - self._data = {"version": self.version, "key": self.key, "data_func": data_func} + # pylint: disable-next=import-outside-toplevel + from homeassistant.helpers.event import async_call_later + + self._data = { + "version": self.version, + "minor_version": self.minor_version, + "key": self.key, + "data_func": data_func, + } self._async_cleanup_delay_listener() self._async_ensure_final_write_listener() @@ -245,9 +281,15 @@ class Store: os.makedirs(os.path.dirname(path)) _LOGGER.debug("Writing data for %s to %s", self.key, path) - json_util.save_json(path, data, self._private, encoder=self._encoder) + json_util.save_json( + path, + data, + self._private, + encoder=self._encoder, + atomic_writes=self._atomic_writes, + ) - async def _async_migrate_func(self, old_version, old_data): + async def _async_migrate_func(self, old_major_version, old_minor_version, old_data): """Migrate to the new version.""" raise NotImplementedError diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index 766fa90af96..7e65ab858ad 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -23,13 +23,17 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: "virtualenv": is_virtual_env(), "python_version": platform.python_version(), "docker": False, - "user": getuser(), "arch": platform.machine(), "timezone": str(hass.config.time_zone), "os_name": platform.system(), "os_version": platform.release(), } + try: + info_object["user"] = getuser() + except KeyError: + info_object["user"] = None + if platform.system() == "Windows": info_object["os_version"] = platform.win32_ver()[0] elif platform.system() == "Darwin": diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index a181fbbfb44..0ba1d6bfa14 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -17,6 +17,7 @@ from operator import attrgetter import random import re import statistics +from struct import error as StructError, pack, unpack_from import sys from typing import Any, cast from urllib.parse import urlencode as urllib_urlencode @@ -109,18 +110,25 @@ def attach(hass: HomeAssistant, obj: Any) -> None: def render_complex( - value: Any, variables: TemplateVarsType = None, limited: bool = False + value: Any, + variables: TemplateVarsType = None, + limited: bool = False, + parse_result: bool = True, ) -> Any: """Recursive template creator helper function.""" if isinstance(value, list): - return [render_complex(item, variables) for item in value] + return [ + render_complex(item, variables, limited, parse_result) for item in value + ] if isinstance(value, collections.abc.Mapping): return { - render_complex(key, variables): render_complex(item, variables) + render_complex(key, variables, limited, parse_result): render_complex( + item, variables, limited, parse_result + ) for key, item in value.items() } if isinstance(value, Template): - return value.async_render(variables, limited=limited) + return value.async_render(variables, limited=limited, parse_result=parse_result) return value @@ -886,8 +894,7 @@ def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: entity = search.pop() if isinstance(entity, str): entity_id = entity - entity = _get_state(hass, entity) - if entity is None: + if (entity := _get_state(hass, entity)) is None: continue elif isinstance(entity, State): entity_id = entity.entity_id @@ -917,6 +924,38 @@ def device_entities(hass: HomeAssistant, _device_id: str) -> Iterable[str]: return [entry.entity_id for entry in entries] +def integration_entities(hass: HomeAssistant, entry_name: str) -> Iterable[str]: + """ + Get entity ids for entities tied to an integration/domain. + + Provide entry_name as domain to get all entity id's for a integration/domain + or provide a config entry title for filtering between instances of the same integration. + """ + # first try if this is a config entry match + conf_entry = next( + ( + entry.entry_id + for entry in hass.config_entries.async_entries() + if entry.title == entry_name + ), + None, + ) + if conf_entry is not None: + ent_reg = entity_registry.async_get(hass) + entries = entity_registry.async_entries_for_config_entry(ent_reg, conf_entry) + return [entry.entity_id for entry in entries] + + # fallback to just returning all entities for a domain + # pylint: disable=import-outside-toplevel + from homeassistant.helpers.entity import entity_sources + + return [ + entity_id + for entity_id, info in entity_sources(hass).items() + if info["domain"] == entry_name + ] + + 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) @@ -1004,8 +1043,7 @@ def _get_area_name(area_reg: area_registry.AreaRegistry, valid_area_id: str) -> def area_name(hass: HomeAssistant, lookup_value: str) -> str | None: """Get the area name from an area id, device id, or entity id.""" area_reg = area_registry.async_get(hass) - area = area_reg.async_get_area(lookup_value) - if area: + if area := area_reg.async_get_area(lookup_value): return area.name dev_reg = device_registry.async_get(hass) @@ -1226,8 +1264,7 @@ def is_state_attr(hass: HomeAssistant, entity_id: str, name: str, value: Any) -> def state_attr(hass: HomeAssistant, entity_id: str, name: str) -> Any: """Get a specific attribute from a state.""" - state_obj = _get_state(hass, entity_id) - if state_obj is not None: + if (state_obj := _get_state(hass, entity_id)) is not None: return state_obj.attributes.get(name) return None @@ -1433,9 +1470,7 @@ def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True, default=_SE def timestamp_local(value, default=_SENTINEL): """Filter to convert given timestamp to local date/time.""" try: - return dt_util.as_local(dt_util.utc_from_timestamp(value)).strftime( - DATE_STR_FORMAT - ) + return dt_util.as_local(dt_util.utc_from_timestamp(value)).isoformat() except (ValueError, TypeError): # If timestamp can't be converted if default is _SENTINEL: @@ -1447,7 +1482,7 @@ def timestamp_local(value, default=_SENTINEL): def timestamp_utc(value, default=_SENTINEL): """Filter to convert given timestamp to UTC date/time.""" try: - return dt_util.utc_from_timestamp(value).strftime(DATE_STR_FORMAT) + return dt_util.utc_from_timestamp(value).isoformat() except (ValueError, TypeError): # If timestamp can't be converted if default is _SENTINEL: @@ -1467,6 +1502,16 @@ def forgiving_as_timestamp(value, default=_SENTINEL): return default +def as_datetime(value): + """Filter and to convert a time string or UNIX timestamp to datetime object.""" + try: + # Check for a valid UNIX timestamp string, int or float + timestamp = float(value) + return dt_util.utc_from_timestamp(timestamp) + except ValueError: + return dt_util.parse_datetime(value) + + def strptime(string, fmt, default=_SENTINEL): """Parse a time string to datetime.""" try: @@ -1602,6 +1647,34 @@ def bitwise_or(first_value, second_value): return first_value | second_value +def struct_pack(value: Any | None, format_string: str) -> bytes | None: + """Pack an object into a bytes object.""" + try: + return pack(format_string, value) + except StructError: + _LOGGER.warning( + "Template warning: 'pack' unable to pack object '%s' with type '%s' and format_string '%s' see https://docs.python.org/3/library/struct.html for more information", + str(value), + type(value).__name__, + format_string, + ) + return None + + +def struct_unpack(value: bytes, format_string: str, offset: int = 0) -> Any | None: + """Unpack an object from bytes an return the first native object.""" + try: + return unpack_from(format_string, value, offset)[0] + except StructError: + _LOGGER.warning( + "Template warning: 'unpack' unable to unpack object '%s' with format_string '%s' and offset %s see https://docs.python.org/3/library/struct.html for more information", + value, + format_string, + offset, + ) + return None + + def base64_encode(value): """Perform base64 encode.""" return base64.b64encode(value.encode("utf-8")).decode("utf-8") @@ -1626,9 +1699,9 @@ def from_json(value): return json.loads(value) -def to_json(value): +def to_json(value, ensure_ascii=True): """Convert an object to a JSON string.""" - return json.dumps(value) + return json.dumps(value, ensure_ascii=ensure_ascii) @pass_context @@ -1643,16 +1716,16 @@ def random_every_time(context, values): def today_at(time_str: str = "") -> datetime: """Record fetching now where the time has been replaced with value.""" - start = dt_util.start_of_local_day(datetime.now()) + today = dt_util.start_of_local_day() + if not time_str: + return today - dttime = start.time() if time_str == "" else dt_util.parse_time(time_str) + if (time_today := dt_util.parse_time(time_str)) is None: + raise ValueError( + f"could not convert {type(time_str).__name__} to datetime: '{time_str}'" + ) - if dttime: - return datetime.combine(start.date(), dttime, tzinfo=dt_util.DEFAULT_TIME_ZONE) - - raise ValueError( - f"could not convert {type(time_str).__name__} to datetime: '{time_str}'" - ) + return datetime.combine(today, time_today, today.tzinfo) def relative_time(value): @@ -1762,7 +1835,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["atan"] = arc_tangent self.filters["atan2"] = arc_tangent2 self.filters["sqrt"] = square_root - self.filters["as_datetime"] = dt_util.parse_datetime + self.filters["as_datetime"] = as_datetime self.filters["as_timestamp"] = forgiving_as_timestamp self.filters["today_at"] = today_at self.filters["as_local"] = dt_util.as_local @@ -1786,10 +1859,13 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["regex_findall_index"] = regex_findall_index self.filters["bitwise_and"] = bitwise_and self.filters["bitwise_or"] = bitwise_or + self.filters["pack"] = struct_pack + self.filters["unpack"] = struct_unpack self.filters["ord"] = ord self.filters["is_number"] = is_number self.filters["float"] = forgiving_float_filter self.filters["int"] = forgiving_int_filter + self.filters["relative_time"] = relative_time self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -1803,7 +1879,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["atan"] = arc_tangent self.globals["atan2"] = arc_tangent2 self.globals["float"] = forgiving_float - self.globals["as_datetime"] = dt_util.parse_datetime + self.globals["as_datetime"] = as_datetime self.globals["as_local"] = dt_util.as_local self.globals["as_timestamp"] = forgiving_as_timestamp self.globals["today_at"] = today_at @@ -1816,6 +1892,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["min"] = min self.globals["is_number"] = is_number self.globals["int"] = forgiving_int + self.globals["pack"] = struct_pack + self.globals["unpack"] = struct_unpack self.tests["match"] = regex_match self.tests["search"] = regex_search @@ -1856,6 +1934,11 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["area_devices"] = hassfunction(area_devices) self.filters["area_devices"] = pass_context(self.globals["area_devices"]) + self.globals["integration_entities"] = hassfunction(integration_entities) + self.filters["integration_entities"] = pass_context( + self.globals["integration_entities"] + ) + if limited: # Only device_entities is available to limited templates, mark other # functions and filters as unsupported. diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1b4edb91b19..18c7c1befda 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,24 +1,25 @@ PyJWT==2.1.0 PyNaCl==1.4.0 aiodiscover==1.4.5 -aiohttp==3.7.4.post0 +aiohttp==3.8.1 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.22.10 -async_timeout==3.0.1 +async-upnp-client==0.22.12 +async_timeout==4.0.0 +atomicwrites==1.4.0 attrs==21.2.0 -awesomeversion==21.10.1 +awesomeversion==21.11.0 backports.zoneinfo;python_version<"3.9" bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 -cryptography==3.4.8 +cryptography==35.0.0 emoji==1.5.0 hass-nabucasa==0.50.0 -home-assistant-frontend==20211109.0 -httpx==0.19.0 +home-assistant-frontend==20211211.0 +httpx==0.21.0 ifaddr==0.1.7 -jinja2==3.0.2 +jinja2==3.0.3 paho-mqtt==1.6.1 pillow==8.2.0 pip>=8.0.3,<20.3 @@ -28,11 +29,11 @@ pyudev==0.22.0 pyyaml==6.0 requests==2.26.0 scapy==2.4.5 -sqlalchemy==1.4.23 +sqlalchemy==1.4.27 voluptuous-serialize==2.4.0 voluptuous==0.12.2 yarl==1.6.3 -zeroconf==0.36.13 +zeroconf==0.37.0 pycryptodome>=3.6.6 diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 8e683bb5a1b..6182b909f74 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -21,7 +21,7 @@ import homeassistant.util.yaml.loader as yaml_loader # mypy: allow-untyped-calls, allow-untyped-defs -REQUIREMENTS = ("colorlog==6.4.1",) +REQUIREMENTS = ("colorlog==6.6.0",) _LOGGER = logging.getLogger(__name__) # pylint: disable=protected-access diff --git a/homeassistant/setup.py b/homeassistant/setup.py index a917eb65b69..3e6db28a194 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -14,6 +14,7 @@ from homeassistant.const import ( EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START, PLATFORM_FORMAT, + Platform, ) from homeassistant.core import CALLBACK_TYPE from homeassistant.exceptions import HomeAssistantError @@ -26,33 +27,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_COMPONENT = "component" -BASE_PLATFORMS = { - "air_quality", - "alarm_control_panel", - "binary_sensor", - "camera", - "climate", - "cover", - "device_tracker", - "fan", - "humidifier", - "image_processing", - "light", - "lock", - "media_player", - "notify", - "number", - "remote", - "scene", - "select", - "sensor", - "siren", - "switch", - "tts", - "vacuum", - "water_heater", - "weather", -} +BASE_PLATFORMS = {platform.value for platform in Platform} DATA_SETUP_DONE = "setup_done" DATA_SETUP_STARTED = "setup_started" diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index 4f7f1af2e7d..b7758df0cb0 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -5,10 +5,9 @@ import asyncio from collections.abc import Callable, Coroutine, Iterable, KeysView from datetime import datetime, timedelta import enum -from functools import lru_cache, wraps +from functools import wraps import random import re -import socket import string import threading from types import MappingProxyType @@ -16,7 +15,6 @@ from typing import Any, TypeVar import slugify as unicode_slug -from ..helpers.deprecation import deprecated_function from .dt import as_local, utcnow T = TypeVar("T") @@ -47,38 +45,6 @@ def raise_if_invalid_path(path: str) -> None: raise ValueError(f"{path} is not a safe path") -@deprecated_function(replacement="raise_if_invalid_filename") -def sanitize_filename(filename: str) -> str: - """Check if a filename is safe. - - Only to be used to compare to original filename to check if changed. - If result changed, the given path is not safe and should not be used, - raise an error. - - DEPRECATED. - """ - # Backwards compatible fix for misuse of method - if RE_SANITIZE_FILENAME.sub("", filename) != filename: - return "" - return filename - - -@deprecated_function(replacement="raise_if_invalid_path") -def sanitize_path(path: str) -> str: - """Check if a path is safe. - - Only to be used to compare to original path to check if changed. - If result changed, the given path is not safe and should not be used, - raise an error. - - DEPRECATED. - """ - # Backwards compatible fix for misuse of method - if RE_SANITIZE_PATH.sub("", path) != path: - return "" - return path - - def slugify(text: str | None, *, separator: str = "_") -> str: """Slugify a given text.""" if text == "" or text is None: @@ -129,26 +95,6 @@ def ensure_unique_string( return test_string -# Taken from: http://stackoverflow.com/a/11735897 -@lru_cache(maxsize=None) -def get_local_ip() -> str: - """Try to determine the local IP address of the machine.""" - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - - # Use Google Public DNS server to determine own IP - sock.connect(("8.8.8.8", 80)) - - return sock.getsockname()[0] # type: ignore - except OSError: - try: - return socket.gethostbyname(socket.gethostname()) - except socket.gaierror: - return "127.0.0.1" - finally: - sock.close() - - # Taken from http://stackoverflow.com/a/23728630 def get_random_string(length: int = 10) -> str: """Return a random string with letters and digits.""" @@ -158,34 +104,6 @@ def get_random_string(length: int = 10) -> str: return "".join(generator.choice(source_chars) for _ in range(length)) -class OrderedEnum(enum.Enum): - """Taken from Python 3.4.0 docs.""" - - def __ge__(self, other: ENUM_T) -> bool: - """Return the greater than element.""" - if self.__class__ is other.__class__: - return bool(self.value >= other.value) - return NotImplemented - - def __gt__(self, other: ENUM_T) -> bool: - """Return the greater element.""" - if self.__class__ is other.__class__: - return bool(self.value > other.value) - return NotImplemented - - def __le__(self, other: ENUM_T) -> bool: - """Return the lower than element.""" - if self.__class__ is other.__class__: - return bool(self.value <= other.value) - return NotImplemented - - def __lt__(self, other: ENUM_T) -> bool: - """Return the lower element.""" - if self.__class__ is other.__class__: - return bool(self.value < other.value) - return NotImplemented - - class Throttle: """A class for throttling the execution of tasks. diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 1cfd6447e8a..3d4f7122ad0 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -404,8 +404,8 @@ def color_hs_to_xy( return color_RGB_to_xy(*color_hs_to_RGB(iH, iS), Gamut) -def _match_max_scale( - input_colors: tuple[int, ...], output_colors: tuple[int, ...] +def match_max_scale( + input_colors: tuple[int, ...], output_colors: tuple[float, ...] ) -> tuple[int, ...]: """Match the maximum value of the output to the input.""" max_in = max(input_colors) @@ -426,7 +426,7 @@ def color_rgb_to_rgbw(r: int, g: int, b: int) -> tuple[int, int, int, int]: # Match the output maximum value to the input. This ensures the full # channel range is used. - return _match_max_scale((r, g, b), rgbw) # type: ignore + return match_max_scale((r, g, b), rgbw) # type: ignore[return-value] def color_rgbw_to_rgb(r: int, g: int, b: int, w: int) -> tuple[int, int, int]: @@ -436,7 +436,7 @@ def color_rgbw_to_rgb(r: int, g: int, b: int, w: int) -> tuple[int, int, int]: # Match the output maximum value to the input. This ensures the # output doesn't overflow. - return _match_max_scale((r, g, b, w), rgb) # type: ignore + return match_max_scale((r, g, b, w), rgb) # type: ignore[return-value] def color_rgb_to_rgbww( @@ -450,7 +450,7 @@ def color_rgb_to_rgbww( w_r, w_g, w_b = color_temperature_to_rgb(color_temp_kelvin) # Find the ratio of the midpoint white in the input rgb channels - white_level = min(r / w_r, g / w_g, b / w_b) + white_level = min(r / w_r, g / w_g, b / w_b if w_b else 0) # Subtract the white portion from the rgb channels. rgb = (r - w_r * white_level, g - w_g * white_level, b - w_b * white_level) @@ -458,7 +458,7 @@ def color_rgb_to_rgbww( # Match the output maximum value to the input. This ensures the full # channel range is used. - return _match_max_scale((r, g, b), rgbww) # type: ignore + return match_max_scale((r, g, b), rgbww) # type: ignore[return-value] def color_rgbww_to_rgb( @@ -481,7 +481,7 @@ def color_rgbww_to_rgb( # Match the output maximum value to the input. This ensures the # output doesn't overflow. - return _match_max_scale((r, g, b, cw, ww), rgb) # type: ignore + return match_max_scale((r, g, b, cw, ww), rgb) # type: ignore[return-value] def color_rgb_to_hex(r: int, g: int, b: int) -> str: diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 39f8a63e53f..0c8a1cd9aad 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -10,8 +10,6 @@ from typing import Any, cast import ciso8601 -from homeassistant.const import MATCH_ALL - if sys.version_info[:2] >= (3, 9): import zoneinfo else: @@ -215,7 +213,7 @@ def get_age(date: dt.datetime) -> str: def parse_time_expression(parameter: Any, min_value: int, max_value: int) -> list[int]: """Parse the time expression part and return a list of times to match.""" - if parameter is None or parameter == MATCH_ALL: + if parameter is None or parameter == "*": res = list(range(min_value, max_value + 1)) elif isinstance(parameter, str): if parameter.startswith("/"): diff --git a/homeassistant/util/file.py b/homeassistant/util/file.py new file mode 100644 index 00000000000..cb5969b3079 --- /dev/null +++ b/homeassistant/util/file.py @@ -0,0 +1,83 @@ +"""File utility functions.""" +from __future__ import annotations + +import logging +import os +import tempfile + +from atomicwrites import AtomicWriter + +from homeassistant.exceptions import HomeAssistantError + +_LOGGER = logging.getLogger(__name__) + + +class WriteError(HomeAssistantError): + """Error writing the data.""" + + +def write_utf8_file_atomic( + filename: str, + utf8_data: str, + private: bool = False, +) -> None: + """Write a file and rename it into place using atomicwrites. + + Writes all or nothing. + + This function uses fsync under the hood. It should + only be used to write mission critical files as + fsync can block for a few seconds or longer is the + disk is busy. + + Using this function frequently will significantly + negatively impact performance. + """ + try: + with AtomicWriter(filename, overwrite=True).open() as fdesc: + if not private: + os.fchmod(fdesc.fileno(), 0o644) + fdesc.write(utf8_data) + except OSError as error: + _LOGGER.exception("Saving file failed: %s", filename) + raise WriteError(error) from error + + +def write_utf8_file( + filename: str, + utf8_data: str, + private: bool = False, +) -> None: + """Write a file and rename it into place. + + Writes all or nothing. + """ + + tmp_filename = "" + tmp_path = os.path.split(filename)[0] + try: + # Modern versions of Python tempfile create this file with mode 0o600 + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", dir=tmp_path, delete=False + ) as fdesc: + fdesc.write(utf8_data) + tmp_filename = fdesc.name + if not private: + os.fchmod(fdesc.fileno(), 0o644) + os.replace(tmp_filename, filename) + except OSError as error: + _LOGGER.exception("Saving file failed: %s", filename) + raise WriteError(error) from error + finally: + if os.path.exists(tmp_filename): + try: + os.remove(tmp_filename) + except OSError as err: + # If we are cleaning up then something else went wrong, so + # we should suppress likely follow-on errors in the cleanup + _LOGGER.error( + "File replacement cleanup failed for %s while saving %s: %s", + tmp_filename, + filename, + err, + ) diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index e82bd968754..9c98691c605 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -5,13 +5,13 @@ from collections import deque from collections.abc import Callable import json import logging -import os -import tempfile from typing import Any from homeassistant.core import Event, State from homeassistant.exceptions import HomeAssistantError +from .file import write_utf8_file, write_utf8_file_atomic + _LOGGER = logging.getLogger(__name__) @@ -49,6 +49,7 @@ def save_json( private: bool = False, *, encoder: type[json.JSONEncoder] | None = None, + atomic_writes: bool = False, ) -> None: """Save JSON data to a file. @@ -61,29 +62,10 @@ def save_json( _LOGGER.error(msg) raise SerializationError(msg) from error - tmp_filename = "" - tmp_path = os.path.split(filename)[0] - try: - # Modern versions of Python tempfile create this file with mode 0o600 - with tempfile.NamedTemporaryFile( - mode="w", encoding="utf-8", dir=tmp_path, delete=False - ) as fdesc: - fdesc.write(json_data) - tmp_filename = fdesc.name - if not private: - os.chmod(tmp_filename, 0o644) - os.replace(tmp_filename, filename) - except OSError as error: - _LOGGER.exception("Saving JSON file failed: %s", filename) - raise WriteError(error) from error - finally: - if os.path.exists(tmp_filename): - try: - os.remove(tmp_filename) - except OSError as err: - # If we are cleaning up then something else went wrong, so - # we should suppress likely follow-on errors in the cleanup - _LOGGER.error("JSON replacement cleanup failed: %s", err) + if atomic_writes: + write_utf8_file_atomic(filename, json_data, private) + else: + write_utf8_file(filename, json_data, private) def format_unserializable_data(data: dict[str, Any]) -> str: diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index abe8fedd21a..b967a6a0b1e 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -52,9 +52,7 @@ async def async_detect_location_info( session: aiohttp.ClientSession, ) -> LocationInfo | None: """Detect location information.""" - data = await _get_whoami(session) - - if data is None: + if (data := await _get_whoami(session)) is None: return None data["use_metric"] = data["country_code"] not in ("US", "MM", "LR") diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 609d09e4f55..a0b5c2832ad 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -93,6 +93,7 @@ def install_package( # Workaround for incompatible prefix setting # See http://stackoverflow.com/a/4495175 args += ["--prefix="] + _LOGGER.debug("Running pip command: args=%s", args) with Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env) as process: _, stderr = process.communicate() if process.returncode != 0: diff --git a/homeassistant/util/pressure.py b/homeassistant/util/pressure.py index 53bbbffc01e..8ff3f9f5f93 100644 --- a/homeassistant/util/pressure.py +++ b/homeassistant/util/pressure.py @@ -6,6 +6,7 @@ from numbers import Number from homeassistant.const import ( PRESSURE, PRESSURE_BAR, + PRESSURE_CBAR, PRESSURE_HPA, PRESSURE_INHG, PRESSURE_KPA, @@ -20,6 +21,7 @@ VALID_UNITS: tuple[str, ...] = ( PRESSURE_HPA, PRESSURE_KPA, PRESSURE_BAR, + PRESSURE_CBAR, PRESSURE_MBAR, PRESSURE_INHG, PRESSURE_PSI, @@ -30,6 +32,7 @@ UNIT_CONVERSION: dict[str, float] = { 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, diff --git a/homeassistant/util/speed.py b/homeassistant/util/speed.py new file mode 100644 index 00000000000..f3fc652e90f --- /dev/null +++ b/homeassistant/util/speed.py @@ -0,0 +1,56 @@ +"""Distance util functions.""" +from __future__ import annotations + +from numbers import Number + +from homeassistant.const import ( + SPEED, + SPEED_INCHES_PER_DAY, + SPEED_INCHES_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + SPEED_MILLIMETERS_PER_DAY, + UNIT_NOT_RECOGNIZED_TEMPLATE, +) + +VALID_UNITS: tuple[str, ...] = ( + SPEED_METERS_PER_SECOND, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, + SPEED_MILLIMETERS_PER_DAY, + SPEED_INCHES_PER_DAY, + SPEED_INCHES_PER_HOUR, +) + +HRS_TO_SECS = 60 * 60 # 1 hr = 3600 seconds +KM_TO_M = 1000 # 1 km = 1000 m +KM_TO_MILE = 0.62137119 # 1 km = 0.62137119 mi +M_TO_IN = 39.3700787 # 1 m = 39.3700787 in + +# Units in terms of m/s +UNIT_CONVERSION: dict[str, float] = { + SPEED_METERS_PER_SECOND: 1, + SPEED_KILOMETERS_PER_HOUR: HRS_TO_SECS / KM_TO_M, + SPEED_MILES_PER_HOUR: HRS_TO_SECS * KM_TO_MILE / KM_TO_M, + SPEED_MILLIMETERS_PER_DAY: (24 * HRS_TO_SECS) * 1000, + SPEED_INCHES_PER_DAY: (24 * HRS_TO_SECS) * M_TO_IN, + SPEED_INCHES_PER_HOUR: HRS_TO_SECS * M_TO_IN, +} + + +def convert(value: float, unit_1: str, unit_2: str) -> float: + """Convert one unit of measurement to another.""" + if unit_1 not in VALID_UNITS: + raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_1, SPEED)) + if unit_2 not in VALID_UNITS: + raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_2, SPEED)) + + if not isinstance(value, Number): + raise TypeError(f"{value} is not of numeric type") + + if unit_1 == unit_2: + return value + + meters_per_second = value / UNIT_CONVERSION[unit_1] + return meters_per_second * UNIT_CONVERSION[unit_2] diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index bdd47112dde..bdb637a9149 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -4,11 +4,14 @@ from __future__ import annotations from numbers import Number 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, @@ -17,6 +20,8 @@ from homeassistant.const import ( PRESSURE, PRESSURE_PA, PRESSURE_PSI, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMPERATURE, @@ -24,10 +29,12 @@ from homeassistant.const import ( VOLUME, VOLUME_GALLONS, VOLUME_LITERS, + WIND_SPEED, ) from homeassistant.util import ( distance as distance_util, pressure as pressure_util, + speed as speed_util, temperature as temperature_util, volume as volume_util, ) @@ -42,6 +49,8 @@ PRESSURE_UNITS = pressure_util.VALID_UNITS VOLUME_UNITS = volume_util.VALID_UNITS +WIND_SPEED_UNITS = speed_util.VALID_UNITS + TEMPERATURE_UNITS: tuple[str, ...] = (TEMP_FAHRENHEIT, TEMP_CELSIUS) @@ -49,6 +58,10 @@ 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: @@ -71,16 +84,20 @@ class UnitSystem: name: str, temperature: str, length: str, + wind_speed: str, volume: str, mass: str, pressure: str, + accumulated_precipitation: str, ) -> None: """Initialize the unit system object.""" errors: str = ", ".join( UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit, unit_type) for unit, unit_type in ( + (accumulated_precipitation, ACCUMULATED_PRECIPITATION), (temperature, TEMPERATURE), (length, LENGTH), + (wind_speed, WIND_SPEED), (volume, VOLUME), (mass, MASS), (pressure, PRESSURE), @@ -92,11 +109,13 @@ class UnitSystem: raise ValueError(errors) self.name = name + self.accumulated_precipitation_unit = accumulated_precipitation self.temperature_unit = temperature self.length_unit = length self.mass_unit = mass self.pressure_unit = pressure self.volume_unit = volume + self.wind_speed_unit = wind_speed @property def is_metric(self) -> bool: @@ -120,6 +139,16 @@ class UnitSystem: length, from_unit, self.length_unit ) + def accumulated_precipitation(self, precip: float | None, from_unit: str) -> float: + """Convert the given length to this unit system.""" + if not isinstance(precip, Number): + raise TypeError(f"{precip!s} is not a numeric value.") + + # type ignore: https://github.com/python/mypy/issues/7207 + return distance_util.convert( # type: ignore + precip, from_unit, self.accumulated_precipitation_unit + ) + def pressure(self, pressure: float | None, from_unit: str) -> float: """Convert the given pressure to this unit system.""" if not isinstance(pressure, Number): @@ -130,6 +159,14 @@ class UnitSystem: pressure, from_unit, self.pressure_unit ) + def wind_speed(self, wind_speed: float | None, from_unit: str) -> float: + """Convert the given wind_speed to this unit system.""" + if not isinstance(wind_speed, Number): + raise TypeError(f"{wind_speed!s} is not a numeric value.") + + # type ignore: https://github.com/python/mypy/issues/7207 + return speed_util.convert(wind_speed, from_unit, self.wind_speed_unit) # type: ignore + def volume(self, volume: float | None, from_unit: str) -> float: """Convert the given volume to this unit system.""" if not isinstance(volume, Number): @@ -142,10 +179,12 @@ class UnitSystem: """Convert the unit system to a dictionary.""" return { LENGTH: self.length_unit, + ACCUMULATED_PRECIPITATION: self.accumulated_precipitation_unit, MASS: self.mass_unit, PRESSURE: self.pressure_unit, TEMPERATURE: self.temperature_unit, VOLUME: self.volume_unit, + WIND_SPEED: self.wind_speed_unit, } @@ -153,16 +192,20 @@ METRIC_SYSTEM = UnitSystem( CONF_UNIT_SYSTEM_METRIC, TEMP_CELSIUS, LENGTH_KILOMETERS, + SPEED_METERS_PER_SECOND, VOLUME_LITERS, MASS_GRAMS, PRESSURE_PA, + LENGTH_MILLIMETERS, ) IMPERIAL_SYSTEM = UnitSystem( CONF_UNIT_SYSTEM_IMPERIAL, TEMP_FAHRENHEIT, LENGTH_MILES, + SPEED_MILES_PER_HOUR, VOLUME_GALLONS, MASS_POUNDS, PRESSURE_PSI, + LENGTH_INCHES, ) diff --git a/machine/build.json b/machine/build.json deleted file mode 100644 index 3b4d804dc1c..00000000000 --- a/machine/build.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "image": "homeassistant/{machine}-homeassistant", - "shadow_repository": "ghcr.io/home-assistant", - "build_from": { - "aarch64": "ghcr.io/home-assistant/aarch64-homeassistant:", - "armv7": "ghcr.io/home-assistant/armv7-homeassistant:", - "armhf": "ghcr.io/home-assistant/armhf-homeassistant:", - "amd64": "ghcr.io/home-assistant/amd64-homeassistant:", - "i386": "ghcr.io/home-assistant/i386-homeassistant:" - }, - "labels": { - "io.hass.type": "core", - "org.opencontainers.image.source": "https://github.com/home-assistant/core" - }, - "version_tag": true -} diff --git a/machine/build.yaml b/machine/build.yaml new file mode 100644 index 00000000000..340b8079b9f --- /dev/null +++ b/machine/build.yaml @@ -0,0 +1,14 @@ +image: homeassistant/{machine}-homeassistant +shadow_repository: ghcr.io/home-assistant +build_from: + aarch64: "ghcr.io/home-assistant/aarch64-homeassistant:" + armv7: "ghcr.io/home-assistant/armv7-homeassistant:" + armhf: "ghcr.io/home-assistant/armhf-homeassistant:" + amd64: "ghcr.io/home-assistant/amd64-homeassistant:" + i386: "ghcr.io/home-assistant/i386-homeassistant:" +codenotary: + signer: notary@home-assistant.io + base_image: notary@home-assistant.io +labels: + io.hass.type: core + org.opencontainers.image.source: https://github.com/home-assistant/core diff --git a/mypy.ini b/mypy.ini index 5e9501d5407..7346cc83ba9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -275,6 +275,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.button.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.calendar.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -363,6 +374,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.devolo_home_network.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.dlna_dmr.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -451,6 +473,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.evil_genius_labs.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.fastdotcom.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -517,6 +550,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.fronius.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.frontend.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -572,6 +616,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.greeneye_monitor.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.group.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -693,6 +748,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.jellyfin.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.jewish_calendar.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1034,6 +1100,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.rdw.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.recollect_waste.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1100,6 +1177,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ridwell.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.rituals_perfume_genie.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1320,6 +1408,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tailscale.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.tautulli.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1364,6 +1463,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tolo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.tractive.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1397,6 +1507,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.twentemilieu.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.upcloud.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1452,6 +1573,39 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.velbus.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.vlc_telnet.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.wallbox.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.water_heater.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1551,9 +1705,6 @@ no_implicit_optional = false warn_return_any = false warn_unreachable = false -[mypy-homeassistant.components.awair.*] -ignore_errors = true - [mypy-homeassistant.components.blueprint.*] ignore_errors = true diff --git a/pyproject.toml b/pyproject.toml index 32c87227940..d5be195d2b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ good-names = [ "k", "Run", "T", + "ip", ] [tool.pylint."MESSAGES CONTROL"] @@ -113,6 +114,7 @@ score = false ignored-classes = [ "_CountingAttr", # for attrs ] +mixin-class-rgx = ".*[Mm]ix[Ii]n" [tool.pylint.FORMAT] expected-line-ending-format = "LF" diff --git a/requirements.txt b/requirements.txt index 84f3e342435..5832d0ea2d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,21 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiohttp==3.7.4.post0 +aiohttp==3.8.1 astral==2.2 -async_timeout==3.0.1 +async_timeout==4.0.0 attrs==21.2.0 -awesomeversion==21.10.1 +atomicwrites==1.4.0 +awesomeversion==21.11.0 backports.zoneinfo;python_version<"3.9" bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 -httpx==0.19.0 -jinja2==3.0.2 +httpx==0.21.0 +ifaddr==0.1.7 +jinja2==3.0.3 PyJWT==2.1.0 -cryptography==3.4.8 +cryptography==35.0.0 pip>=8.0.3,<20.3 python-slugify==4.0.1 pyyaml==6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 89496ea700d..42e3237f874 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -40,19 +40,19 @@ PyNaCl==1.4.0 PyQRCode==1.2.1 # homeassistant.components.rmvtransport -PyRMVtransport==0.3.2 +PyRMVtransport==0.3.3 # homeassistant.components.telegram_bot PySocks==1.7.1 # homeassistant.components.switchbot -# PySwitchbot==0.12.0 +# PySwitchbot==0.13.0 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 # homeassistant.components.camera -PyTurboJPEG==1.6.1 +PyTurboJPEG==1.6.3 # homeassistant.components.vicare PyViCare==2.13.1 @@ -100,7 +100,7 @@ adafruit-circuitpython-dht==3.6.0 adafruit-circuitpython-mcp230xx==2.2.2 # homeassistant.components.adax -adax==0.1.1 +adax==0.2.0 # homeassistant.components.androidtv adb-shell[async]==0.4.0 @@ -133,10 +133,10 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.5 # homeassistant.components.ambient_station -aioambient==2021.10.1 +aioambient==2021.11.0 # homeassistant.components.asuswrt -aioasuswrt==1.3.4 +aioasuswrt==1.4.0 # homeassistant.components.azure_devops aioazuredevops==1.3.5 @@ -161,16 +161,16 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==10.2.0 +aioesphomeapi==10.6.0 # homeassistant.components.flo -aioflo==0.4.1 +aioflo==2021.11.0 # homeassistant.components.yi aioftp==0.12.0 # homeassistant.components.github -aiogithubapi==21.8.0 +aiogithubapi==21.11.0 # homeassistant.components.guardian aioguardian==2021.11.0 @@ -179,14 +179,14 @@ aioguardian==2021.11.0 aioharmony==0.2.8 # homeassistant.components.homekit_controller -aiohomekit==0.6.3 +aiohomekit==0.6.4 # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.6.3 +aiohue==3.0.2 # homeassistant.components.imap aioimaplib==0.9.0 @@ -210,16 +210,16 @@ aiolip==1.1.6 aiolookin==0.0.4 # homeassistant.components.lyric -aiolyric==1.0.7 +aiolyric==1.0.8 # homeassistant.components.modern_forms aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.11.0 +aiomusiccast==0.14.2 # homeassistant.components.nanoleaf -aionanoleaf==0.0.3 +aionanoleaf==0.1.1 # homeassistant.components.keyboard_remote aionotify==0.2.0 @@ -228,13 +228,13 @@ aionotify==0.2.0 aionotion==3.0.2 # homeassistant.components.acmeda -aiopulse==0.4.2 +aiopulse==0.4.3 # homeassistant.components.hunterdouglas_powerview aiopvapi==1.6.14 # homeassistant.components.pvpc_hourly_pricing -aiopvpc==2.2.1 +aiopvpc==2.2.4 # homeassistant.components.webostv aiopylgtv==0.4.0 @@ -242,8 +242,11 @@ aiopylgtv==0.4.0 # homeassistant.components.recollect_waste aiorecollect==1.0.8 +# homeassistant.components.ridwell +aioridwell==0.2.0 + # homeassistant.components.shelly -aioshelly==1.0.4 +aioshelly==1.0.5 # homeassistant.components.switcher_kis aioswitcher==2.0.6 @@ -264,13 +267,13 @@ aiovlc==0.1.0 aiowatttime==0.1.1 # homeassistant.components.yandex_transport -aioymaps==1.2.1 +aioymaps==1.2.2 # homeassistant.components.airly airly==1.1.0 # homeassistant.components.airthings -airthings_cloud==0.0.1 +airthings_cloud==0.1.0 # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 @@ -309,7 +312,7 @@ apcaccess==0.0.13 apns2==0.3.0 # homeassistant.components.apprise -apprise==0.9.5.1 +apprise==0.9.6 # homeassistant.components.aprs aprslib==0.6.46 @@ -333,7 +336,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.22.10 +async-upnp-client==0.22.12 # homeassistant.components.supla asyncpysupla==0.0.5 @@ -381,10 +384,10 @@ beautifulsoup4==4.10.0 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.28.0 +bellows==0.29.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.21 +bimmer_connected==0.8.5 # homeassistant.components.bizkaibus bizkaibus==0.1.1 @@ -415,7 +418,7 @@ blockchain==1.4.4 # bme680==1.0.5 # homeassistant.components.bond -bond-api==0.1.14 +bond-api==0.1.15 # homeassistant.components.bosch_shc boschshcpy==0.2.19 @@ -437,7 +440,7 @@ brother==1.1.0 brottsplatskartan==0.0.1 # homeassistant.components.brunt -brunt==0.1.3 +brunt==1.0.0 # homeassistant.components.bsblan bsblan==0.4.0 @@ -452,7 +455,7 @@ bthomehub5-devicelist==0.1.1 btsmarthub_devicelist==0.2.0 # homeassistant.components.buienradar -buienradar==1.0.4 +buienradar==1.0.5 # homeassistant.components.caldav caldav==0.7.1 @@ -476,7 +479,7 @@ co2signal==0.4.2 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==6.4.1 +colorlog==6.6.0 # homeassistant.components.color_extractor colorthief==0.2.1 @@ -499,10 +502,10 @@ coronavirus==1.1.1 croniter==1.0.6 # homeassistant.components.crownstone -crownstone-cloud==1.4.8 +crownstone-cloud==1.4.9 # homeassistant.components.crownstone -crownstone-sse==2.0.2 +crownstone-sse==2.0.3 # homeassistant.components.crownstone crownstone-uart==2.1.0 @@ -514,7 +517,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.debugpy -debugpy==1.5.0 +debugpy==1.5.1 # homeassistant.components.decora # decora==0.6 @@ -536,6 +539,9 @@ denonavr==0.10.9 # homeassistant.components.devolo_home_control devolo-home-control-api==0.17.4 +# homeassistant.components.devolo_home_network +devolo-plc-api==0.6.3 + # homeassistant.components.directv directv==0.4.0 @@ -573,7 +579,7 @@ ebusdpy==0.0.16 ecoaliface==0.4.0 # homeassistant.components.elgato -elgato==2.1.1 +elgato==2.2.0 # homeassistant.components.eliqonline eliqonline==1.2.2 @@ -594,13 +600,13 @@ enocean==0.50 enturclient==0.2.2 # homeassistant.components.environment_canada -env_canada==0.5.14 +env_canada==0.5.18 # homeassistant.components.envirophat # envirophat==0.0.6 # homeassistant.components.enphase_envoy -envoy_reader==0.20.0 +envoy_reader==0.20.1 # homeassistant.components.season ephem==3.7.7.0 @@ -652,7 +658,7 @@ fjaraskupan==1.0.2 flipr-api==1.4.1 # homeassistant.components.flux_led -flux_led==0.24.24 +flux_led==0.26.7 # homeassistant.components.homekit fnvhash==0.1.0 @@ -674,7 +680,7 @@ freesms==0.2.0 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor -fritzconnection==1.7.0 +fritzconnection==1.7.2 # homeassistant.components.google_translate gTTS==2.2.3 @@ -720,7 +726,7 @@ glances_api==0.2.0 gntp==1.0.3 # homeassistant.components.goalzero -goalzero==0.2.0 +goalzero==0.2.1 # homeassistant.components.google google-api-python-client==1.6.4 @@ -732,7 +738,7 @@ google-cloud-pubsub==2.1.0 google-cloud-texttospeech==0.4.0 # homeassistant.components.nest -google-nest-sdm==0.3.8 +google-nest-sdm==0.4.5 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -762,7 +768,7 @@ growattServer==1.1.0 gstreamer-player==1.1.2 # homeassistant.components.profiler -guppy3==3.1.0 +guppy3==3.1.2 # homeassistant.components.stream ha-av==8.0.4-rc.1 @@ -771,7 +777,7 @@ ha-av==8.0.4-rc.1 ha-ffmpeg==3.0.2 # homeassistant.components.philips_js -ha-philipsjs==2.7.5 +ha-philipsjs==2.7.6 # homeassistant.components.habitica habitipy==0.2.0 @@ -786,7 +792,7 @@ hass-nabucasa==0.50.0 hass_splunk==0.1.1 # homeassistant.components.tasmota -hatasmota==0.2.21 +hatasmota==0.3.1 # homeassistant.components.jewish_calendar hdate==0.10.4 @@ -807,13 +813,13 @@ hkavr==0.0.5 hlk-sw16==0.0.9 # homeassistant.components.pi_hole -hole==0.5.1 +hole==0.7.0 # homeassistant.components.workday holidays==0.11.3.1 # homeassistant.components.frontend -home-assistant-frontend==20211109.0 +home-assistant-frontend==20211211.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -893,6 +899,9 @@ iperf3==0.1.11 # homeassistant.components.gogogate2 ismartgate==4.0.4 +# homeassistant.components.jellyfin +jellyfin-apiclient-python==1.7.2 + # homeassistant.components.rest jsonpath==0.82 @@ -917,9 +926,6 @@ krakenex==2.1.0 # homeassistant.components.eufy lakeside==0.12 -# homeassistant.components.dyson -libpurecool==0.6.4 - # homeassistant.components.foscam libpyfoscam==1.0 @@ -927,7 +933,7 @@ libpyfoscam==1.0 libpyvivotek==0.4.0 # homeassistant.components.mikrotik -librouteros==3.0.0 +librouteros==3.2.0 # homeassistant.components.soundtouch libsoundtouch==0.8 @@ -960,10 +966,7 @@ logi_circle==0.2.2 london-tube-status==0.2 # homeassistant.components.luftdaten -luftdaten==0.6.5 - -# homeassistant.components.lupusec -lupupy==0.0.21 +luftdaten==0.7.1 # homeassistant.components.lw12wifi lw12==0.9.2 @@ -1005,19 +1008,22 @@ micloud==0.4 miflora==0.7.0 # homeassistant.components.mill -millheater==0.7.4 +mill-local==0.1.0 + +# homeassistant.components.mill +millheater==0.9.0 # homeassistant.components.minio -minio==4.0.9 +minio==5.0.10 # homeassistant.components.mitemp_bt -mitemp_bt==0.0.3 +mitemp_bt==0.0.5 # homeassistant.components.motion_blinds -motionblinds==0.5.7 +motionblinds==0.5.8 # homeassistant.components.motioneye -motioneye-client==0.3.11 +motioneye-client==0.3.12 # homeassistant.components.mullvad mullvad-api==1.0.0 @@ -1028,9 +1034,6 @@ mutagen==1.45.1 # homeassistant.components.mutesync mutesync==0.0.1 -# homeassistant.components.mychevy -mychevy==2.1.1 - # homeassistant.components.mycroft mycroftapi==2.0 @@ -1044,7 +1047,7 @@ ndms2_client==0.1.1 nessclient==0.9.15 # homeassistant.components.netdata -netdata==0.2.0 +netdata==1.0.1 # homeassistant.components.discovery netdisco==3.0.0 @@ -1053,7 +1056,7 @@ netdisco==3.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==1.1.1 +nettigo-air-monitor==1.2.1 # homeassistant.components.neurio_energy neurio==0.3.1 @@ -1096,7 +1099,7 @@ numato-gpio==0.10.0 # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.21.2 +numpy==1.21.4 # homeassistant.components.oasa_telematics oasatelematics==0.3 @@ -1123,7 +1126,7 @@ onkyo-eiscp==1.2.7 onvif-zeep-async==1.2.0 # homeassistant.components.opengarage -open-garage==0.1.6 +open-garage==0.2.0 # homeassistant.components.opencv # opencv-python-headless==4.5.2.54 @@ -1144,7 +1147,7 @@ opensensemap-api==0.1.5 openwebifpy==3.2.7 # homeassistant.components.luci -openwrt-luci-rpc==1.1.8 +openwrt-luci-rpc==1.1.11 # homeassistant.components.ubus openwrt-ubus-rpc==0.0.2 @@ -1299,7 +1302,7 @@ py-synologydsm-api==1.0.4 py-zabbix==1.1.7 # homeassistant.components.seventeentrack -py17track==3.2.1 +py17track==2021.12.2 # homeassistant.components.hdmi_cec pyCEC==0.5.1 @@ -1312,7 +1315,7 @@ pyMetEireann==2021.8.0 # homeassistant.components.met # homeassistant.components.norway_air -pyMetno==0.8.4 +pyMetno==0.9.0 # homeassistant.components.rfxtrx pyRFXtrx==0.27.0 @@ -1321,7 +1324,7 @@ pyRFXtrx==0.27.0 # pySwitchmate==0.4.6 # homeassistant.components.tibber -pyTibber==0.19.1 +pyTibber==0.21.0 # homeassistant.components.dlink pyW215==0.7.0 @@ -1357,7 +1360,7 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==6.1.0 +pyatmo==6.2.0 # homeassistant.components.atome pyatome==0.1.1 @@ -1365,6 +1368,9 @@ pyatome==0.1.1 # homeassistant.components.apple_tv pyatv==0.8.2 +# homeassistant.components.balboa +pybalboa==0.13 + # homeassistant.components.bbox pybbox==0.0.5-alpha @@ -1381,13 +1387,13 @@ pybotvac==0.0.22 pycarwings2==2.12 # homeassistant.components.cloudflare -pycfdns==1.2.1 +pycfdns==1.2.2 # homeassistant.components.channels pychannels==1.0.0 # homeassistant.components.cast -pychromecast==9.3.1 +pychromecast==10.1.1 # homeassistant.components.pocketcasts pycketcasts==1.0.0 @@ -1426,7 +1432,7 @@ pydeconz==85 pydelijn==0.6.1 # homeassistant.components.dexcom -pydexcom==0.2.0 +pydexcom==0.2.1 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -1447,13 +1453,13 @@ pyeconet==0.1.14 pyedimax==0.2.1 # homeassistant.components.efergy -pyefergy==0.1.3 +pyefergy==0.1.5 # homeassistant.components.eight_sleep pyeight==0.1.9 # homeassistant.components.emby -pyemby==1.7 +pyemby==1.8 # homeassistant.components.envisalink pyenvisalink==4.0 @@ -1464,8 +1470,11 @@ pyephember==0.3.1 # homeassistant.components.everlights pyeverlights==0.1.0 +# homeassistant.components.evil_genius_labs +pyevilgenius==1.0.0 + # homeassistant.components.ezviz -pyezviz==0.1.9.4 +pyezviz==0.2.0.5 # homeassistant.components.fido pyfido==2.1.1 @@ -1495,7 +1504,7 @@ pyfreedompro==1.1.0 pyfritzhome==0.6.2 # homeassistant.components.fronius -pyfronius==0.7.0 +pyfronius==0.7.1 # homeassistant.components.ifttt pyfttt==0.3 @@ -1511,7 +1520,7 @@ pygtfs==0.1.6 pygti==0.9.2 # homeassistant.components.version -pyhaversion==21.10.0 +pyhaversion==21.11.1 # homeassistant.components.heos pyheos==0.7.2 @@ -1547,7 +1556,7 @@ pyipma==2.0.5 pyipp==0.11.0 # homeassistant.components.iqvia -pyiqvia==1.1.0 +pyiqvia==2021.11.0 # homeassistant.components.irish_rail_transport pyirishrail==0.0.2 @@ -1568,7 +1577,7 @@ pykira==0.1.1 pykmtronic==0.3.0 # homeassistant.components.kodi -pykodi==0.2.6 +pykodi==0.2.7 # homeassistant.components.kraken pykrakenapi==0.1.8 @@ -1586,7 +1595,7 @@ pylacrosse==0.4 pylast==4.2.1 # homeassistant.components.launch_library -pylaunches==1.0.0 +pylaunches==1.2.0 # homeassistant.components.lg_netcast pylgnetcast==0.3.5 @@ -1598,10 +1607,7 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.10.1 - -# homeassistant.components.loopenergy -pyloopenergy==0.2.1 +pylitterbot==2021.11.0 # homeassistant.components.lutron_caseta pylutron-caseta==0.11.0 @@ -1622,7 +1628,7 @@ pymazda==0.2.2 pymediaroom==0.6.4.1 # homeassistant.components.melcloud -pymelcloud==2.5.4 +pymelcloud==2.5.5 # homeassistant.components.meteoclimatic pymeteoclimatic==0.0.6 @@ -1649,7 +1655,7 @@ pymsteams==0.1.12 pymyq==3.1.4 # homeassistant.components.mysensors -pymysensors==0.21.0 +pymysensors==0.22.1 # homeassistant.components.netgear pynetgear==0.7.0 @@ -1682,7 +1688,7 @@ pyoctoprintapi==0.1.6 pyombi==0.1.10 # homeassistant.components.openuv -pyopenuv==2.2.1 +pyopenuv==2021.11.0 # homeassistant.components.opnsense pyopnsense==0.2.0 @@ -1690,9 +1696,6 @@ pyopnsense==0.2.0 # homeassistant.components.opple pyoppleio==1.0.5 -# homeassistant.components.iota -pyota==2.0.5 - # homeassistant.components.opentherm_gw pyotgw==1.1b1 @@ -1796,7 +1799,7 @@ pysignalclirestapi==0.3.4 pyskyqhub==0.1.3 # homeassistant.components.sma -pysma==0.6.7 +pysma==0.6.9 # homeassistant.components.smappee pysmappee==0.2.27 @@ -1838,7 +1841,7 @@ pysyncthru==0.7.10 pytankerkoenig==0.0.6 # homeassistant.components.tautulli -pytautulli==21.10.0 +pytautulli==21.11.0 # homeassistant.components.tfiac pytfiac==0.4 @@ -1856,7 +1859,7 @@ python-clementine-remote==1.0.1 python-digitalocean==1.13.2 # homeassistant.components.ecobee -python-ecobee-api==0.2.11 +python-ecobee-api==0.2.14 # homeassistant.components.eq3btsmart # python-eq3bt==0.1.11 @@ -1883,7 +1886,7 @@ python-gitlab==1.6.0 python-hpilo==4.3 # homeassistant.components.izone -python-izone==1.1.6 +python-izone==1.1.8 # homeassistant.components.joaoapps_join python-join-api==0.0.6 @@ -1898,7 +1901,7 @@ python-kasa==0.4.0 # python-lirc==1.2.3 # homeassistant.components.xiaomi_miio -python-miio==0.5.8 +python-miio==0.5.9.1 # homeassistant.components.mpd python-mpd2==3.0.4 @@ -1922,7 +1925,7 @@ python-qbittorrent==0.4.2 python-ripple-api==0.0.3 # homeassistant.components.smarttub -python-smarttub==0.0.27 +python-smarttub==0.0.28 # homeassistant.components.sochain python-sochain-api==0.0.2 @@ -1961,10 +1964,10 @@ pytile==5.2.4 pytouchline==0.7 # homeassistant.components.traccar -pytraccar==0.9.0 +pytraccar==0.10.0 # homeassistant.components.tradfri -pytradfri[async]==7.1.1 +pytradfri[async]==7.2.1 # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation @@ -1974,7 +1977,7 @@ pytrafikverket==0.1.6.2 pyudev==0.22.0 # homeassistant.components.uptimerobot -pyuptimerobot==21.9.0 +pyuptimerobot==21.11.0 # homeassistant.components.keyboard # pyuserinput==0.1.11 @@ -1986,7 +1989,7 @@ pyvera==0.3.13 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==1.4.0 +pyvesync==1.4.1 # homeassistant.components.vizio pyvizio==0.1.57 @@ -2043,7 +2046,7 @@ regenmaschine==2021.10.0 renault-api==0.1.4 # homeassistant.components.python_script -restrictedpython==5.1 +restrictedpython==5.2 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2064,10 +2067,10 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.8.1 +rokuecp==0.8.4 # homeassistant.components.roomba -roombapy==1.6.3 +roombapy==1.6.4 # homeassistant.components.roon roonapi==0.0.38 @@ -2106,7 +2109,7 @@ scapy==2.4.5 schiene==0.23 # homeassistant.components.screenlogic -screenlogicpy==0.4.1 +screenlogicpy==0.5.3 # homeassistant.components.scsgate scsgate==0.1.0 @@ -2119,10 +2122,10 @@ sense-hat==2.2.0 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense_energy==0.9.2 +sense_energy==0.9.3 # homeassistant.components.sentry -sentry-sdk==1.4.3 +sentry-sdk==1.5.0 # homeassistant.components.sharkiq sharkiqpy==0.1.8 @@ -2140,7 +2143,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==12.0.2 +simplisafe-python==2021.12.1 # homeassistant.components.sisyphus sisyphus-control==3.0 @@ -2178,7 +2181,7 @@ smhi-pkg==1.0.15 snapcast==2.1.3 # homeassistant.components.sonos -soco==0.24.0 +soco==0.25.0 # homeassistant.components.solaredge_local solaredge-local==0.2.0 @@ -2190,7 +2193,7 @@ solaredge==0.0.2 solax==0.2.8 # homeassistant.components.honeywell -somecomfort==0.7.0 +somecomfort==0.8.0 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 @@ -2208,11 +2211,11 @@ speedtest-cli==2.1.3 spiderpy==1.6.1 # homeassistant.components.spotify -spotipy==2.18.0 +spotipy==2.19.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.23 +sqlalchemy==1.4.27 # homeassistant.components.srp_energy srpenergy==1.3.2 @@ -2260,11 +2263,14 @@ swisshydrodata==0.1.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridge==2.1.3 +systembridge==2.2.3 # homeassistant.components.tahoma tahoma-api==0.0.16 +# homeassistant.components.tailscale +tailscale==0.1.4 + # homeassistant.components.tank_utility tank_utility==1.4.0 @@ -2292,6 +2298,9 @@ temperusb==1.5.3 # homeassistant.components.powerwall tesla-powerwall==0.3.12 +# homeassistant.components.tesla_wall_connector +tesla-wall-connector==1.0.1 + # homeassistant.components.tensorflow # tf-models-official==2.3.0 @@ -2310,11 +2319,14 @@ tmb==0.0.4 # homeassistant.components.todoist todoist-python==8.0.0 +# homeassistant.components.tolo +tololib==0.1.0b3 + # homeassistant.components.toon toonapi==0.2.1 # homeassistant.components.totalconnect -total_connect_client==2021.11.2 +total_connect_client==2021.11.4 # homeassistant.components.tplink_lte tp-connected==0.0.4 @@ -2326,7 +2338,7 @@ transmissionrpc==0.11 tuya-iot-py-sdk==0.6.3 # homeassistant.components.twentemilieu -twentemilieu==0.3.0 +twentemilieu==0.5.0 # homeassistant.components.twilio twilio==6.32.0 @@ -2359,11 +2371,14 @@ uvcclient==0.11.0 # homeassistant.components.vallox vallox-websocket-api==2.8.1 +# homeassistant.components.rdw +vehicle==0.2.2 + # homeassistant.components.velbus velbus-aio==2021.11.7 # homeassistant.components.venstar -venstarcolortouch==0.14 +venstarcolortouch==0.15 # homeassistant.components.vilfo vilfo-api-client==0.3.2 @@ -2372,7 +2387,7 @@ vilfo-api-client==0.3.2 volkszaehler==0.2.1 # homeassistant.components.volvooncall -volvooncall==0.8.12 +volvooncall==0.9.1 # homeassistant.components.verisure vsure==1.7.3 @@ -2406,16 +2421,16 @@ webexteamssdk==1.1.1 whirlpool-sixth-sense==0.15.1 # homeassistant.components.wiffi -wiffi==1.0.1 +wiffi==1.1.0 # homeassistant.components.wirelesstag -wirelesstagpy==0.5.0 +wirelesstagpy==0.8.1 # homeassistant.components.withings withings-api==2.3.2 # homeassistant.components.wled -wled==0.8.0 +wled==0.10.1 # homeassistant.components.wolflink wolf_smartset==0.1.11 @@ -2430,7 +2445,7 @@ xbox-webapi==2.0.11 xboxapi==2.0.1 # homeassistant.components.knx -xknx==0.18.11 +xknx==0.18.13 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2465,10 +2480,10 @@ youtube_dl==2021.06.06 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.36.13 +zeroconf==0.37.0 # homeassistant.components.zha -zha-quirks==0.0.63 +zha-quirks==0.0.65 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2477,7 +2492,7 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.13.0 +zigpy-deconz==0.14.0 # homeassistant.components.zha zigpy-xbee==0.14.0 @@ -2486,13 +2501,13 @@ zigpy-xbee==0.14.0 zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.5.4 +zigpy-znp==0.6.4 # homeassistant.components.zha -zigpy==0.39.0 +zigpy==0.42.0 # homeassistant.components.zoneminder zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.31.3 +zwave-js-server-python==0.33.0 diff --git a/requirements_test.txt b/requirements_test.txt index 36b8f41fea1..6f580cb5159 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,27 +8,30 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt codecov==2.1.12 -coverage==6.0.2 +coverage==6.2.0 +freezegun==1.1.0 jsonpickle==1.4.1 mock-open==1.4.0 mypy==0.910 -pre-commit==2.15.0 -pylint==2.11.1 -pipdeptree==2.1.0 +pre-commit==2.16.0 +pylint==2.12.1 +pipdeptree==2.2.0 pylint-strict-informational==0.1 pytest-aiohttp==0.3.0 pytest-cov==2.12.1 +pytest-freezegun==0.4.2 pytest-socket==0.4.1 pytest-test-groups==1.0.3 pytest-sugar==0.9.4 -pytest-timeout==1.4.2 +pytest-timeout==2.0.1 pytest-xdist==2.4.0 pytest==6.2.5 requests_mock==1.9.2 responses==0.12.0 -respx==0.17.0 +respx==0.19.0 stdlib-list==0.7.0 tqdm==4.49.0 +types-atomicwrites==1.4.1 types-croniter==1.0.0 types-backports==0.1.3 types-certifi==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af0f2452ea9..9c4def0e712 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -21,16 +21,19 @@ PyNaCl==1.4.0 PyQRCode==1.2.1 # homeassistant.components.rmvtransport -PyRMVtransport==0.3.2 +PyRMVtransport==0.3.3 # homeassistant.components.switchbot -# PySwitchbot==0.12.0 +# PySwitchbot==0.13.0 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 # homeassistant.components.camera -PyTurboJPEG==1.6.1 +PyTurboJPEG==1.6.3 + +# homeassistant.components.vicare +PyViCare==2.13.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.13.4 @@ -51,7 +54,7 @@ abodepy==1.2.0 accuweather==0.3.0 # homeassistant.components.adax -adax==0.1.1 +adax==0.2.0 # homeassistant.components.androidtv adb-shell[async]==0.4.0 @@ -81,10 +84,10 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.5 # homeassistant.components.ambient_station -aioambient==2021.10.1 +aioambient==2021.11.0 # homeassistant.components.asuswrt -aioasuswrt==1.3.4 +aioasuswrt==1.4.0 # homeassistant.components.azure_devops aioazuredevops==1.3.5 @@ -109,10 +112,10 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==10.2.0 +aioesphomeapi==10.6.0 # homeassistant.components.flo -aioflo==0.4.1 +aioflo==2021.11.0 # homeassistant.components.guardian aioguardian==2021.11.0 @@ -121,14 +124,14 @@ aioguardian==2021.11.0 aioharmony==0.2.8 # homeassistant.components.homekit_controller -aiohomekit==0.6.3 +aiohomekit==0.6.4 # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.6.3 +aiohue==3.0.2 # homeassistant.components.apache_kafka aiokafka==0.6.0 @@ -140,28 +143,28 @@ aiolip==1.1.6 aiolookin==0.0.4 # homeassistant.components.lyric -aiolyric==1.0.7 +aiolyric==1.0.8 # homeassistant.components.modern_forms aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.11.0 +aiomusiccast==0.14.2 # homeassistant.components.nanoleaf -aionanoleaf==0.0.3 +aionanoleaf==0.1.1 # homeassistant.components.notion aionotion==3.0.2 # homeassistant.components.acmeda -aiopulse==0.4.2 +aiopulse==0.4.3 # homeassistant.components.hunterdouglas_powerview aiopvapi==1.6.14 # homeassistant.components.pvpc_hourly_pricing -aiopvpc==2.2.1 +aiopvpc==2.2.4 # homeassistant.components.webostv aiopylgtv==0.4.0 @@ -169,8 +172,11 @@ aiopylgtv==0.4.0 # homeassistant.components.recollect_waste aiorecollect==1.0.8 +# homeassistant.components.ridwell +aioridwell==0.2.0 + # homeassistant.components.shelly -aioshelly==1.0.4 +aioshelly==1.0.5 # homeassistant.components.switcher_kis aioswitcher==2.0.6 @@ -191,13 +197,13 @@ aiovlc==0.1.0 aiowatttime==0.1.1 # homeassistant.components.yandex_transport -aioymaps==1.2.1 +aioymaps==1.2.2 # homeassistant.components.airly airly==1.1.0 # homeassistant.components.airthings -airthings_cloud==0.0.1 +airthings_cloud==0.1.0 # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 @@ -218,7 +224,7 @@ androidtv[async]==0.0.60 apns2==0.3.0 # homeassistant.components.apprise -apprise==0.9.5.1 +apprise==0.9.6 # homeassistant.components.aprs aprslib==0.6.46 @@ -230,7 +236,7 @@ arcam-fmj==0.12.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.22.10 +async-upnp-client==0.22.12 # homeassistant.components.aurora auroranoaa==0.0.2 @@ -248,10 +254,10 @@ azure-eventhub==5.5.0 base36==0.1.1 # homeassistant.components.zha -bellows==0.28.0 +bellows==0.29.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.21 +bimmer_connected==0.8.5 # homeassistant.components.blebox blebox_uniapi==1.3.3 @@ -260,7 +266,7 @@ blebox_uniapi==1.3.3 blinkpy==0.17.0 # homeassistant.components.bond -bond-api==0.1.14 +bond-api==0.1.15 # homeassistant.components.bosch_shc boschshcpy==0.2.19 @@ -274,11 +280,14 @@ broadlink==0.18.0 # homeassistant.components.brother brother==1.1.0 +# homeassistant.components.brunt +brunt==1.0.0 + # homeassistant.components.bsblan bsblan==0.4.0 # homeassistant.components.buienradar -buienradar==1.0.4 +buienradar==1.0.5 # homeassistant.components.caldav caldav==0.7.1 @@ -290,7 +299,7 @@ co2signal==0.4.2 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==6.4.1 +colorlog==6.6.0 # homeassistant.components.color_extractor colorthief==0.2.1 @@ -307,10 +316,10 @@ coronavirus==1.1.1 croniter==1.0.6 # homeassistant.components.crownstone -crownstone-cloud==1.4.8 +crownstone-cloud==1.4.9 # homeassistant.components.crownstone -crownstone-sse==2.0.2 +crownstone-sse==2.0.3 # homeassistant.components.crownstone crownstone-uart==2.1.0 @@ -322,7 +331,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.debugpy -debugpy==1.5.0 +debugpy==1.5.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -335,6 +344,9 @@ denonavr==0.10.9 # homeassistant.components.devolo_home_control devolo-home-control-api==0.17.4 +# homeassistant.components.devolo_home_network +devolo-plc-api==0.6.3 + # homeassistant.components.directv directv==0.4.0 @@ -348,7 +360,7 @@ dsmr_parser==0.30 dynalite_devices==0.1.46 # homeassistant.components.elgato -elgato==2.1.1 +elgato==2.2.0 # homeassistant.components.elkm1 elkm1-lib==1.0.0 @@ -363,10 +375,10 @@ emulated_roku==0.2.1 enocean==0.50 # homeassistant.components.environment_canada -env_canada==0.5.14 +env_canada==0.5.18 # homeassistant.components.enphase_envoy -envoy_reader==0.20.0 +envoy_reader==0.20.1 # homeassistant.components.season ephem==3.7.7.0 @@ -387,7 +399,7 @@ fjaraskupan==1.0.2 flipr-api==1.4.1 # homeassistant.components.flux_led -flux_led==0.24.24 +flux_led==0.26.7 # homeassistant.components.homekit fnvhash==0.1.0 @@ -403,7 +415,7 @@ freebox-api==0.0.10 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor -fritzconnection==1.7.0 +fritzconnection==1.7.2 # homeassistant.components.google_translate gTTS==2.2.3 @@ -440,7 +452,7 @@ gios==2.1.0 glances_api==0.2.0 # homeassistant.components.goalzero -goalzero==0.2.0 +goalzero==0.2.1 # homeassistant.components.google google-api-python-client==1.6.4 @@ -449,7 +461,7 @@ google-api-python-client==1.6.4 google-cloud-pubsub==2.1.0 # homeassistant.components.nest -google-nest-sdm==0.3.8 +google-nest-sdm==0.4.5 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -457,11 +469,14 @@ googlemaps==2.5.1 # homeassistant.components.gree greeclimate==0.12.5 +# homeassistant.components.greeneye_monitor +greeneye_monitor==2.1 + # homeassistant.components.growatt_server growattServer==1.1.0 # homeassistant.components.profiler -guppy3==3.1.0 +guppy3==3.1.2 # homeassistant.components.stream ha-av==8.0.4-rc.1 @@ -470,7 +485,7 @@ ha-av==8.0.4-rc.1 ha-ffmpeg==3.0.2 # homeassistant.components.philips_js -ha-philipsjs==2.7.5 +ha-philipsjs==2.7.6 # homeassistant.components.habitica habitipy==0.2.0 @@ -482,7 +497,7 @@ hangups==0.4.14 hass-nabucasa==0.50.0 # homeassistant.components.tasmota -hatasmota==0.2.21 +hatasmota==0.3.1 # homeassistant.components.jewish_calendar hdate==0.10.4 @@ -494,13 +509,13 @@ herepy==2.0.0 hlk-sw16==0.0.9 # homeassistant.components.pi_hole -hole==0.5.1 +hole==0.7.0 # homeassistant.components.workday holidays==0.11.3.1 # homeassistant.components.frontend -home-assistant-frontend==20211109.0 +home-assistant-frontend==20211211.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -548,6 +563,9 @@ iotawattpy==0.1.0 # homeassistant.components.gogogate2 ismartgate==4.0.4 +# homeassistant.components.jellyfin +jellyfin-apiclient-python==1.7.2 + # homeassistant.components.rest jsonpath==0.82 @@ -560,14 +578,11 @@ kostal_plenticore==0.2.0 # homeassistant.components.kraken krakenex==2.1.0 -# homeassistant.components.dyson -libpurecool==0.6.4 - # homeassistant.components.foscam libpyfoscam==1.0 # homeassistant.components.mikrotik -librouteros==3.0.0 +librouteros==3.2.0 # homeassistant.components.soundtouch libsoundtouch==0.8 @@ -576,7 +591,7 @@ libsoundtouch==0.8 logi_circle==0.2.2 # homeassistant.components.luftdaten -luftdaten==0.6.5 +luftdaten==0.7.1 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.11 @@ -600,16 +615,19 @@ mficlient==0.3.0 micloud==0.4 # homeassistant.components.mill -millheater==0.7.4 +mill-local==0.1.0 + +# homeassistant.components.mill +millheater==0.9.0 # homeassistant.components.minio -minio==4.0.9 +minio==5.0.10 # homeassistant.components.motion_blinds -motionblinds==0.5.7 +motionblinds==0.5.8 # homeassistant.components.motioneye -motioneye-client==0.3.11 +motioneye-client==0.3.12 # homeassistant.components.mullvad mullvad-api==1.0.0 @@ -633,7 +651,7 @@ netdisco==3.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==1.1.1 +nettigo-air-monitor==1.2.1 # homeassistant.components.nexia nexia==0.9.11 @@ -658,7 +676,7 @@ numato-gpio==0.10.0 # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.21.2 +numpy==1.21.4 # homeassistant.components.google oauth2client==4.0.0 @@ -676,7 +694,7 @@ ondilo==0.2.0 onvif-zeep-async==1.2.0 # homeassistant.components.opengarage -open-garage==0.1.6 +open-garage==0.2.0 # homeassistant.components.openerz openerz-api==0.1.0 @@ -774,7 +792,7 @@ py-nightscout==1.2.2 py-synologydsm-api==1.0.4 # homeassistant.components.seventeentrack -py17track==3.2.1 +py17track==2021.12.2 # homeassistant.components.control4 pyControl4==0.0.6 @@ -784,13 +802,13 @@ pyMetEireann==2021.8.0 # homeassistant.components.met # homeassistant.components.norway_air -pyMetno==0.8.4 +pyMetno==0.9.0 # homeassistant.components.rfxtrx pyRFXtrx==0.27.0 # homeassistant.components.tibber -pyTibber==0.19.1 +pyTibber==0.21.0 # homeassistant.components.nextbus py_nextbusnext==0.1.5 @@ -814,11 +832,14 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==6.1.0 +pyatmo==6.2.0 # homeassistant.components.apple_tv pyatv==0.8.2 +# homeassistant.components.balboa +pybalboa==0.13 + # homeassistant.components.blackbird pyblackbird==0.5 @@ -826,10 +847,10 @@ pyblackbird==0.5 pybotvac==0.0.22 # homeassistant.components.cloudflare -pycfdns==1.2.1 +pycfdns==1.2.2 # homeassistant.components.cast -pychromecast==9.3.1 +pychromecast==10.1.1 # homeassistant.components.climacell pyclimacell==0.18.2 @@ -847,7 +868,7 @@ pydaikin==2.6.0 pydeconz==85 # homeassistant.components.dexcom -pydexcom==0.2.0 +pydexcom==0.2.1 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -856,13 +877,16 @@ pydispatcher==2.0.5 pyeconet==0.1.14 # homeassistant.components.efergy -pyefergy==0.1.3 +pyefergy==0.1.5 # homeassistant.components.everlights pyeverlights==0.1.0 +# homeassistant.components.evil_genius_labs +pyevilgenius==1.0.0 + # homeassistant.components.ezviz -pyezviz==0.1.9.4 +pyezviz==0.2.0.5 # homeassistant.components.fido pyfido==2.1.1 @@ -885,6 +909,9 @@ pyfreedompro==1.1.0 # homeassistant.components.fritzbox pyfritzhome==0.6.2 +# homeassistant.components.fronius +pyfronius==0.7.1 + # homeassistant.components.ifttt pyfttt==0.3 @@ -896,7 +923,7 @@ pygatt[GATTTOOL]==4.0.5 pygti==0.9.2 # homeassistant.components.version -pyhaversion==21.10.0 +pyhaversion==21.11.1 # homeassistant.components.heos pyheos==0.7.2 @@ -923,7 +950,7 @@ pyipma==2.0.5 pyipp==0.11.0 # homeassistant.components.iqvia -pyiqvia==1.1.0 +pyiqvia==2021.11.0 # homeassistant.components.isy994 pyisy==3.0.0 @@ -935,7 +962,7 @@ pykira==0.1.1 pykmtronic==0.3.0 # homeassistant.components.kodi -pykodi==0.2.6 +pykodi==0.2.7 # homeassistant.components.kraken pykrakenapi==0.1.8 @@ -953,7 +980,7 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.10.1 +pylitterbot==2021.11.0 # homeassistant.components.lutron_caseta pylutron-caseta==0.11.0 @@ -968,7 +995,7 @@ pymata-express==1.19 pymazda==0.2.2 # homeassistant.components.melcloud -pymelcloud==2.5.4 +pymelcloud==2.5.5 # homeassistant.components.meteoclimatic pymeteoclimatic==0.0.6 @@ -989,7 +1016,7 @@ pymonoprice==0.3 pymyq==3.1.4 # homeassistant.components.mysensors -pymysensors==0.21.0 +pymysensors==0.22.1 # homeassistant.components.netgear pynetgear==0.7.0 @@ -1013,7 +1040,7 @@ pynzbgetapi==0.2.0 pyoctoprintapi==0.1.6 # homeassistant.components.openuv -pyopenuv==2.2.1 +pyopenuv==2021.11.0 # homeassistant.components.opnsense pyopnsense==0.2.0 @@ -1079,7 +1106,7 @@ pysiaalarm==3.0.2 pysignalclirestapi==0.3.4 # homeassistant.components.sma -pysma==0.6.7 +pysma==0.6.9 # homeassistant.components.smappee pysmappee==0.2.27 @@ -1103,13 +1130,13 @@ pysqueezebox==0.5.5 pysyncthru==0.7.10 # homeassistant.components.ecobee -python-ecobee-api==0.2.11 +python-ecobee-api==0.2.14 # homeassistant.components.darksky python-forecastio==1.4.0 # homeassistant.components.izone -python-izone==1.1.6 +python-izone==1.1.8 # homeassistant.components.juicenet python-juicenet==1.0.2 @@ -1118,7 +1145,7 @@ python-juicenet==1.0.2 python-kasa==0.4.0 # homeassistant.components.xiaomi_miio -python-miio==0.5.8 +python-miio==0.5.9.1 # homeassistant.components.nest python-nest==4.1.0 @@ -1130,7 +1157,7 @@ python-openzwave-mqtt[mqtt-client]==1.4.0 python-picnic-api==1.1.0 # homeassistant.components.smarttub -python-smarttub==0.0.27 +python-smarttub==0.0.28 # homeassistant.components.songpal python-songpal==0.12 @@ -1148,22 +1175,26 @@ python_awair==0.2.1 pytile==5.2.4 # homeassistant.components.traccar -pytraccar==0.9.0 +pytraccar==0.10.0 # homeassistant.components.tradfri -pytradfri[async]==7.1.1 +pytradfri[async]==7.2.1 + +# homeassistant.components.trafikverket_train +# homeassistant.components.trafikverket_weatherstation +pytrafikverket==0.1.6.2 # homeassistant.components.usb pyudev==0.22.0 # homeassistant.components.uptimerobot -pyuptimerobot==21.9.0 +pyuptimerobot==21.11.0 # homeassistant.components.vera pyvera==0.3.13 # homeassistant.components.vesync -pyvesync==1.4.0 +pyvesync==1.4.1 # homeassistant.components.vizio pyvizio==0.1.57 @@ -1193,7 +1224,7 @@ regenmaschine==2021.10.0 renault-api==0.1.4 # homeassistant.components.python_script -restrictedpython==5.1 +restrictedpython==5.2 # homeassistant.components.rflink rflink==0.0.58 @@ -1202,10 +1233,10 @@ rflink==0.0.58 ring_doorbell==0.7.1 # homeassistant.components.roku -rokuecp==0.8.1 +rokuecp==0.8.4 # homeassistant.components.roomba -roombapy==1.6.3 +roombapy==1.6.4 # homeassistant.components.roon roonapi==0.0.38 @@ -1226,14 +1257,14 @@ samsungtvws==1.6.0 scapy==2.4.5 # homeassistant.components.screenlogic -screenlogicpy==0.4.1 +screenlogicpy==0.5.3 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense_energy==0.9.2 +sense_energy==0.9.3 # homeassistant.components.sentry -sentry-sdk==1.4.3 +sentry-sdk==1.5.0 # homeassistant.components.sharkiq sharkiqpy==0.1.8 @@ -1242,7 +1273,7 @@ sharkiqpy==0.1.8 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==12.0.2 +simplisafe-python==2021.12.1 # homeassistant.components.slack slackclient==2.5.0 @@ -1260,13 +1291,13 @@ smarthab==0.21 smhi-pkg==1.0.15 # homeassistant.components.sonos -soco==0.24.0 +soco==0.25.0 # homeassistant.components.solaredge solaredge==0.0.2 # homeassistant.components.honeywell -somecomfort==0.7.0 +somecomfort==0.8.0 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 @@ -1284,11 +1315,11 @@ speedtest-cli==2.1.3 spiderpy==1.6.1 # homeassistant.components.spotify -spotipy==2.18.0 +spotipy==2.19.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.23 +sqlalchemy==1.4.27 # homeassistant.components.srp_energy srpenergy==1.3.2 @@ -1318,7 +1349,10 @@ sunwatcher==0.2.1 surepy==0.7.2 # homeassistant.components.system_bridge -systembridge==2.1.3 +systembridge==2.2.3 + +# homeassistant.components.tailscale +tailscale==0.1.4 # homeassistant.components.tellduslive tellduslive==0.10.11 @@ -1326,11 +1360,17 @@ tellduslive==0.10.11 # homeassistant.components.powerwall tesla-powerwall==0.3.12 +# homeassistant.components.tesla_wall_connector +tesla-wall-connector==1.0.1 + +# homeassistant.components.tolo +tololib==0.1.0b3 + # homeassistant.components.toon toonapi==0.2.1 # homeassistant.components.totalconnect -total_connect_client==2021.11.2 +total_connect_client==2021.11.4 # homeassistant.components.transmission transmissionrpc==0.11 @@ -1339,7 +1379,7 @@ transmissionrpc==0.11 tuya-iot-py-sdk==0.6.3 # homeassistant.components.twentemilieu -twentemilieu==0.3.0 +twentemilieu==0.5.0 # homeassistant.components.twilio twilio==6.32.0 @@ -1363,11 +1403,14 @@ url-normalize==1.4.1 # homeassistant.components.uvc uvcclient==0.11.0 +# homeassistant.components.rdw +vehicle==0.2.2 + # homeassistant.components.velbus velbus-aio==2021.11.7 # homeassistant.components.venstar -venstarcolortouch==0.14 +venstarcolortouch==0.15 # homeassistant.components.vilfo vilfo-api-client==0.3.2 @@ -1392,13 +1435,13 @@ watchdog==2.1.6 whirlpool-sixth-sense==0.15.1 # homeassistant.components.wiffi -wiffi==1.0.1 +wiffi==1.1.0 # homeassistant.components.withings withings-api==2.3.2 # homeassistant.components.wled -wled==0.8.0 +wled==0.10.1 # homeassistant.components.wolflink wolf_smartset==0.1.11 @@ -1407,7 +1450,7 @@ wolf_smartset==0.1.11 xbox-webapi==2.0.11 # homeassistant.components.knx -xknx==0.18.11 +xknx==0.18.13 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -1430,13 +1473,13 @@ yeelight==0.7.8 youless-api==0.15 # homeassistant.components.zeroconf -zeroconf==0.36.13 +zeroconf==0.37.0 # homeassistant.components.zha -zha-quirks==0.0.63 +zha-quirks==0.0.65 # homeassistant.components.zha -zigpy-deconz==0.13.0 +zigpy-deconz==0.14.0 # homeassistant.components.zha zigpy-xbee==0.14.0 @@ -1445,10 +1488,10 @@ zigpy-xbee==0.14.0 zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.5.4 +zigpy-znp==0.6.4 # homeassistant.components.zha -zigpy==0.39.0 +zigpy==0.42.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.31.3 +zwave-js-server-python==0.33.0 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index ffa11a0fc30..66786035e98 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,16 +1,16 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit bandit==1.7.0 -black==21.9b0 +black==21.11b1 codespell==2.0.0 flake8-comprehensions==3.7.0 flake8-docstrings==1.6.0 flake8-noqa==1.2.0 flake8==4.0.1 -isort==5.9.3 +isort==5.10.0 mccabe==0.6.1 pycodestyle==2.8.0 pydocstyle==6.1.1 pyflakes==2.4.0 -pyupgrade==2.27.0 -yamllint==1.26.1 +pyupgrade==2.29.0 +yamllint==1.26.3 diff --git a/rootfs/etc/services.d/home-assistant/finish b/rootfs/etc/services.d/home-assistant/finish index 119a90ea3c6..3691583ec81 100644 --- a/rootfs/etc/services.d/home-assistant/finish +++ b/rootfs/etc/services.d/home-assistant/finish @@ -13,8 +13,12 @@ ifelse { s6-test ${1} -eq ${SIGNAL_EXIT_CODE} } { # Process terminated by a signal define signal ${2} foreground { s6-echo "[finish] process received signal ${signal}" } + backtick -n new_exit_code { s6-expr 128 + ${signal} } + importas -ui new_exit_code new_exit_code + foreground { redirfd -w 1 /var/run/s6/env-stage3/S6_STAGE2_EXITED s6-echo -n -- ${new_exit_code} } if { s6-test ${signal} -ne ${SIGTERM} } s6-svscanctl -t /var/run/s6/services } +foreground { redirfd -w 1 /var/run/s6/env-stage3/S6_STAGE2_EXITED s6-echo -n -- ${1} } s6-svscanctl -t /var/run/s6/services diff --git a/script/hassfest/coverage.py b/script/hassfest/coverage.py index 1e91edee90e..e4e8058c69b 100644 --- a/script/hassfest/coverage.py +++ b/script/hassfest/coverage.py @@ -34,7 +34,6 @@ ALLOWED_IGNORE_VIOLATIONS = { ("ifttt", "config_flow.py"), ("ios", "config_flow.py"), ("iqvia", "config_flow.py"), - ("knx", "scene.py"), ("konnected", "config_flow.py"), ("lcn", "scene.py"), ("life360", "config_flow.py"), diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index b650d3232ac..c52a926e4e1 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -4,8 +4,8 @@ from __future__ import annotations import ast from pathlib import Path +from homeassistant.const import Platform from homeassistant.requirements import DISCOVERY_INTEGRATIONS -from homeassistant.setup import BASE_PLATFORMS from .model import Integration @@ -91,6 +91,7 @@ class ImportCollector(ast.NodeVisitor): ALLOWED_USED_COMPONENTS = { + *{platform.value for platform in Platform}, # Internal integrations "alert", "automation", @@ -117,8 +118,6 @@ ALLOWED_USED_COMPONENTS = { "webhook", "websocket_api", "zone", - # Entity integrations with platforms - *BASE_PLATFORMS, # Other "mjpeg", # base class, has no reqs or component to load. "stream", # Stream cannot install on all systems, can be imported without reqs. @@ -134,8 +133,8 @@ IGNORE_VIOLATIONS = { # Demo ("demo", "manual"), ("demo", "openalpr_local"), - # Migration of settings from zeroconf to network - ("network", "zeroconf"), + # This would be a circular dep + ("http", "network"), # This should become a helper method that integrations can submit data to ("websocket_api", "lovelace"), ("websocket_api", "shopping_list"), diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index abade24dbf9..ecc00142e30 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -38,6 +38,7 @@ NO_IOT_CLASS = [ "automation", "binary_sensor", "blueprint", + "button", "calendar", "camera", "climate", @@ -186,6 +187,7 @@ MANIFEST_SCHEMA = vol.Schema( str, verify_uppercase, verify_wildcard ), vol.Optional("manufacturer"): vol.All(str, verify_lowercase), + vol.Optional("model"): vol.All(str, verify_lowercase), vol.Optional("name"): vol.All(str, verify_lowercase), } ), diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 7d289984335..6fc6c0e3993 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -14,7 +14,6 @@ from .model import Config, Integration # remove your component from this list to enable type checks. # Do your best to not add anything new here. IGNORED_MODULES: Final[list[str]] = [ - "homeassistant.components.awair.*", "homeassistant.components.blueprint.*", "homeassistant.components.climacell.*", "homeassistant.components.cloud.*", diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 26cb834e4e2..2da82762240 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -26,6 +26,7 @@ PACKAGE_REGEX = re.compile( r"^(?:--.+\s)?([-_\.\w\d\[\]]+)(==|>=|<=|~=|!=|<|>|===)*(.*)$" ) PIP_REGEX = re.compile(r"^(--.+\s)?([-_\.\w\d]+.*(?:==|>=|<=|~=|!=|<|>|===)?.*$)") +PIP_VERSION_RANGE_SEPARATOR = re.compile(r"^(==|>=|<=|~=|!=|<|>|===)?(.*)$") SUPPORTED_PYTHON_TUPLES = [ REQUIRED_PYTHON_VER[:2], tuple(map(operator.add, REQUIRED_PYTHON_VER, (0, 1, 0)))[:2], @@ -95,16 +96,22 @@ def validate_requirements_format(integration: Integration) -> bool: ) continue - if ( - version - and AwesomeVersion(version).strategy == AwesomeVersionStrategy.UNKNOWN - ): - integration.add_error( - "requirements", - f"Unable to parse package version ({version}) for {pkg}.", - ) + if not version: continue + for part in version.split(","): + version_part = PIP_VERSION_RANGE_SEPARATOR.match(part) + if ( + version_part + and AwesomeVersion(version_part.group(2)).strategy + == AwesomeVersionStrategy.UNKNOWN + ): + integration.add_error( + "requirements", + f"Unable to parse package version ({version}) for {pkg}.", + ) + continue + return len(integration.errors) == start_errors diff --git a/script/scaffold/templates/device_condition/integration/device_condition.py b/script/scaffold/templates/device_condition/integration/device_condition.py index 4180f81b3ff..6f129289af8 100644 --- a/script/scaffold/templates/device_condition/integration/device_condition.py +++ b/script/scaffold/templates/device_condition/integration/device_condition.py @@ -59,11 +59,9 @@ async def async_get_conditions( @callback def async_condition_from_config( - config: ConfigType, config_validation: bool + hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" - if config_validation: - config = CONDITION_SCHEMA(config) if config[CONF_TYPE] == "is_on": state = STATE_ON else: diff --git a/script/scaffold/templates/device_trigger/integration/device_trigger.py b/script/scaffold/templates/device_trigger/integration/device_trigger.py index 45c6adb4dcf..9082d27953a 100644 --- a/script/scaffold/templates/device_trigger/integration/device_trigger.py +++ b/script/scaffold/templates/device_trigger/integration/device_trigger.py @@ -10,7 +10,7 @@ from homeassistant.components.automation import ( AutomationTriggerInfo, ) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA -from homeassistant.components.homeassistant.triggers import state +from homeassistant.components.homeassistant.triggers import state as state_trigger from homeassistant.const import ( CONF_DEVICE_ID, CONF_DOMAIN, @@ -86,11 +86,11 @@ async def async_attach_trigger( to_state = STATE_OFF state_config = { - state.CONF_PLATFORM: "state", + state_trigger.CONF_PLATFORM: "state", CONF_ENTITY_ID: config[CONF_ENTITY_ID], - state.CONF_TO: to_state, + state_trigger.CONF_TO: to_state, } - state_config = state.TRIGGER_SCHEMA(state_config) - return await state.async_attach_trigger( + state_config = await state_trigger.async_validate_trigger_config(hass, state_config) + return await state_trigger.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) diff --git a/setup.py b/setup.py index 2834a0b973d..ee163bc79f4 100755 --- a/setup.py +++ b/setup.py @@ -32,20 +32,22 @@ PROJECT_URLS = { PACKAGES = find_packages(exclude=["tests", "tests.*"]) REQUIRES = [ - "aiohttp==3.7.4.post0", + "aiohttp==3.8.1", "astral==2.2", - "async_timeout==3.0.1", + "async_timeout==4.0.0", "attrs==21.2.0", - "awesomeversion==21.10.1", + "atomicwrites==1.4.0", + "awesomeversion==21.11.0", 'backports.zoneinfo;python_version<"3.9"', "bcrypt==3.1.7", "certifi>=2021.5.30", "ciso8601==2.2.0", - "httpx==0.19.0", - "jinja2==3.0.2", + "httpx==0.21.0", + "ifaddr==0.1.7", + "jinja2==3.0.3", "PyJWT==2.1.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==3.4.8", + "cryptography==35.0.0", "pip>=8.0.3,<20.3", "python-slugify==4.0.1", "pyyaml==6.0", diff --git a/tests/auth/providers/test_trusted_networks.py b/tests/auth/providers/test_trusted_networks.py index d7574bf0da1..406e9a033da 100644 --- a/tests/auth/providers/test_trusted_networks.py +++ b/tests/auth/providers/test_trusted_networks.py @@ -2,6 +2,7 @@ from ipaddress import ip_address, ip_network from unittest.mock import Mock, patch +from hass_nabucasa import remote import pytest import voluptuous as vol @@ -169,6 +170,27 @@ async def test_validate_access_proxy(hass, provider): provider.async_validate_access(ip_address("fd00::1")) +async def test_validate_access_cloud(hass, provider): + """Test validate access from trusted networks are blocked from cloud.""" + await async_setup_component( + hass, + "http", + { + "http": { + CONF_TRUSTED_PROXIES: ["192.168.128.0/31", "fd00::1"], + CONF_USE_X_FORWARDED_FOR: True, + } + }, + ) + hass.config.components.add("cloud") + + provider.async_validate_access(ip_address("192.168.128.2")) + + remote.is_cloud_request.set(True) + with pytest.raises(tn_auth.InvalidAuthError): + provider.async_validate_access(ip_address("192.168.128.2")) + + async def test_validate_refresh_token(provider): """Verify re-validation of refresh token.""" with patch.object(provider, "async_validate_access") as mock: diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index ef1430f99a6..53c2a4261ae 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -13,7 +13,7 @@ from homeassistant.auth import ( const as auth_const, models as auth_models, ) -from homeassistant.auth.const import MFA_SESSION_EXPIRATION +from homeassistant.auth.const import GROUP_ID_ADMIN, MFA_SESSION_EXPIRATION from homeassistant.core import callback from homeassistant.util import dt as dt_util @@ -390,6 +390,8 @@ async def test_generating_system_user(hass): user = await manager.async_create_system_user("Hass.io") token = await manager.async_create_refresh_token(user) assert user.system_generated + assert user.groups == [] + assert not user.local_only assert token is not None assert token.client_id is None @@ -397,6 +399,21 @@ async def test_generating_system_user(hass): assert len(events) == 1 assert events[0].data["user_id"] == user.id + # Passing arguments + user = await manager.async_create_system_user( + "Hass.io", group_ids=[GROUP_ID_ADMIN], local_only=True + ) + token = await manager.async_create_refresh_token(user) + assert user.system_generated + assert user.is_admin + assert user.local_only + assert token is not None + assert token.client_id is None + + await hass.async_block_till_done() + assert len(events) == 2 + assert events[1].data["user_id"] == user.id + async def test_refresh_token_requires_client_for_user(hass): """Test create refresh token for a user with client_id.""" @@ -1038,15 +1055,19 @@ async def test_new_users(mock_hass): # first user in the system is owner and admin assert user.is_owner assert user.is_admin + assert not user.local_only assert user.groups == [] user = await manager.async_create_user("Hello 2") assert not user.is_admin assert user.groups == [] - user = await manager.async_create_user("Hello 3", ["system-admin"]) + user = await manager.async_create_user( + "Hello 3", group_ids=["system-admin"], local_only=True + ) assert user.is_admin assert user.groups[0].id == "system-admin" + assert user.local_only user_cred = await manager.async_get_or_create_user( auth_models.Credentials( diff --git a/tests/backports/__init__.py b/tests/backports/__init__.py new file mode 100644 index 00000000000..3f701810a5d --- /dev/null +++ b/tests/backports/__init__.py @@ -0,0 +1 @@ +"""The tests for the backports.""" diff --git a/tests/backports/test_enum.py b/tests/backports/test_enum.py new file mode 100644 index 00000000000..645db2bd7ca --- /dev/null +++ b/tests/backports/test_enum.py @@ -0,0 +1,35 @@ +"""Test Home Assistant enum utils.""" + +from enum import auto + +import pytest + +from homeassistant.backports.enum import StrEnum + + +def test_strenum(): + """Test StrEnum.""" + + class TestEnum(StrEnum): + Test = "test" + + assert str(TestEnum.Test) == "test" + assert TestEnum.Test == "test" + assert TestEnum("test") is TestEnum.Test + assert TestEnum(TestEnum.Test) is TestEnum.Test + + with pytest.raises(ValueError): + TestEnum(42) + + with pytest.raises(ValueError): + TestEnum("str but unknown") + + with pytest.raises(TypeError): + + class FailEnum(StrEnum): + Test = 42 + + with pytest.raises(TypeError): + + class FailEnum2(StrEnum): + Test = auto() diff --git a/tests/common.py b/tests/common.py index 519b53cd991..9d4a9cfe366 100644 --- a/tests/common.py +++ b/tests/common.py @@ -371,9 +371,12 @@ fire_mqtt_message = threadsafe_callback_factory(async_fire_mqtt_message) @ha.callback def async_fire_time_changed( - hass: HomeAssistant, datetime_: datetime, fire_all: bool = False + hass: HomeAssistant, datetime_: datetime = None, fire_all: bool = False ) -> None: - """Fire a time changes event.""" + """Fire a time changed event.""" + if datetime_ is None: + datetime_ = date_util.utcnow() + hass.bus.async_fire(EVENT_TIME_CHANGED, {"now": date_util.as_utc(datetime_)}) for task in list(hass.loop._scheduled): @@ -397,11 +400,22 @@ def async_fire_time_changed( fire_time_changed = threadsafe_callback_factory(async_fire_time_changed) -def load_fixture(filename): +def get_fixture_path(filename: str, integration: str | None = None) -> pathlib.Path: + """Get path of fixture.""" + if integration is None and "/" in filename and not filename.startswith("helpers/"): + integration, filename = filename.split("/", 1) + + if integration is None: + return pathlib.Path(__file__).parent.joinpath("fixtures", filename) + else: + return pathlib.Path(__file__).parent.joinpath( + "components", integration, "fixtures", filename + ) + + +def load_fixture(filename, integration=None): """Load a fixture.""" - path = os.path.join(os.path.dirname(__file__), "fixtures", filename) - with open(path, encoding="utf-8") as fptr: - return fptr.read() + return get_fixture_path(filename, integration).read_text() def mock_state_change_event(hass, new_state, old_state=None): @@ -426,8 +440,11 @@ def mock_component(hass, component): def mock_registry(hass, mock_entries=None): """Mock the Entity Registry.""" registry = entity_registry.EntityRegistry(hass) - registry.entities = mock_entries or OrderedDict() - registry._rebuild_index() + if mock_entries is None: + mock_entries = {} + registry.entities = entity_registry.EntityRegistryItems() + for key, entry in mock_entries.items(): + registry.entities[key] = entry hass.data[entity_registry.DATA_REGISTRY] = registry return registry @@ -885,8 +902,9 @@ def init_recorder_component(hass, add_config=None): async def async_init_recorder_component(hass, add_config=None): """Initialize the recorder asynchronously.""" - config = dict(add_config) if add_config else {} - config[recorder.CONF_DB_URL] = "sqlite://" + config = add_config or {} + if recorder.CONF_DB_URL not in config: + config[recorder.CONF_DB_URL] = "sqlite://" with patch("homeassistant.components.recorder.migration.migrate_schema"): assert await async_setup_component( @@ -928,6 +946,41 @@ class MockEntity(entity.Entity): if "entity_id" in values: self.entity_id = values["entity_id"] + @property + def available(self): + """Return True if entity is available.""" + return self._handle("available") + + @property + def capability_attributes(self): + """Info about capabilities.""" + return self._handle("capability_attributes") + + @property + def device_class(self): + """Info how device should be classified.""" + return self._handle("device_class") + + @property + def device_info(self): + """Info how it links to a device.""" + return self._handle("device_info") + + @property + def entity_category(self): + """Return the entity category.""" + return self._handle("entity_category") + + @property + def entity_registry_enabled_default(self): + """Return if the entity should be enabled when first added to the entity registry.""" + return self._handle("entity_registry_enabled_default") + + @property + def icon(self): + """Return the suggested icon.""" + return self._handle("icon") + @property def name(self): """Return the name of the entity.""" @@ -938,50 +991,25 @@ class MockEntity(entity.Entity): """Return the ste of the polling.""" return self._handle("should_poll") - @property - def unique_id(self): - """Return the unique ID of the entity.""" - return self._handle("unique_id") - @property def state(self): """Return the state of the entity.""" return self._handle("state") - @property - def available(self): - """Return True if entity is available.""" - return self._handle("available") - - @property - def device_info(self): - """Info how it links to a device.""" - return self._handle("device_info") - - @property - def device_class(self): - """Info how device should be classified.""" - return self._handle("device_class") - - @property - def unit_of_measurement(self): - """Info on the units the entity state is in.""" - return self._handle("unit_of_measurement") - - @property - def capability_attributes(self): - """Info about capabilities.""" - return self._handle("capability_attributes") - @property def supported_features(self): """Info about supported features.""" return self._handle("supported_features") @property - def entity_registry_enabled_default(self): - """Return if the entity should be enabled when first added to the entity registry.""" - return self._handle("entity_registry_enabled_default") + def unique_id(self): + """Return the unique ID of the entity.""" + return self._handle("unique_id") + + @property + def unit_of_measurement(self): + """Info on the units the entity state is in.""" + return self._handle("unit_of_measurement") def _handle(self, attr): """Return attribute value.""" diff --git a/tests/components/abode/conftest.py b/tests/components/abode/conftest.py index 21d64a644a9..472587781ca 100644 --- a/tests/components/abode/conftest.py +++ b/tests/components/abode/conftest.py @@ -10,16 +10,18 @@ from tests.components.light.conftest import mock_light_profiles # noqa: F401 def requests_mock_fixture(requests_mock): """Fixture to provide a requests mocker.""" # Mocks the login response for abodepy. - requests_mock.post(CONST.LOGIN_URL, text=load_fixture("abode_login.json")) + requests_mock.post(CONST.LOGIN_URL, text=load_fixture("login.json", "abode")) # Mocks the logout response for abodepy. - requests_mock.post(CONST.LOGOUT_URL, text=load_fixture("abode_logout.json")) + requests_mock.post(CONST.LOGOUT_URL, text=load_fixture("logout.json", "abode")) # Mocks the oauth claims response for abodepy. requests_mock.get( - CONST.OAUTH_TOKEN_URL, text=load_fixture("abode_oauth_claims.json") + CONST.OAUTH_TOKEN_URL, text=load_fixture("oauth_claims.json", "abode") ) # Mocks the panel response for abodepy. - requests_mock.get(CONST.PANEL_URL, text=load_fixture("abode_panel.json")) + requests_mock.get(CONST.PANEL_URL, text=load_fixture("panel.json", "abode")) # Mocks the automations response for abodepy. - requests_mock.get(CONST.AUTOMATION_URL, text=load_fixture("abode_automation.json")) + requests_mock.get( + CONST.AUTOMATION_URL, text=load_fixture("automation.json", "abode") + ) # Mocks the devices response for abodepy. - requests_mock.get(CONST.DEVICES_URL, text=load_fixture("abode_devices.json")) + requests_mock.get(CONST.DEVICES_URL, text=load_fixture("devices.json", "abode")) diff --git a/tests/fixtures/abode_automation.json b/tests/components/abode/fixtures/automation.json similarity index 100% rename from tests/fixtures/abode_automation.json rename to tests/components/abode/fixtures/automation.json diff --git a/tests/fixtures/abode_automation_changed.json b/tests/components/abode/fixtures/automation_changed.json similarity index 100% rename from tests/fixtures/abode_automation_changed.json rename to tests/components/abode/fixtures/automation_changed.json diff --git a/tests/fixtures/abode_devices.json b/tests/components/abode/fixtures/devices.json similarity index 100% rename from tests/fixtures/abode_devices.json rename to tests/components/abode/fixtures/devices.json diff --git a/tests/fixtures/abode_login.json b/tests/components/abode/fixtures/login.json similarity index 100% rename from tests/fixtures/abode_login.json rename to tests/components/abode/fixtures/login.json diff --git a/tests/fixtures/abode_logout.json b/tests/components/abode/fixtures/logout.json similarity index 100% rename from tests/fixtures/abode_logout.json rename to tests/components/abode/fixtures/logout.json diff --git a/tests/fixtures/abode_oauth_claims.json b/tests/components/abode/fixtures/oauth_claims.json similarity index 100% rename from tests/fixtures/abode_oauth_claims.json rename to tests/components/abode/fixtures/oauth_claims.json diff --git a/tests/fixtures/abode_panel.json b/tests/components/abode/fixtures/panel.json similarity index 100% rename from tests/fixtures/abode_panel.json rename to tests/components/abode/fixtures/panel.json diff --git a/tests/fixtures/accuweather/current_conditions_data.json b/tests/components/accuweather/fixtures/current_conditions_data.json similarity index 100% rename from tests/fixtures/accuweather/current_conditions_data.json rename to tests/components/accuweather/fixtures/current_conditions_data.json diff --git a/tests/fixtures/accuweather/forecast_data.json b/tests/components/accuweather/fixtures/forecast_data.json similarity index 100% rename from tests/fixtures/accuweather/forecast_data.json rename to tests/components/accuweather/fixtures/forecast_data.json diff --git a/tests/fixtures/accuweather/location_data.json b/tests/components/accuweather/fixtures/location_data.json similarity index 100% rename from tests/fixtures/accuweather/location_data.json rename to tests/components/accuweather/fixtures/location_data.json diff --git a/tests/components/acmeda/test_config_flow.py b/tests/components/acmeda/test_config_flow.py index 269a72cd839..c50e0b9971f 100644 --- a/tests/components/acmeda/test_config_flow.py +++ b/tests/components/acmeda/test_config_flow.py @@ -69,7 +69,7 @@ async def test_show_form_one_hub(hass, mock_hub_discover, mock_hub_run): assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == dummy_hub_1.id assert result["result"].data == { - "host": DUMMY_HOST1, + CONF_HOST: DUMMY_HOST1, } # Check we performed the discovery @@ -120,7 +120,7 @@ async def test_create_second_entry(hass, mock_hub_run, mock_hub_discover): assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == dummy_hub_2.id assert result["result"].data == { - "host": DUMMY_HOST2, + CONF_HOST: DUMMY_HOST2, } diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py index 75d138f400c..f5b30308a52 100644 --- a/tests/components/adguard/test_config_flow.py +++ b/tests/components/adguard/test_config_flow.py @@ -3,6 +3,7 @@ import aiohttp from homeassistant import config_entries, data_entry_flow from homeassistant.components.adguard.const import DOMAIN +from homeassistant.components.hassio import HassioServiceInfo from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_HOST, @@ -121,7 +122,13 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data={"addon": "AdGuard Home Addon", "host": "mock-adguard", "port": "3000"}, + data=HassioServiceInfo( + config={ + "addon": "AdGuard Home Addon", + "host": "mock-adguard", + "port": "3000", + } + ), context={"source": config_entries.SOURCE_HASSIO}, ) assert result @@ -137,7 +144,13 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data={"addon": "AdGuard Home Addon", "host": "mock-adguard", "port": "3000"}, + data=HassioServiceInfo( + config={ + "addon": "AdGuard Home Addon", + "host": "mock-adguard", + "port": "3000", + } + ), context={"source": config_entries.SOURCE_HASSIO}, ) assert result @@ -157,7 +170,9 @@ async def test_hassio_confirm( result = await hass.config_entries.flow.async_init( DOMAIN, - data={"addon": "AdGuard Home Addon", "host": "mock-adguard", "port": 3000}, + data=HassioServiceInfo( + config={"addon": "AdGuard Home Addon", "host": "mock-adguard", "port": 3000} + ), context={"source": config_entries.SOURCE_HASSIO}, ) assert result @@ -191,7 +206,9 @@ async def test_hassio_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, - data={"addon": "AdGuard Home Addon", "host": "mock-adguard", "port": 3000}, + data=HassioServiceInfo( + config={"addon": "AdGuard Home Addon", "host": "mock-adguard", "port": 3000} + ), context={"source": config_entries.SOURCE_HASSIO}, ) diff --git a/tests/fixtures/advantage_air/getSystemData.json b/tests/components/advantage_air/fixtures/getSystemData.json similarity index 100% rename from tests/fixtures/advantage_air/getSystemData.json rename to tests/components/advantage_air/fixtures/getSystemData.json diff --git a/tests/fixtures/advantage_air/setAircon.json b/tests/components/advantage_air/fixtures/setAircon.json similarity index 100% rename from tests/fixtures/advantage_air/setAircon.json rename to tests/components/advantage_air/fixtures/setAircon.json diff --git a/tests/fixtures/aemet/station-3195-data.json b/tests/components/aemet/fixtures/station-3195-data.json similarity index 100% rename from tests/fixtures/aemet/station-3195-data.json rename to tests/components/aemet/fixtures/station-3195-data.json diff --git a/tests/fixtures/aemet/station-3195.json b/tests/components/aemet/fixtures/station-3195.json similarity index 100% rename from tests/fixtures/aemet/station-3195.json rename to tests/components/aemet/fixtures/station-3195.json diff --git a/tests/fixtures/aemet/station-list-data.json b/tests/components/aemet/fixtures/station-list-data.json similarity index 100% rename from tests/fixtures/aemet/station-list-data.json rename to tests/components/aemet/fixtures/station-list-data.json diff --git a/tests/fixtures/aemet/station-list.json b/tests/components/aemet/fixtures/station-list.json similarity index 100% rename from tests/fixtures/aemet/station-list.json rename to tests/components/aemet/fixtures/station-list.json diff --git a/tests/fixtures/aemet/town-28065-forecast-daily-data.json b/tests/components/aemet/fixtures/town-28065-forecast-daily-data.json similarity index 100% rename from tests/fixtures/aemet/town-28065-forecast-daily-data.json rename to tests/components/aemet/fixtures/town-28065-forecast-daily-data.json diff --git a/tests/fixtures/aemet/town-28065-forecast-daily.json b/tests/components/aemet/fixtures/town-28065-forecast-daily.json similarity index 100% rename from tests/fixtures/aemet/town-28065-forecast-daily.json rename to tests/components/aemet/fixtures/town-28065-forecast-daily.json diff --git a/tests/fixtures/aemet/town-28065-forecast-hourly-data.json b/tests/components/aemet/fixtures/town-28065-forecast-hourly-data.json similarity index 100% rename from tests/fixtures/aemet/town-28065-forecast-hourly-data.json rename to tests/components/aemet/fixtures/town-28065-forecast-hourly-data.json diff --git a/tests/fixtures/aemet/town-28065-forecast-hourly.json b/tests/components/aemet/fixtures/town-28065-forecast-hourly.json similarity index 100% rename from tests/fixtures/aemet/town-28065-forecast-hourly.json rename to tests/components/aemet/fixtures/town-28065-forecast-hourly.json diff --git a/tests/fixtures/aemet/town-id28065.json b/tests/components/aemet/fixtures/town-id28065.json similarity index 100% rename from tests/fixtures/aemet/town-id28065.json rename to tests/components/aemet/fixtures/town-id28065.json diff --git a/tests/fixtures/aemet/town-list.json b/tests/components/aemet/fixtures/town-list.json similarity index 100% rename from tests/fixtures/aemet/town-list.json rename to tests/components/aemet/fixtures/town-list.json diff --git a/tests/fixtures/agent_dvr/objects.json b/tests/components/agent_dvr/fixtures/objects.json similarity index 100% rename from tests/fixtures/agent_dvr/objects.json rename to tests/components/agent_dvr/fixtures/objects.json diff --git a/tests/fixtures/agent_dvr/status.json b/tests/components/agent_dvr/fixtures/status.json similarity index 100% rename from tests/fixtures/agent_dvr/status.json rename to tests/components/agent_dvr/fixtures/status.json diff --git a/tests/components/airly/__init__.py b/tests/components/airly/__init__.py index 64f2059857a..452df6d9c27 100644 --- a/tests/components/airly/__init__.py +++ b/tests/components/airly/__init__.py @@ -23,7 +23,7 @@ async def init_integration(hass, aioclient_mock) -> MockConfigEntry: }, ) - aioclient_mock.get(API_POINT_URL, text=load_fixture("airly_valid_station.json")) + aioclient_mock.get(API_POINT_URL, text=load_fixture("valid_station.json", "airly")) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/fixtures/airly_no_station.json b/tests/components/airly/fixtures/no_station.json similarity index 100% rename from tests/fixtures/airly_no_station.json rename to tests/components/airly/fixtures/no_station.json diff --git a/tests/fixtures/airly_valid_station.json b/tests/components/airly/fixtures/valid_station.json similarity index 100% rename from tests/fixtures/airly_valid_station.json rename to tests/components/airly/fixtures/valid_station.json diff --git a/tests/components/airly/test_config_flow.py b/tests/components/airly/test_config_flow.py index c19618da0a7..8b593e85cf4 100644 --- a/tests/components/airly/test_config_flow.py +++ b/tests/components/airly/test_config_flow.py @@ -48,7 +48,7 @@ async def test_invalid_api_key(hass, aioclient_mock): async def test_invalid_location(hass, aioclient_mock): """Test that errors are shown when location is invalid.""" - aioclient_mock.get(API_POINT_URL, text=load_fixture("airly_no_station.json")) + aioclient_mock.get(API_POINT_URL, text=load_fixture("no_station.json", "airly")) aioclient_mock.get( API_NEAREST_URL, @@ -64,7 +64,7 @@ async def test_invalid_location(hass, aioclient_mock): async def test_duplicate_error(hass, aioclient_mock): """Test that errors are shown when duplicates are added.""" - aioclient_mock.get(API_POINT_URL, text=load_fixture("airly_valid_station.json")) + aioclient_mock.get(API_POINT_URL, text=load_fixture("valid_station.json", "airly")) MockConfigEntry(domain=DOMAIN, unique_id="123-456", data=CONFIG).add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -77,7 +77,7 @@ async def test_duplicate_error(hass, aioclient_mock): async def test_create_entry(hass, aioclient_mock): """Test that the user step works.""" - aioclient_mock.get(API_POINT_URL, text=load_fixture("airly_valid_station.json")) + aioclient_mock.get(API_POINT_URL, text=load_fixture("valid_station.json", "airly")) with patch("homeassistant.components.airly.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_init( @@ -95,9 +95,11 @@ async def test_create_entry(hass, aioclient_mock): async def test_create_entry_with_nearest_method(hass, aioclient_mock): """Test that the user step works with nearest method.""" - aioclient_mock.get(API_POINT_URL, text=load_fixture("airly_no_station.json")) + aioclient_mock.get(API_POINT_URL, text=load_fixture("no_station.json", "airly")) - aioclient_mock.get(API_NEAREST_URL, text=load_fixture("airly_valid_station.json")) + aioclient_mock.get( + API_NEAREST_URL, text=load_fixture("valid_station.json", "airly") + ) with patch("homeassistant.components.airly.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index 252c01c124a..434c7e5022f 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -66,7 +66,7 @@ async def test_config_without_unique_id(hass, aioclient_mock): }, ) - aioclient_mock.get(API_POINT_URL, text=load_fixture("airly_valid_station.json")) + aioclient_mock.get(API_POINT_URL, text=load_fixture("valid_station.json", "airly")) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.LOADED @@ -87,7 +87,7 @@ async def test_config_with_turned_off_station(hass, aioclient_mock): }, ) - aioclient_mock.get(API_POINT_URL, text=load_fixture("airly_no_station.json")) + aioclient_mock.get(API_POINT_URL, text=load_fixture("no_station.json", "airly")) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY @@ -115,7 +115,7 @@ async def test_update_interval(hass, aioclient_mock): aioclient_mock.get( API_POINT_URL, - text=load_fixture("airly_valid_station.json"), + text=load_fixture("valid_station.json", "airly"), headers=HEADERS, ) entry.add_to_hass(hass) @@ -152,7 +152,7 @@ async def test_update_interval(hass, aioclient_mock): aioclient_mock.get( "https://airapi.airly.eu/v2/measurements/point?lat=66.660000&lng=111.110000", - text=load_fixture("airly_valid_station.json"), + text=load_fixture("valid_station.json", "airly"), headers=HEADERS, ) entry.add_to_hass(hass) @@ -203,7 +203,7 @@ async def test_migrate_device_entry(hass, aioclient_mock, old_identifier): }, ) - aioclient_mock.get(API_POINT_URL, text=load_fixture("airly_valid_station.json")) + aioclient_mock.get(API_POINT_URL, text=load_fixture("valid_station.json", "airly")) config_entry.add_to_hass(hass) device_reg = mock_device_registry(hass) diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py index cd17c692176..71912bb768b 100644 --- a/tests/components/airly/test_sensor.py +++ b/tests/components/airly/test_sensor.py @@ -149,7 +149,7 @@ async def test_availability(hass, aioclient_mock): assert state.state == STATE_UNAVAILABLE aioclient_mock.clear_requests() - aioclient_mock.get(API_POINT_URL, text=load_fixture("airly_valid_station.json")) + aioclient_mock.get(API_POINT_URL, text=load_fixture("valid_station.json", "airly")) future = utcnow() + timedelta(minutes=120) async_fire_time_changed(hass, future) await hass.async_block_till_done() diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py index 1fdb908d2e6..75f1dc76aaf 100644 --- a/tests/components/alarm_control_panel/test_device_action.py +++ b/tests/components/alarm_control_panel/test_device_action.py @@ -160,6 +160,7 @@ async def test_get_action_capabilities( } actions = await async_get_device_automations(hass, "action", device_entry.id) assert len(actions) == 6 + assert {action["type"] for action in actions} == set(expected_capabilities) for action in actions: capabilities = await async_get_device_automation_capabilities( hass, "action", action @@ -209,6 +210,7 @@ async def test_get_action_capabilities_arm_code( } actions = await async_get_device_automations(hass, "action", device_entry.id) assert len(actions) == 6 + assert {action["type"] for action in actions} == set(expected_capabilities) for action in actions: capabilities = await async_get_device_automation_capabilities( hass, "action", action diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py index 199be9845ca..ef21b463a12 100644 --- a/tests/components/alert/test_init.py +++ b/tests/components/alert/test_init.py @@ -310,13 +310,6 @@ async def test_skipfirst(hass): assert len(events) == 0 -async def test_noack(hass): - """Test no ack feature.""" - entity = alert.Alert(hass, *TEST_NOACK) - hass.async_add_job(entity.begin_alerting) - await hass.async_block_till_done() - - async def test_done_message_state_tracker_reset_on_cancel(hass): """Test that the done message is reset when canceled.""" entity = alert.Alert(hass, *TEST_NOACK) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 99d43816050..12708c9b55a 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -25,7 +25,7 @@ from homeassistant.components.media_player.const import ( ) import homeassistant.components.vacuum as vacuum from homeassistant.config import async_process_ha_core_config -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import Context from homeassistant.helpers import entityfilter from homeassistant.setup import async_setup_component @@ -3937,3 +3937,23 @@ async def test_initialize_camera_stream(hass, mock_camera, mock_stream): "https://mycamerastream.test/api/camera_proxy/camera.demo_camera?token=" in response["payload"]["imageUri"] ) + + +async def test_button(hass): + """Test button discovery.""" + device = ("button.ring_doorbell", STATE_UNKNOWN, {"friendly_name": "Ring Doorbell"}) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "button#ring_doorbell" + assert appliance["displayCategories"][0] == "ACTIVITY_TRIGGER" + assert appliance["friendlyName"] == "Ring Doorbell" + + capabilities = assert_endpoint_capabilities( + appliance, "Alexa.SceneController", "Alexa" + ) + scene_capability = get_capability(capabilities, "Alexa.SceneController") + assert scene_capability["supportsDeactivation"] is False + + await assert_scene_controller_works( + "button#ring_doorbell", "button.press", False, hass + ) diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index bd91dc8f846..29624e7d1ff 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -117,10 +117,18 @@ async def test_send_add_or_update_message(hass, aioclient_mock): {"friendly_name": "Test Contact Sensor", "device_class": "door"}, ) - await state_report.async_send_add_or_update_message( - hass, DEFAULT_CONFIG, ["binary_sensor.test_contact", "zwave.bla"] + hass.states.async_set( + "zwave.bla", + "wow_such_unsupported", ) + entities = [ + "binary_sensor.test_contact", + "binary_sensor.non_existing", # Supported, but does not exist + "zwave.bla", # Unsupported + ] + await state_report.async_send_add_or_update_message(hass, DEFAULT_CONFIG, entities) + assert len(aioclient_mock.mock_calls) == 1 call = aioclient_mock.mock_calls diff --git a/tests/components/almond/test_config_flow.py b/tests/components/almond/test_config_flow.py index e0e88d0f43b..32f49cff043 100644 --- a/tests/components/almond/test_config_flow.py +++ b/tests/components/almond/test_config_flow.py @@ -6,6 +6,7 @@ from unittest.mock import patch from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.almond import config_flow from homeassistant.components.almond.const import DOMAIN +from homeassistant.components.hassio import HassioServiceInfo from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.helpers import config_entry_oauth2_flow @@ -51,7 +52,9 @@ async def test_hassio(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data={"addon": "Almond add-on", "host": "almond-addon", "port": "1234"}, + data=HassioServiceInfo( + config={"addon": "Almond add-on", "host": "almond-addon", "port": "1234"} + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -87,7 +90,7 @@ async def test_abort_if_existing_entry(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "single_instance_allowed" - result = await flow.async_step_hassio({}) + result = await flow.async_step_hassio(HassioServiceInfo(config={})) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "single_instance_allowed" diff --git a/tests/fixtures/ambee/air_quality.json b/tests/components/ambee/fixtures/air_quality.json similarity index 100% rename from tests/fixtures/ambee/air_quality.json rename to tests/components/ambee/fixtures/air_quality.json diff --git a/tests/fixtures/ambee/pollen.json b/tests/components/ambee/fixtures/pollen.json similarity index 100% rename from tests/fixtures/ambee/pollen.json rename to tests/components/ambee/fixtures/pollen.json diff --git a/tests/components/ambee/test_sensor.py b/tests/components/ambee/test_sensor.py index 34eaa273901..a198d420378 100644 --- a/tests/components/ambee/test_sensor.py +++ b/tests/components/ambee/test_sensor.py @@ -3,11 +3,7 @@ from unittest.mock import AsyncMock import pytest -from homeassistant.components.ambee.const import ( - DEVICE_CLASS_AMBEE_RISK, - DOMAIN, - ENTRY_TYPE_SERVICE, -) +from homeassistant.components.ambee.const import DEVICE_CLASS_AMBEE_RISK, DOMAIN from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, @@ -147,7 +143,7 @@ async def test_air_quality( assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_air_quality")} assert device_entry.manufacturer == "Ambee" assert device_entry.name == "Air Quality" - assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert device_entry.entry_type is dr.DeviceEntryType.SERVICE assert not device_entry.model assert not device_entry.sw_version @@ -248,7 +244,7 @@ async def test_pollen( assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_pollen")} assert device_entry.manufacturer == "Ambee" assert device_entry.name == "Pollen" - assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert device_entry.entry_type is dr.DeviceEntryType.SERVICE assert not device_entry.model assert not device_entry.sw_version diff --git a/tests/components/amberelectric/test_sensor.py b/tests/components/amberelectric/test_sensor.py index ccfcd82b3bd..deafcb70fb7 100644 --- a/tests/components/amberelectric/test_sensor.py +++ b/tests/components/amberelectric/test_sensor.py @@ -164,7 +164,6 @@ async def test_general_and_feed_in_price_sensor( ) -> None: """Test the Feed In sensor.""" assert len(hass.states.async_all()) == 6 - print(hass.states) price = hass.states.get("sensor.mock_title_feed_in_price") assert price assert price.state == "-0.08" diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index 45edaa36251..a99df9ad856 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -7,15 +7,19 @@ from pyatv.const import Protocol import pytest from homeassistant import config_entries, data_entry_flow +from homeassistant.components import zeroconf from homeassistant.components.apple_tv.const import CONF_START_OFF, DOMAIN from tests.common import MockConfigEntry -DMAP_SERVICE = { - "type": "_touch-able._tcp.local.", - "name": "dmapid.something", - "properties": {"CtlN": "Apple TV"}, -} +DMAP_SERVICE = zeroconf.ZeroconfServiceInfo( + host="mock_host", + hostname="mock_hostname", + name="dmapid.something", + port=None, + properties={"CtlN": "Apple TV"}, + type="_touch-able._tcp.local.", +) @pytest.fixture(autouse=True) @@ -399,10 +403,14 @@ async def test_zeroconf_unsupported_service_aborts(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "type": "_dummy._tcp.local.", - "properties": {}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="mock_host", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={}, + type="_dummy._tcp.local.", + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "unknown" @@ -413,10 +421,14 @@ async def test_zeroconf_add_mrp_device(hass, mrp_device, pairing): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "type": "_mediaremotetv._tcp.local.", - "properties": {"UniqueIdentifier": "mrpid", "Name": "Kitchen"}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="mock_host", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={"UniqueIdentifier": "mrpid", "Name": "Kitchen"}, + type="_mediaremotetv._tcp.local.", + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["description_placeholders"] == {"name": "MRP Device"} diff --git a/tests/components/aprs/test_device_tracker.py b/tests/components/aprs/test_device_tracker.py index dc0cf09f28d..3da8cb825e4 100644 --- a/tests/components/aprs/test_device_tracker.py +++ b/tests/components/aprs/test_device_tracker.py @@ -327,19 +327,18 @@ def test_setup_scanner(): def test_setup_scanner_timeout(): """Test setup_scanner failure from timeout.""" - hass = get_test_home_assistant() - hass.start() + with patch("aprslib.IS.connect", side_effect=TimeoutError): + hass = get_test_home_assistant() + hass.start() - config = { - "username": TEST_CALLSIGN, - "password": TEST_PASSWORD, - "host": "localhost", - "timeout": 0.01, - "callsigns": ["XX0FOO*", "YY0BAR-1"], - } + config = { + "username": TEST_CALLSIGN, + "password": TEST_PASSWORD, + "host": "localhost", + "timeout": 0.01, + "callsigns": ["XX0FOO*", "YY0BAR-1"], + } - see = Mock() - try: + see = Mock() assert not device_tracker.setup_scanner(hass, config, see) - finally: hass.stop() diff --git a/tests/components/arcam_fmj/test_config_flow.py b/tests/components/arcam_fmj/test_config_flow.py index 6c86b2bbf96..efcbb0b691c 100644 --- a/tests/components/arcam_fmj/test_config_flow.py +++ b/tests/components/arcam_fmj/test_config_flow.py @@ -33,16 +33,20 @@ MOCK_UPNP_DEVICE = f""" MOCK_UPNP_LOCATION = f"http://{MOCK_HOST}:8080/dd.xml" -MOCK_DISCOVER = { - ssdp.ATTR_UPNP_MANUFACTURER: "ARCAM", - ssdp.ATTR_UPNP_MODEL_NAME: " ", - ssdp.ATTR_UPNP_MODEL_NUMBER: "AVR450, AVR750", - ssdp.ATTR_UPNP_FRIENDLY_NAME: f"Arcam media client {MOCK_UUID}", - ssdp.ATTR_UPNP_SERIAL: "12343", - ssdp.ATTR_SSDP_LOCATION: f"http://{MOCK_HOST}:8080/dd.xml", - ssdp.ATTR_UPNP_UDN: MOCK_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:MediaRenderer:1", -} +MOCK_DISCOVER = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=f"http://{MOCK_HOST}:8080/dd.xml", + upnp={ + ssdp.ATTR_UPNP_MANUFACTURER: "ARCAM", + ssdp.ATTR_UPNP_MODEL_NAME: " ", + ssdp.ATTR_UPNP_MODEL_NUMBER: "AVR450, AVR750", + ssdp.ATTR_UPNP_FRIENDLY_NAME: f"Arcam media client {MOCK_UUID}", + ssdp.ATTR_UPNP_SERIAL: "12343", + ssdp.ATTR_UPNP_UDN: MOCK_UDN, + ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:MediaRenderer:1", + }, +) @pytest.fixture(name="dummy_client", autouse=True) diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index 19c27777c2a..b8537a5e6a6 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -40,6 +40,7 @@ CONFIG_DATA = { MOCK_BYTES_TOTAL = [60000000000, 50000000000] MOCK_CURRENT_TRANSFER_RATES = [20000000, 10000000] MOCK_LOAD_AVG = [1.1, 1.2, 1.3] +MOCK_TEMPERATURES = {"2.4GHz": 40, "5.0GHz": 0, "CPU": 71.2} SENSOR_NAMES = [ "Devices Connected", @@ -50,6 +51,9 @@ SENSOR_NAMES = [ "Load Avg (1m)", "Load Avg (5m)", "Load Avg (15m)", + "2.4GHz Temperature", + "5GHz Temperature", + "CPU Temperature", ] @@ -62,8 +66,16 @@ def mock_devices_fixture(): } +@pytest.fixture(name="mock_available_temps") +def mock_available_temps_list(): + """Mock a list of available temperature sensors.""" + + # Only length of 3 booleans is valid. First checking the exception handling. + return [True, False] + + @pytest.fixture(name="connect") -def mock_controller_connect(mock_devices): +def mock_controller_connect(mock_devices, mock_available_temps): """Mock a successful connection.""" with patch("homeassistant.components.asuswrt.router.AsusWrt") as service_mock: service_mock.return_value.connection.async_connect = AsyncMock() @@ -88,10 +100,16 @@ def mock_controller_connect(mock_devices): service_mock.return_value.async_get_loadavg = AsyncMock( return_value=MOCK_LOAD_AVG ) + service_mock.return_value.async_get_temperature = AsyncMock( + return_value=MOCK_TEMPERATURES + ) + service_mock.return_value.async_find_temperature_commands = AsyncMock( + return_value=mock_available_temps + ) yield service_mock -async def test_sensors(hass, connect, mock_devices): +async def test_sensors(hass, connect, mock_devices, mock_available_temps): """Test creating an AsusWRT sensor.""" entity_reg = er.async_get(hass) @@ -137,6 +155,11 @@ async def test_sensors(hass, connect, mock_devices): assert hass.states.get(f"{sensor_prefix}_load_avg_15m").state == "1.3" assert hass.states.get(f"{sensor_prefix}_devices_connected").state == "2" + # assert temperature availability exception is handled correctly + assert not hass.states.get(f"{sensor_prefix}_2_4ghz_temperature") + assert not hass.states.get(f"{sensor_prefix}_5ghz_temperature") + assert not hass.states.get(f"{sensor_prefix}_cpu_temperature") + # add one device and remove another mock_devices.pop("a1:b1:c1:d1:e1:f1") mock_devices["a3:b3:c3:d3:e3:f3"] = Device( @@ -161,3 +184,15 @@ async def test_sensors(hass, connect, mock_devices): # consider home option not set, device "test" not home assert hass.states.get(f"{device_tracker.DOMAIN}.test").state == STATE_NOT_HOME + + # checking temperature sensors without exceptions + mock_available_temps.append(True) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert hass.states.get(f"{sensor_prefix}_load_avg_15m").state == "1.3" + assert hass.states.get(f"{sensor_prefix}_2_4ghz_temperature").state == "40.0" + assert not hass.states.get(f"{sensor_prefix}_5ghz_temperature") + assert hass.states.get(f"{sensor_prefix}_cpu_temperature").state == "71.2" diff --git a/tests/components/atag/test_climate.py b/tests/components/atag/test_climate.py index 3c9a9c3f820..ba6bc892e40 100644 --- a/tests/components/atag/test_climate.py +++ b/tests/components/atag/test_climate.py @@ -1,7 +1,7 @@ """Tests for the Atag climate platform.""" from unittest.mock import PropertyMock, patch -from homeassistant.components.atag.climate import CLIMATE, DOMAIN, PRESET_MAP +from homeassistant.components.atag.climate import DOMAIN, PRESET_MAP from homeassistant.components.climate import ( ATTR_HVAC_ACTION, ATTR_HVAC_MODE, @@ -13,7 +13,12 @@ from homeassistant.components.climate import ( ) from homeassistant.components.climate.const import CURRENT_HVAC_IDLE, PRESET_AWAY from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -21,7 +26,7 @@ from homeassistant.setup import async_setup_component from tests.components.atag import UID, init_integration from tests.test_util.aiohttp import AiohttpClientMocker -CLIMATE_ID = f"{CLIMATE}.{DOMAIN}" +CLIMATE_ID = f"{Platform.CLIMATE}.{DOMAIN}" async def test_climate( @@ -33,7 +38,7 @@ async def test_climate( assert entity_registry.async_is_registered(CLIMATE_ID) entity = entity_registry.async_get(CLIMATE_ID) - assert entity.unique_id == f"{UID}-{CLIMATE}" + assert entity.unique_id == f"{UID}-{Platform.CLIMATE}" assert hass.states.get(CLIMATE_ID).attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE @@ -44,7 +49,7 @@ async def test_setting_climate( await init_integration(hass, aioclient_mock) with patch("pyatag.entities.Climate.set_temp") as mock_set_temp: await hass.services.async_call( - CLIMATE, + Platform.CLIMATE, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: CLIMATE_ID, ATTR_TEMPERATURE: 15}, blocking=True, @@ -54,7 +59,7 @@ async def test_setting_climate( with patch("pyatag.entities.Climate.set_preset_mode") as mock_set_preset: await hass.services.async_call( - CLIMATE, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: CLIMATE_ID, ATTR_PRESET_MODE: PRESET_AWAY}, blocking=True, @@ -64,7 +69,7 @@ async def test_setting_climate( with patch("pyatag.entities.Climate.set_hvac_mode") as mock_set_hvac: await hass.services.async_call( - CLIMATE, + Platform.CLIMATE, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: CLIMATE_ID, ATTR_HVAC_MODE: HVAC_MODE_HEAT}, blocking=True, diff --git a/tests/components/atag/test_water_heater.py b/tests/components/atag/test_water_heater.py index 4c78302224d..df83fa6d40b 100644 --- a/tests/components/atag/test_water_heater.py +++ b/tests/components/atag/test_water_heater.py @@ -1,16 +1,16 @@ """Tests for the Atag water heater platform.""" from unittest.mock import patch -from homeassistant.components.atag import DOMAIN, WATER_HEATER +from homeassistant.components.atag import DOMAIN from homeassistant.components.water_heater import SERVICE_SET_TEMPERATURE -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from tests.components.atag import UID, init_integration from tests.test_util.aiohttp import AiohttpClientMocker -WATER_HEATER_ID = f"{WATER_HEATER}.{DOMAIN}" +WATER_HEATER_ID = f"{Platform.WATER_HEATER}.{DOMAIN}" async def test_water_heater( @@ -23,7 +23,7 @@ async def test_water_heater( assert registry.async_is_registered(WATER_HEATER_ID) entry = registry.async_get(WATER_HEATER_ID) - assert entry.unique_id == f"{UID}-{WATER_HEATER}" + assert entry.unique_id == f"{UID}-{Platform.WATER_HEATER}" async def test_setting_target_temperature( @@ -33,7 +33,7 @@ async def test_setting_target_temperature( await init_integration(hass, aioclient_mock) with patch("pyatag.entities.DHW.set_temp") as mock_set_temp: await hass.services.async_call( - WATER_HEATER, + Platform.WATER_HEATER, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: WATER_HEATER_ID, ATTR_TEMPERATURE: 50}, blocking=True, diff --git a/tests/fixtures/august/get_activity.bridge_offline.json b/tests/components/august/fixtures/get_activity.bridge_offline.json similarity index 100% rename from tests/fixtures/august/get_activity.bridge_offline.json rename to tests/components/august/fixtures/get_activity.bridge_offline.json diff --git a/tests/fixtures/august/get_activity.bridge_online.json b/tests/components/august/fixtures/get_activity.bridge_online.json similarity index 100% rename from tests/fixtures/august/get_activity.bridge_online.json rename to tests/components/august/fixtures/get_activity.bridge_online.json diff --git a/tests/fixtures/august/get_activity.doorbell_motion.json b/tests/components/august/fixtures/get_activity.doorbell_motion.json similarity index 100% rename from tests/fixtures/august/get_activity.doorbell_motion.json rename to tests/components/august/fixtures/get_activity.doorbell_motion.json diff --git a/tests/fixtures/august/get_activity.jammed.json b/tests/components/august/fixtures/get_activity.jammed.json similarity index 100% rename from tests/fixtures/august/get_activity.jammed.json rename to tests/components/august/fixtures/get_activity.jammed.json diff --git a/tests/fixtures/august/get_activity.lock.json b/tests/components/august/fixtures/get_activity.lock.json similarity index 100% rename from tests/fixtures/august/get_activity.lock.json rename to tests/components/august/fixtures/get_activity.lock.json diff --git a/tests/fixtures/august/get_activity.lock_from_autorelock.json b/tests/components/august/fixtures/get_activity.lock_from_autorelock.json similarity index 100% rename from tests/fixtures/august/get_activity.lock_from_autorelock.json rename to tests/components/august/fixtures/get_activity.lock_from_autorelock.json diff --git a/tests/fixtures/august/get_activity.lock_from_bluetooth.json b/tests/components/august/fixtures/get_activity.lock_from_bluetooth.json similarity index 100% rename from tests/fixtures/august/get_activity.lock_from_bluetooth.json rename to tests/components/august/fixtures/get_activity.lock_from_bluetooth.json diff --git a/tests/fixtures/august/get_activity.lock_from_keypad.json b/tests/components/august/fixtures/get_activity.lock_from_keypad.json similarity index 100% rename from tests/fixtures/august/get_activity.lock_from_keypad.json rename to tests/components/august/fixtures/get_activity.lock_from_keypad.json diff --git a/tests/fixtures/august/get_activity.locking.json b/tests/components/august/fixtures/get_activity.locking.json similarity index 100% rename from tests/fixtures/august/get_activity.locking.json rename to tests/components/august/fixtures/get_activity.locking.json diff --git a/tests/fixtures/august/get_activity.unlocking.json b/tests/components/august/fixtures/get_activity.unlocking.json similarity index 100% rename from tests/fixtures/august/get_activity.unlocking.json rename to tests/components/august/fixtures/get_activity.unlocking.json diff --git a/tests/fixtures/august/get_doorbell.json b/tests/components/august/fixtures/get_doorbell.json similarity index 100% rename from tests/fixtures/august/get_doorbell.json rename to tests/components/august/fixtures/get_doorbell.json diff --git a/tests/fixtures/august/get_doorbell.nobattery.json b/tests/components/august/fixtures/get_doorbell.nobattery.json similarity index 100% rename from tests/fixtures/august/get_doorbell.nobattery.json rename to tests/components/august/fixtures/get_doorbell.nobattery.json diff --git a/tests/fixtures/august/get_doorbell.offline.json b/tests/components/august/fixtures/get_doorbell.offline.json similarity index 100% rename from tests/fixtures/august/get_doorbell.offline.json rename to tests/components/august/fixtures/get_doorbell.offline.json diff --git a/tests/fixtures/august/get_lock.doorsense_init.json b/tests/components/august/fixtures/get_lock.doorsense_init.json similarity index 100% rename from tests/fixtures/august/get_lock.doorsense_init.json rename to tests/components/august/fixtures/get_lock.doorsense_init.json diff --git a/tests/fixtures/august/get_lock.low_keypad_battery.json b/tests/components/august/fixtures/get_lock.low_keypad_battery.json similarity index 100% rename from tests/fixtures/august/get_lock.low_keypad_battery.json rename to tests/components/august/fixtures/get_lock.low_keypad_battery.json diff --git a/tests/fixtures/august/get_lock.offline.json b/tests/components/august/fixtures/get_lock.offline.json similarity index 100% rename from tests/fixtures/august/get_lock.offline.json rename to tests/components/august/fixtures/get_lock.offline.json diff --git a/tests/fixtures/august/get_lock.online.json b/tests/components/august/fixtures/get_lock.online.json similarity index 100% rename from tests/fixtures/august/get_lock.online.json rename to tests/components/august/fixtures/get_lock.online.json diff --git a/tests/fixtures/august/get_lock.online.unknown_state.json b/tests/components/august/fixtures/get_lock.online.unknown_state.json similarity index 100% rename from tests/fixtures/august/get_lock.online.unknown_state.json rename to tests/components/august/fixtures/get_lock.online.unknown_state.json diff --git a/tests/fixtures/august/get_lock.online_missing_doorsense.json b/tests/components/august/fixtures/get_lock.online_missing_doorsense.json similarity index 100% rename from tests/fixtures/august/get_lock.online_missing_doorsense.json rename to tests/components/august/fixtures/get_lock.online_missing_doorsense.json diff --git a/tests/fixtures/august/get_lock.online_with_doorsense.json b/tests/components/august/fixtures/get_lock.online_with_doorsense.json similarity index 100% rename from tests/fixtures/august/get_lock.online_with_doorsense.json rename to tests/components/august/fixtures/get_lock.online_with_doorsense.json diff --git a/tests/fixtures/august/get_locks.json b/tests/components/august/fixtures/get_locks.json similarity index 100% rename from tests/fixtures/august/get_locks.json rename to tests/components/august/fixtures/get_locks.json diff --git a/tests/fixtures/august/lock_open.json b/tests/components/august/fixtures/lock_open.json similarity index 100% rename from tests/fixtures/august/lock_open.json rename to tests/components/august/fixtures/lock_open.json diff --git a/tests/fixtures/august/unlock_closed.json b/tests/components/august/fixtures/unlock_closed.json similarity index 100% rename from tests/fixtures/august/unlock_closed.json rename to tests/components/august/fixtures/unlock_closed.json diff --git a/tests/components/aurora_abb_powerone/test_config_flow.py b/tests/components/aurora_abb_powerone/test_config_flow.py index 620d7e10e53..e9a6a100f4a 100644 --- a/tests/components/aurora_abb_powerone/test_config_flow.py +++ b/tests/components/aurora_abb_powerone/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Aurora ABB PowerOne Solar PV config flow.""" from datetime import timedelta +import logging from logging import INFO from unittest.mock import patch @@ -17,7 +18,9 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ADDRESS, CONF_PORT from homeassistant.util.dt import utcnow -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed + +TEST_DATA = {"device": "/dev/ttyUSB7", "address": 3, "name": "MyAuroraPV"} def _simulated_returns(index, global_measure=None): @@ -163,10 +166,60 @@ async def test_form_invalid_com_ports(hass): assert len(mock_clientclose.mock_calls) == 1 +async def test_import_invalid_com_ports(hass, caplog): + """Test we display correct info when the comport is invalid..""" + + caplog.set_level(logging.ERROR) + with patch( + "aurorapy.client.AuroraSerialClient.connect", + side_effect=OSError(19, "...no such device..."), + return_value=None, + ): + await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TEST_DATA + ) + configs = hass.config_entries.async_entries(DOMAIN) + assert len(configs) == 1 + entry = configs[0] + assert entry.state == ConfigEntryState.SETUP_ERROR + assert "Failed to connect to inverter: " in caplog.text + + +async def test_import_com_port_wont_open(hass): + """Test we display correct info when comport won't open.""" + + with patch( + "aurorapy.client.AuroraSerialClient.connect", + side_effect=AuroraError("..could not open port..."), + ): + await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TEST_DATA + ) + configs = hass.config_entries.async_entries(DOMAIN) + assert len(configs) == 1 + entry = configs[0] + assert entry.state == ConfigEntryState.SETUP_ERROR + + +async def test_import_other_oserror(hass): + """Test we display correct info when comport won't open.""" + + with patch( + "aurorapy.client.AuroraSerialClient.connect", + side_effect=OSError(18, "...another error..."), + ): + await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TEST_DATA + ) + configs = hass.config_entries.async_entries(DOMAIN) + assert len(configs) == 1 + entry = configs[0] + assert entry.state == ConfigEntryState.SETUP_ERROR + + # Tests below can be deleted after deprecation period is finished. async def test_import_day(hass): """Test .yaml import when the inverter is able to communicate.""" - TEST_DATA = {"device": "/dev/ttyUSB7", "address": 3, "name": "MyAuroraPV"} with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None,), patch( "aurorapy.client.AuroraSerialClient.serial_number", @@ -195,7 +248,6 @@ async def test_import_day(hass): async def test_import_night(hass): """Test .yaml import when the inverter is inaccessible (e.g. darkness).""" - TEST_DATA = {"device": "/dev/ttyUSB7", "address": 3, "name": "MyAuroraPV"} # First time round, no response. with patch( @@ -241,13 +293,14 @@ async def test_import_night(hass): assert entry.unique_id assert len(mock_connect.mock_calls) == 1 - assert hass.states.get("sensor.power_output").state == "45.7" + power = hass.states.get("sensor.power_output") + assert power + assert power.state == "45.7" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 async def test_import_night_then_user(hass): """Attempt yaml import and fail (dark), but user sets up manually before auto retry.""" - TEST_DATA = {"device": "/dev/ttyUSB7", "address": 3, "name": "MyAuroraPV"} # First time round, no response. with patch( @@ -322,3 +375,29 @@ async def test_import_night_then_user(hass): await hass.async_block_till_done() assert entry.state == ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_import_already_existing(hass): + """Test configuration.yaml import when already configured.""" + TESTDATA = {"device": "/dev/ttyUSB7", "address": 7, "name": "MyAuroraPV"} + + entry = MockConfigEntry( + domain=DOMAIN, + title="MyAuroraPV", + unique_id="0123456", + data={ + CONF_PORT: "/dev/ttyUSB7", + CONF_ADDRESS: 7, + ATTR_FIRMWARE: "1.234", + ATTR_MODEL: "9.8.7.6 (A.B.C)", + ATTR_SERIAL_NUMBER: "9876543", + "title": "PhotoVoltaic Inverters", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index c1d1d4ad5c8..0a7b7e33302 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -12,16 +12,10 @@ from homeassistant.components.aurora_abb_powerone.const import ( DEFAULT_INTEGRATION_TITLE, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_ADDRESS, CONF_PORT -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import ( - MockConfigEntry, - assert_setup_component, - async_fire_time_changed, -) +from tests.common import MockConfigEntry, async_fire_time_changed TEST_CONFIG = { "sensor": { @@ -56,49 +50,10 @@ def _mock_config_entry(): }, source="dummysource", entry_id="13579", + unique_id="654321", ) -async def test_setup_platform_valid_config(hass): - """Test that (deprecated) yaml import still works.""" - with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( - "aurorapy.client.AuroraSerialClient.measure", - side_effect=_simulated_returns, - ), patch( - "aurorapy.client.AuroraSerialClient.serial_number", - return_value="9876543", - ), patch( - "aurorapy.client.AuroraSerialClient.version", - return_value="9.8.7.6", - ), patch( - "aurorapy.client.AuroraSerialClient.pn", - return_value="A.B.C", - ), patch( - "aurorapy.client.AuroraSerialClient.firmware", - return_value="1.234", - ), patch( - "aurorapy.client.AuroraSerialClient.cumulated_energy", - side_effect=_simulated_returns, - ), assert_setup_component( - 1, "sensor" - ): - assert await async_setup_component(hass, "sensor", TEST_CONFIG) - await hass.async_block_till_done() - power = hass.states.get("sensor.power_output") - assert power - assert power.state == "45.7" - - # try to set up a second time - should abort. - - result = await hass.config_entries.flow.async_init( - DOMAIN, - data=TEST_CONFIG, - context={"source": SOURCE_IMPORT}, - ) - assert result["type"] == "abort" - assert result["reason"] == "already_setup" - - async def test_sensors(hass): """Test data coming back from inverter.""" mock_entry = _mock_config_entry() diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 2c96f545b41..39c7c4897c4 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -3,10 +3,11 @@ from datetime import timedelta from http import HTTPStatus from unittest.mock import patch +import pytest + from homeassistant.auth import InvalidAuthError from homeassistant.auth.models import Credentials from homeassistant.components import auth -from homeassistant.components.auth import RESULT_TYPE_USER from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -15,6 +16,18 @@ from . import async_setup_auth from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI, MockUser +@pytest.fixture +def mock_credential(): + """Return a mock credential.""" + return Credentials( + id="mock-credential-id", + auth_provider_type="insecure_example", + auth_provider_id=None, + data={"username": "test-user"}, + is_new=False, + ) + + async def async_setup_user_refresh_token(hass): """Create a testing user with a connected credential.""" user = await hass.auth.async_create_user("Test User") @@ -96,29 +109,80 @@ async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client): assert resp.status == HTTPStatus.OK -def test_auth_code_store_expiration(): +async def test_auth_code_checks_local_only_user(hass, aiohttp_client): + """Test local only user cannot exchange auth code for refresh tokens when external.""" + client = await async_setup_auth(hass, aiohttp_client, setup_api=True) + resp = await client.post( + "/auth/login_flow", + json={ + "client_id": CLIENT_ID, + "handler": ["insecure_example", None], + "redirect_uri": CLIENT_REDIRECT_URI, + }, + ) + assert resp.status == HTTPStatus.OK + step = await resp.json() + + resp = await client.post( + f"/auth/login_flow/{step['flow_id']}", + json={"client_id": CLIENT_ID, "username": "test-user", "password": "test-pass"}, + ) + + assert resp.status == HTTPStatus.OK + step = await resp.json() + code = step["result"] + + # Exchange code for tokens + with patch( + "homeassistant.components.auth.async_user_not_allowed_do_auth", + return_value="User is local only", + ): + resp = await client.post( + "/auth/token", + data={ + "client_id": CLIENT_ID, + "grant_type": "authorization_code", + "code": code, + }, + ) + + assert resp.status == HTTPStatus.FORBIDDEN + error = await resp.json() + assert error["error"] == "access_denied" + + +def test_auth_code_store_expiration(mock_credential): """Test that the auth code store will not return expired tokens.""" store, retrieve = auth._create_auth_code_store() client_id = "bla" - user = MockUser(id="mock_user") now = utcnow() with patch("homeassistant.util.dt.utcnow", return_value=now): - code = store(client_id, user) + code = store(client_id, mock_credential) with patch( "homeassistant.util.dt.utcnow", return_value=now + timedelta(minutes=10) ): - assert retrieve(client_id, RESULT_TYPE_USER, code) is None + assert retrieve(client_id, code) is None with patch("homeassistant.util.dt.utcnow", return_value=now): - code = store(client_id, user) + code = store(client_id, mock_credential) with patch( "homeassistant.util.dt.utcnow", return_value=now + timedelta(minutes=9, seconds=59), ): - assert retrieve(client_id, RESULT_TYPE_USER, code) == user + assert retrieve(client_id, code) == mock_credential + + +def test_auth_code_store_requires_credentials(mock_credential): + """Test we require credentials.""" + store, _retrieve = auth._create_auth_code_store() + + with pytest.raises(ValueError): + store(None, MockUser()) + + store(None, mock_credential) async def test_ws_current_user(hass, hass_ws_client, hass_access_token): @@ -242,6 +306,30 @@ async def test_refresh_token_different_client_id(hass, aiohttp_client): ) +async def test_refresh_token_checks_local_only_user(hass, aiohttp_client): + """Test that we can't refresh token for a local only user when external.""" + client = await async_setup_auth(hass, aiohttp_client) + refresh_token = await async_setup_user_refresh_token(hass) + refresh_token.user.local_only = True + + with patch( + "homeassistant.components.auth.async_user_not_allowed_do_auth", + return_value="User is local only", + ): + resp = await client.post( + "/auth/token", + data={ + "client_id": CLIENT_ID, + "grant_type": "refresh_token", + "refresh_token": refresh_token.token, + }, + ) + + assert resp.status == HTTPStatus.FORBIDDEN + result = await resp.json() + assert result["error"] == "access_denied" + + async def test_refresh_token_provider_rejected( hass, aiohttp_client, hass_admin_user, hass_admin_credential ): diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index 72881023fe5..1fa06045de6 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -54,33 +54,41 @@ async def test_invalid_username_password(hass, aiohttp_client): step = await resp.json() # Incorrect username - resp = await client.post( - f"/auth/login_flow/{step['flow_id']}", - json={ - "client_id": CLIENT_ID, - "username": "wrong-user", - "password": "test-pass", - }, - ) + with patch( + "homeassistant.components.auth.login_flow.process_wrong_login" + ) as mock_process_wrong_login: + resp = await client.post( + f"/auth/login_flow/{step['flow_id']}", + json={ + "client_id": CLIENT_ID, + "username": "wrong-user", + "password": "test-pass", + }, + ) assert resp.status == HTTPStatus.OK step = await resp.json() + assert len(mock_process_wrong_login.mock_calls) == 1 assert step["step_id"] == "init" assert step["errors"]["base"] == "invalid_auth" # Incorrect password - resp = await client.post( - f"/auth/login_flow/{step['flow_id']}", - json={ - "client_id": CLIENT_ID, - "username": "test-user", - "password": "wrong-pass", - }, - ) + with patch( + "homeassistant.components.auth.login_flow.process_wrong_login" + ) as mock_process_wrong_login: + resp = await client.post( + f"/auth/login_flow/{step['flow_id']}", + json={ + "client_id": CLIENT_ID, + "username": "test-user", + "password": "wrong-pass", + }, + ) assert resp.status == HTTPStatus.OK step = await resp.json() + assert len(mock_process_wrong_login.mock_calls) == 1 assert step["step_id"] == "init" assert step["errors"]["base"] == "invalid_auth" @@ -105,15 +113,61 @@ async def test_login_exist_user(hass, aiohttp_client): assert resp.status == HTTPStatus.OK step = await resp.json() - resp = await client.post( - f"/auth/login_flow/{step['flow_id']}", - json={"client_id": CLIENT_ID, "username": "test-user", "password": "test-pass"}, - ) + with patch( + "homeassistant.components.auth.login_flow.process_success_login" + ) as mock_process_success_login: + resp = await client.post( + f"/auth/login_flow/{step['flow_id']}", + json={ + "client_id": CLIENT_ID, + "username": "test-user", + "password": "test-pass", + }, + ) assert resp.status == HTTPStatus.OK step = await resp.json() assert step["type"] == "create_entry" assert len(step["result"]) > 1 + assert len(mock_process_success_login.mock_calls) == 1 + + +async def test_login_local_only_user(hass, aiohttp_client): + """Test logging in with local only user.""" + client = await async_setup_auth(hass, aiohttp_client, setup_api=True) + cred = await hass.auth.auth_providers[0].async_get_or_create_credentials( + {"username": "test-user"} + ) + user = await hass.auth.async_get_or_create_user(cred) + await hass.auth.async_update_user(user, local_only=True) + + resp = await client.post( + "/auth/login_flow", + json={ + "client_id": CLIENT_ID, + "handler": ["insecure_example", None], + "redirect_uri": CLIENT_REDIRECT_URI, + }, + ) + assert resp.status == HTTPStatus.OK + step = await resp.json() + + with patch( + "homeassistant.components.auth.login_flow.async_user_not_allowed_do_auth", + return_value="User is local only", + ) as mock_not_allowed_do_auth: + resp = await client.post( + f"/auth/login_flow/{step['flow_id']}", + json={ + "client_id": CLIENT_ID, + "username": "test-user", + "password": "test-pass", + }, + ) + + assert len(mock_not_allowed_do_auth.mock_calls) == 1 + assert resp.status == HTTPStatus.FORBIDDEN + assert await resp.json() == {"message": "Login blocked: User is local only"} async def test_login_exist_user_ip_changes(hass, aiohttp_client): diff --git a/tests/fixtures/awair/awair-offline.json b/tests/components/awair/fixtures/awair-offline.json similarity index 100% rename from tests/fixtures/awair/awair-offline.json rename to tests/components/awair/fixtures/awair-offline.json diff --git a/tests/fixtures/awair/awair-r2.json b/tests/components/awair/fixtures/awair-r2.json similarity index 100% rename from tests/fixtures/awair/awair-r2.json rename to tests/components/awair/fixtures/awair-r2.json diff --git a/tests/fixtures/awair/awair.json b/tests/components/awair/fixtures/awair.json similarity index 100% rename from tests/fixtures/awair/awair.json rename to tests/components/awair/fixtures/awair.json diff --git a/tests/fixtures/awair/devices.json b/tests/components/awair/fixtures/devices.json similarity index 100% rename from tests/fixtures/awair/devices.json rename to tests/components/awair/fixtures/devices.json diff --git a/tests/fixtures/awair/glow.json b/tests/components/awair/fixtures/glow.json similarity index 100% rename from tests/fixtures/awair/glow.json rename to tests/components/awair/fixtures/glow.json diff --git a/tests/fixtures/awair/mint.json b/tests/components/awair/fixtures/mint.json similarity index 100% rename from tests/fixtures/awair/mint.json rename to tests/components/awair/fixtures/mint.json diff --git a/tests/fixtures/awair/no_devices.json b/tests/components/awair/fixtures/no_devices.json similarity index 100% rename from tests/fixtures/awair/no_devices.json rename to tests/components/awair/fixtures/no_devices.json diff --git a/tests/fixtures/awair/omni.json b/tests/components/awair/fixtures/omni.json similarity index 100% rename from tests/fixtures/awair/omni.json rename to tests/components/awair/fixtures/omni.json diff --git a/tests/fixtures/awair/user.json b/tests/components/awair/fixtures/user.json similarity index 100% rename from tests/fixtures/awair/user.json rename to tests/components/awair/fixtures/user.json diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index 7d62e999809..6263c62be42 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -5,6 +5,7 @@ import pytest import respx from homeassistant import data_entry_flow +from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.components.axis import config_flow from homeassistant.components.axis.const import ( CONF_EVENTS, @@ -15,7 +16,6 @@ from homeassistant.components.axis.const import ( DEFAULT_VIDEO_SOURCE, DOMAIN as AXIS_DOMAIN, ) -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS from homeassistant.config_entries import ( SOURCE_DHCP, SOURCE_IGNORE, @@ -255,54 +255,58 @@ async def test_reauth_flow_update_configuration(hass): [ ( SOURCE_DHCP, - { - HOSTNAME: f"axis-{MAC}", - IP_ADDRESS: DEFAULT_HOST, - MAC_ADDRESS: MAC, - }, + dhcp.DhcpServiceInfo( + hostname=f"axis-{MAC}", + ip=DEFAULT_HOST, + macaddress=MAC, + ), ), ( SOURCE_SSDP, - { - "st": "urn:axis-com:service:BasicService:1", - "usn": f"uuid:Upnp-BasicDevice-1_0-{MAC}::urn:axis-com:service:BasicService:1", - "ext": "", - "server": "Linux/4.14.173-axis8, UPnP/1.0, Portable SDK for UPnP devices/1.8.7", - "deviceType": "urn:schemas-upnp-org:device:Basic:1", - "friendlyName": f"AXIS M1065-LW - {MAC}", - "manufacturer": "AXIS", - "manufacturerURL": "http://www.axis.com/", - "modelDescription": "AXIS M1065-LW Network Camera", - "modelName": "AXIS M1065-LW", - "modelNumber": "M1065-LW", - "modelURL": "http://www.axis.com/", - "serialNumber": MAC, - "UDN": f"uuid:Upnp-BasicDevice-1_0-{MAC}", - "serviceList": { - "service": { - "serviceType": "urn:axis-com:service:BasicService:1", - "serviceId": "urn:axis-com:serviceId:BasicServiceId", - "controlURL": "/upnp/control/BasicServiceId", - "eventSubURL": "/upnp/event/BasicServiceId", - "SCPDURL": "/scpd_basic.xml", - } + ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + upnp={ + "st": "urn:axis-com:service:BasicService:1", + "usn": f"uuid:Upnp-BasicDevice-1_0-{MAC}::urn:axis-com:service:BasicService:1", + "ext": "", + "server": "Linux/4.14.173-axis8, UPnP/1.0, Portable SDK for UPnP devices/1.8.7", + "deviceType": "urn:schemas-upnp-org:device:Basic:1", + "friendlyName": f"AXIS M1065-LW - {MAC}", + "manufacturer": "AXIS", + "manufacturerURL": "http://www.axis.com/", + "modelDescription": "AXIS M1065-LW Network Camera", + "modelName": "AXIS M1065-LW", + "modelNumber": "M1065-LW", + "modelURL": "http://www.axis.com/", + "serialNumber": MAC, + "UDN": f"uuid:Upnp-BasicDevice-1_0-{MAC}", + "serviceList": { + "service": { + "serviceType": "urn:axis-com:service:BasicService:1", + "serviceId": "urn:axis-com:serviceId:BasicServiceId", + "controlURL": "/upnp/control/BasicServiceId", + "eventSubURL": "/upnp/event/BasicServiceId", + "SCPDURL": "/scpd_basic.xml", + } + }, + "presentationURL": f"http://{DEFAULT_HOST}:80/", }, - "presentationURL": f"http://{DEFAULT_HOST}:80/", - }, + ), ), ( SOURCE_ZEROCONF, - { - "host": DEFAULT_HOST, - "port": 80, - "hostname": f"axis-{MAC.lower()}.local.", - "type": "_axis-video._tcp.local.", - "name": f"AXIS M1065-LW - {MAC}._axis-video._tcp.local.", - "properties": { + zeroconf.ZeroconfServiceInfo( + host=DEFAULT_HOST, + port=80, + hostname=f"axis-{MAC.lower()}.local.", + type="_axis-video._tcp.local.", + name=f"AXIS M1065-LW - {MAC}._axis-video._tcp.local.", + properties={ "_raw": {"macaddress": MAC.encode()}, "macaddress": MAC, }, - }, + ), ), ], ) @@ -346,28 +350,34 @@ async def test_discovery_flow(hass, source: str, discovery_info: dict): [ ( SOURCE_DHCP, - { - HOSTNAME: f"axis-{MAC}", - IP_ADDRESS: DEFAULT_HOST, - MAC_ADDRESS: MAC, - }, + dhcp.DhcpServiceInfo( + hostname=f"axis-{MAC}", + ip=DEFAULT_HOST, + macaddress=MAC, + ), ), ( SOURCE_SSDP, - { - "friendlyName": f"AXIS M1065-LW - {MAC}", - "serialNumber": MAC, - "presentationURL": f"http://{DEFAULT_HOST}:80/", - }, + ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + upnp={ + "friendlyName": f"AXIS M1065-LW - {MAC}", + "serialNumber": MAC, + "presentationURL": f"http://{DEFAULT_HOST}:80/", + }, + ), ), ( SOURCE_ZEROCONF, - { - CONF_HOST: DEFAULT_HOST, - CONF_PORT: 80, - "name": f"AXIS M1065-LW - {MAC}._axis-video._tcp.local.", - "properties": {"macaddress": MAC}, - }, + zeroconf.ZeroconfServiceInfo( + host=DEFAULT_HOST, + hostname="mock_hostname", + name=f"AXIS M1065-LW - {MAC}._axis-video._tcp.local.", + port=80, + properties={"macaddress": MAC}, + type="mock_type", + ), ), ], ) @@ -392,30 +402,36 @@ async def test_discovered_device_already_configured( [ ( SOURCE_DHCP, - { - HOSTNAME: f"axis-{MAC}", - IP_ADDRESS: "2.3.4.5", - MAC_ADDRESS: MAC, - }, + dhcp.DhcpServiceInfo( + hostname=f"axis-{MAC}", + ip="2.3.4.5", + macaddress=MAC, + ), 80, ), ( SOURCE_SSDP, - { - "friendlyName": f"AXIS M1065-LW - {MAC}", - "serialNumber": MAC, - "presentationURL": "http://2.3.4.5:8080/", - }, + ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + upnp={ + "friendlyName": f"AXIS M1065-LW - {MAC}", + "serialNumber": MAC, + "presentationURL": "http://2.3.4.5:8080/", + }, + ), 8080, ), ( SOURCE_ZEROCONF, - { - CONF_HOST: "2.3.4.5", - CONF_PORT: 8080, - "name": f"AXIS M1065-LW - {MAC}._axis-video._tcp.local.", - "properties": {"macaddress": MAC}, - }, + zeroconf.ZeroconfServiceInfo( + host="2.3.4.5", + hostname="mock_hostname", + name=f"AXIS M1065-LW - {MAC}._axis-video._tcp.local.", + port=8080, + properties={"macaddress": MAC}, + type="mock_type", + ), 8080, ), ], @@ -462,28 +478,34 @@ async def test_discovery_flow_updated_configuration( [ ( SOURCE_DHCP, - { - HOSTNAME: "", - IP_ADDRESS: "", - MAC_ADDRESS: "01234567890", - }, + dhcp.DhcpServiceInfo( + hostname="", + ip="", + macaddress="01234567890", + ), ), ( SOURCE_SSDP, - { - "friendlyName": "", - "serialNumber": "01234567890", - "presentationURL": "", - }, + ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + upnp={ + "friendlyName": "", + "serialNumber": "01234567890", + "presentationURL": "", + }, + ), ), ( SOURCE_ZEROCONF, - { - CONF_HOST: "", - CONF_PORT: 0, - "name": "", - "properties": {"macaddress": "01234567890"}, - }, + zeroconf.ZeroconfServiceInfo( + host="", + hostname="mock_hostname", + name="", + port=0, + properties={"macaddress": "01234567890"}, + type="mock_type", + ), ), ], ) @@ -504,24 +526,34 @@ async def test_discovery_flow_ignore_non_axis_device( [ ( SOURCE_DHCP, - {HOSTNAME: f"axis-{MAC}", IP_ADDRESS: "169.254.3.4", MAC_ADDRESS: MAC}, + dhcp.DhcpServiceInfo( + hostname=f"axis-{MAC}", + ip="169.254.3.4", + macaddress=MAC, + ), ), ( SOURCE_SSDP, - { - "friendlyName": f"AXIS M1065-LW - {MAC}", - "serialNumber": MAC, - "presentationURL": "http://169.254.3.4:80/", - }, + ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + upnp={ + "friendlyName": f"AXIS M1065-LW - {MAC}", + "serialNumber": MAC, + "presentationURL": "http://169.254.3.4:80/", + }, + ), ), ( SOURCE_ZEROCONF, - { - CONF_HOST: "169.254.3.4", - CONF_PORT: 80, - "name": f"AXIS M1065-LW - {MAC}._axis-video._tcp.local.", - "properties": {"macaddress": MAC}, - }, + zeroconf.ZeroconfServiceInfo( + host="169.254.3.4", + hostname="mock_hostname", + name=f"AXIS M1065-LW - {MAC}._axis-video._tcp.local.", + port=80, + properties={"macaddress": MAC}, + type="mock_type", + ), ), ], ) diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py index 2d2ba83f633..d43845e01ad 100644 --- a/tests/components/axis/test_device.py +++ b/tests/components/axis/test_device.py @@ -8,7 +8,7 @@ from axis.event_stream import OPERATION_INITIALIZED import pytest import respx -from homeassistant.components import axis +from homeassistant.components import axis, zeroconf from homeassistant.components.axis.const import ( CONF_EVENTS, CONF_MODEL, @@ -383,12 +383,14 @@ async def test_update_address(hass): mock_default_vapix_requests(respx, "2.3.4.5") await hass.config_entries.flow.async_init( AXIS_DOMAIN, - data={ - "host": "2.3.4.5", - "port": 80, - "name": "name", - "properties": {"macaddress": MAC}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="2.3.4.5", + hostname="mock_hostname", + name="name", + port=80, + properties={"macaddress": MAC}, + type="mock_type", + ), context={"source": SOURCE_ZEROCONF}, ) await hass.async_block_till_done() diff --git a/tests/components/balboa/__init__.py b/tests/components/balboa/__init__.py new file mode 100644 index 00000000000..13c8b6240a7 --- /dev/null +++ b/tests/components/balboa/__init__.py @@ -0,0 +1,167 @@ +"""Test the Balboa Spa Client integration.""" +import asyncio +from unittest.mock import patch + +from homeassistant.components.balboa.const import DOMAIN as BALBOA_DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +BALBOA_DEFAULT_PORT = 4257 +TEST_HOST = "balboatest.localdomain" + + +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Mock integration setup.""" + config_entry = MockConfigEntry( + domain=BALBOA_DOMAIN, + data={ + CONF_HOST: TEST_HOST, + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.balboa.BalboaSpaWifi", + new=BalboaMock, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +async def init_integration_mocked(hass: HomeAssistant) -> MockConfigEntry: + """Mock integration setup.""" + config_entry = MockConfigEntry( + domain=BALBOA_DOMAIN, + data={ + CONF_HOST: TEST_HOST, + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.balboa.BalboaSpaWifi.connect", + new=BalboaMock.connect, + ), patch( + "homeassistant.components.balboa.BalboaSpaWifi.listen_until_configured", + new=BalboaMock.listen_until_configured, + ), patch( + "homeassistant.components.balboa.BalboaSpaWifi.listen", + new=BalboaMock.listen, + ), patch( + "homeassistant.components.balboa.BalboaSpaWifi.check_connection_status", + new=BalboaMock.check_connection_status, + ), patch( + "homeassistant.components.balboa.BalboaSpaWifi.send_panel_req", + new=BalboaMock.send_panel_req, + ), patch( + "homeassistant.components.balboa.BalboaSpaWifi.send_mod_ident_req", + new=BalboaMock.send_mod_ident_req, + ), patch( + "homeassistant.components.balboa.BalboaSpaWifi.spa_configured", + new=BalboaMock.spa_configured, + ), patch( + "homeassistant.components.balboa.BalboaSpaWifi.get_model_name", + new=BalboaMock.get_model_name, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +class BalboaMock: + """Mock pybalboa library.""" + + def __init__(self, hostname, port=BALBOA_DEFAULT_PORT): + """Mock init.""" + self.host = hostname + self.port = port + self.connected = False + self.new_data_cb = None + self.lastupd = 0 + self.connected = False + self.fake_action = False + + async def connect(self): + """Connect to the spa.""" + self.connected = True + return True + + async def broken_connect(self): + """Connect to the spa.""" + self.connected = False + return False + + async def disconnect(self): + """Stop talking to the spa.""" + self.connected = False + + async def send_panel_req(self, arg_ba, arg_bb): + """Send a panel request, 2 bytes of data.""" + self.fake_action = False + return + + async def send_mod_ident_req(self): + """Ask for the module identification.""" + self.fake_action = False + return + + @staticmethod + def get_macaddr(): + """Return the macaddr of the spa wifi.""" + return "ef:ef:ef:c0:ff:ee" + + def get_model_name(self): + """Return the model name.""" + self.fake_action = False + return "FakeSpa" + + @staticmethod + def get_ssid(): + """Return the software version.""" + return "V0.0" + + @staticmethod + async def set_time(new_time, timescale=None): + """Set time on spa to new_time with optional timescale.""" + return + + async def listen(self): + """Listen to the spa babble forever.""" + while True: + if not self.connected: + # sleep and hope the checker fixes us + await asyncio.sleep(5) + continue + + # fake it + await asyncio.sleep(5) + + async def check_connection_status(self): + """Set this up to periodically check the spa connection and fix.""" + self.fake_action = False + while True: + # fake it + await asyncio.sleep(15) + + async def spa_configured(self): + """Check if the spa has been configured.""" + self.fake_action = False + return + + async def int_new_data_cb(self): + """Call false internal data callback.""" + + if self.new_data_cb is None: + return + await self.new_data_cb() # pylint: disable=not-callable + + async def listen_until_configured(self, maxiter=20): + """Listen to the spa babble until we are configured.""" + if not self.connected: + return False + return True diff --git a/tests/components/balboa/test_binary_sensor.py b/tests/components/balboa/test_binary_sensor.py new file mode 100644 index 00000000000..748801b7b8f --- /dev/null +++ b/tests/components/balboa/test_binary_sensor.py @@ -0,0 +1,76 @@ +"""Tests of the climate entity of the balboa integration.""" + +from unittest.mock import patch + +from homeassistant.components.balboa.const import DOMAIN as BALBOA_DOMAIN, SIGNAL_UPDATE +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.setup import async_setup_component + +from . import init_integration_mocked + +ENTITY_BINARY_SENSOR = "binary_sensor.fakespa_" + +FILTER_MAP = [ + [STATE_OFF, STATE_OFF], + [STATE_ON, STATE_OFF], + [STATE_OFF, STATE_ON], + [STATE_ON, STATE_ON], +] + + +async def test_filters(hass: HomeAssistant): + """Test spa filters.""" + + config_entry = await _setup_binary_sensor_test(hass) + + for filter_mode in range(4): + for spa_filter in range(1, 3): + state = await _patch_filter(hass, config_entry, filter_mode, spa_filter) + assert state.state == FILTER_MAP[filter_mode][spa_filter - 1] + + +async def test_circ_pump(hass: HomeAssistant): + """Test spa circ pump.""" + with patch( + "homeassistant.components.balboa.BalboaSpaWifi.have_circ_pump", + return_value=True, + ): + config_entry = await _setup_binary_sensor_test(hass) + + state = await _patch_circ_pump(hass, config_entry, True) + assert state.state == STATE_ON + state = await _patch_circ_pump(hass, config_entry, False) + assert state.state == STATE_OFF + + +async def _patch_circ_pump(hass, config_entry, pump_state): + """Patch the circ pump state.""" + with patch( + "homeassistant.components.balboa.BalboaSpaWifi.get_circ_pump", + return_value=pump_state, + ): + async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id)) + await hass.async_block_till_done() + return hass.states.get(f"{ENTITY_BINARY_SENSOR}circ_pump") + + +async def _patch_filter(hass, config_entry, filter_mode, num): + """Patch the filter state.""" + with patch( + "homeassistant.components.balboa.BalboaSpaWifi.get_filtermode", + return_value=filter_mode, + ): + async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id)) + await hass.async_block_till_done() + return hass.states.get(f"{ENTITY_BINARY_SENSOR}filter{num}") + + +async def _setup_binary_sensor_test(hass): + """Prepare the test.""" + config_entry = await init_integration_mocked(hass) + await async_setup_component(hass, BALBOA_DOMAIN, config_entry) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/balboa/test_climate.py b/tests/components/balboa/test_climate.py new file mode 100644 index 00000000000..53eb0307beb --- /dev/null +++ b/tests/components/balboa/test_climate.py @@ -0,0 +1,272 @@ +"""Tests of the climate entity of the balboa integration.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.balboa.const import DOMAIN as BALBOA_DOMAIN, SIGNAL_UPDATE +from homeassistant.components.climate.const import ( + ATTR_FAN_MODE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_FAHRENHEIT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.setup import async_setup_component + +from . import init_integration_mocked + +from tests.components.climate import common + +FAN_SETTINGS = [ + FAN_OFF, + FAN_LOW, + FAN_MEDIUM, + FAN_HIGH, +] + +HVAC_SETTINGS = [ + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, +] + +ENTITY_CLIMATE = "climate.fakespa_climate" + + +async def test_spa_defaults(hass: HomeAssistant): + """Test supported features flags.""" + + await _setup_climate_test(hass) + + state = hass.states.get(ENTITY_CLIMATE) + + assert ( + state.attributes["supported_features"] + == SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE + ) + assert state.state == HVAC_MODE_HEAT + assert state.attributes[ATTR_MIN_TEMP] == 10.0 + assert state.attributes[ATTR_MAX_TEMP] == 40.0 + assert state.attributes[ATTR_PRESET_MODE] == "Ready" + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE + + +async def test_spa_defaults_fake_tscale(hass: HomeAssistant): + """Test supported features flags.""" + + with patch( + "homeassistant.components.balboa.BalboaSpaWifi.get_tempscale", return_value=1 + ): + await _setup_climate_test(hass) + + state = hass.states.get(ENTITY_CLIMATE) + + assert ( + state.attributes["supported_features"] + == SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE + ) + assert state.state == HVAC_MODE_HEAT + assert state.attributes[ATTR_MIN_TEMP] == 10.0 + assert state.attributes[ATTR_MAX_TEMP] == 40.0 + assert state.attributes[ATTR_PRESET_MODE] == "Ready" + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE + + +async def test_spa_with_blower(hass: HomeAssistant): + """Test supported features flags.""" + + with patch( + "homeassistant.components.balboa.BalboaSpaWifi.have_blower", return_value=True + ): + config_entry = await _setup_climate_test(hass) + + # force a refresh + async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id)) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_CLIMATE) + + assert ( + state.attributes["supported_features"] + == SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE | SUPPORT_FAN_MODE + ) + + for fan_state in range(4): + # set blower + state = await _patch_blower(hass, config_entry, fan_state) + assert state.attributes[ATTR_FAN_MODE] == FAN_SETTINGS[fan_state] + + # test the nonsense checks + for fan_state in (None, 70): + state = await _patch_blower(hass, config_entry, fan_state) + assert state.attributes[ATTR_FAN_MODE] == FAN_OFF + + +async def test_spa_temperature(hass: HomeAssistant): + """Test spa temperature settings.""" + + config_entry = await _setup_climate_test(hass) + + # flip the spa into F + # set temp to a valid number + state = await _patch_spa_settemp(hass, config_entry, 0, 100.0) + assert state.attributes.get(ATTR_TEMPERATURE) == 38.0 + + +async def test_spa_temperature_unit(hass: HomeAssistant): + """Test temperature unit conversions.""" + + with patch.object(hass.config.units, "temperature_unit", TEMP_FAHRENHEIT): + config_entry = await _setup_climate_test(hass) + + state = await _patch_spa_settemp(hass, config_entry, 0, 15.4) + assert state.attributes.get(ATTR_TEMPERATURE) == 15.0 + + +async def test_spa_hvac_modes(hass: HomeAssistant): + """Test hvac modes.""" + + config_entry = await _setup_climate_test(hass) + + # try out the different heat modes + for heat_mode in range(2): + state = await _patch_spa_heatmode(hass, config_entry, heat_mode) + modes = state.attributes.get(ATTR_HVAC_MODES) + assert [HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF] == modes + assert state.state == HVAC_SETTINGS[heat_mode] + + with pytest.raises(HomeAssistantError): + await _patch_spa_heatmode(hass, config_entry, 2) + + +async def test_spa_hvac_action(hass: HomeAssistant): + """Test setting of the HVAC action.""" + + config_entry = await _setup_climate_test(hass) + + # try out the different heat states + state = await _patch_spa_heatstate(hass, config_entry, 1) + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT + + state = await _patch_spa_heatstate(hass, config_entry, 0) + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE + + +async def test_spa_preset_modes(hass: HomeAssistant): + """Test the various preset modes.""" + + config_entry = await _setup_climate_test(hass) + + state = hass.states.get(ENTITY_CLIMATE) + modes = state.attributes.get(ATTR_PRESET_MODES) + assert ["Ready", "Rest", "Ready in Rest"] == modes + + # Put it in Ready and Rest + modelist = ["Ready", "Rest"] + for mode in modelist: + with patch( + "homeassistant.components.balboa.BalboaSpaWifi.get_heatmode", + return_value=modelist.index(mode), + ): + await common.async_set_preset_mode(hass, mode, ENTITY_CLIMATE) + async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id)) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes[ATTR_PRESET_MODE] == modelist.index(mode) + + # put it in RNR and test assertion + with patch( + "homeassistant.components.balboa.BalboaSpaWifi.get_heatmode", + return_value=2, + ), pytest.raises(HomeAssistantError): + await common.async_set_preset_mode(hass, 2, ENTITY_CLIMATE) + + +# Helpers +async def _patch_blower(hass, config_entry, fan_state): + """Patch the blower state.""" + with patch( + "homeassistant.components.balboa.BalboaSpaWifi.get_blower", + return_value=fan_state, + ): + if fan_state is not None and fan_state <= len(FAN_SETTINGS): + await common.async_set_fan_mode(hass, FAN_SETTINGS[fan_state]) + async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id)) + await hass.async_block_till_done() + + return hass.states.get(ENTITY_CLIMATE) + + +async def _patch_spa_settemp(hass, config_entry, tscale, settemp): + """Patch the settemp.""" + with patch( + "homeassistant.components.balboa.BalboaSpaWifi.get_tempscale", + return_value=tscale, + ), patch( + "homeassistant.components.balboa.BalboaSpaWifi.get_settemp", + return_value=settemp, + ): + await common.async_set_temperature( + hass, temperature=settemp, entity_id=ENTITY_CLIMATE + ) + async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id)) + await hass.async_block_till_done() + + return hass.states.get(ENTITY_CLIMATE) + + +async def _patch_spa_heatmode(hass, config_entry, heat_mode): + """Patch the heatmode.""" + with patch( + "homeassistant.components.balboa.BalboaSpaWifi.get_heatmode", + return_value=heat_mode, + ): + await common.async_set_hvac_mode(hass, HVAC_SETTINGS[heat_mode], ENTITY_CLIMATE) + async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id)) + await hass.async_block_till_done() + + return hass.states.get(ENTITY_CLIMATE) + + +async def _patch_spa_heatstate(hass, config_entry, heat_state): + """Patch the heatmode.""" + with patch( + "homeassistant.components.balboa.BalboaSpaWifi.get_heatstate", + return_value=heat_state, + ): + await common.async_set_hvac_mode( + hass, HVAC_SETTINGS[heat_state], ENTITY_CLIMATE + ) + async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id)) + await hass.async_block_till_done() + + return hass.states.get(ENTITY_CLIMATE) + + +async def _setup_climate_test(hass): + """Prepare the test.""" + config_entry = await init_integration_mocked(hass) + await async_setup_component(hass, BALBOA_DOMAIN, config_entry) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/balboa/test_config_flow.py b/tests/components/balboa/test_config_flow.py new file mode 100644 index 00000000000..fc12289d90a --- /dev/null +++ b/tests/components/balboa/test_config_flow.py @@ -0,0 +1,167 @@ +"""Test the Balboa Spa Client config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.balboa.const import CONF_SYNC_TIME, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import BalboaMock + +from tests.common import MockConfigEntry + +TEST_DATA = { + CONF_HOST: "1.1.1.1", +} +TEST_ID = "FakeBalboa" + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.balboa.config_flow.BalboaSpaWifi.connect", + new=BalboaMock.connect, + ), patch( + "homeassistant.components.balboa.config_flow.BalboaSpaWifi.disconnect", + new=BalboaMock.disconnect, + ), patch( + "homeassistant.components.balboa.config_flow.BalboaSpaWifi.listen", + new=BalboaMock.listen, + ), patch( + "homeassistant.components.balboa.config_flow.BalboaSpaWifi.send_mod_ident_req", + new=BalboaMock.send_mod_ident_req, + ), patch( + "homeassistant.components.balboa.config_flow.BalboaSpaWifi.send_panel_req", + new=BalboaMock.send_panel_req, + ), patch( + "homeassistant.components.balboa.config_flow.BalboaSpaWifi.spa_configured", + new=BalboaMock.spa_configured, + ), patch( + "homeassistant.components.balboa.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == TEST_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.balboa.config_flow.BalboaSpaWifi.connect", + new=BalboaMock.broken_connect, + ), patch( + "homeassistant.components.balboa.config_flow.BalboaSpaWifi.disconnect", + new=BalboaMock.disconnect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_unknown_error(hass: HomeAssistant) -> 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.balboa.config_flow.BalboaSpaWifi.connect", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_already_configured(hass: HomeAssistant) -> None: + """Test when provided credentials are already configured.""" + MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + + with patch( + "homeassistant.components.balboa.config_flow.BalboaSpaWifi.connect", + new=BalboaMock.connect, + ), patch( + "homeassistant.components.balboa.config_flow.BalboaSpaWifi.disconnect", + new=BalboaMock.disconnect, + ), patch( + "homeassistant.components.balboa.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + +async def test_options_flow(hass): + """Test specifying non default settings using options flow.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID) + config_entry.add_to_hass(hass) + + # Rather than mocking out 15 or so functions, we just need to mock + # the entire library, otherwise it will get stuck in a listener and + # the various loops in pybalboa. + with patch( + "homeassistant.components.balboa.config_flow.BalboaSpaWifi", + new=BalboaMock, + ), patch( + "homeassistant.components.balboa.BalboaSpaWifi", + new=BalboaMock, + ): + assert 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) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_SYNC_TIME: True}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == {CONF_SYNC_TIME: True} diff --git a/tests/components/balboa/test_init.py b/tests/components/balboa/test_init.py new file mode 100644 index 00000000000..ac0dea3b007 --- /dev/null +++ b/tests/components/balboa/test_init.py @@ -0,0 +1,43 @@ +"""Tests of the initialization of the balboa integration.""" + +from unittest.mock import patch + +from homeassistant.components.balboa.const import DOMAIN as BALBOA_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from . import TEST_HOST, BalboaMock, init_integration + +from tests.common import MockConfigEntry + + +async def test_setup_entry(hass: HomeAssistant): + """Validate that setup entry also configure the client.""" + config_entry = await init_integration(hass) + + assert config_entry.state == ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + + +async def test_setup_entry_fails(hass): + """Validate that setup entry also configure the client.""" + config_entry = MockConfigEntry( + domain=BALBOA_DOMAIN, + data={ + CONF_HOST: TEST_HOST, + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.balboa.BalboaSpaWifi.connect", + new=BalboaMock.broken_connect, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/fixtures/bayesian/configuration.yaml b/tests/components/bayesian/fixtures/configuration.yaml similarity index 100% rename from tests/fixtures/bayesian/configuration.yaml rename to tests/components/bayesian/fixtures/configuration.yaml diff --git a/tests/components/bayesian/test_binary_sensor.py b/tests/components/bayesian/test_binary_sensor.py index 9c181e90deb..c2f289b0697 100644 --- a/tests/components/bayesian/test_binary_sensor.py +++ b/tests/components/bayesian/test_binary_sensor.py @@ -1,6 +1,5 @@ """The test for the bayesian sensor platform.""" import json -from os import path from unittest.mock import patch from homeassistant import config as hass_config @@ -19,6 +18,8 @@ from homeassistant.const import ( from homeassistant.core import Context, callback from homeassistant.setup import async_setup_component +from tests.common import get_fixture_path + async def test_load_values_when_added_to_hass(hass): """Test that sensor initializes with observations of relevant entities.""" @@ -666,11 +667,8 @@ async def test_reload(hass): assert hass.states.get("binary_sensor.test") - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "bayesian/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "bayesian") + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( DOMAIN, @@ -686,10 +684,6 @@ async def test_reload(hass): assert hass.states.get("binary_sensor.test2") -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) - - async def test_template_triggers(hass): """Test sensor with template triggers.""" hass.states.async_set("input_boolean.test", STATE_OFF) diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index 3d1b694c7ce..5a609fbc30a 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -93,7 +93,7 @@ async def test_get_conditions_no_state(hass, device_reg, entity_reg): "test", f"5678_{device_class}", device_id=device_entry.id, - device_class=device_class, + original_device_class=device_class, ).entity_id await hass.async_block_till_done() diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index 8bd80be6524..0cf76453238 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -96,7 +96,7 @@ async def test_get_triggers_no_state(hass, device_reg, entity_reg): "test", f"5678_{device_class}", device_id=device_entry.id, - device_class=device_class, + original_device_class=device_class, ).entity_id await hass.async_block_till_done() diff --git a/tests/components/blebox/test_cover.py b/tests/components/blebox/test_cover.py index 8355411a0bb..e8cf67dad01 100644 --- a/tests/components/blebox/test_cover.py +++ b/tests/components/blebox/test_cover.py @@ -136,7 +136,7 @@ async def test_init_shutterbox(shutterbox, hass, config): state = hass.states.get(entity_id) assert state.name == "shutterBox-position" - assert entry.device_class == DEVICE_CLASS_SHUTTER + assert entry.original_device_class == DEVICE_CLASS_SHUTTER supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] assert supported_features & SUPPORT_OPEN diff --git a/tests/components/blueprint/conftest.py b/tests/components/blueprint/conftest.py index ec76451065c..fe0df5d8260 100644 --- a/tests/components/blueprint/conftest.py +++ b/tests/components/blueprint/conftest.py @@ -7,7 +7,7 @@ import pytest @pytest.fixture(autouse=True) def stub_blueprint_populate(): - """Stub copying the blueprint automations to the config folder.""" + """Stub copying the blueprints to the config folder.""" with patch( "homeassistant.components.blueprint.models.DomainBlueprints.async_populate" ): diff --git a/tests/fixtures/blueprint/community_post.json b/tests/components/blueprint/fixtures/community_post.json similarity index 100% rename from tests/fixtures/blueprint/community_post.json rename to tests/components/blueprint/fixtures/community_post.json diff --git a/tests/fixtures/blueprint/github_gist.json b/tests/components/blueprint/fixtures/github_gist.json similarity index 100% rename from tests/fixtures/blueprint/github_gist.json rename to tests/components/blueprint/fixtures/github_gist.json diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index 6a0bd210387..b0bc3ce292c 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -3,10 +3,7 @@ from unittest.mock import patch from homeassistant import config_entries, data_entry_flow from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN -from homeassistant.components.bmw_connected_drive.const import ( - CONF_READ_ONLY, - CONF_USE_LOCATION, -) +from homeassistant.components.bmw_connected_drive.const import CONF_READ_ONLY from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from tests.common import MockConfigEntry @@ -28,7 +25,7 @@ FIXTURE_CONFIG_ENTRY = { CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD], CONF_REGION: FIXTURE_USER_INPUT[CONF_REGION], }, - "options": {CONF_READ_ONLY: False, CONF_USE_LOCATION: False}, + "options": {CONF_READ_ONLY: False}, "source": config_entries.SOURCE_USER, "unique_id": f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_REGION]}", } @@ -137,14 +134,13 @@ async def test_options_flow_implementation(hass): result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_READ_ONLY: False, CONF_USE_LOCATION: False}, + user_input={CONF_READ_ONLY: False}, ) await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["data"] == { CONF_READ_ONLY: False, - CONF_USE_LOCATION: False, } assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index 27f0e69e79b..b36637897d8 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -1,12 +1,14 @@ """Test the Bond config flow.""" from __future__ import annotations +from http import HTTPStatus from typing import Any from unittest.mock import MagicMock, Mock, patch from aiohttp import ClientConnectionError, ClientResponseError from homeassistant import config_entries, core +from homeassistant.components import zeroconf from homeassistant.components.bond.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST @@ -193,13 +195,21 @@ async def test_user_form_one_entry_per_device_allowed(hass: core.HomeAssistant): async def test_zeroconf_form(hass: core.HomeAssistant): """Test we get the discovery form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data={"name": "test-bond-id.some-other-tail-info", "host": "test-host"}, - ) - assert result["type"] == "form" - assert result["errors"] == {} + with patch_bond_version(), patch_bond_token(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="test-host", + hostname="mock_hostname", + name="test-bond-id.some-other-tail-info", + port=None, + properties={}, + type="mock_type", + ), + ) + assert result["type"] == "form" + assert result["errors"] == {} with patch_bond_version( return_value={"bondid": "test-bond-id"} @@ -226,7 +236,14 @@ async def test_zeroconf_form_token_unavailable(hass: core.HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={"name": "test-bond-id.some-other-tail-info", "host": "test-host"}, + data=zeroconf.ZeroconfServiceInfo( + host="test-host", + hostname="mock_hostname", + name="test-bond-id.some-other-tail-info", + port=None, + properties={}, + type="mock_type", + ), ) await hass.async_block_till_done() assert result["type"] == "form" @@ -259,7 +276,14 @@ async def test_zeroconf_form_with_token_available(hass: core.HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={"name": "test-bond-id.some-other-tail-info", "host": "test-host"}, + data=zeroconf.ZeroconfServiceInfo( + host="test-host", + hostname="mock_hostname", + name="test-bond-id.some-other-tail-info", + port=None, + properties={}, + type="mock_type", + ), ) await hass.async_block_till_done() assert result["type"] == "form" @@ -281,6 +305,46 @@ async def test_zeroconf_form_with_token_available(hass: core.HomeAssistant): assert len(mock_setup_entry.mock_calls) == 1 +async def test_zeroconf_form_with_token_available_name_unavailable( + hass: core.HomeAssistant, +): + """Test we get the discovery form when we can get the token but the name is unavailable.""" + + with patch_bond_version( + side_effect=ClientResponseError(Mock(), (), status=HTTPStatus.BAD_REQUEST) + ), patch_bond_token(return_value={"token": "discovered-token"}): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="test-host", + hostname="mock_hostname", + name="test-bond-id.some-other-tail-info", + port=None, + properties={}, + type="mock_type", + ), + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["errors"] == {} + + with _patch_async_setup_entry() as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-bond-id" + assert result2["data"] == { + CONF_HOST: "test-host", + CONF_ACCESS_TOKEN: "discovered-token", + } + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_zeroconf_already_configured(hass: core.HomeAssistant): """Test starting a flow from discovery when already configured.""" @@ -295,18 +359,21 @@ async def test_zeroconf_already_configured(hass: core.HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "name": "already-registered-bond-id.some-other-tail-info", - "host": "updated-host", - }, + data=zeroconf.ZeroconfServiceInfo( + host="updated-host", + hostname="mock_hostname", + name="already-registered-bond-id.some-other-tail-info", + port=None, + properties={}, + type="mock_type", + ), ) + await hass.async_block_till_done() assert result["type"] == "abort" assert result["reason"] == "already_configured" assert entry.data["host"] == "updated-host" - - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 1 async def test_zeroconf_already_configured_refresh_token(hass: core.HomeAssistant): @@ -336,10 +403,14 @@ async def test_zeroconf_already_configured_refresh_token(hass: core.HomeAssistan result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "name": "already-registered-bond-id.some-other-tail-info", - "host": "updated-host", - }, + data=zeroconf.ZeroconfServiceInfo( + host="updated-host", + hostname="mock_hostname", + name="already-registered-bond-id.some-other-tail-info", + port=None, + properties={}, + type="mock_type", + ), ) await hass.async_block_till_done() @@ -369,10 +440,14 @@ async def test_zeroconf_already_configured_no_reload_same_host( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "name": "already-registered-bond-id.some-other-tail-info", - "host": "stored-host", - }, + data=zeroconf.ZeroconfServiceInfo( + host="stored-host", + hostname="mock_hostname", + name="already-registered-bond-id.some-other-tail-info", + port=None, + properties={}, + type="mock_type", + ), ) await hass.async_block_till_done() @@ -386,10 +461,14 @@ async def test_zeroconf_form_unexpected_error(hass: core.HomeAssistant): await _help_test_form_unexpected_error( hass, source=config_entries.SOURCE_ZEROCONF, - initial_input={ - "name": "test-bond-id.some-other-tail-info", - "host": "test-host", - }, + initial_input=zeroconf.ZeroconfServiceInfo( + host="test-host", + hostname="mock_hostname", + name="test-bond-id.some-other-tail-info", + port=None, + properties={}, + type="mock_type", + ), user_input={CONF_ACCESS_TOKEN: "test-token"}, error=Exception(), ) @@ -404,9 +483,10 @@ async def _help_test_form_unexpected_error( error: Exception, ): """Test we handle unexpected error gracefully.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": source}, data=initial_input - ) + with patch_bond_token(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=initial_input + ) with patch_bond_version( return_value={"bond_id": "test-bond-id"} diff --git a/tests/components/bond/test_cover.py b/tests/components/bond/test_cover.py index f516d84d50a..7a27617d607 100644 --- a/tests/components/bond/test_cover.py +++ b/tests/components/bond/test_cover.py @@ -4,12 +4,16 @@ from datetime import timedelta from bond_api import Action, DeviceType from homeassistant import core -from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, STATE_CLOSED from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, + STATE_UNKNOWN, ) from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry @@ -27,7 +31,29 @@ from tests.common import async_fire_time_changed def shades(name: str): """Create motorized shades with given name.""" - return {"name": name, "type": DeviceType.MOTORIZED_SHADES} + return { + "name": name, + "type": DeviceType.MOTORIZED_SHADES, + "actions": ["Open", "Close", "Hold"], + } + + +def tilt_only_shades(name: str): + """Create motorized shades that only tilt.""" + return { + "name": name, + "type": DeviceType.MOTORIZED_SHADES, + "actions": ["TiltOpen", "TiltClose", "Hold"], + } + + +def tilt_shades(name: str): + """Create motorized shades with given name that can also tilt.""" + return { + "name": name, + "type": DeviceType.MOTORIZED_SHADES, + "actions": ["Open", "Close", "Hold", "TiltOpen", "TiltClose", "Hold"], + } async def test_entity_registry(hass: core.HomeAssistant): @@ -99,6 +125,90 @@ async def test_stop_cover(hass: core.HomeAssistant): mock_hold.assert_called_once_with("test-device-id", Action.hold()) +async def test_tilt_open_cover(hass: core.HomeAssistant): + """Tests that tilt open cover command delegates to API.""" + await setup_platform( + hass, COVER_DOMAIN, tilt_only_shades("name-1"), bond_device_id="test-device-id" + ) + + with patch_bond_action() as mock_open, patch_bond_device_state(): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: "cover.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_open.assert_called_once_with("test-device-id", Action.tilt_open()) + assert hass.states.get("cover.name_1").state == STATE_UNKNOWN + + +async def test_tilt_close_cover(hass: core.HomeAssistant): + """Tests that tilt close cover command delegates to API.""" + await setup_platform( + hass, COVER_DOMAIN, tilt_only_shades("name-1"), bond_device_id="test-device-id" + ) + + with patch_bond_action() as mock_close, patch_bond_device_state(): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_close.assert_called_once_with("test-device-id", Action.tilt_close()) + assert hass.states.get("cover.name_1").state == STATE_UNKNOWN + + +async def test_tilt_stop_cover(hass: core.HomeAssistant): + """Tests that tilt stop cover command delegates to API.""" + await setup_platform( + hass, + COVER_DOMAIN, + tilt_only_shades("name-1"), + bond_device_id="test-device-id", + state={"counter1": 123}, + ) + + with patch_bond_action() as mock_hold, patch_bond_device_state(): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER_TILT, + {ATTR_ENTITY_ID: "cover.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_hold.assert_called_once_with("test-device-id", Action.hold()) + assert hass.states.get("cover.name_1").state == STATE_UNKNOWN + + +async def test_tilt_and_open(hass: core.HomeAssistant): + """Tests that supports both tilt and open.""" + await setup_platform( + hass, + COVER_DOMAIN, + tilt_shades("name-1"), + bond_device_id="test-device-id", + state={"open": False}, + ) + + with patch_bond_action() as mock_open, patch_bond_device_state(): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: "cover.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_open.assert_called_once_with("test-device-id", Action.tilt_open()) + assert hass.states.get("cover.name_1").state == STATE_CLOSED + + async def test_update_reports_open_cover(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports cover is open.""" await setup_platform(hass, COVER_DOMAIN, shades("name-1")) diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index d24128617d2..0c58596bb7f 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -25,7 +25,7 @@ from homeassistant.components.fan import ( ) from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow @@ -84,6 +84,10 @@ async def test_entity_registry(hass: core.HomeAssistant): entity = registry.entities["fan.name_1"] assert entity.unique_id == "test-hub-id_test-device-id" + device_registry = dr.async_get(hass) + device = device_registry.async_get(entity.device_id) + assert device.configuration_url == "http://some host" + async def test_non_standard_speed_list(hass: core.HomeAssistant): """Tests that the device is registered with custom speed list if number of supported speeds differs form 3.""" diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 42eca44dfa7..db54ffdf716 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -107,6 +107,7 @@ async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAss assert hub.manufacturer == "Olibra" assert hub.model == "test-model" assert hub.sw_version == "test-version" + assert hub.configuration_url == "http://some host" # verify supported domains are setup assert len(mock_cover_async_setup_entry.mock_calls) == 1 diff --git a/tests/components/bosch_shc/conftest.py b/tests/components/bosch_shc/conftest.py new file mode 100644 index 00000000000..6a3797ad094 --- /dev/null +++ b/tests/components/bosch_shc/conftest.py @@ -0,0 +1,8 @@ +"""bosch_shc session fixtures.""" + +import pytest + + +@pytest.fixture(autouse=True) +def bosch_shc_mock_async_zeroconf(mock_async_zeroconf): + """Auto mock zeroconf.""" diff --git a/tests/components/bosch_shc/test_config_flow.py b/tests/components/bosch_shc/test_config_flow.py index be5c3d76b53..065035bedbe 100644 --- a/tests/components/bosch_shc/test_config_flow.py +++ b/tests/components/bosch_shc/test_config_flow.py @@ -10,6 +10,7 @@ from boschshcpy.exceptions import ( from boschshcpy.information import SHCInformation from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.components.bosch_shc.config_flow import write_tls_asset from homeassistant.components.bosch_shc.const import CONF_SHC_CERT, CONF_SHC_KEY, DOMAIN @@ -19,13 +20,14 @@ MOCK_SETTINGS = { "name": "Test name", "device": {"mac": "test-mac", "hostname": "test-host"}, } -DISCOVERY_INFO = { - "host": ["169.1.1.1", "1.1.1.1"], - "port": 0, - "hostname": "shc012345.local.", - "type": "_http._tcp.local.", - "name": "Bosch SHC [test-mac]._http._tcp.local.", -} +DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + hostname="shc012345.local.", + name="Bosch SHC [test-mac]._http._tcp.local.", + port=0, + properties={}, + type="_http._tcp.local.", +) async def test_form_user(hass, mock_zeroconf): @@ -525,33 +527,18 @@ async def test_zeroconf_cannot_connect(hass, mock_zeroconf): assert result["reason"] == "cannot_connect" -async def test_zeroconf_link_local(hass, mock_zeroconf): - """Test we get the form.""" - DISCOVERY_INFO_LINK_LOCAL = { - "host": ["169.1.1.1"], - "port": 0, - "hostname": "shc012345.local.", - "type": "_http._tcp.local.", - "name": "Bosch SHC [test-mac]._http._tcp.local.", - } - - with patch( - "boschshcpy.session.SHCSession.mdns_info", side_effect=SHCConnectionError - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - data=DISCOVERY_INFO_LINK_LOCAL, - context={"source": config_entries.SOURCE_ZEROCONF}, - ) - assert result["type"] == "abort" - assert result["reason"] == "cannot_connect" - - async def test_zeroconf_not_bosch_shc(hass, mock_zeroconf): """Test we filter out non-bosch_shc devices.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data={"host": "1.1.1.1", "name": "notboschshc"}, + data=zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + hostname="mock_hostname", + name="notboschshc", + port=None, + properties={}, + type="mock_type", + ), context={"source": config_entries.SOURCE_ZEROCONF}, ) assert result["type"] == "abort" diff --git a/tests/components/broadlink/test_config_flow.py b/tests/components/broadlink/test_config_flow.py index ed27d497d23..8dbd28e0081 100644 --- a/tests/components/broadlink/test_config_flow.py +++ b/tests/components/broadlink/test_config_flow.py @@ -7,8 +7,8 @@ import broadlink.exceptions as blke import pytest from homeassistant import config_entries +from homeassistant.components import dhcp from homeassistant.components.broadlink.const import DOMAIN -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS from homeassistant.helpers import device_registry from . import get_device @@ -834,11 +834,11 @@ async def test_dhcp_can_finish(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - HOSTNAME: "broadlink", - IP_ADDRESS: "1.2.3.4", - MAC_ADDRESS: device_registry.format_mac(device.mac), - }, + data=dhcp.DhcpServiceInfo( + hostname="broadlink", + ip="1.2.3.4", + macaddress=device_registry.format_mac(device.mac), + ), ) await hass.async_block_till_done() @@ -868,11 +868,11 @@ async def test_dhcp_fails_to_connect(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - HOSTNAME: "broadlink", - IP_ADDRESS: "1.2.3.4", - MAC_ADDRESS: "34:ea:34:b4:3b:5a", - }, + data=dhcp.DhcpServiceInfo( + hostname="broadlink", + ip="1.2.3.4", + macaddress="34:ea:34:b4:3b:5a", + ), ) await hass.async_block_till_done() @@ -887,11 +887,11 @@ async def test_dhcp_unreachable(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - HOSTNAME: "broadlink", - IP_ADDRESS: "1.2.3.4", - MAC_ADDRESS: "34:ea:34:b4:3b:5a", - }, + data=dhcp.DhcpServiceInfo( + hostname="broadlink", + ip="1.2.3.4", + macaddress="34:ea:34:b4:3b:5a", + ), ) await hass.async_block_till_done() @@ -906,11 +906,11 @@ async def test_dhcp_connect_unknown_error(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - HOSTNAME: "broadlink", - IP_ADDRESS: "1.2.3.4", - MAC_ADDRESS: "34:ea:34:b4:3b:5a", - }, + data=dhcp.DhcpServiceInfo( + hostname="broadlink", + ip="1.2.3.4", + macaddress="34:ea:34:b4:3b:5a", + ), ) await hass.async_block_till_done() @@ -928,11 +928,11 @@ async def test_dhcp_device_not_supported(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - HOSTNAME: "broadlink", - IP_ADDRESS: device.host, - MAC_ADDRESS: device_registry.format_mac(device.mac), - }, + data=dhcp.DhcpServiceInfo( + hostname="broadlink", + ip=device.host, + macaddress=device_registry.format_mac(device.mac), + ), ) assert result["type"] == "abort" @@ -952,11 +952,11 @@ async def test_dhcp_already_exists(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - HOSTNAME: "broadlink", - IP_ADDRESS: "1.2.3.4", - MAC_ADDRESS: "34:ea:34:b4:3b:5a", - }, + data=dhcp.DhcpServiceInfo( + hostname="broadlink", + ip="1.2.3.4", + macaddress="34:ea:34:b4:3b:5a", + ), ) await hass.async_block_till_done() @@ -977,11 +977,11 @@ async def test_dhcp_updates_host(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - HOSTNAME: "broadlink", - IP_ADDRESS: "4.5.6.7", - MAC_ADDRESS: "34:ea:34:b4:3b:5a", - }, + data=dhcp.DhcpServiceInfo( + hostname="broadlink", + ip="4.5.6.7", + macaddress="34:ea:34:b4:3b:5a", + ), ) await hass.async_block_till_done() diff --git a/tests/components/broadlink/test_remote.py b/tests/components/broadlink/test_remote.py index abc500479ea..1ee48063613 100644 --- a/tests/components/broadlink/test_remote.py +++ b/tests/components/broadlink/test_remote.py @@ -2,13 +2,13 @@ from base64 import b64decode from unittest.mock import call -from homeassistant.components.broadlink.const import DOMAIN, REMOTE_DOMAIN +from homeassistant.components.broadlink.const import DOMAIN from homeassistant.components.remote import ( SERVICE_SEND_COMMAND, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.helpers.entity_registry import async_entries_for_device from . import get_device @@ -34,7 +34,7 @@ async def test_remote_setup_works(hass): {(DOMAIN, mock_setup.entry.unique_id)} ) entries = async_entries_for_device(entity_registry, device_entry.id) - remotes = {entry for entry in entries if entry.domain == REMOTE_DOMAIN} + remotes = {entry for entry in entries if entry.domain == Platform.REMOTE} assert len(remotes) == 1 remote = remotes.pop() @@ -54,12 +54,12 @@ async def test_remote_send_command(hass): {(DOMAIN, mock_setup.entry.unique_id)} ) entries = async_entries_for_device(entity_registry, device_entry.id) - remotes = {entry for entry in entries if entry.domain == REMOTE_DOMAIN} + remotes = {entry for entry in entries if entry.domain == Platform.REMOTE} assert len(remotes) == 1 remote = remotes.pop() await hass.services.async_call( - REMOTE_DOMAIN, + Platform.REMOTE, SERVICE_SEND_COMMAND, {"entity_id": remote.entity_id, "command": "b64:" + IR_PACKET}, blocking=True, @@ -81,12 +81,12 @@ async def test_remote_turn_off_turn_on(hass): {(DOMAIN, mock_setup.entry.unique_id)} ) entries = async_entries_for_device(entity_registry, device_entry.id) - remotes = {entry for entry in entries if entry.domain == REMOTE_DOMAIN} + remotes = {entry for entry in entries if entry.domain == Platform.REMOTE} assert len(remotes) == 1 remote = remotes.pop() await hass.services.async_call( - REMOTE_DOMAIN, + Platform.REMOTE, SERVICE_TURN_OFF, {"entity_id": remote.entity_id}, blocking=True, @@ -94,7 +94,7 @@ async def test_remote_turn_off_turn_on(hass): assert hass.states.get(remote.entity_id).state == STATE_OFF await hass.services.async_call( - REMOTE_DOMAIN, + Platform.REMOTE, SERVICE_SEND_COMMAND, {"entity_id": remote.entity_id, "command": "b64:" + IR_PACKET}, blocking=True, @@ -102,7 +102,7 @@ async def test_remote_turn_off_turn_on(hass): assert mock_setup.api.send_data.call_count == 0 await hass.services.async_call( - REMOTE_DOMAIN, + Platform.REMOTE, SERVICE_TURN_ON, {"entity_id": remote.entity_id}, blocking=True, @@ -110,7 +110,7 @@ async def test_remote_turn_off_turn_on(hass): assert hass.states.get(remote.entity_id).state == STATE_ON await hass.services.async_call( - REMOTE_DOMAIN, + Platform.REMOTE, SERVICE_SEND_COMMAND, {"entity_id": remote.entity_id, "command": "b64:" + IR_PACKET}, blocking=True, diff --git a/tests/components/broadlink/test_sensors.py b/tests/components/broadlink/test_sensors.py index b7fccd2e2ff..28caa212278 100644 --- a/tests/components/broadlink/test_sensors.py +++ b/tests/components/broadlink/test_sensors.py @@ -1,8 +1,9 @@ """Tests for Broadlink sensors.""" from datetime import timedelta -from homeassistant.components.broadlink.const import DOMAIN, SENSOR_DOMAIN +from homeassistant.components.broadlink.const import DOMAIN from homeassistant.components.broadlink.updater import BroadlinkSP4UpdateManager +from homeassistant.const import Platform from homeassistant.helpers.entity_registry import async_entries_for_device from homeassistant.util import dt @@ -33,7 +34,7 @@ async def test_a1_sensor_setup(hass): {(DOMAIN, mock_setup.entry.unique_id)} ) entries = async_entries_for_device(entity_registry, device_entry.id) - sensors = [entry for entry in entries if entry.domain == SENSOR_DOMAIN] + sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] assert len(sensors) == 5 sensors_and_states = { @@ -70,7 +71,7 @@ async def test_a1_sensor_update(hass): {(DOMAIN, mock_setup.entry.unique_id)} ) entries = async_entries_for_device(entity_registry, device_entry.id) - sensors = [entry for entry in entries if entry.domain == SENSOR_DOMAIN] + sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] assert len(sensors) == 5 mock_setup.api.check_sensors_raw.return_value = { @@ -114,7 +115,7 @@ async def test_rm_pro_sensor_setup(hass): {(DOMAIN, mock_setup.entry.unique_id)} ) entries = async_entries_for_device(entity_registry, device_entry.id) - sensors = [entry for entry in entries if entry.domain == SENSOR_DOMAIN] + sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] assert len(sensors) == 1 sensors_and_states = { @@ -139,7 +140,7 @@ async def test_rm_pro_sensor_update(hass): {(DOMAIN, mock_setup.entry.unique_id)} ) entries = async_entries_for_device(entity_registry, device_entry.id) - sensors = [entry for entry in entries if entry.domain == SENSOR_DOMAIN] + sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] assert len(sensors) == 1 mock_setup.api.check_sensors.return_value = {"temperature": 25.8} @@ -173,7 +174,7 @@ async def test_rm_pro_filter_crazy_temperature(hass): {(DOMAIN, mock_setup.entry.unique_id)} ) entries = async_entries_for_device(entity_registry, device_entry.id) - sensors = [entry for entry in entries if entry.domain == SENSOR_DOMAIN] + sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] assert len(sensors) == 1 mock_setup.api.check_sensors.return_value = {"temperature": -7} @@ -205,7 +206,7 @@ async def test_rm_mini3_no_sensor(hass): {(DOMAIN, mock_setup.entry.unique_id)} ) entries = async_entries_for_device(entity_registry, device_entry.id) - sensors = [entry for entry in entries if entry.domain == SENSOR_DOMAIN] + sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] assert len(sensors) == 0 @@ -225,7 +226,7 @@ async def test_rm4_pro_hts2_sensor_setup(hass): {(DOMAIN, mock_setup.entry.unique_id)} ) entries = async_entries_for_device(entity_registry, device_entry.id) - sensors = [entry for entry in entries if entry.domain == SENSOR_DOMAIN] + sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] assert len(sensors) == 2 sensors_and_states = { @@ -253,7 +254,7 @@ async def test_rm4_pro_hts2_sensor_update(hass): {(DOMAIN, mock_setup.entry.unique_id)} ) entries = async_entries_for_device(entity_registry, device_entry.id) - sensors = [entry for entry in entries if entry.domain == SENSOR_DOMAIN] + sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] assert len(sensors) == 2 mock_setup.api.check_sensors.return_value = {"temperature": 16.8, "humidity": 34.0} @@ -288,7 +289,7 @@ async def test_rm4_pro_no_sensor(hass): {(DOMAIN, mock_setup.entry.unique_id)} ) entries = async_entries_for_device(entity_registry, device_entry.id) - sensors = {entry for entry in entries if entry.domain == SENSOR_DOMAIN} + sensors = {entry for entry in entries if entry.domain == Platform.SENSOR} assert len(sensors) == 0 @@ -318,7 +319,7 @@ async def test_scb1e_sensor_setup(hass): {(DOMAIN, mock_setup.entry.unique_id)} ) entries = async_entries_for_device(entity_registry, device_entry.id) - sensors = [entry for entry in entries if entry.domain == SENSOR_DOMAIN] + sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] assert len(sensors) == 5 sensors_and_states = { @@ -363,7 +364,7 @@ async def test_scb1e_sensor_update(hass): {(DOMAIN, mock_setup.entry.unique_id)} ) entries = async_entries_for_device(entity_registry, device_entry.id) - sensors = [entry for entry in entries if entry.domain == SENSOR_DOMAIN] + sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] assert len(sensors) == 5 mock_setup.api.get_state.return_value = { diff --git a/tests/components/brother/__init__.py b/tests/components/brother/__init__.py index b4706d56fba..57cbe8b71de 100644 --- a/tests/components/brother/__init__.py +++ b/tests/components/brother/__init__.py @@ -22,7 +22,7 @@ async def init_integration(hass, skip_setup=False) -> MockConfigEntry: if not skip_setup: with patch( "brother.Brother._get_data", - return_value=json.loads(load_fixture("brother_printer_data.json")), + return_value=json.loads(load_fixture("printer_data.json", "brother")), ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/fixtures/brother_printer_data.json b/tests/components/brother/fixtures/printer_data.json similarity index 100% rename from tests/fixtures/brother_printer_data.json rename to tests/components/brother/fixtures/printer_data.json diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py index b7e4b31cfab..3bc7738bb8d 100644 --- a/tests/components/brother/test_config_flow.py +++ b/tests/components/brother/test_config_flow.py @@ -5,6 +5,7 @@ from unittest.mock import patch from brother import SnmpError, UnsupportedModel from homeassistant import data_entry_flow +from homeassistant.components import zeroconf from homeassistant.components.brother.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_TYPE @@ -28,7 +29,7 @@ async def test_create_entry_with_hostname(hass): """Test that the user step works with printer hostname.""" with patch( "brother.Brother._get_data", - return_value=json.loads(load_fixture("brother_printer_data.json")), + return_value=json.loads(load_fixture("printer_data.json", "brother")), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=CONFIG @@ -44,7 +45,7 @@ async def test_create_entry_with_ipv4_address(hass): """Test that the user step works with printer IPv4 address.""" with patch( "brother.Brother._get_data", - return_value=json.loads(load_fixture("brother_printer_data.json")), + return_value=json.loads(load_fixture("printer_data.json", "brother")), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -62,7 +63,7 @@ async def test_create_entry_with_ipv6_address(hass): """Test that the user step works with printer IPv6 address.""" with patch( "brother.Brother._get_data", - return_value=json.loads(load_fixture("brother_printer_data.json")), + return_value=json.loads(load_fixture("printer_data.json", "brother")), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -123,7 +124,7 @@ async def test_device_exists_abort(hass): """Test we abort config flow if Brother printer already configured.""" with patch( "brother.Brother._get_data", - return_value=json.loads(load_fixture("brother_printer_data.json")), + return_value=json.loads(load_fixture("printer_data.json", "brother")), ): MockConfigEntry(domain=DOMAIN, unique_id="0123456789", data=CONFIG).add_to_hass( hass @@ -143,7 +144,14 @@ async def test_zeroconf_snmp_error(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={"hostname": "example.local.", "name": "Brother Printer"}, + data=zeroconf.ZeroconfServiceInfo( + host="mock_host", + hostname="example.local.", + name="Brother Printer", + port=None, + properties={}, + type="mock_type", + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -156,11 +164,14 @@ async def test_zeroconf_unsupported_model(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={ - "hostname": "example.local.", - "name": "Brother Printer", - "properties": {"product": "MFC-8660DN"}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="mock_host", + hostname="example.local.", + name="Brother Printer", + port=None, + properties={"product": "MFC-8660DN"}, + type="mock_type", + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -172,7 +183,7 @@ async def test_zeroconf_device_exists_abort(hass): """Test we abort zeroconf flow if Brother printer already configured.""" with patch( "brother.Brother._get_data", - return_value=json.loads(load_fixture("brother_printer_data.json")), + return_value=json.loads(load_fixture("printer_data.json", "brother")), ): MockConfigEntry(domain=DOMAIN, unique_id="0123456789", data=CONFIG).add_to_hass( hass @@ -181,7 +192,14 @@ async def test_zeroconf_device_exists_abort(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={"hostname": "example.local.", "name": "Brother Printer"}, + data=zeroconf.ZeroconfServiceInfo( + host="mock_host", + hostname="example.local.", + name="Brother Printer", + port=None, + properties={}, + type="mock_type", + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -196,7 +214,14 @@ async def test_zeroconf_no_probe_existing_device(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={"hostname": "localhost", "name": "Brother Printer"}, + data=zeroconf.ZeroconfServiceInfo( + host="mock_host", + hostname="localhost", + name="Brother Printer", + port=None, + properties={}, + type="mock_type", + ), ) await hass.async_block_till_done() @@ -209,13 +234,20 @@ async def test_zeroconf_confirm_create_entry(hass): """Test zeroconf confirmation and create config entry.""" with patch( "brother.Brother._get_data", - return_value=json.loads(load_fixture("brother_printer_data.json")), + return_value=json.loads(load_fixture("printer_data.json", "brother")), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={"hostname": "example.local.", "name": "Brother Printer"}, + data=zeroconf.ZeroconfServiceInfo( + host="mock_host", + hostname="example.local.", + name="Brother Printer", + port=None, + properties={}, + type="mock_type", + ), ) assert result["step_id"] == "zeroconf_confirm" diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py index b51577b5f3d..a2763c3ed91 100644 --- a/tests/components/brother/test_sensor.py +++ b/tests/components/brother/test_sensor.py @@ -3,7 +3,8 @@ from datetime import datetime, timedelta import json from unittest.mock import Mock, patch -from homeassistant.components.brother.const import DOMAIN, UNIT_PAGES +from homeassistant.components.brother.const import DOMAIN +from homeassistant.components.brother.sensor import UNIT_PAGES from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, @@ -46,7 +47,7 @@ async def test_sensors(hass): test_time = datetime(2019, 11, 11, 9, 10, 32, tzinfo=UTC) with patch("brother.datetime", utcnow=Mock(return_value=test_time)), patch( "brother.Brother._get_data", - return_value=json.loads(load_fixture("brother_printer_data.json")), + return_value=json.loads(load_fixture("printer_data.json", "brother")), ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -296,7 +297,7 @@ async def test_availability(hass): future = utcnow() + timedelta(minutes=10) with patch( "brother.Brother._get_data", - return_value=json.loads(load_fixture("brother_printer_data.json")), + return_value=json.loads(load_fixture("printer_data.json", "brother")), ): async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -311,7 +312,7 @@ async def test_manual_update_entity(hass): """Test manual update entity via service homeasasistant/update_entity.""" await init_integration(hass) - data = json.loads(load_fixture("brother_printer_data.json")) + data = json.loads(load_fixture("printer_data.json", "brother")) await async_setup_component(hass, "homeassistant", {}) with patch( diff --git a/tests/components/brunt/__init__.py b/tests/components/brunt/__init__.py new file mode 100644 index 00000000000..15060cbaf4c --- /dev/null +++ b/tests/components/brunt/__init__.py @@ -0,0 +1 @@ +"""Brunt tests.""" diff --git a/tests/components/brunt/test_config_flow.py b/tests/components/brunt/test_config_flow.py new file mode 100644 index 00000000000..f053a6d18b0 --- /dev/null +++ b/tests/components/brunt/test_config_flow.py @@ -0,0 +1,180 @@ +"""Test the Brunt config flow.""" +from unittest.mock import Mock, patch + +from aiohttp import ClientResponseError +from aiohttp.client_exceptions import ServerDisconnectedError +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.brunt.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +CONFIG = {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"} + + +async def test_form(hass): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch( + "homeassistant.components.brunt.config_flow.BruntClientAsync.async_login", + return_value=None, + ), patch( + "homeassistant.components.brunt.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() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "test-username" + assert result2["data"] == CONFIG + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import(hass): + """Test we get the form.""" + + with patch( + "homeassistant.components.brunt.config_flow.BruntClientAsync.async_login", + return_value=None, + ), patch( + "homeassistant.components.brunt.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=CONFIG + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == CONFIG + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_duplicate_login(hass): + """Test uniqueness of username.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG, + title="test-username", + unique_id="test-username", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.brunt.config_flow.BruntClientAsync.async_login", + return_value=None, + ), patch( + "homeassistant.components.brunt.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=CONFIG + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_form_duplicate_login(hass): + """Test uniqueness of username.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG, + title="test-username", + unique_id="test-username", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.brunt.config_flow.BruntClientAsync.async_login", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=CONFIG + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "side_effect, error_message", + [ + (ServerDisconnectedError, "cannot_connect"), + (ClientResponseError(Mock(), None, status=403), "invalid_auth"), + (ClientResponseError(Mock(), None, status=401), "unknown"), + (Exception, "unknown"), + ], +) +async def test_form_error(hass, side_effect, error_message): + """Test we handle cannot connect.""" + with patch( + "homeassistant.components.brunt.config_flow.BruntClientAsync.async_login", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": error_message} + + +@pytest.mark.parametrize( + "side_effect, result_type, password, step_id, reason", + [ + (None, data_entry_flow.RESULT_TYPE_ABORT, "test", None, "reauth_successful"), + ( + Exception, + data_entry_flow.RESULT_TYPE_FORM, + CONFIG[CONF_PASSWORD], + "reauth_confirm", + None, + ), + ], +) +async def test_reauth(hass, side_effect, result_type, password, step_id, reason): + """Test uniqueness of username.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG, + title="test-username", + unique_id="test-username", + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, + data=None, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + with patch( + "homeassistant.components.brunt.config_flow.BruntClientAsync.async_login", + return_value=None, + side_effect=side_effect, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"password": "test"}, + ) + assert result3["type"] == result_type + assert entry.data["password"] == password + assert result3.get("step_id", None) == step_id + assert result3.get("reason", None) == reason diff --git a/tests/fixtures/bsblan/info.json b/tests/components/bsblan/fixtures/info.json similarity index 100% rename from tests/fixtures/bsblan/info.json rename to tests/components/bsblan/fixtures/info.json diff --git a/tests/components/button/__init__.py b/tests/components/button/__init__.py new file mode 100644 index 00000000000..77489053189 --- /dev/null +++ b/tests/components/button/__init__.py @@ -0,0 +1 @@ +"""The tests for the Button integration.""" diff --git a/tests/components/button/test_device_action.py b/tests/components/button/test_device_action.py new file mode 100644 index 00000000000..984be163d42 --- /dev/null +++ b/tests/components/button/test_device_action.py @@ -0,0 +1,87 @@ +"""The tests for Button device actions.""" +import pytest + +from homeassistant.components import automation +from homeassistant.components.button import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry, entity_registry +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_get_device_automations, + async_mock_service, + mock_device_registry, + mock_registry, +) + + +@pytest.fixture +def device_reg(hass: HomeAssistant) -> device_registry.DeviceRegistry: + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass: HomeAssistant) -> entity_registry.EntityRegistry: + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +async def test_get_actions( + hass: HomeAssistant, + device_reg: device_registry.DeviceRegistry, + entity_reg: entity_registry.EntityRegistry, +) -> None: + """Test we get the expected actions from a button.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) + expected_actions = [ + { + "domain": DOMAIN, + "type": "press", + "device_id": device_entry.id, + "entity_id": "button.test_5678", + } + ] + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert_lists_same(actions, expected_actions) + + +async def test_action(hass: HomeAssistant) -> None: + """Test for press action.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event", + }, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": "button.entity", + "type": "press", + }, + }, + ] + }, + ) + + press_calls = async_mock_service(hass, DOMAIN, "press") + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(press_calls) == 1 + assert press_calls[0].domain == DOMAIN + assert press_calls[0].service == "press" + assert press_calls[0].data == {"entity_id": "button.entity"} diff --git a/tests/components/button/test_device_trigger.py b/tests/components/button/test_device_trigger.py new file mode 100644 index 00000000000..913aec9088e --- /dev/null +++ b/tests/components/button/test_device_trigger.py @@ -0,0 +1,108 @@ +"""The tests for Button device triggers.""" +from __future__ import annotations + +import pytest + +from homeassistant.components import automation +from homeassistant.components.button import DOMAIN +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import device_registry +from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_get_device_automations, + async_mock_service, + mock_device_registry, + mock_registry, +) + + +@pytest.fixture +def device_reg(hass: HomeAssistant) -> device_registry.DeviceRegistry: + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass: HomeAssistant) -> EntityRegistry: + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +@pytest.fixture +def calls(hass: HomeAssistant) -> list[ServiceCall]: + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_get_triggers( + hass: HomeAssistant, + device_reg: device_registry.DeviceRegistry, + entity_reg: EntityRegistry, +) -> None: + """Test we get the expected triggers from a button.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) + expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "type": "pressed", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + } + ] + triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + assert_lists_same(triggers, expected_triggers) + + +async def test_if_fires_on_state_change(hass, calls): + """Test for turn_on and turn_off triggers firing.""" + hass.states.async_set("button.entity", "unknown") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "button.entity", + "type": "pressed", + }, + "action": { + "service": "test.automation", + "data": { + "some": ( + "to - {{ trigger.platform}} - " + "{{ trigger.entity_id}} - {{ trigger.from_state.state}} - " + "{{ trigger.to_state.state}} - {{ trigger.for }} - " + "{{ trigger.id}}" + ) + }, + }, + } + ] + }, + ) + + # Test triggering device trigger with a to state + hass.states.async_set("button.entity", "2021-01-01T23:59:59+00:00") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data[ + "some" + ] == "to - device - {} - unknown - 2021-01-01T23:59:59+00:00 - None - 0".format( + "button.entity" + ) diff --git a/tests/components/button/test_init.py b/tests/components/button/test_init.py new file mode 100644 index 00000000000..2846a6f3a8d --- /dev/null +++ b/tests/components/button/test_init.py @@ -0,0 +1,64 @@ +"""The tests for the Button component.""" +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.button import DOMAIN, SERVICE_PRESS, ButtonEntity +from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, State +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import mock_restore_cache + + +async def test_button(hass: HomeAssistant) -> None: + """Test getting data from the mocked button entity.""" + button = ButtonEntity() + assert button.state is None + + button.hass = hass + + with pytest.raises(NotImplementedError): + await button.async_press() + + button.press = MagicMock() + await button.async_press() + + assert button.press.called + + +async def test_custom_integration(hass, caplog, enable_custom_integrations): + """Test we integration.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + assert hass.states.get("button.button_1").state == STATE_UNKNOWN + + now = dt_util.utcnow() + with patch("homeassistant.core.dt_util.utcnow", return_value=now): + await hass.services.async_call( + DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.button_1"}, + blocking=True, + ) + + assert hass.states.get("button.button_1").state == now.isoformat() + assert "The button has been pressed" in caplog.text + + +async def test_restore_state(hass, enable_custom_integrations): + """Test we restore state integration.""" + mock_restore_cache(hass, (State("button.button_1", "2021-01-01T23:59:59+00:00"),)) + + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + assert hass.states.get("button.button_1").state == "2021-01-01T23:59:59+00:00" diff --git a/tests/components/canary/test_sensor.py b/tests/components/canary/test_sensor.py index c59810ac72f..530ca1cccc1 100644 --- a/tests/components/canary/test_sensor.py +++ b/tests/components/canary/test_sensor.py @@ -78,7 +78,7 @@ async def test_sensors_pro(hass, canary) -> None: for (sensor_id, data) in sensors.items(): entity_entry = registry.async_get(f"sensor.{sensor_id}") assert entity_entry - assert entity_entry.device_class == data[3] + assert entity_entry.original_device_class == data[3] assert entity_entry.unique_id == data[0] assert entity_entry.original_icon == data[4] @@ -197,7 +197,7 @@ async def test_sensors_flex(hass, canary) -> None: for (sensor_id, data) in sensors.items(): entity_entry = registry.async_get(f"sensor.{sensor_id}") assert entity_entry - assert entity_entry.device_class == data[3] + assert entity_entry.original_device_class == data[3] assert entity_entry.unique_id == data[0] assert entity_entry.original_icon == data[4] diff --git a/tests/components/cast/conftest.py b/tests/components/cast/conftest.py index a8118a94967..3b96f378906 100644 --- a/tests/components/cast/conftest.py +++ b/tests/components/cast/conftest.py @@ -7,46 +7,23 @@ import pytest @pytest.fixture() -def dial_mock(): +def get_multizone_status_mock(): """Mock pychromecast dial.""" - dial_mock = MagicMock() - dial_mock.get_device_status.return_value.uuid = "fake_uuid" - dial_mock.get_device_status.return_value.manufacturer = "fake_manufacturer" - dial_mock.get_device_status.return_value.model_name = "fake_model_name" - dial_mock.get_device_status.return_value.friendly_name = "fake_friendly_name" - dial_mock.get_multizone_status.return_value.dynamic_groups = [] - return dial_mock + mock = MagicMock(spec_set=pychromecast.dial.get_multizone_status) + mock.return_value.dynamic_groups = [] + return mock @pytest.fixture() def castbrowser_mock(): """Mock pychromecast CastBrowser.""" - return MagicMock() - - -@pytest.fixture() -def castbrowser_constructor_mock(): - """Mock pychromecast CastBrowser constructor.""" - return MagicMock() + return MagicMock(spec=pychromecast.discovery.CastBrowser) @pytest.fixture() def mz_mock(): """Mock pychromecast MultizoneManager.""" - return MagicMock() - - -@pytest.fixture() -def pycast_mock(castbrowser_mock, castbrowser_constructor_mock): - """Mock pychromecast.""" - pycast_mock = MagicMock() - pycast_mock.IGNORE_CEC = [] - pycast_mock.discovery.CastBrowser = castbrowser_constructor_mock - pycast_mock.discovery.CastBrowser.return_value = castbrowser_mock - pycast_mock.discovery.AbstractCastListener = ( - pychromecast.discovery.AbstractCastListener - ) - return pycast_mock + return MagicMock(spec_set=pychromecast.controllers.multizone.MultizoneManager) @pytest.fixture() @@ -55,15 +32,29 @@ def quick_play_mock(): return MagicMock() +@pytest.fixture() +def get_chromecast_mock(): + """Mock pychromecast get_chromecast_from_cast_info.""" + return MagicMock() + + @pytest.fixture(autouse=True) -def cast_mock(dial_mock, mz_mock, pycast_mock, quick_play_mock): +def cast_mock( + mz_mock, + quick_play_mock, + castbrowser_mock, + get_chromecast_mock, + get_multizone_status_mock, +): """Mock pychromecast.""" + ignore_cec_orig = list(pychromecast.IGNORE_CEC) + with patch( - "homeassistant.components.cast.media_player.pychromecast", pycast_mock + "homeassistant.components.cast.discovery.pychromecast.discovery.CastBrowser", + castbrowser_mock, ), patch( - "homeassistant.components.cast.discovery.pychromecast", pycast_mock - ), patch( - "homeassistant.components.cast.helpers.dial", dial_mock + "homeassistant.components.cast.helpers.dial.get_multizone_status", + get_multizone_status_mock, ), patch( "homeassistant.components.cast.media_player.MultizoneManager", return_value=mz_mock, @@ -73,5 +64,10 @@ def cast_mock(dial_mock, mz_mock, pycast_mock, quick_play_mock): ), patch( "homeassistant.components.cast.media_player.quick_play", quick_play_mock, + ), patch( + "homeassistant.components.cast.media_player.pychromecast.get_chromecast_from_cast_info", + get_chromecast_mock, ): yield + + pychromecast.IGNORE_CEC = list(ignore_cec_orig) diff --git a/tests/components/cast/test_config_flow.py b/tests/components/cast/test_config_flow.py index 7c3fb774722..1ad89c7a8e5 100644 --- a/tests/components/cast/test_config_flow.py +++ b/tests/components/cast/test_config_flow.py @@ -245,7 +245,7 @@ async def test_option_flow(hass, parameter_data): assert dict(config_entry.data) == expected_data -async def test_known_hosts(hass, castbrowser_mock, castbrowser_constructor_mock): +async def test_known_hosts(hass, castbrowser_mock): """Test known hosts is passed to pychromecasts.""" result = await hass.config_entries.flow.async_init( "cast", context={"source": config_entries.SOURCE_USER} @@ -257,12 +257,9 @@ async def test_known_hosts(hass, castbrowser_mock, castbrowser_constructor_mock) await hass.async_block_till_done() config_entry = hass.config_entries.async_entries("cast")[0] - assert castbrowser_mock.start_discovery.call_count == 1 - castbrowser_constructor_mock.assert_called_once_with( - ANY, ANY, ["192.168.0.1", "192.168.0.2"] - ) + assert castbrowser_mock.return_value.start_discovery.call_count == 1 + castbrowser_mock.assert_called_once_with(ANY, ANY, ["192.168.0.1", "192.168.0.2"]) castbrowser_mock.reset_mock() - castbrowser_constructor_mock.reset_mock() result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_configure( @@ -272,8 +269,8 @@ async def test_known_hosts(hass, castbrowser_mock, castbrowser_constructor_mock) await hass.async_block_till_done() - castbrowser_mock.start_discovery.assert_not_called() - castbrowser_constructor_mock.assert_not_called() - castbrowser_mock.host_browser.update_hosts.assert_called_once_with( + castbrowser_mock.return_value.start_discovery.assert_not_called() + castbrowser_mock.assert_not_called() + castbrowser_mock.return_value.host_browser.update_hosts.assert_called_once_with( ["192.168.0.11", "192.168.0.12"] ) diff --git a/tests/components/cast/test_home_assistant_cast.py b/tests/components/cast/test_home_assistant_cast.py index 1e0618b066e..67b5454b6e1 100644 --- a/tests/components/cast/test_home_assistant_cast.py +++ b/tests/components/cast/test_home_assistant_cast.py @@ -29,7 +29,7 @@ async def test_service_show_view(hass, mock_zeroconf): assert controller.hass_url == "https://example.com" assert controller.client_id is None # Verify user did not accidentally submit their dev app id - assert controller.supporting_app_id == "B12CE3CA" + assert controller.supporting_app_id == "A078F6B0" assert entity_id == "media_player.kitchen" assert view_path == "mock_path" assert url_path is None diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 3bb2b895c1a..adab55c50df 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -8,6 +8,7 @@ from uuid import UUID import attr import pychromecast +from pychromecast.const import CAST_TYPE_CHROMECAST, CAST_TYPE_GROUP import pytest from homeassistant.components import media_player, tts @@ -27,7 +28,11 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_SET, ) from homeassistant.config import async_process_ha_core_config -from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + ATTR_ENTITY_ID, + CAST_APP_ID_HOMEASSISTANT_LOVELACE, + EVENT_HOMEASSISTANT_STOP, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -41,47 +46,42 @@ FakeUUID = UUID("57355bce-9364-4aa6-ac1e-eb849dccf9e2") FakeUUID2 = UUID("57355bce-9364-4aa6-ac1e-eb849dccf9e4") FakeGroupUUID = UUID("57355bce-9364-4aa6-ac1e-eb849dccf9e3") +FAKE_HOST_SERVICE = pychromecast.discovery.ServiceInfo( + pychromecast.const.SERVICE_TYPE_HOST, ("127.0.0.1", 8009) +) +FAKE_MDNS_SERVICE = pychromecast.discovery.ServiceInfo( + pychromecast.const.SERVICE_TYPE_MDNS, "the-service" +) + def get_fake_chromecast(info: ChromecastInfo): """Generate a Fake Chromecast object with the specified arguments.""" mock = MagicMock(uuid=info.uuid) + mock.app_id = None mock.media_controller.status = None return mock def get_fake_chromecast_info( - host="192.168.178.42", port=8009, uuid: UUID | None = FakeUUID + host="192.168.178.42", port=8009, service=None, uuid: UUID | None = FakeUUID ): """Generate a Fake ChromecastInfo with the specified arguments.""" - @attr.s(slots=True, frozen=True, eq=False) - class ExtendedChromecastInfo(ChromecastInfo): - host: str | None = attr.ib(default=None) - port: int | None = attr.ib(default=0) - - def __eq__(self, other): - if isinstance(other, ChromecastInfo): - return ( - ChromecastInfo( - services=self.services, - uuid=self.uuid, - manufacturer=self.manufacturer, - model_name=self.model_name, - friendly_name=self.friendly_name, - is_audio_group=self.is_audio_group, - is_dynamic_group=self.is_dynamic_group, - ) - == other - ) - return super().__eq__(other) - - return ExtendedChromecastInfo( - host=host, - port=port, - uuid=uuid, - friendly_name="Speaker", - services={"the-service"}, - is_audio_group=port != 8009, + if service is None: + service = pychromecast.discovery.ServiceInfo( + pychromecast.const.SERVICE_TYPE_HOST, (host, port) + ) + return ChromecastInfo( + cast_info=pychromecast.models.CastInfo( + services={service}, + uuid=uuid, + model_name="Chromecast", + friendly_name="Speaker", + host=host, + port=port, + cast_type=CAST_TYPE_GROUP if port != 8009 else CAST_TYPE_CHROMECAST, + manufacturer="Nabu Casa", + ) ) @@ -128,30 +128,36 @@ async def async_setup_cast_internal_discovery(hass, config=None): discovery_callback = cast_browser.call_args[0][0].add_cast remove_callback = cast_browser.call_args[0][0].remove_cast - def discover_chromecast(service_name: str, info: ChromecastInfo) -> None: + def discover_chromecast( + service: pychromecast.discovery.ServiceInfo, info: ChromecastInfo + ) -> None: """Discover a chromecast device.""" browser.devices[info.uuid] = pychromecast.discovery.CastInfo( - {service_name}, + {service}, info.uuid, - info.model_name, + info.cast_info.model_name, info.friendly_name, - info.host, - info.port, + info.cast_info.host, + info.cast_info.port, + info.cast_info.cast_type, + info.cast_info.manufacturer, ) - discovery_callback(info.uuid, service_name) + discovery_callback(info.uuid, "") def remove_chromecast(service_name: str, info: ChromecastInfo) -> None: """Remove a chromecast device.""" remove_callback( info.uuid, service_name, - pychromecast.discovery.CastInfo( + pychromecast.models.CastInfo( set(), info.uuid, - info.model_name, - info.friendly_name, - info.host, - info.port, + info.cast_info.model_name, + info.cast_info.friendly_name, + info.cast_info.host, + info.cast_info.port, + info.cast_info.cast_type, + info.cast_info.manufacturer, ), ) @@ -162,7 +168,7 @@ async def async_setup_media_player_cast(hass: HomeAssistant, info: ChromecastInf """Set up the cast platform with async_setup_component.""" browser = MagicMock(devices={}, zc={}) chromecast = get_fake_chromecast(info) - zconf = get_fake_zconf(host=info.host, port=info.port) + zconf = get_fake_zconf(host=info.cast_info.host, port=info.cast_info.port) with patch( "homeassistant.components.cast.discovery.pychromecast.get_chromecast_from_cast_info", @@ -182,16 +188,17 @@ async def async_setup_media_player_cast(hass: HomeAssistant, info: ChromecastInf discovery_callback = cast_browser.call_args[0][0].add_cast - service_name = "the-service" browser.devices[info.uuid] = pychromecast.discovery.CastInfo( - {service_name}, + {FAKE_MDNS_SERVICE}, info.uuid, - info.model_name, + info.cast_info.model_name, info.friendly_name, - info.host, - info.port, + info.cast_info.host, + info.cast_info.port, + info.cast_info.cast_type, + info.cast_info.manufacturer, ) - discovery_callback(info.uuid, service_name) + discovery_callback(info.uuid, FAKE_MDNS_SERVICE[1]) await hass.async_block_till_done() await hass.async_block_till_done() @@ -200,14 +207,16 @@ async def async_setup_media_player_cast(hass: HomeAssistant, info: ChromecastInf def discover_chromecast(service_name: str, info: ChromecastInfo) -> None: """Discover a chromecast device.""" browser.devices[info.uuid] = pychromecast.discovery.CastInfo( - {service_name}, + {FAKE_MDNS_SERVICE}, info.uuid, - info.model_name, + info.cast_info.model_name, info.friendly_name, - info.host, - info.port, + info.cast_info.host, + info.cast_info.port, + info.cast_info.cast_type, + info.cast_info.manufacturer, ) - discovery_callback(info.uuid, service_name) + discovery_callback(info.uuid, FAKE_MDNS_SERVICE[1]) return chromecast, discover_chromecast @@ -234,138 +243,104 @@ def get_status_callbacks(chromecast_mock, mz_mock=None): async def test_start_discovery_called_once(hass, castbrowser_mock): """Test pychromecast.start_discovery called exactly once.""" await async_setup_cast(hass) - assert castbrowser_mock.start_discovery.call_count == 1 + assert castbrowser_mock.return_value.start_discovery.call_count == 1 await async_setup_cast(hass) - assert castbrowser_mock.start_discovery.call_count == 1 + assert castbrowser_mock.return_value.start_discovery.call_count == 1 -async def test_internal_discovery_callback_fill_out(hass): +async def test_internal_discovery_callback_fill_out_group_fail( + hass, get_multizone_status_mock +): """Test internal discovery automatically filling out information.""" discover_cast, _, _ = await async_setup_cast_internal_discovery(hass) - info = get_fake_chromecast_info(host="host1") - zconf = get_fake_zconf(host="host1", port=8009) - full_info = attr.evolve( - info, - model_name="google home", - friendly_name="Speaker", - uuid=FakeUUID, - manufacturer="Nabu Casa", - ) - - with patch( - "homeassistant.components.cast.helpers.dial.get_device_status", - return_value=full_info, - ), patch( - "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", - return_value=zconf, - ): - signal = MagicMock() - - async_dispatcher_connect(hass, "cast_discovered", signal) - discover_cast("the-service", info) - await hass.async_block_till_done() - - # when called with incomplete info, it should use HTTP to get missing - discover = signal.mock_calls[0][1][0] - assert discover == full_info - - -async def test_internal_discovery_callback_fill_out_default_manufacturer(hass): - """Test internal discovery automatically filling out information.""" - discover_cast, _, _ = await async_setup_cast_internal_discovery(hass) - info = get_fake_chromecast_info(host="host1") - zconf = get_fake_zconf(host="host1", port=8009) - full_info = attr.evolve( - info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID - ) - - with patch( - "homeassistant.components.cast.helpers.dial.get_device_status", - return_value=full_info, - ), patch( - "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", - return_value=zconf, - ): - signal = MagicMock() - - async_dispatcher_connect(hass, "cast_discovered", signal) - discover_cast("the-service", info) - await hass.async_block_till_done() - - # when called with incomplete info, it should use HTTP to get missing - discover = signal.mock_calls[0][1][0] - assert discover == attr.evolve(full_info, manufacturer="Google Inc.") - - -async def test_internal_discovery_callback_fill_out_fail(hass): - """Test internal discovery automatically filling out information.""" - discover_cast, _, _ = await async_setup_cast_internal_discovery(hass) - info = get_fake_chromecast_info(host="host1") - zconf = get_fake_zconf(host="host1", port=8009) - full_info = ( - info # attr.evolve(info, model_name="", friendly_name="Speaker", uuid=FakeUUID) - ) - - with patch( - "homeassistant.components.cast.helpers.dial.get_device_status", - return_value=None, - ), patch( - "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", - return_value=zconf, - ): - signal = MagicMock() - - async_dispatcher_connect(hass, "cast_discovered", signal) - discover_cast("the-service", info) - await hass.async_block_till_done() - - # when called with incomplete info, it should use HTTP to get missing - discover = signal.mock_calls[0][1][0] - assert discover == full_info - - -async def test_internal_discovery_callback_fill_out_group(hass): - """Test internal discovery automatically filling out information.""" - discover_cast, _, _ = await async_setup_cast_internal_discovery(hass) - info = get_fake_chromecast_info(host="host1", port=12345) + info = get_fake_chromecast_info(host="host1", port=12345, service=FAKE_MDNS_SERVICE) zconf = get_fake_zconf(host="host1", port=12345) full_info = attr.evolve( info, - model_name="", - friendly_name="Speaker", - uuid=FakeUUID, + cast_info=pychromecast.discovery.CastInfo( + services=info.cast_info.services, + uuid=FakeUUID, + model_name="Chromecast", + friendly_name="Speaker", + host=info.cast_info.host, + port=info.cast_info.port, + cast_type=info.cast_info.cast_type, + manufacturer=info.cast_info.manufacturer, + ), is_dynamic_group=False, ) + get_multizone_status_mock.assert_not_called() + get_multizone_status_mock.return_value = None + with patch( - "homeassistant.components.cast.helpers.dial.get_device_status", - return_value=full_info, - ), patch( "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", return_value=zconf, ): signal = MagicMock() async_dispatcher_connect(hass, "cast_discovered", signal) - discover_cast("the-service", info) + discover_cast(FAKE_MDNS_SERVICE, info) await hass.async_block_till_done() # when called with incomplete info, it should use HTTP to get missing discover = signal.mock_calls[0][1][0] assert discover == full_info + get_multizone_status_mock.assert_called_once() + + +async def test_internal_discovery_callback_fill_out_group( + hass, get_multizone_status_mock +): + """Test internal discovery automatically filling out information.""" + discover_cast, _, _ = await async_setup_cast_internal_discovery(hass) + info = get_fake_chromecast_info(host="host1", port=12345, service=FAKE_MDNS_SERVICE) + zconf = get_fake_zconf(host="host1", port=12345) + full_info = attr.evolve( + info, + cast_info=pychromecast.discovery.CastInfo( + services=info.cast_info.services, + uuid=FakeUUID, + model_name="Chromecast", + friendly_name="Speaker", + host=info.cast_info.host, + port=info.cast_info.port, + cast_type=info.cast_info.cast_type, + manufacturer=info.cast_info.manufacturer, + ), + is_dynamic_group=False, + ) + + get_multizone_status_mock.assert_not_called() + get_multizone_status_mock.return_value = None + + with patch( + "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", + return_value=zconf, + ): + signal = MagicMock() + + async_dispatcher_connect(hass, "cast_discovered", signal) + discover_cast(FAKE_MDNS_SERVICE, info) + await hass.async_block_till_done() + + # when called with incomplete info, it should use HTTP to get missing + discover = signal.mock_calls[0][1][0] + assert discover == full_info + get_multizone_status_mock.assert_called_once() async def test_stop_discovery_called_on_stop(hass, castbrowser_mock): """Test pychromecast.stop_discovery called on shutdown.""" # start_discovery should be called with empty config await async_setup_cast(hass, {}) - assert castbrowser_mock.start_discovery.call_count == 1 + assert castbrowser_mock.return_value.start_discovery.call_count == 1 # stop discovery should be called on shutdown hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() - assert castbrowser_mock.stop_discovery.call_count == 1 + assert castbrowser_mock.return_value.stop_discovery.call_count == 1 async def test_create_cast_device_without_uuid(hass): @@ -404,7 +379,12 @@ async def test_manual_cast_chromecasts_uuid(hass): "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", return_value=zconf_2, ): - discover_cast("service2", cast_2) + discover_cast( + pychromecast.discovery.ServiceInfo( + pychromecast.const.SERVICE_TYPE_MDNS, "service2" + ), + cast_2, + ) await hass.async_block_till_done() await hass.async_block_till_done() # having tasks that add jobs assert add_dev1.call_count == 0 @@ -413,7 +393,12 @@ async def test_manual_cast_chromecasts_uuid(hass): "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", return_value=zconf_1, ): - discover_cast("service1", cast_1) + discover_cast( + pychromecast.discovery.ServiceInfo( + pychromecast.const.SERVICE_TYPE_MDNS, "service1" + ), + cast_1, + ) await hass.async_block_till_done() await hass.async_block_till_done() # having tasks that add jobs assert add_dev1.call_count == 1 @@ -432,7 +417,12 @@ async def test_auto_cast_chromecasts(hass): "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", return_value=zconf_1, ): - discover_cast("service2", cast_2) + discover_cast( + pychromecast.discovery.ServiceInfo( + pychromecast.const.SERVICE_TYPE_MDNS, "service2" + ), + cast_2, + ) await hass.async_block_till_done() await hass.async_block_till_done() # having tasks that add jobs assert add_dev1.call_count == 1 @@ -441,13 +431,20 @@ async def test_auto_cast_chromecasts(hass): "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", return_value=zconf_2, ): - discover_cast("service1", cast_1) + discover_cast( + pychromecast.discovery.ServiceInfo( + pychromecast.const.SERVICE_TYPE_MDNS, "service1" + ), + cast_1, + ) await hass.async_block_till_done() await hass.async_block_till_done() # having tasks that add jobs assert add_dev1.call_count == 2 -async def test_discover_dynamic_group(hass, dial_mock, pycast_mock, caplog): +async def test_discover_dynamic_group( + hass, get_multizone_status_mock, get_chromecast_mock, caplog +): """Test dynamic group does not create device or entity.""" cast_1 = get_fake_chromecast_info(host="host_1", port=23456, uuid=FakeUUID) cast_2 = get_fake_chromecast_info(host="host_2", port=34567, uuid=FakeUUID2) @@ -461,9 +458,9 @@ async def test_discover_dynamic_group(hass, dial_mock, pycast_mock, caplog): tmp1.uuid = FakeUUID tmp2 = MagicMock() tmp2.uuid = FakeUUID2 - dial_mock.get_multizone_status.return_value.dynamic_groups = [tmp1, tmp2] + get_multizone_status_mock.return_value.dynamic_groups = [tmp1, tmp2] - pycast_mock.get_chromecast_from_cast_info.assert_not_called() + get_chromecast_mock.assert_not_called() discover_cast, remove_cast, add_dev1 = await async_setup_cast_internal_discovery( hass ) @@ -473,11 +470,16 @@ async def test_discover_dynamic_group(hass, dial_mock, pycast_mock, caplog): "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", return_value=zconf_1, ): - discover_cast("service", cast_1) + discover_cast( + pychromecast.discovery.ServiceInfo( + pychromecast.const.SERVICE_TYPE_MDNS, "service" + ), + cast_1, + ) await hass.async_block_till_done() await hass.async_block_till_done() # having tasks that add jobs - pycast_mock.get_chromecast_from_cast_info.assert_called() - pycast_mock.get_chromecast_from_cast_info.reset_mock() + get_chromecast_mock.assert_called() + get_chromecast_mock.reset_mock() assert add_dev1.call_count == 0 assert reg.async_get_entity_id("media_player", "cast", cast_1.uuid) is None @@ -486,23 +488,33 @@ async def test_discover_dynamic_group(hass, dial_mock, pycast_mock, caplog): "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", return_value=zconf_2, ): - discover_cast("service", cast_2) + discover_cast( + pychromecast.discovery.ServiceInfo( + pychromecast.const.SERVICE_TYPE_MDNS, "service" + ), + cast_2, + ) await hass.async_block_till_done() await hass.async_block_till_done() # having tasks that add jobs - pycast_mock.get_chromecast_from_cast_info.assert_called() - pycast_mock.get_chromecast_from_cast_info.reset_mock() + get_chromecast_mock.assert_called() + get_chromecast_mock.reset_mock() assert add_dev1.call_count == 0 - assert reg.async_get_entity_id("media_player", "cast", cast_1.uuid) is None + assert reg.async_get_entity_id("media_player", "cast", cast_2.uuid) is None # Get update for cast service with patch( "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", return_value=zconf_1, ): - discover_cast("service", cast_1) + discover_cast( + pychromecast.discovery.ServiceInfo( + pychromecast.const.SERVICE_TYPE_MDNS, "service" + ), + cast_1, + ) await hass.async_block_till_done() await hass.async_block_till_done() # having tasks that add jobs - pycast_mock.get_chromecast_from_cast_info.assert_not_called() + get_chromecast_mock.assert_not_called() assert add_dev1.call_count == 0 assert reg.async_get_entity_id("media_player", "cast", cast_1.uuid) is None @@ -513,7 +525,12 @@ async def test_discover_dynamic_group(hass, dial_mock, pycast_mock, caplog): "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", return_value=zconf_1, ): - remove_cast("service", cast_1) + remove_cast( + pychromecast.discovery.ServiceInfo( + pychromecast.const.SERVICE_TYPE_MDNS, "service" + ), + cast_1, + ) await hass.async_block_till_done() await hass.async_block_till_done() # having tasks that add jobs @@ -534,7 +551,12 @@ async def test_update_cast_chromecasts(hass): "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", return_value=zconf_1, ): - discover_cast("service1", cast_1) + discover_cast( + pychromecast.discovery.ServiceInfo( + pychromecast.const.SERVICE_TYPE_MDNS, "service1" + ), + cast_1, + ) await hass.async_block_till_done() await hass.async_block_till_done() # having tasks that add jobs assert add_dev1.call_count == 1 @@ -543,7 +565,12 @@ async def test_update_cast_chromecasts(hass): "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", return_value=zconf_2, ): - discover_cast("service2", cast_2) + discover_cast( + pychromecast.discovery.ServiceInfo( + pychromecast.const.SERVICE_TYPE_MDNS, "service2" + ), + cast_2, + ) await hass.async_block_till_done() await hass.async_block_till_done() # having tasks that add jobs assert add_dev1.call_count == 1 @@ -565,7 +592,7 @@ async def test_entity_availability(hass: HomeAssistant): conn_status_cb(connection_status) await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.state == "unknown" + assert state.state == "off" connection_status = MagicMock() connection_status.status = "DISCONNECTED" @@ -581,11 +608,9 @@ async def test_entity_cast_status(hass: HomeAssistant): reg = er.async_get(hass) info = get_fake_chromecast_info() - full_info = attr.evolve( - info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID - ) chromecast, _ = await async_setup_media_player_cast(hass, info) + chromecast.cast_type = pychromecast.const.CAST_TYPE_CHROMECAST cast_status_cb, conn_status_cb, _ = get_status_callbacks(chromecast) connection_status = MagicMock() @@ -596,20 +621,29 @@ async def test_entity_cast_status(hass: HomeAssistant): state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "unknown" - assert entity_id == reg.async_get_entity_id("media_player", "cast", full_info.uuid) + assert state.state == "off" + assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + # No media status, pause, play, stop not supported assert state.attributes.get("supported_features") == ( - SUPPORT_PAUSE - | SUPPORT_PLAY - | SUPPORT_PLAY_MEDIA - | SUPPORT_STOP + SUPPORT_PLAY_MEDIA | SUPPORT_TURN_OFF | SUPPORT_TURN_ON | SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET ) + cast_status = MagicMock() + cast_status.volume_level = 0.5 + cast_status.volume_muted = False + cast_status_cb(cast_status) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + # Volume hidden if no app is active + assert state.attributes.get("volume_level") is None + assert not state.attributes.get("is_volume_muted") + + chromecast.app_id = "1234" cast_status = MagicMock() cast_status.volume_level = 0.5 cast_status.volume_muted = False @@ -635,24 +669,97 @@ async def test_entity_cast_status(hass: HomeAssistant): await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.attributes.get("supported_features") == ( - SUPPORT_PAUSE - | SUPPORT_PLAY - | SUPPORT_PLAY_MEDIA - | SUPPORT_STOP - | SUPPORT_TURN_OFF - | SUPPORT_TURN_ON + SUPPORT_PLAY_MEDIA | SUPPORT_TURN_OFF | SUPPORT_TURN_ON ) +@pytest.mark.parametrize( + "cast_type,supported_features,supported_features_no_media", + [ + ( + pychromecast.const.CAST_TYPE_AUDIO, + SUPPORT_PAUSE + | SUPPORT_PLAY + | SUPPORT_PLAY_MEDIA + | SUPPORT_STOP + | SUPPORT_TURN_OFF + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_SET, + SUPPORT_PLAY_MEDIA + | SUPPORT_TURN_OFF + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_SET, + ), + ( + pychromecast.const.CAST_TYPE_CHROMECAST, + SUPPORT_PAUSE + | SUPPORT_PLAY + | SUPPORT_PLAY_MEDIA + | SUPPORT_STOP + | SUPPORT_TURN_OFF + | SUPPORT_TURN_ON + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_SET, + SUPPORT_PLAY_MEDIA + | SUPPORT_TURN_OFF + | SUPPORT_TURN_ON + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_SET, + ), + ( + pychromecast.const.CAST_TYPE_GROUP, + SUPPORT_PAUSE + | SUPPORT_PLAY + | SUPPORT_PLAY_MEDIA + | SUPPORT_STOP + | SUPPORT_TURN_OFF + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_SET, + SUPPORT_PLAY_MEDIA + | SUPPORT_TURN_OFF + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_SET, + ), + ], +) +async def test_supported_features( + hass: HomeAssistant, cast_type, supported_features, supported_features_no_media +): + """Test supported features.""" + entity_id = "media_player.speaker" + + info = get_fake_chromecast_info() + + chromecast, _ = await async_setup_media_player_cast(hass, info) + chromecast.cast_type = cast_type + _, conn_status_cb, media_status_cb = get_status_callbacks(chromecast) + + connection_status = MagicMock() + connection_status.status = "CONNECTED" + conn_status_cb(connection_status) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.name == "Speaker" + assert state.state == "off" + assert state.attributes.get("supported_features") == supported_features_no_media + + media_status = MagicMock(images=None) + media_status.supports_queue_next = False + media_status.supports_seek = False + media_status_cb(media_status) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes.get("supported_features") == supported_features + + async def test_entity_play_media(hass: HomeAssistant): """Test playing media.""" entity_id = "media_player.speaker" reg = er.async_get(hass) info = get_fake_chromecast_info() - full_info = attr.evolve( - info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID - ) chromecast, _ = await async_setup_media_player_cast(hass, info) _, conn_status_cb, _ = get_status_callbacks(chromecast) @@ -665,8 +772,8 @@ async def test_entity_play_media(hass: HomeAssistant): state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "unknown" - assert entity_id == reg.async_get_entity_id("media_player", "cast", full_info.uuid) + assert state.state == "off" + assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) # Play_media await common.async_play_media(hass, "audio", "best.mp3", entity_id) @@ -679,9 +786,6 @@ async def test_entity_play_media_cast(hass: HomeAssistant, quick_play_mock): reg = er.async_get(hass) info = get_fake_chromecast_info() - full_info = attr.evolve( - info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID - ) chromecast, _ = await async_setup_media_player_cast(hass, info) _, conn_status_cb, _ = get_status_callbacks(chromecast) @@ -694,8 +798,8 @@ async def test_entity_play_media_cast(hass: HomeAssistant, quick_play_mock): state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "unknown" - assert entity_id == reg.async_get_entity_id("media_player", "cast", full_info.uuid) + assert state.state == "off" + assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) # Play_media - cast with app ID await common.async_play_media(hass, "cast", '{"app_id": "abc123"}', entity_id) @@ -724,9 +828,6 @@ async def test_entity_play_media_cast_invalid(hass, caplog, quick_play_mock): reg = er.async_get(hass) info = get_fake_chromecast_info() - full_info = attr.evolve( - info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID - ) chromecast, _ = await async_setup_media_player_cast(hass, info) _, conn_status_cb, _ = get_status_callbacks(chromecast) @@ -739,8 +840,8 @@ async def test_entity_play_media_cast_invalid(hass, caplog, quick_play_mock): state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "unknown" - assert entity_id == reg.async_get_entity_id("media_player", "cast", full_info.uuid) + assert state.state == "off" + assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) # play_media - media_type cast with invalid JSON with pytest.raises(json.decoder.JSONDecodeError): @@ -797,9 +898,6 @@ async def test_entity_media_content_type(hass: HomeAssistant): reg = er.async_get(hass) info = get_fake_chromecast_info() - full_info = attr.evolve( - info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID - ) chromecast, _ = await async_setup_media_player_cast(hass, info) _, conn_status_cb, media_status_cb = get_status_callbacks(chromecast) @@ -812,8 +910,8 @@ async def test_entity_media_content_type(hass: HomeAssistant): state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "unknown" - assert entity_id == reg.async_get_entity_id("media_player", "cast", full_info.uuid) + assert state.state == "off" + assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) media_status = MagicMock(images=None) media_status.media_is_movie = False @@ -851,23 +949,29 @@ async def test_entity_control(hass: HomeAssistant): reg = er.async_get(hass) info = get_fake_chromecast_info() - full_info = attr.evolve( - info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID - ) chromecast, _ = await async_setup_media_player_cast(hass, info) + chromecast.cast_type = pychromecast.const.CAST_TYPE_CHROMECAST _, conn_status_cb, media_status_cb = get_status_callbacks(chromecast) + # Fake connection status connection_status = MagicMock() connection_status.status = "CONNECTED" conn_status_cb(connection_status) await hass.async_block_till_done() + # Fake media status + media_status = MagicMock(images=None) + media_status.supports_queue_next = False + media_status.supports_seek = False + media_status_cb(media_status) + await hass.async_block_till_done() + state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "unknown" - assert entity_id == reg.async_get_entity_id("media_player", "cast", full_info.uuid) + assert state.state == "playing" + assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) assert state.attributes.get("supported_features") == ( SUPPORT_PAUSE @@ -960,9 +1064,6 @@ async def test_entity_media_states(hass: HomeAssistant): reg = er.async_get(hass) info = get_fake_chromecast_info() - full_info = attr.evolve( - info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID - ) chromecast, _ = await async_setup_media_player_cast(hass, info) _, conn_status_cb, media_status_cb = get_status_callbacks(chromecast) @@ -975,8 +1076,8 @@ async def test_entity_media_states(hass: HomeAssistant): state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "unknown" - assert entity_id == reg.async_get_entity_id("media_player", "cast", full_info.uuid) + assert state.state == "off" + assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) media_status = MagicMock(images=None) media_status.player_is_playing = True @@ -1013,15 +1114,79 @@ async def test_entity_media_states(hass: HomeAssistant): assert state.state == "unknown" +async def test_entity_media_states_lovelace_app(hass: HomeAssistant): + """Test various entity media states when the lovelace app is active.""" + entity_id = "media_player.speaker" + reg = er.async_get(hass) + + info = get_fake_chromecast_info() + + chromecast, _ = await async_setup_media_player_cast(hass, info) + cast_status_cb, conn_status_cb, media_status_cb = get_status_callbacks(chromecast) + + connection_status = MagicMock() + connection_status.status = "CONNECTED" + conn_status_cb(connection_status) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.name == "Speaker" + assert state.state == "off" + assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) + + chromecast.app_id = CAST_APP_ID_HOMEASSISTANT_LOVELACE + cast_status = MagicMock() + cast_status_cb(cast_status) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "playing" + assert state.attributes.get("supported_features") == ( + SUPPORT_PLAY_MEDIA | SUPPORT_TURN_OFF | SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET + ) + + media_status = MagicMock(images=None) + media_status.player_is_playing = True + media_status_cb(media_status) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "playing" + + media_status.player_is_playing = False + media_status.player_is_paused = True + media_status_cb(media_status) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "playing" + + media_status.player_is_paused = False + media_status.player_is_idle = True + media_status_cb(media_status) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "playing" + + chromecast.app_id = pychromecast.IDLE_APP_ID + media_status.player_is_idle = False + chromecast.is_idle = True + media_status_cb(media_status) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "off" + + chromecast.is_idle = False + media_status_cb(media_status) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "unknown" + + async def test_group_media_states(hass, mz_mock): """Test media states are read from group if entity has no state.""" entity_id = "media_player.speaker" reg = er.async_get(hass) info = get_fake_chromecast_info() - full_info = attr.evolve( - info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID - ) chromecast, _ = await async_setup_media_player_cast(hass, info) _, conn_status_cb, media_status_cb, group_media_status_cb = get_status_callbacks( @@ -1036,8 +1201,8 @@ async def test_group_media_states(hass, mz_mock): state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "unknown" - assert entity_id == reg.async_get_entity_id("media_player", "cast", full_info.uuid) + assert state.state == "off" + assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) group_media_status = MagicMock(images=None) player_media_status = MagicMock(images=None) @@ -1072,9 +1237,6 @@ async def test_group_media_control(hass, mz_mock): reg = er.async_get(hass) info = get_fake_chromecast_info() - full_info = attr.evolve( - info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID - ) chromecast, _ = await async_setup_media_player_cast(hass, info) @@ -1090,8 +1252,8 @@ async def test_group_media_control(hass, mz_mock): state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "unknown" - assert entity_id == reg.async_get_entity_id("media_player", "cast", full_info.uuid) + assert state.state == "off" + assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) group_media_status = MagicMock(images=None) player_media_status = MagicMock(images=None) @@ -1283,7 +1445,7 @@ async def test_entry_setup_empty_config(hass: HomeAssistant): assert config_entry.data["ignore_cec"] == [] -async def test_entry_setup_single_config(hass: HomeAssistant, pycast_mock): +async def test_entry_setup_single_config(hass: HomeAssistant): """Test deprecated yaml config with a single config media_player.""" await async_setup_component( hass, "cast", {"cast": {"media_player": {"uuid": "bla", "ignore_cec": "cast1"}}} @@ -1294,10 +1456,10 @@ async def test_entry_setup_single_config(hass: HomeAssistant, pycast_mock): assert config_entry.data["uuid"] == ["bla"] assert config_entry.data["ignore_cec"] == ["cast1"] - assert pycast_mock.IGNORE_CEC == ["cast1"] + assert pychromecast.IGNORE_CEC == ["cast1"] -async def test_entry_setup_list_config(hass: HomeAssistant, pycast_mock): +async def test_entry_setup_list_config(hass: HomeAssistant): """Test deprecated yaml config with multiple media_players.""" await async_setup_component( hass, @@ -1316,4 +1478,4 @@ async def test_entry_setup_list_config(hass: HomeAssistant, pycast_mock): config_entry = hass.config_entries.async_entries("cast")[0] assert set(config_entry.data["uuid"]) == {"bla", "blu"} assert set(config_entry.data["ignore_cec"]) == {"cast1", "cast2", "cast3"} - assert set(pycast_mock.IGNORE_CEC) == {"cast1", "cast2", "cast3"} + assert set(pychromecast.IGNORE_CEC) == {"cast1", "cast2", "cast3"} diff --git a/tests/fixtures/climacell/v3_forecast_daily.json b/tests/components/climacell/fixtures/v3_forecast_daily.json similarity index 100% rename from tests/fixtures/climacell/v3_forecast_daily.json rename to tests/components/climacell/fixtures/v3_forecast_daily.json diff --git a/tests/fixtures/climacell/v3_forecast_hourly.json b/tests/components/climacell/fixtures/v3_forecast_hourly.json similarity index 100% rename from tests/fixtures/climacell/v3_forecast_hourly.json rename to tests/components/climacell/fixtures/v3_forecast_hourly.json diff --git a/tests/fixtures/climacell/v3_forecast_nowcast.json b/tests/components/climacell/fixtures/v3_forecast_nowcast.json similarity index 100% rename from tests/fixtures/climacell/v3_forecast_nowcast.json rename to tests/components/climacell/fixtures/v3_forecast_nowcast.json diff --git a/tests/fixtures/climacell/v3_realtime.json b/tests/components/climacell/fixtures/v3_realtime.json similarity index 100% rename from tests/fixtures/climacell/v3_realtime.json rename to tests/components/climacell/fixtures/v3_realtime.json diff --git a/tests/fixtures/climacell/v4.json b/tests/components/climacell/fixtures/v4.json similarity index 100% rename from tests/fixtures/climacell/v4.json rename to tests/components/climacell/fixtures/v4.json diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 9b0075db09d..8d08e924be7 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -22,19 +22,26 @@ async def test_alexa_config_expose_entity_prefs(hass, cloud_prefs, cloud_stub): entity_registry = mock_registry(hass) entity_entry1 = entity_registry.async_get_or_create( - "switch", + "light", "test", - "switch_config_id", - suggested_object_id="config_switch", + "light_config_id", + suggested_object_id="config_light", entity_category="config", ) entity_entry2 = entity_registry.async_get_or_create( - "switch", + "light", "test", - "switch_diagnostic_id", - suggested_object_id="diagnostic_switch", + "light_diagnostic_id", + suggested_object_id="diagnostic_light", entity_category="diagnostic", ) + entity_entry3 = entity_registry.async_get_or_create( + "light", + "test", + "light_system_id", + suggested_object_id="system_light", + entity_category="system", + ) entity_conf = {"should_expose": False} await cloud_prefs.async_update( @@ -50,18 +57,21 @@ async def test_alexa_config_expose_entity_prefs(hass, cloud_prefs, cloud_stub): assert not conf.should_expose("light.kitchen") assert not conf.should_expose(entity_entry1.entity_id) assert not conf.should_expose(entity_entry2.entity_id) + assert not conf.should_expose(entity_entry3.entity_id) entity_conf["should_expose"] = True assert conf.should_expose("light.kitchen") # config and diagnostic entities should not be exposed assert not conf.should_expose(entity_entry1.entity_id) assert not conf.should_expose(entity_entry2.entity_id) + assert not conf.should_expose(entity_entry3.entity_id) entity_conf["should_expose"] = None assert conf.should_expose("light.kitchen") # config and diagnostic entities should not be exposed assert not conf.should_expose(entity_entry1.entity_id) assert not conf.should_expose(entity_entry2.entity_id) + assert not conf.should_expose(entity_entry3.entity_id) assert "alexa" not in hass.config.components await cloud_prefs.async_update( diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 99fa24a6cb9..478fa22f66c 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -223,19 +223,26 @@ async def test_google_config_expose_entity_prefs(hass, mock_conf, cloud_prefs): entity_registry = mock_registry(hass) entity_entry1 = entity_registry.async_get_or_create( - "switch", + "light", "test", - "switch_config_id", - suggested_object_id="config_switch", + "light_config_id", + suggested_object_id="config_light", entity_category="config", ) entity_entry2 = entity_registry.async_get_or_create( - "switch", + "light", "test", - "switch_diagnostic_id", - suggested_object_id="diagnostic_switch", + "light_diagnostic_id", + suggested_object_id="diagnostic_light", entity_category="diagnostic", ) + entity_entry3 = entity_registry.async_get_or_create( + "light", + "test", + "light_system_id", + suggested_object_id="system_light", + entity_category="system", + ) entity_conf = {"should_expose": False} await cloud_prefs.async_update( @@ -246,22 +253,26 @@ async def test_google_config_expose_entity_prefs(hass, mock_conf, cloud_prefs): state = State("light.kitchen", "on") state_config = State(entity_entry1.entity_id, "on") state_diagnostic = State(entity_entry2.entity_id, "on") + state_system = State(entity_entry3.entity_id, "on") assert not mock_conf.should_expose(state) assert not mock_conf.should_expose(state_config) assert not mock_conf.should_expose(state_diagnostic) + assert not mock_conf.should_expose(state_system) entity_conf["should_expose"] = True assert mock_conf.should_expose(state) # config and diagnostic entities should not be exposed assert not mock_conf.should_expose(state_config) assert not mock_conf.should_expose(state_diagnostic) + assert not mock_conf.should_expose(state_system) entity_conf["should_expose"] = None assert mock_conf.should_expose(state) # config and diagnostic entities should not be exposed assert not mock_conf.should_expose(state_config) assert not mock_conf.should_expose(state_diagnostic) + assert not mock_conf.should_expose(state_system) await cloud_prefs.async_update( google_default_expose=["sensor"], diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 566f2041fdd..42a498528ce 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1,7 +1,6 @@ """Tests for the HTTP API for the cloud component.""" import asyncio from http import HTTPStatus -from ipaddress import ip_network from unittest.mock import AsyncMock, MagicMock, Mock, patch import aiohttp @@ -11,7 +10,6 @@ from hass_nabucasa.const import STATE_CONNECTED from jose import jwt import pytest -from homeassistant.auth.providers import trusted_networks as tn_auth from homeassistant.components.alexa import errors as alexa_errors from homeassistant.components.alexa.entities import LightCapabilities from homeassistant.components.cloud.const import DOMAIN, RequireRelink @@ -119,7 +117,7 @@ async def test_login_view(hass, cloud_client): async def test_login_view_random_exception(cloud_client): """Try logging in with invalid JSON.""" - with patch("async_timeout.timeout", side_effect=ValueError("Boom")): + with patch("hass_nabucasa.Cloud.login", side_effect=ValueError("Boom")): req = await cloud_client.post( "/api/cloud/login", json={"email": "my_username", "password": "my_password"} ) @@ -558,100 +556,6 @@ async def test_enabling_remote(hass, hass_ws_client, setup_api, mock_cloud_login assert len(mock_disconnect.mock_calls) == 1 -async def test_enabling_remote_trusted_networks_local4( - hass, hass_ws_client, setup_api, mock_cloud_login -): - """Test we cannot enable remote UI when trusted networks active.""" - # pylint: disable=protected-access - hass.auth._providers[ - ("trusted_networks", None) - ] = tn_auth.TrustedNetworksAuthProvider( - hass, - None, - tn_auth.CONFIG_SCHEMA( - {"type": "trusted_networks", "trusted_networks": ["127.0.0.1"]} - ), - ) - - client = await hass_ws_client(hass) - - with patch( - "hass_nabucasa.remote.RemoteUI.connect", side_effect=AssertionError - ) as mock_connect: - await client.send_json({"id": 5, "type": "cloud/remote/connect"}) - response = await client.receive_json() - - assert not response["success"] - assert response["error"]["code"] == HTTPStatus.INTERNAL_SERVER_ERROR - assert ( - response["error"]["message"] - == "Remote UI not compatible with 127.0.0.1/::1 as a trusted network." - ) - - assert len(mock_connect.mock_calls) == 0 - - -async def test_enabling_remote_trusted_networks_local6( - hass, hass_ws_client, setup_api, mock_cloud_login -): - """Test we cannot enable remote UI when trusted networks active.""" - # pylint: disable=protected-access - hass.auth._providers[ - ("trusted_networks", None) - ] = tn_auth.TrustedNetworksAuthProvider( - hass, - None, - tn_auth.CONFIG_SCHEMA( - {"type": "trusted_networks", "trusted_networks": ["::1"]} - ), - ) - - client = await hass_ws_client(hass) - - with patch( - "hass_nabucasa.remote.RemoteUI.connect", side_effect=AssertionError - ) as mock_connect: - await client.send_json({"id": 5, "type": "cloud/remote/connect"}) - response = await client.receive_json() - - assert not response["success"] - assert response["error"]["code"] == HTTPStatus.INTERNAL_SERVER_ERROR - assert ( - response["error"]["message"] - == "Remote UI not compatible with 127.0.0.1/::1 as a trusted network." - ) - - assert len(mock_connect.mock_calls) == 0 - - -async def test_enabling_remote_trusted_networks_other( - hass, hass_ws_client, setup_api, mock_cloud_login -): - """Test we can enable remote UI when trusted networks active.""" - # pylint: disable=protected-access - hass.auth._providers[ - ("trusted_networks", None) - ] = tn_auth.TrustedNetworksAuthProvider( - hass, - None, - tn_auth.CONFIG_SCHEMA( - {"type": "trusted_networks", "trusted_networks": ["192.168.0.0/24"]} - ), - ) - - client = await hass_ws_client(hass) - cloud = hass.data[DOMAIN] - - with patch("hass_nabucasa.remote.RemoteUI.connect") as mock_connect: - await client.send_json({"id": 5, "type": "cloud/remote/connect"}) - response = await client.receive_json() - - assert response["success"] - assert cloud.client.remote_autostart - - assert len(mock_connect.mock_calls) == 1 - - async def test_list_google_entities(hass, hass_ws_client, setup_api, mock_cloud_login): """Test that we can list Google entities.""" client = await hass_ws_client(hass) @@ -729,54 +633,6 @@ async def test_update_google_entity(hass, hass_ws_client, setup_api, mock_cloud_ } -async def test_enabling_remote_trusted_proxies_local4( - hass, hass_ws_client, setup_api, mock_cloud_login -): - """Test we cannot enable remote UI when trusted networks active.""" - hass.http.trusted_proxies.append(ip_network("127.0.0.1")) - - client = await hass_ws_client(hass) - - with patch( - "hass_nabucasa.remote.RemoteUI.connect", side_effect=AssertionError - ) as mock_connect: - await client.send_json({"id": 5, "type": "cloud/remote/connect"}) - response = await client.receive_json() - - assert not response["success"] - assert response["error"]["code"] == HTTPStatus.INTERNAL_SERVER_ERROR - assert ( - response["error"]["message"] - == "Remote UI not compatible with 127.0.0.1/::1 as trusted proxies." - ) - - assert len(mock_connect.mock_calls) == 0 - - -async def test_enabling_remote_trusted_proxies_local6( - hass, hass_ws_client, setup_api, mock_cloud_login -): - """Test we cannot enable remote UI when trusted networks active.""" - hass.http.trusted_proxies.append(ip_network("::1")) - - client = await hass_ws_client(hass) - - with patch( - "hass_nabucasa.remote.RemoteUI.connect", side_effect=AssertionError - ) as mock_connect: - await client.send_json({"id": 5, "type": "cloud/remote/connect"}) - response = await client.receive_json() - - assert not response["success"] - assert response["error"]["code"] == HTTPStatus.INTERNAL_SERVER_ERROR - assert ( - response["error"]["message"] - == "Remote UI not compatible with 127.0.0.1/::1 as trusted proxies." - ) - - assert len(mock_connect.mock_calls) == 0 - - async def test_list_alexa_entities(hass, hass_ws_client, setup_api, mock_cloud_login): """Test that we can list Alexa entities.""" client = await hass_ws_client(hass) diff --git a/tests/fixtures/command_line/configuration.yaml b/tests/components/command_line/fixtures/configuration.yaml similarity index 100% rename from tests/fixtures/command_line/configuration.yaml rename to tests/components/command_line/fixtures/configuration.yaml diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index d0b36d31c37..0a37449c184 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -18,7 +18,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, get_fixture_path async def setup_test_entity(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: @@ -133,11 +133,7 @@ async def test_reload(hass: HomeAssistant) -> None: assert entity_state assert entity_state.state == "unknown" - yaml_path = os.path.join( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))), - "fixtures", - "command_line/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "command_line") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( "command_line", diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index 3eeded7278b..910d990d07d 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -90,6 +90,7 @@ async def test_state_value(hass: HomeAssistant) -> None: "command_on": f"echo 1 > {path}", "command_off": f"echo 0 > {path}", "value_template": '{{ value=="1" }}', + "icon_template": '{% if value=="1" %} mdi:on {% else %} mdi:off {% endif %}', } }, ) @@ -108,6 +109,7 @@ async def test_state_value(hass: HomeAssistant) -> None: entity_state = hass.states.get("switch.test") assert entity_state assert entity_state.state == STATE_ON + assert entity_state.attributes.get("icon") == "mdi:on" await hass.services.async_call( DOMAIN, @@ -119,6 +121,7 @@ async def test_state_value(hass: HomeAssistant) -> None: entity_state = hass.states.get("switch.test") assert entity_state assert entity_state.state == STATE_OFF + assert entity_state.attributes.get("icon") == "mdi:off" async def test_state_json_value(hass: HomeAssistant) -> None: @@ -136,6 +139,7 @@ async def test_state_json_value(hass: HomeAssistant) -> None: "command_on": f"echo '{oncmd}' > {path}", "command_off": f"echo '{offcmd}' > {path}", "value_template": '{{ value_json.status=="ok" }}', + "icon_template": '{% if value_json.status=="ok" %} mdi:on {% else %} mdi:off {% endif %}', } }, ) @@ -154,6 +158,7 @@ async def test_state_json_value(hass: HomeAssistant) -> None: entity_state = hass.states.get("switch.test") assert entity_state assert entity_state.state == STATE_ON + assert entity_state.attributes.get("icon") == "mdi:on" await hass.services.async_call( DOMAIN, @@ -165,6 +170,7 @@ async def test_state_json_value(hass: HomeAssistant) -> None: entity_state = hass.states.get("switch.test") assert entity_state assert entity_state.state == STATE_OFF + assert entity_state.attributes.get("icon") == "mdi:off" async def test_state_code(hass: HomeAssistant) -> None: diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py index 363910ffd72..7460de6a751 100644 --- a/tests/components/config/test_auth.py +++ b/tests/components/config/test_auth.py @@ -66,6 +66,7 @@ async def test_list(hass, hass_ws_client, hass_admin_user): "name": "Mock User", "is_owner": False, "is_active": True, + "local_only": False, "system_generated": False, "group_ids": [group.id for group in hass_admin_user.groups], "credentials": [{"type": "homeassistant"}], @@ -76,6 +77,7 @@ async def test_list(hass, hass_ws_client, hass_admin_user): "name": "Test Owner", "is_owner": True, "is_active": True, + "local_only": False, "system_generated": False, "group_ids": [group.id for group in owner.groups], "credentials": [{"type": "homeassistant"}], @@ -86,6 +88,7 @@ async def test_list(hass, hass_ws_client, hass_admin_user): "name": "Test Hass.io", "is_owner": False, "is_active": True, + "local_only": False, "system_generated": True, "group_ids": [], "credentials": [], @@ -96,6 +99,7 @@ async def test_list(hass, hass_ws_client, hass_admin_user): "name": "Inactive User", "is_owner": False, "is_active": False, + "local_only": False, "system_generated": False, "group_ids": [group.id for group in inactive.groups], "credentials": [], diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index 0950e3d0358..80ee38350aa 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -3,6 +3,8 @@ from http import HTTPStatus import json from unittest.mock import patch +import pytest + from homeassistant.bootstrap import async_setup_component from homeassistant.components import config from homeassistant.helpers import entity_registry as er @@ -10,7 +12,18 @@ from homeassistant.helpers import entity_registry as er from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 -async def test_get_device_config(hass, hass_client): +@pytest.fixture +async def setup_automation( + hass, automation_config, stub_blueprint_populate # noqa: F811 +): + """Set up automation integration.""" + assert await async_setup_component( + hass, "automation", {"automation": automation_config} + ) + + +@pytest.mark.parametrize("automation_config", ({},)) +async def test_get_device_config(hass, hass_client, setup_automation): """Test getting device config.""" with patch.object(config, "SECTIONS", ["automation"]): await async_setup_component(hass, "config", {}) @@ -30,7 +43,8 @@ async def test_get_device_config(hass, hass_client): assert result == {"id": "moon"} -async def test_update_device_config(hass, hass_client): +@pytest.mark.parametrize("automation_config", ({},)) +async def test_update_device_config(hass, hass_client, setup_automation): """Test updating device config.""" with patch.object(config, "SECTIONS", ["automation"]): await async_setup_component(hass, "config", {}) @@ -66,7 +80,8 @@ async def test_update_device_config(hass, hass_client): assert written[0] == orig_data -async def test_bad_formatted_automations(hass, hass_client): +@pytest.mark.parametrize("automation_config", ({},)) +async def test_bad_formatted_automations(hass, hass_client, setup_automation): """Test that we handle automations without ID.""" with patch.object(config, "SECTIONS", ["automation"]): await async_setup_component(hass, "config", {}) @@ -110,29 +125,27 @@ async def test_bad_formatted_automations(hass, hass_client): assert orig_data[1] == {"id": "moon", "trigger": [], "condition": [], "action": []} -async def test_delete_automation(hass, hass_client): +@pytest.mark.parametrize( + "automation_config", + ( + [ + { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": {"service": "test.automation"}, + }, + { + "id": "moon", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": {"service": "test.automation"}, + }, + ], + ), +) +async def test_delete_automation(hass, hass_client, setup_automation): """Test deleting an automation.""" ent_reg = er.async_get(hass) - assert await async_setup_component( - hass, - "automation", - { - "automation": [ - { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": {"service": "test.automation"}, - }, - { - "id": "moon", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": {"service": "test.automation"}, - }, - ] - }, - ) - assert len(ent_reg.entities) == 2 with patch.object(config, "SECTIONS", ["automation"]): diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 04444e40f5d..20a19495597 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -12,6 +12,7 @@ from homeassistant.components.config import config_entries from homeassistant.config_entries import HANDLERS from homeassistant.core import callback from homeassistant.generated import config_flows +import homeassistant.helpers.config_validation as cv from homeassistant.setup import async_setup_component from tests.common import ( @@ -46,10 +47,16 @@ async def test_get_entries(hass, client): @staticmethod @callback - def async_get_options_flow(config, options): + def async_get_options_flow(config_entry): """Get options flow.""" pass + @classmethod + @callback + def async_supports_options_flow(cls, config_entry): + """Return options flow support for this handler.""" + return True + hass.helpers.config_entry_flow.register_discovery_flow( "comp2", "Comp 2", lambda: None ) @@ -689,6 +696,81 @@ async def test_two_step_options_flow(hass, client): } +async def test_options_flow_with_invalid_data(hass, client): + """Test an options flow with invalid_data.""" + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + + class TestFlow(core_ce.ConfigFlow): + @staticmethod + @callback + def async_get_options_flow(config_entry): + class OptionsFlowHandler(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + return self.async_show_form( + step_id="finish", + data_schema=vol.Schema( + { + vol.Required( + "choices", default=["invalid", "valid"] + ): cv.multi_select({"valid": "Valid"}) + } + ), + ) + + async def async_step_finish(self, user_input=None): + return self.async_create_entry( + title="Enable disable", data=user_input + ) + + return OptionsFlowHandler() + + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with patch.dict(HANDLERS, {"test": TestFlow}): + url = "/api/config/config_entries/options/flow" + resp = await client.post(url, json={"handler": entry.entry_id}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + flow_id = data.pop("flow_id") + assert data == { + "type": "form", + "handler": "test1", + "step_id": "finish", + "data_schema": [ + { + "default": ["invalid", "valid"], + "name": "choices", + "options": {"valid": "Valid"}, + "required": True, + "type": "multi_select", + } + ], + "description_placeholders": None, + "errors": None, + "last_step": None, + } + + with patch.dict(HANDLERS, {"test": TestFlow}): + resp = await client.post( + f"/api/config/config_entries/options/flow/{flow_id}", + json={"choices": ["valid", "invalid"]}, + ) + assert resp.status == HTTPStatus.BAD_REQUEST + data = await resp.json() + assert data == { + "message": "User input malformed: invalid is not a valid option for " + "dictionary value @ data['choices']" + } + + async def test_update_prefrences(hass, hass_ws_client): """Test that we can update system options.""" assert await async_setup_component(hass, "config", {}) diff --git a/tests/components/config/test_customize.py b/tests/components/config/test_customize.py deleted file mode 100644 index d5b4c788bcf..00000000000 --- a/tests/components/config/test_customize.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Test Customize config panel.""" -from http import HTTPStatus -import json -from unittest.mock import patch - -from homeassistant.bootstrap import async_setup_component -from homeassistant.components import config -from homeassistant.config import DATA_CUSTOMIZE - - -async def test_get_entity(hass, hass_client): - """Test getting entity.""" - with patch.object(config, "SECTIONS", ["customize"]): - await async_setup_component(hass, "config", {}) - - client = await hass_client() - - def mock_read(path): - """Mock reading data.""" - return {"hello.beer": {"free": "beer"}, "other.entity": {"do": "something"}} - - hass.data[DATA_CUSTOMIZE] = {"hello.beer": {"cold": "beer"}} - with patch("homeassistant.components.config._read", mock_read): - resp = await client.get("/api/config/customize/config/hello.beer") - - assert resp.status == HTTPStatus.OK - result = await resp.json() - - assert result == {"local": {"free": "beer"}, "global": {"cold": "beer"}} - - -async def test_update_entity(hass, hass_client): - """Test updating entity.""" - with patch.object(config, "SECTIONS", ["customize"]): - await async_setup_component(hass, "config", {}) - - client = await hass_client() - - orig_data = { - "hello.beer": {"ignored": True}, - "other.entity": {"polling_intensity": 2}, - } - - def mock_read(path): - """Mock reading data.""" - return orig_data - - written = [] - - def mock_write(path, data): - """Mock writing data.""" - written.append(data) - - hass.states.async_set("hello.world", "state", {"a": "b"}) - with patch("homeassistant.components.config._read", mock_read), patch( - "homeassistant.components.config._write", mock_write - ), patch( - "homeassistant.config.async_hass_config_yaml", - return_value={}, - ): - resp = await client.post( - "/api/config/customize/config/hello.world", - data=json.dumps( - {"name": "Beer", "entities": ["light.top", "light.bottom"]} - ), - ) - await hass.async_block_till_done() - - assert resp.status == HTTPStatus.OK - result = await resp.json() - assert result == {"result": "ok"} - - state = hass.states.get("hello.world") - assert state.state == "state" - assert dict(state.attributes) == { - "a": "b", - "name": "Beer", - "entities": ["light.top", "light.bottom"], - } - - orig_data["hello.world"]["name"] = "Beer" - orig_data["hello.world"]["entities"] = ["light.top", "light.bottom"] - - assert written[0] == orig_data - - -async def test_update_entity_invalid_key(hass, hass_client): - """Test updating entity.""" - with patch.object(config, "SECTIONS", ["customize"]): - await async_setup_component(hass, "config", {}) - - client = await hass_client() - - resp = await client.post( - "/api/config/customize/config/not_entity", data=json.dumps({"name": "YO"}) - ) - - assert resp.status == HTTPStatus.BAD_REQUEST - - -async def test_update_entity_invalid_json(hass, hass_client): - """Test updating entity.""" - with patch.object(config, "SECTIONS", ["customize"]): - await async_setup_component(hass, "config", {}) - - client = await hass_client() - - resp = await client.post("/api/config/customize/config/hello.beer", data="not json") - - assert resp.status == HTTPStatus.BAD_REQUEST diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 0d9170f0a83..ee8c933f761 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -36,7 +36,7 @@ async def test_list_devices(hass, client, registry): manufacturer="manufacturer", model="model", via_device=("bridgeid", "0123"), - entry_type="service", + entry_type=helpers_dr.DeviceEntryType.SERVICE, ) await client.send_json({"id": 5, "type": "config/device_registry/list"}) @@ -68,7 +68,7 @@ async def test_list_devices(hass, client, registry): "model": "model", "name": None, "sw_version": None, - "entry_type": "service", + "entry_type": helpers_dr.DeviceEntryType.SERVICE, "via_device_id": dev1, "area_id": None, "name_by_user": None, @@ -97,7 +97,7 @@ async def test_update_device(hass, client, registry): "device_id": device.id, "area_id": "12345A", "name_by_user": "Test Friendly Name", - "disabled_by": helpers_dr.DISABLED_USER, + "disabled_by": helpers_dr.DeviceEntryDisabler.USER, "type": "config/device_registry/update", } ) @@ -107,5 +107,5 @@ async def test_update_device(hass, client, registry): assert msg["result"]["id"] == device.id assert msg["result"]["area_id"] == "12345A" assert msg["result"]["name_by_user"] == "Test Friendly Name" - assert msg["result"]["disabled_by"] == helpers_dr.DISABLED_USER + assert msg["result"]["disabled_by"] == helpers_dr.DeviceEntryDisabler.USER assert len(registry.devices) == 1 diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index 3faff1222d4..17762f20df3 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -3,6 +3,7 @@ import pytest from homeassistant.components.config import entity_registry from homeassistant.const import ATTR_ICON +from homeassistant.helpers.device_registry import DeviceEntryDisabler from homeassistant.helpers.entity_registry import DISABLED_USER, RegistryEntry from tests.common import ( @@ -100,19 +101,21 @@ async def test_get_entity(hass, client): msg = await client.receive_json() assert msg["result"] == { - "config_entry_id": None, - "device_id": None, "area_id": None, - "disabled_by": None, - "platform": "test_platform", - "entity_id": "test_domain.name", - "name": "Hello World", - "icon": None, - "original_name": None, - "original_icon": None, "capabilities": None, - "unique_id": "1234", + "config_entry_id": None, + "device_class": None, + "device_id": None, + "disabled_by": None, "entity_category": None, + "entity_id": "test_domain.name", + "icon": None, + "name": "Hello World", + "original_device_class": None, + "original_icon": None, + "original_name": None, + "platform": "test_platform", + "unique_id": "1234", } await client.send_json( @@ -125,19 +128,21 @@ async def test_get_entity(hass, client): msg = await client.receive_json() assert msg["result"] == { - "config_entry_id": None, - "device_id": None, "area_id": None, - "disabled_by": None, - "platform": "test_platform", - "entity_id": "test_domain.no_name", - "name": None, - "icon": None, - "original_name": None, - "original_icon": None, "capabilities": None, - "unique_id": "6789", + "config_entry_id": None, + "device_class": None, + "device_id": None, + "disabled_by": None, "entity_category": None, + "entity_id": "test_domain.no_name", + "icon": None, + "name": None, + "original_device_class": None, + "original_icon": None, + "original_name": None, + "platform": "test_platform", + "unique_id": "6789", } @@ -165,15 +170,16 @@ async def test_update_entity(hass, client): assert state.name == "before update" assert state.attributes[ATTR_ICON] == "icon:before update" - # UPDATE NAME & ICON & AREA + # UPDATE AREA, DEVICE_CLASS, ICON AND NAME await client.send_json( { "id": 6, "type": "config/entity_registry/update", "entity_id": "test_domain.world", - "name": "after update", - "icon": "icon:after update", "area_id": "mock-area-id", + "device_class": "custom_device_class", + "icon": "icon:after update", + "name": "after update", } ) @@ -181,19 +187,21 @@ async def test_update_entity(hass, client): assert msg["result"] == { "entity_entry": { - "config_entry_id": None, - "device_id": None, "area_id": "mock-area-id", - "disabled_by": None, - "platform": "test_platform", - "entity_id": "test_domain.world", - "name": "after update", - "icon": "icon:after update", - "original_name": None, - "original_icon": None, "capabilities": None, - "unique_id": "1234", + "config_entry_id": None, + "device_class": "custom_device_class", + "device_id": None, + "disabled_by": None, "entity_category": None, + "entity_id": "test_domain.world", + "icon": "icon:after update", + "name": "after update", + "original_device_class": None, + "original_icon": None, + "original_name": None, + "platform": "test_platform", + "unique_id": "1234", } } @@ -230,19 +238,21 @@ async def test_update_entity(hass, client): assert msg["result"] == { "entity_entry": { - "config_entry_id": None, - "device_id": None, "area_id": "mock-area-id", - "disabled_by": None, - "platform": "test_platform", - "entity_id": "test_domain.world", - "name": "after update", - "icon": "icon:after update", - "original_name": None, - "original_icon": None, "capabilities": None, - "unique_id": "1234", + "config_entry_id": None, + "device_class": "custom_device_class", + "device_id": None, + "disabled_by": None, "entity_category": None, + "entity_id": "test_domain.world", + "icon": "icon:after update", + "name": "after update", + "original_device_class": None, + "original_icon": None, + "original_name": None, + "platform": "test_platform", + "unique_id": "1234", }, "reload_delay": 30, } @@ -285,19 +295,21 @@ async def test_update_entity_require_restart(hass, client): assert msg["result"] == { "entity_entry": { - "config_entry_id": config_entry.entry_id, - "device_id": None, "area_id": None, - "disabled_by": None, - "platform": "test_platform", - "entity_id": "test_domain.world", - "name": None, - "icon": None, - "original_name": None, - "original_icon": None, "capabilities": None, - "unique_id": "1234", + "config_entry_id": config_entry.entry_id, + "device_class": None, + "device_id": None, + "disabled_by": None, "entity_category": None, + "entity_id": "test_domain.world", + "icon": None, + "name": None, + "original_device_class": None, + "original_icon": None, + "original_name": None, + "platform": "test_platform", + "unique_id": "1234", }, "require_restart": True, } @@ -314,7 +326,7 @@ async def test_enable_entity_disabled_device(hass, client, device_registry): identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", - disabled_by=DISABLED_USER, + disabled_by=DeviceEntryDisabler.USER, ) mock_registry( @@ -387,19 +399,21 @@ async def test_update_entity_no_changes(hass, client): assert msg["result"] == { "entity_entry": { - "config_entry_id": None, - "device_id": None, "area_id": None, - "disabled_by": None, - "platform": "test_platform", - "entity_id": "test_domain.world", - "name": "name of entity", - "icon": None, - "original_name": None, - "original_icon": None, "capabilities": None, - "unique_id": "1234", + "config_entry_id": None, + "device_class": None, + "device_id": None, + "disabled_by": None, "entity_category": None, + "entity_id": "test_domain.world", + "icon": None, + "name": "name of entity", + "original_device_class": None, + "original_icon": None, + "original_name": None, + "platform": "test_platform", + "unique_id": "1234", } } @@ -468,19 +482,21 @@ async def test_update_entity_id(hass, client): assert msg["result"] == { "entity_entry": { - "config_entry_id": None, - "device_id": None, "area_id": None, - "disabled_by": None, - "platform": "test_platform", - "entity_id": "test_domain.planet", - "name": None, - "icon": None, - "original_name": None, - "original_icon": None, "capabilities": None, - "unique_id": "1234", + "config_entry_id": None, + "device_class": None, + "device_id": None, + "disabled_by": None, "entity_category": None, + "entity_id": "test_domain.planet", + "icon": None, + "name": None, + "original_device_class": None, + "original_icon": None, + "original_name": None, + "platform": "test_platform", + "unique_id": "1234", } } diff --git a/tests/components/config/test_group.py b/tests/components/config/test_group.py index 72a9a00cbea..4d1d28020bb 100644 --- a/tests/components/config/test_group.py +++ b/tests/components/config/test_group.py @@ -1,10 +1,14 @@ """Test Group config panel.""" from http import HTTPStatus import json +from pathlib import Path from unittest.mock import AsyncMock, patch from homeassistant.bootstrap import async_setup_component from homeassistant.components import config +from homeassistant.components.config import group +from homeassistant.util.file import write_utf8_file +from homeassistant.util.yaml import dump, load_yaml VIEW_NAME = "api:config:group:config" @@ -113,3 +117,49 @@ async def test_update_device_config_invalid_json(hass, hass_client): resp = await client.post("/api/config/group/config/hello_beer", data="not json") assert resp.status == HTTPStatus.BAD_REQUEST + + +async def test_update_config_write_to_temp_file(hass, hass_client, tmpdir): + """Test config with a temp file.""" + test_dir = await hass.async_add_executor_job(tmpdir.mkdir, "files") + group_yaml = Path(test_dir / "group.yaml") + + with patch.object(group, "GROUP_CONFIG_PATH", group_yaml), patch.object( + config, "SECTIONS", ["group"] + ): + await async_setup_component(hass, "config", {}) + + client = await hass_client() + + orig_data = { + "hello.beer": {"ignored": True}, + "other.entity": {"polling_intensity": 2}, + } + contents = dump(orig_data) + await hass.async_add_executor_job(write_utf8_file, group_yaml, contents) + + mock_call = AsyncMock() + + with patch.object(hass.services, "async_call", mock_call): + resp = await client.post( + "/api/config/group/config/hello_beer", + data=json.dumps( + {"name": "Beer", "entities": ["light.top", "light.bottom"]} + ), + ) + await hass.async_block_till_done() + + assert resp.status == HTTPStatus.OK + result = await resp.json() + assert result == {"result": "ok"} + + new_data = await hass.async_add_executor_job(load_yaml, group_yaml) + + assert new_data == { + **orig_data, + "hello_beer": { + "name": "Beer", + "entities": ["light.top", "light.bottom"], + }, + } + mock_call.assert_called_once_with("group", "reload") diff --git a/tests/components/config/test_scene.py b/tests/components/config/test_scene.py index db938638d01..69f75cc5895 100644 --- a/tests/components/config/test_scene.py +++ b/tests/components/config/test_scene.py @@ -3,13 +3,22 @@ from http import HTTPStatus import json from unittest.mock import patch +import pytest + from homeassistant.bootstrap import async_setup_component from homeassistant.components import config from homeassistant.helpers import entity_registry as er from homeassistant.util.yaml import dump -async def test_create_scene(hass, hass_client): +@pytest.fixture +async def setup_scene(hass, scene_config): + """Set up scene integration.""" + assert await async_setup_component(hass, "scene", {"scene": scene_config}) + + +@pytest.mark.parametrize("scene_config", ({},)) +async def test_create_scene(hass, hass_client, setup_scene): """Test creating a scene.""" with patch.object(config, "SECTIONS", ["scene"]): await async_setup_component(hass, "config", {}) @@ -58,7 +67,8 @@ async def test_create_scene(hass, hass_client): ) -async def test_update_scene(hass, hass_client): +@pytest.mark.parametrize("scene_config", ({},)) +async def test_update_scene(hass, hass_client, setup_scene): """Test updating a scene.""" with patch.object(config, "SECTIONS", ["scene"]): await async_setup_component(hass, "config", {}) @@ -110,7 +120,8 @@ async def test_update_scene(hass, hass_client): ) -async def test_bad_formatted_scene(hass, hass_client): +@pytest.mark.parametrize("scene_config", ({},)) +async def test_bad_formatted_scene(hass, hass_client, setup_scene): """Test that we handle scene without ID.""" with patch.object(config, "SECTIONS", ["scene"]): await async_setup_component(hass, "config", {}) @@ -163,21 +174,19 @@ async def test_bad_formatted_scene(hass, hass_client): } -async def test_delete_scene(hass, hass_client): +@pytest.mark.parametrize( + "scene_config", + ( + [ + {"id": "light_on", "name": "Light on", "entities": {}}, + {"id": "light_off", "name": "Light off", "entities": {}}, + ], + ), +) +async def test_delete_scene(hass, hass_client, setup_scene): """Test deleting a scene.""" ent_reg = er.async_get(hass) - assert await async_setup_component( - hass, - "scene", - { - "scene": [ - {"id": "light_on", "name": "Light on", "entities": {}}, - {"id": "light_off", "name": "Light off", "entities": {}}, - ] - }, - ) - assert len(ent_reg.entities) == 2 with patch.object(config, "SECTIONS", ["scene"]): diff --git a/tests/components/config/test_script.py b/tests/components/config/test_script.py index 18ab87b8c40..dca9a8aa8a7 100644 --- a/tests/components/config/test_script.py +++ b/tests/components/config/test_script.py @@ -2,9 +2,19 @@ from http import HTTPStatus from unittest.mock import patch +import pytest + from homeassistant.bootstrap import async_setup_component from homeassistant.components import config +from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True) +async def setup_script(hass, stub_blueprint_populate): # noqa: F811 + """Set up script integration.""" + assert await async_setup_component(hass, "script", {}) + async def test_delete_script(hass, hass_client): """Test deleting a script.""" diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py index 0dc76aa7e61..491b59d9acb 100644 --- a/tests/components/cover/test_device_action.py +++ b/tests/components/cover/test_device_action.py @@ -11,6 +11,7 @@ from homeassistant.components.cover import ( SUPPORT_SET_POSITION, SUPPORT_SET_TILT_POSITION, SUPPORT_STOP, + SUPPORT_STOP_TILT, ) from homeassistant.const import CONF_PLATFORM from homeassistant.helpers import device_registry @@ -109,7 +110,21 @@ async def test_get_action_capabilities( ): """Test we get the expected capabilities from a cover action.""" platform = getattr(hass.components, f"test.{DOMAIN}") - platform.init() + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockCover( + name="Set position cover", + is_on=True, + unique_id="unique_set_pos_cover", + current_cover_position=50, + supported_features=SUPPORT_OPEN + | SUPPORT_CLOSE + | SUPPORT_STOP + | SUPPORT_OPEN_TILT + | SUPPORT_CLOSE_TILT + | SUPPORT_STOP_TILT, + ), + ) ent = platform.ENTITIES[0] config_entry = MockConfigEntry(domain="test", data={}) @@ -126,7 +141,9 @@ async def test_get_action_capabilities( await hass.async_block_till_done() actions = await async_get_device_automations(hass, "action", device_entry.id) - assert len(actions) == 3 # open, close, stop + assert len(actions) == 5 # open, close, open_tilt, close_tilt + action_types = {action["type"] for action in actions} + assert action_types == {"open", "close", "stop", "open_tilt", "close_tilt"} for action in actions: capabilities = await async_get_device_automation_capabilities( hass, "action", action @@ -169,6 +186,8 @@ async def test_get_action_capabilities_set_pos( } actions = await async_get_device_automations(hass, "action", device_entry.id) assert len(actions) == 1 # set_position + action_types = {action["type"] for action in actions} + assert action_types == {"set_position"} for action in actions: capabilities = await async_get_device_automation_capabilities( hass, "action", action @@ -185,7 +204,7 @@ async def test_get_action_capabilities_set_tilt_pos( """Test we get the expected capabilities from a cover action.""" platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() - ent = platform.ENTITIES[2] + ent = platform.ENTITIES[3] config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) @@ -213,7 +232,9 @@ async def test_get_action_capabilities_set_tilt_pos( ] } actions = await async_get_device_automations(hass, "action", device_entry.id) - assert len(actions) == 4 # open, close, stop, set_tilt_position + assert len(actions) == 3 + action_types = {action["type"] for action in actions} + assert action_types == {"open", "close", "set_tilt_position"} for action in actions: capabilities = await async_get_device_automation_capabilities( hass, "action", action diff --git a/tests/components/cover/test_device_condition.py b/tests/components/cover/test_device_condition.py index cf05a112a0a..efa8e8b3383 100644 --- a/tests/components/cover/test_device_condition.py +++ b/tests/components/cover/test_device_condition.py @@ -15,6 +15,7 @@ from homeassistant.const import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, + STATE_UNAVAILABLE, ) from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -195,7 +196,7 @@ async def test_get_condition_capabilities_set_tilt_pos( """Test we get the expected capabilities from a cover condition.""" platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() - ent = platform.ENTITIES[2] + ent = platform.ENTITIES[3] config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) @@ -353,7 +354,7 @@ async def test_if_state(hass, calls): assert calls[3].data["some"] == "is_closing - event - test_event4" -async def test_if_position(hass, calls, enable_custom_integrations): +async def test_if_position(hass, calls, caplog, enable_custom_integrations): """Test for position conditions.""" platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() @@ -368,20 +369,28 @@ async def test_if_position(hass, calls, enable_custom_integrations): automation.DOMAIN: [ { "trigger": {"platform": "event", "event_type": "test_event1"}, - "condition": [ - { - "condition": "device", - "domain": DOMAIN, - "device_id": "", - "entity_id": ent.entity_id, - "type": "is_position", - "above": 45, - } - ], "action": { - "service": "test.automation", - "data_template": { - "some": "is_pos_gt_45 - {{ trigger.platform }} - {{ trigger.event.event_type }}" + "choose": { + "conditions": { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": ent.entity_id, + "type": "is_position", + "above": 45, + }, + "sequence": { + "service": "test.automation", + "data_template": { + "some": "is_pos_gt_45 - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + "default": { + "service": "test.automation", + "data_template": { + "some": "is_pos_not_gt_45 - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, }, }, }, @@ -427,8 +436,13 @@ async def test_if_position(hass, calls, enable_custom_integrations): ] }, ) + + caplog.clear() + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() hass.bus.async_fire("test_event2") + await hass.async_block_till_done() hass.bus.async_fire("test_event3") await hass.async_block_till_done() assert len(calls) == 3 @@ -440,11 +454,14 @@ async def test_if_position(hass, calls, enable_custom_integrations): ent.entity_id, STATE_CLOSED, attributes={"current_position": 45} ) hass.bus.async_fire("test_event1") + await hass.async_block_till_done() hass.bus.async_fire("test_event2") + await hass.async_block_till_done() hass.bus.async_fire("test_event3") await hass.async_block_till_done() - assert len(calls) == 4 - assert calls[3].data["some"] == "is_pos_lt_90 - event - test_event2" + assert len(calls) == 5 + assert calls[3].data["some"] == "is_pos_not_gt_45 - event - test_event1" + assert calls[4].data["some"] == "is_pos_lt_90 - event - test_event2" hass.states.async_set( ent.entity_id, STATE_CLOSED, attributes={"current_position": 90} @@ -453,15 +470,24 @@ async def test_if_position(hass, calls, enable_custom_integrations): hass.bus.async_fire("test_event2") hass.bus.async_fire("test_event3") await hass.async_block_till_done() - assert len(calls) == 5 - assert calls[4].data["some"] == "is_pos_gt_45 - event - test_event1" + assert len(calls) == 6 + assert calls[5].data["some"] == "is_pos_gt_45 - event - test_event1" + + hass.states.async_set(ent.entity_id, STATE_UNAVAILABLE, attributes={}) + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert len(calls) == 7 + assert calls[6].data["some"] == "is_pos_not_gt_45 - event - test_event1" + + for record in caplog.records: + assert record.levelname in ("DEBUG", "INFO") -async def test_if_tilt_position(hass, calls, enable_custom_integrations): +async def test_if_tilt_position(hass, calls, caplog, enable_custom_integrations): """Test for tilt position conditions.""" platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() - ent = platform.ENTITIES[2] + ent = platform.ENTITIES[3] assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() @@ -472,20 +498,28 @@ async def test_if_tilt_position(hass, calls, enable_custom_integrations): automation.DOMAIN: [ { "trigger": {"platform": "event", "event_type": "test_event1"}, - "condition": [ - { - "condition": "device", - "domain": DOMAIN, - "device_id": "", - "entity_id": ent.entity_id, - "type": "is_tilt_position", - "above": 45, - } - ], "action": { - "service": "test.automation", - "data_template": { - "some": "is_pos_gt_45 - {{ trigger.platform }} - {{ trigger.event.event_type }}" + "choose": { + "conditions": { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": ent.entity_id, + "type": "is_tilt_position", + "above": 45, + }, + "sequence": { + "service": "test.automation", + "data_template": { + "some": "is_pos_gt_45 - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + "default": { + "service": "test.automation", + "data_template": { + "some": "is_pos_not_gt_45 - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, }, }, }, @@ -531,8 +565,13 @@ async def test_if_tilt_position(hass, calls, enable_custom_integrations): ] }, ) + + caplog.clear() + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() hass.bus.async_fire("test_event2") + await hass.async_block_till_done() hass.bus.async_fire("test_event3") await hass.async_block_till_done() assert len(calls) == 3 @@ -544,18 +583,32 @@ async def test_if_tilt_position(hass, calls, enable_custom_integrations): ent.entity_id, STATE_CLOSED, attributes={"current_tilt_position": 45} ) hass.bus.async_fire("test_event1") + await hass.async_block_till_done() hass.bus.async_fire("test_event2") + await hass.async_block_till_done() hass.bus.async_fire("test_event3") await hass.async_block_till_done() - assert len(calls) == 4 - assert calls[3].data["some"] == "is_pos_lt_90 - event - test_event2" + assert len(calls) == 5 + assert calls[3].data["some"] == "is_pos_not_gt_45 - event - test_event1" + assert calls[4].data["some"] == "is_pos_lt_90 - event - test_event2" hass.states.async_set( ent.entity_id, STATE_CLOSED, attributes={"current_tilt_position": 90} ) hass.bus.async_fire("test_event1") + await hass.async_block_till_done() hass.bus.async_fire("test_event2") + await hass.async_block_till_done() hass.bus.async_fire("test_event3") await hass.async_block_till_done() - assert len(calls) == 5 - assert calls[4].data["some"] == "is_pos_gt_45 - event - test_event1" + assert len(calls) == 6 + assert calls[5].data["some"] == "is_pos_gt_45 - event - test_event1" + + hass.states.async_set(ent.entity_id, STATE_UNAVAILABLE, attributes={}) + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert len(calls) == 7 + assert calls[6].data["some"] == "is_pos_not_gt_45 - event - test_event1" + + for record in caplog.records: + assert record.levelname in ("DEBUG", "INFO") diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index b7f15de1e3c..0c7c99bc521 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -228,7 +228,7 @@ async def test_get_trigger_capabilities_set_tilt_pos( """Test we get the expected capabilities from a cover trigger.""" platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() - ent = platform.ENTITIES[2] + ent = platform.ENTITIES[3] config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index df8df2c4bf1..b46c0417cd2 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -1,5 +1,116 @@ """The tests for Cover.""" import homeassistant.components.cover as cover +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_PLATFORM, + SERVICE_TOGGLE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.setup import async_setup_component + + +async def test_services(hass, enable_custom_integrations): + """Test the provided services.""" + platform = getattr(hass.components, "test.cover") + + platform.init() + assert await async_setup_component( + hass, cover.DOMAIN, {cover.DOMAIN: {CONF_PLATFORM: "test"}} + ) + await hass.async_block_till_done() + + # ent1 = cover without tilt and position + # ent2 = cover with position but no tilt + # ent3 = cover with simple tilt functions and no position + # ent4 = cover with all tilt functions but no position + # ent5 = cover with all functions + ent1, ent2, ent3, ent4, ent5 = platform.ENTITIES + + # Test init all covers should be open + assert is_open(hass, ent1) + assert is_open(hass, ent2) + assert is_open(hass, ent3) + assert is_open(hass, ent4) + assert is_open(hass, ent5) + + # call basic toggle services + await call_service(hass, SERVICE_TOGGLE, ent1) + await call_service(hass, SERVICE_TOGGLE, ent2) + await call_service(hass, SERVICE_TOGGLE, ent3) + await call_service(hass, SERVICE_TOGGLE, ent4) + await call_service(hass, SERVICE_TOGGLE, ent5) + + # entities without stop should be closed and with stop should be closing + assert is_closed(hass, ent1) + assert is_closing(hass, ent2) + assert is_closed(hass, ent3) + assert is_closed(hass, ent4) + assert is_closing(hass, ent5) + + # call basic toggle services and set different cover position states + await call_service(hass, SERVICE_TOGGLE, ent1) + set_cover_position(ent2, 0) + await call_service(hass, SERVICE_TOGGLE, ent2) + await call_service(hass, SERVICE_TOGGLE, ent3) + await call_service(hass, SERVICE_TOGGLE, ent4) + set_cover_position(ent5, 15) + await call_service(hass, SERVICE_TOGGLE, ent5) + + # entities should be in correct state depending on the SUPPORT_STOP feature and cover position + assert is_open(hass, ent1) + assert is_closed(hass, ent2) + assert is_open(hass, ent3) + assert is_open(hass, ent4) + assert is_open(hass, ent5) + + # call basic toggle services + await call_service(hass, SERVICE_TOGGLE, ent1) + await call_service(hass, SERVICE_TOGGLE, ent2) + await call_service(hass, SERVICE_TOGGLE, ent3) + await call_service(hass, SERVICE_TOGGLE, ent4) + await call_service(hass, SERVICE_TOGGLE, ent5) + + # entities should be in correct state depending on the SUPPORT_STOP feature and cover position + assert is_closed(hass, ent1) + assert is_opening(hass, ent2) + assert is_closed(hass, ent3) + assert is_closed(hass, ent4) + assert is_opening(hass, ent5) + + +def call_service(hass, service, ent): + """Call any service on entity.""" + return hass.services.async_call( + cover.DOMAIN, service, {ATTR_ENTITY_ID: ent.entity_id}, blocking=True + ) + + +def set_cover_position(ent, position) -> None: + """Set a position value to a cover.""" + ent._values["current_cover_position"] = position + + +def is_open(hass, ent): + """Return if the cover is closed based on the statemachine.""" + return hass.states.is_state(ent.entity_id, STATE_OPEN) + + +def is_opening(hass, ent): + """Return if the cover is closed based on the statemachine.""" + return hass.states.is_state(ent.entity_id, STATE_OPENING) + + +def is_closed(hass, ent): + """Return if the cover is closed based on the statemachine.""" + return hass.states.is_state(ent.entity_id, STATE_CLOSED) + + +def is_closing(hass, ent): + """Return if the cover is closed based on the statemachine.""" + return hass.states.is_state(ent.entity_id, STATE_CLOSING) def test_deprecated_base_class(caplog): diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py index f39d117ed39..5421f6e1d52 100644 --- a/tests/components/daikin/test_config_flow.py +++ b/tests/components/daikin/test_config_flow.py @@ -7,6 +7,7 @@ from aiohttp import ClientError, web_exceptions from pydaikin.exceptions import DaikinException import pytest +from homeassistant.components import zeroconf from homeassistant.components.daikin.const import KEY_MAC from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD @@ -119,7 +120,18 @@ async def test_api_password_abort(hass): @pytest.mark.parametrize( "source, data, unique_id", [ - (SOURCE_ZEROCONF, {CONF_HOST: HOST}, MAC), + ( + SOURCE_ZEROCONF, + zeroconf.ZeroconfServiceInfo( + host=HOST, + hostname="mock_hostname", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), + MAC, + ), ], ) async def test_discovery_zeroconf( diff --git a/tests/components/deconz/test_climate.py b/tests/components/deconz/test_climate.py index 92ca38fdf4d..3febbae510b 100644 --- a/tests/components/deconz/test_climate.py +++ b/tests/components/deconz/test_climate.py @@ -347,7 +347,7 @@ async def test_climate_device_with_cooling_support( "0": { "config": { "battery": 25, - "coolsetpoint": None, + "coolsetpoint": 1111, "fanmode": None, "heatsetpoint": 2222, "mode": "heat", @@ -398,8 +398,10 @@ async def test_climate_device_with_cooling_support( } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get("climate.zen_01").state == HVAC_MODE_COOL + assert hass.states.get("climate.zen_01").attributes["temperature"] == 11.1 # Verify service calls diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index 4380b8c6021..ecfb324207f 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pydeconz +from homeassistant.components import ssdp from homeassistant.components.deconz.config_flow import ( CONF_MANUAL_INPUT, CONF_SERIAL, @@ -17,11 +18,8 @@ from homeassistant.components.deconz.const import ( CONF_MASTER_GATEWAY, DOMAIN as DECONZ_DOMAIN, ) -from homeassistant.components.ssdp import ( - ATTR_SSDP_LOCATION, - ATTR_UPNP_MANUFACTURER_URL, - ATTR_UPNP_SERIAL, -) +from homeassistant.components.hassio import HassioServiceInfo +from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL from homeassistant.config_entries import ( SOURCE_HASSIO, SOURCE_REAUTH, @@ -412,11 +410,15 @@ async def test_flow_ssdp_discovery(hass, aioclient_mock): """Test that config flow for one discovered bridge works.""" result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data={ - ATTR_SSDP_LOCATION: "http://1.2.3.4:80/", - ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL, - ATTR_UPNP_SERIAL: BRIDGEID, - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://1.2.3.4:80/", + upnp={ + ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL, + ATTR_UPNP_SERIAL: BRIDGEID, + }, + ), context={"source": SOURCE_SSDP}, ) @@ -446,7 +448,11 @@ async def test_flow_ssdp_bad_discovery(hass, aioclient_mock): """Test that SSDP discovery aborts if manufacturer URL is wrong.""" result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data={ATTR_UPNP_MANUFACTURER_URL: "other"}, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + upnp={ATTR_UPNP_MANUFACTURER_URL: "other"}, + ), context={"source": SOURCE_SSDP}, ) @@ -464,11 +470,15 @@ async def test_ssdp_discovery_update_configuration(hass, aioclient_mock): ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data={ - ATTR_SSDP_LOCATION: "http://2.3.4.5:80/", - ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL, - ATTR_UPNP_SERIAL: BRIDGEID, - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://2.3.4.5:80/", + upnp={ + ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL, + ATTR_UPNP_SERIAL: BRIDGEID, + }, + ), context={"source": SOURCE_SSDP}, ) await hass.async_block_till_done() @@ -485,11 +495,15 @@ async def test_ssdp_discovery_dont_update_configuration(hass, aioclient_mock): result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data={ - ATTR_SSDP_LOCATION: "http://1.2.3.4:80/", - ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL, - ATTR_UPNP_SERIAL: BRIDGEID, - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://1.2.3.4:80/", + upnp={ + ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL, + ATTR_UPNP_SERIAL: BRIDGEID, + }, + ), context={"source": SOURCE_SSDP}, ) @@ -508,11 +522,15 @@ async def test_ssdp_discovery_dont_update_existing_hassio_configuration( result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data={ - ATTR_SSDP_LOCATION: "http://1.2.3.4:80/", - ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL, - ATTR_UPNP_SERIAL: BRIDGEID, - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://1.2.3.4:80/", + upnp={ + ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL, + ATTR_UPNP_SERIAL: BRIDGEID, + }, + ), context={"source": SOURCE_SSDP}, ) @@ -525,13 +543,15 @@ async def test_flow_hassio_discovery(hass): """Test hassio discovery flow works.""" result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data={ - "addon": "Mock Addon", - CONF_HOST: "mock-deconz", - CONF_PORT: 80, - CONF_SERIAL: BRIDGEID, - CONF_API_KEY: API_KEY, - }, + data=HassioServiceInfo( + config={ + "addon": "Mock Addon", + CONF_HOST: "mock-deconz", + CONF_PORT: 80, + CONF_SERIAL: BRIDGEID, + CONF_API_KEY: API_KEY, + } + ), context={"source": SOURCE_HASSIO}, ) assert result["type"] == RESULT_TYPE_FORM @@ -566,12 +586,14 @@ async def test_hassio_discovery_update_configuration(hass, aioclient_mock): ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data={ - CONF_HOST: "2.3.4.5", - CONF_PORT: 8080, - CONF_API_KEY: "updated", - CONF_SERIAL: BRIDGEID, - }, + data=HassioServiceInfo( + config={ + CONF_HOST: "2.3.4.5", + CONF_PORT: 8080, + CONF_API_KEY: "updated", + CONF_SERIAL: BRIDGEID, + } + ), context={"source": SOURCE_HASSIO}, ) await hass.async_block_till_done() @@ -590,12 +612,14 @@ async def test_hassio_discovery_dont_update_configuration(hass, aioclient_mock): result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data={ - CONF_HOST: "1.2.3.4", - CONF_PORT: 80, - CONF_API_KEY: API_KEY, - CONF_SERIAL: BRIDGEID, - }, + data=HassioServiceInfo( + config={ + CONF_HOST: "1.2.3.4", + CONF_PORT: 80, + CONF_API_KEY: API_KEY, + CONF_SERIAL: BRIDGEID, + } + ), context={"source": SOURCE_HASSIO}, ) diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py index 2cb73102bf1..30473814f26 100644 --- a/tests/components/deconz/test_gateway.py +++ b/tests/components/deconz/test_gateway.py @@ -7,6 +7,7 @@ import pydeconz from pydeconz.websocket import STATE_RETRYING, STATE_RUNNING import pytest +from homeassistant.components import ssdp from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, ) @@ -28,7 +29,6 @@ from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN from homeassistant.components.ssdp import ( - ATTR_SSDP_LOCATION, ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL, ATTR_UPNP_UDN, @@ -176,10 +176,10 @@ async def test_gateway_setup(hass, aioclient_mock): ) assert gateway_entry.configuration_url == f"http://{HOST}:{PORT}" - assert gateway_entry.entry_type == "service" + assert gateway_entry.entry_type is dr.DeviceEntryType.SERVICE -async def test_gateway_device_no_configuration_url_when_addon(hass, aioclient_mock): +async def test_gateway_device_configuration_url_when_addon(hass, aioclient_mock): """Successful setup.""" with patch( "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", @@ -195,7 +195,9 @@ async def test_gateway_device_no_configuration_url_when_addon(hass, aioclient_mo identifiers={(DECONZ_DOMAIN, gateway.bridgeid)} ) - assert not gateway_entry.configuration_url + assert ( + gateway_entry.configuration_url == "homeassistant://hassio/ingress/core_deconz" + ) async def test_gateway_retry(hass): @@ -260,12 +262,16 @@ async def test_update_address(hass, aioclient_mock): ) as mock_setup_entry: await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data={ - ATTR_SSDP_LOCATION: "http://2.3.4.5:80/", - ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL, - ATTR_UPNP_SERIAL: BRIDGEID, - ATTR_UPNP_UDN: "uuid:456DEF", - }, + data=ssdp.SsdpServiceInfo( + ssdp_st="mock_st", + ssdp_usn="mock_usn", + ssdp_location="http://2.3.4.5:80/", + upnp={ + ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL, + ATTR_UPNP_SERIAL: BRIDGEID, + ATTR_UPNP_UDN: "uuid:456DEF", + }, + ), context={"source": SOURCE_SSDP}, ) await hass.async_block_till_done() diff --git a/tests/components/deconz/test_services.py b/tests/components/deconz/test_services.py index c27530b012e..5cdd36440ea 100644 --- a/tests/components/deconz/test_services.py +++ b/tests/components/deconz/test_services.py @@ -6,6 +6,7 @@ import voluptuous as vol from homeassistant.components.deconz.const import ( CONF_BRIDGE_ID, + CONF_MASTER_GATEWAY, DOMAIN as DECONZ_DOMAIN, ) from homeassistant.components.deconz.deconz_event import CONF_DECONZ_EVENT @@ -187,6 +188,26 @@ async def test_configure_service_with_faulty_entity(hass, aioclient_mock): assert len(aioclient_mock.mock_calls) == 0 +async def test_calling_service_with_no_master_gateway_fails(hass, aioclient_mock): + """Test that service call fails when no master gateway exist.""" + await setup_deconz_integration( + hass, aioclient_mock, options={CONF_MASTER_GATEWAY: False} + ) + aioclient_mock.clear_requests() + + data = { + SERVICE_FIELD: "/lights/1", + SERVICE_DATA: {"on": True}, + } + + await hass.services.async_call( + DECONZ_DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data + ) + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 0 + + async def test_service_refresh_devices(hass, aioclient_mock): """Test that service can refresh devices.""" config_entry = await setup_deconz_integration(hass, aioclient_mock) diff --git a/tests/components/default_config/conftest.py b/tests/components/default_config/conftest.py new file mode 100644 index 00000000000..4714102eff9 --- /dev/null +++ b/tests/components/default_config/conftest.py @@ -0,0 +1,8 @@ +"""default_config session fixtures.""" + +import pytest + + +@pytest.fixture(autouse=True) +def default_config_mock_async_zeroconf(mock_async_zeroconf): + """Auto mock zeroconf.""" diff --git a/tests/components/demo/test_button.py b/tests/components/demo/test_button.py new file mode 100644 index 00000000000..d4f6f97cb7a --- /dev/null +++ b/tests/components/demo/test_button.py @@ -0,0 +1,47 @@ +"""The tests for the demo button component.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.button.const import DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +ENTITY_PUSH = "button.push" + + +@pytest.fixture(autouse=True) +async def setup_demo_button(hass: HomeAssistant) -> None: + """Initialize setup demo button entity.""" + assert await async_setup_component(hass, DOMAIN, {"button": {"platform": "demo"}}) + await hass.async_block_till_done() + + +def test_setup_params(hass: HomeAssistant) -> None: + """Test the initial parameters.""" + state = hass.states.get(ENTITY_PUSH) + assert state + assert state.state == STATE_UNKNOWN + + +async def test_press(hass: HomeAssistant) -> None: + """Test pressing the button.""" + state = hass.states.get(ENTITY_PUSH) + assert state + assert state.state == STATE_UNKNOWN + + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + with patch("homeassistant.util.dt.utcnow", return_value=now): + await hass.services.async_call( + DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ENTITY_PUSH}, + blocking=True, + ) + + state = hass.states.get(ENTITY_PUSH) + assert state + assert state.state == now.isoformat() diff --git a/tests/components/demo/test_init.py b/tests/components/demo/test_init.py index 68d4dfbf379..a446856de7b 100644 --- a/tests/components/demo/test_init.py +++ b/tests/components/demo/test_init.py @@ -7,8 +7,11 @@ import pytest from homeassistant.components.demo import DOMAIN from homeassistant.components.device_tracker.legacy import YAML_DEVICES +from homeassistant.components.recorder.statistics import list_statistic_ids from homeassistant.helpers.json import JSONEncoder -from homeassistant.setup import async_setup_component +from homeassistant.setup import async_setup_component, setup_component + +from tests.components.recorder.common import wait_recording_done @pytest.fixture(autouse=True) @@ -40,3 +43,27 @@ async def test_setting_up_demo(hass): "Unable to convert all demo entities to JSON. " "Wrong data in state machine!" ) + + +def test_demo_statistics(hass_recorder): + """Test that the demo components makes some statistics available.""" + hass = hass_recorder() + + assert setup_component(hass, DOMAIN, {DOMAIN: {}}) + hass.block_till_done() + hass.start() + wait_recording_done(hass) + + statistic_ids = list_statistic_ids(hass) + assert { + "name": None, + "source": "demo", + "statistic_id": "demo:temperature_outdoor", + "unit_of_measurement": "°C", + } in statistic_ids + assert { + "name": None, + "source": "demo", + "statistic_id": "demo:energy_consumption", + "unit_of_measurement": "kWh", + } in statistic_ids diff --git a/tests/components/demo/test_number.py b/tests/components/demo/test_number.py index 88e46f5c66d..64f690d9eac 100644 --- a/tests/components/demo/test_number.py +++ b/tests/components/demo/test_number.py @@ -3,15 +3,13 @@ import pytest import voluptuous as vol +from homeassistant.components.number import NumberMode from homeassistant.components.number.const import ( ATTR_MAX, ATTR_MIN, ATTR_STEP, ATTR_VALUE, DOMAIN, - MODE_AUTO, - MODE_BOX, - MODE_SLIDER, SERVICE_SET_VALUE, ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE @@ -42,25 +40,25 @@ def test_default_setup_params(hass): assert state.attributes.get(ATTR_MIN) == 0.0 assert state.attributes.get(ATTR_MAX) == 100.0 assert state.attributes.get(ATTR_STEP) == 1.0 - assert state.attributes.get(ATTR_MODE) == MODE_SLIDER + assert state.attributes.get(ATTR_MODE) == NumberMode.SLIDER state = hass.states.get(ENTITY_PWM) assert state.attributes.get(ATTR_MIN) == 0.0 assert state.attributes.get(ATTR_MAX) == 1.0 assert state.attributes.get(ATTR_STEP) == 0.01 - assert state.attributes.get(ATTR_MODE) == MODE_BOX + assert state.attributes.get(ATTR_MODE) == NumberMode.BOX state = hass.states.get(ENTITY_LARGE_RANGE) assert state.attributes.get(ATTR_MIN) == 1.0 assert state.attributes.get(ATTR_MAX) == 1000.0 assert state.attributes.get(ATTR_STEP) == 1.0 - assert state.attributes.get(ATTR_MODE) == MODE_AUTO + assert state.attributes.get(ATTR_MODE) == NumberMode.AUTO state = hass.states.get(ENTITY_SMALL_RANGE) assert state.attributes.get(ATTR_MIN) == 1.0 assert state.attributes.get(ATTR_MAX) == 255.0 assert state.attributes.get(ATTR_STEP) == 1.0 - assert state.attributes.get(ATTR_MODE) == MODE_AUTO + assert state.attributes.get(ATTR_MODE) == NumberMode.AUTO async def test_set_value_bad_attr(hass): diff --git a/tests/components/weather/test_weather.py b/tests/components/demo/test_weather.py similarity index 98% rename from tests/components/weather/test_weather.py rename to tests/components/demo/test_weather.py index 3057532668a..c4ae8fcd79c 100644 --- a/tests/components/weather/test_weather.py +++ b/tests/components/demo/test_weather.py @@ -1,4 +1,4 @@ -"""The tests for the Weather component.""" +"""The tests for the demo weather component.""" from homeassistant.components import weather from homeassistant.components.weather import ( ATTR_FORECAST, diff --git a/tests/components/denonavr/test_config_flow.py b/tests/components/denonavr/test_config_flow.py index b38c43775f9..ff811e0d235 100644 --- a/tests/components/denonavr/test_config_flow.py +++ b/tests/components/denonavr/test_config_flow.py @@ -300,12 +300,16 @@ async def test_config_flow_ssdp(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, - ssdp.ATTR_UPNP_MODEL_NAME: TEST_MODEL, - ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, - ssdp.ATTR_SSDP_LOCATION: TEST_SSDP_LOCATION, - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=TEST_SSDP_LOCATION, + upnp={ + ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, + ssdp.ATTR_UPNP_MODEL_NAME: TEST_MODEL, + ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, + }, + ), ) assert result["type"] == "form" @@ -336,12 +340,16 @@ async def test_config_flow_ssdp_not_denon(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_UPNP_MANUFACTURER: "NotSupported", - ssdp.ATTR_UPNP_MODEL_NAME: TEST_MODEL, - ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, - ssdp.ATTR_SSDP_LOCATION: TEST_SSDP_LOCATION, - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=TEST_SSDP_LOCATION, + upnp={ + ssdp.ATTR_UPNP_MANUFACTURER: "NotSupported", + ssdp.ATTR_UPNP_MODEL_NAME: TEST_MODEL, + ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, + }, + ), ) assert result["type"] == "abort" @@ -357,10 +365,14 @@ async def test_config_flow_ssdp_missing_info(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, - ssdp.ATTR_SSDP_LOCATION: TEST_SSDP_LOCATION, - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=TEST_SSDP_LOCATION, + upnp={ + ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, + }, + ), ) assert result["type"] == "abort" @@ -376,12 +388,16 @@ async def test_config_flow_ssdp_ignored_model(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, - ssdp.ATTR_UPNP_MODEL_NAME: TEST_IGNORED_MODEL, - ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, - ssdp.ATTR_SSDP_LOCATION: TEST_SSDP_LOCATION, - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=TEST_SSDP_LOCATION, + upnp={ + ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, + ssdp.ATTR_UPNP_MODEL_NAME: TEST_IGNORED_MODEL, + ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, + }, + ), ) assert result["type"] == "abort" diff --git a/tests/components/devolo_home_control/conftest.py b/tests/components/devolo_home_control/conftest.py index 487831b0fa4..65e8b9b1c64 100644 --- a/tests/components/devolo_home_control/conftest.py +++ b/tests/components/devolo_home_control/conftest.py @@ -31,3 +31,8 @@ def patch_mydevolo(request): return_value=["1400000000000001", "1400000000000002"], ): yield + + +@pytest.fixture(autouse=True) +def devolo_home_control_mock_async_zeroconf(mock_async_zeroconf): + """Auto mock zeroconf.""" diff --git a/tests/components/devolo_home_control/const.py b/tests/components/devolo_home_control/const.py index 33a98a15e2d..96686e204eb 100644 --- a/tests/components/devolo_home_control/const.py +++ b/tests/components/devolo_home_control/const.py @@ -1,12 +1,14 @@ """Constants used for mocking data.""" -DISCOVERY_INFO = { - "host": "192.168.0.1", - "port": 14791, - "hostname": "test.local.", - "type": "_dvl-deviceapi._tcp.local.", - "name": "dvl-deviceapi", - "properties": { +from homeassistant.components import zeroconf + +DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( + host="192.168.0.1", + port=14791, + hostname="test.local.", + type="_dvl-deviceapi._tcp.local.", + name="dvl-deviceapi", + properties={ "Path": "/deviceapi", "Version": "v0", "Features": "", @@ -15,8 +17,22 @@ DISCOVERY_INFO = { "FirmwareVersion": "8.90.4", "PlcMacAddress": "AA:BB:CC:DD:EE:FF", }, -} +) -DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = {"properties": {"MT": "2700"}} +DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = zeroconf.ZeroconfServiceInfo( + host="mock_host", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={"MT": "2700"}, + type="mock_type", +) -DISCOVERY_INFO_WRONG_DEVICE = {"properties": {"Features": ""}} +DISCOVERY_INFO_WRONG_DEVICE = zeroconf.ZeroconfServiceInfo( + host="mock_host", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={"Features": ""}, + type="mock_type", +) diff --git a/tests/components/devolo_home_control/mocks.py b/tests/components/devolo_home_control/mocks.py index 6651215251a..693b4e7351d 100644 --- a/tests/components/devolo_home_control/mocks.py +++ b/tests/components/devolo_home_control/mocks.py @@ -57,6 +57,16 @@ class BinarySensorMock(DeviceMock): self.binary_sensor_property = {"Test": BinarySensorPropertyMock()} +class BinarySensorMockOverload(DeviceMock): + """devolo Home Control disabled binary sensor device mock.""" + + def __init__(self) -> None: + """Initialize the mock.""" + super().__init__() + self.binary_sensor_property = {"Overload": BinarySensorPropertyMock()} + self.binary_sensor_property["Overload"].sensor_type = "overload" + + class RemoteControlMock(DeviceMock): """devolo Home Control remote control device mock.""" @@ -90,12 +100,15 @@ class HomeControlMock(HomeControl): class HomeControlMockBinarySensor(HomeControlMock): - """devolo Home Control gateway mock with binary sensor device.""" + """devolo Home Control gateway mock with binary sensor devices.""" def __init__(self, **kwargs: Any) -> None: """Initialize the mock.""" super().__init__() - self.devices = {"Test": BinarySensorMock()} + self.devices = { + "Test": BinarySensorMock(), + "Overload": BinarySensorMockOverload(), + } self.publisher = Publisher(self.devices.keys()) self.publisher.unregister = MagicMock() diff --git a/tests/components/devolo_home_control/test_binary_sensor.py b/tests/components/devolo_home_control/test_binary_sensor.py index 32c2e97e7c9..9b13220ad7c 100644 --- a/tests/components/devolo_home_control/test_binary_sensor.py +++ b/tests/components/devolo_home_control/test_binary_sensor.py @@ -4,8 +4,14 @@ from unittest.mock import patch import pytest from homeassistant.components.binary_sensor import DOMAIN -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import ( + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry from . import configure_integration from .mocks import ( @@ -33,6 +39,13 @@ async def test_binary_sensor(hass: HomeAssistant): assert state is not None assert state.state == STATE_OFF + state = hass.states.get(f"{DOMAIN}.test_2") + assert state is not None + er = entity_registry.async_get(hass) + assert ( + er.async_get(f"{DOMAIN}.test_2").entity_category == ENTITY_CATEGORY_DIAGNOSTIC + ) + # Emulate websocket message: sensor turned on test_gateway.publisher.dispatch("Test", ("Test", True)) await hass.async_block_till_done() @@ -111,4 +124,4 @@ async def test_remove_from_hass(hass: HomeAssistant): await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 - test_gateway.publisher.unregister.assert_called_once() + assert test_gateway.publisher.unregister.call_count == 2 diff --git a/tests/components/devolo_home_network/__init__.py b/tests/components/devolo_home_network/__init__.py new file mode 100644 index 00000000000..913193be3f7 --- /dev/null +++ b/tests/components/devolo_home_network/__init__.py @@ -0,0 +1,32 @@ +"""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.core import HomeAssistant + +from .const import DISCOVERY_INFO, IP + +from tests.common import MockConfigEntry + + +def configure_integration(hass: HomeAssistant) -> MockConfigEntry: + """Configure the integration.""" + config = { + CONF_IP_ADDRESS: IP, + } + 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)) diff --git a/tests/components/devolo_home_network/conftest.py b/tests/components/devolo_home_network/conftest.py new file mode 100644 index 00000000000..1d8d2a6da19 --- /dev/null +++ b/tests/components/devolo_home_network/conftest.py @@ -0,0 +1,46 @@ +"""Fixtures for tests.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from . import async_connect +from .const import CONNECTED_STATIONS, DISCOVERY_INFO, NEIGHBOR_ACCESS_POINTS, PLCNET + + +@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), + ): + yield + + +@pytest.fixture(name="info") +def mock_validate_input(): + """Mock setup entry and user input.""" + info = { + "serial_number": DISCOVERY_INFO.properties["SN"], + "title": DISCOVERY_INFO.properties["Product"], + } + + with patch( + "homeassistant.components.devolo_home_network.config_flow.validate_input", + return_value=info, + ): + yield info + + +@pytest.fixture(autouse=True) +def devolo_home_network_mock_async_zeroconf(mock_async_zeroconf): + """Auto mock zeroconf.""" diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py new file mode 100644 index 00000000000..681c2673dff --- /dev/null +++ b/tests/components/devolo_home_network/const.py @@ -0,0 +1,73 @@ +"""Constants used for mocking data.""" + +from homeassistant.components import zeroconf + +IP = "1.1.1.1" + +CONNECTED_STATIONS = { + "connected_stations": [ + { + "mac_address": "AA:BB:CC:DD:EE:FF", + "vap_type": "WIFI_VAP_MAIN_AP", + "band": "WIFI_BAND_5G", + "rx_rate": 87800, + "tx_rate": 87800, + } + ], +} + +DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( + host=IP, + port=14791, + hostname="test.local.", + type="_dvl-deviceapi._tcp.local.", + name="dLAN pro 1200+ WiFi ac._dvl-deviceapi._tcp.local.", + properties={ + "Path": "abcdefghijkl/deviceapi", + "Version": "v0", + "Product": "dLAN pro 1200+ WiFi ac", + "Features": "reset,update,led,intmtg,wifi1", + "MT": "2730", + "SN": "1234567890", + "FirmwareVersion": "5.6.1", + "FirmwareDate": "2020-10-23", + "PS": "", + "PlcMacAddress": "AA:BB:CC:DD:EE:FF", + }, +) + +DISCOVERY_INFO_WRONG_DEVICE = zeroconf.ZeroconfServiceInfo( + host="mock_host", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={"MT": "2600"}, + type="mock_type", +) + +NEIGHBOR_ACCESS_POINTS = { + "neighbor_aps": [ + { + "mac_address": "AA:BB:CC:DD:EE:FF", + "ssid": "wifi", + "band": "WIFI_BAND_2G", + "channel": 1, + "signal": -73, + "signal_bars": 1, + } + ] +} + +PLCNET = { + "network": { + "data_rates": [ + { + "mac_address_from": "AA:BB:CC:DD:EE:FF", + "mac_address_to": "11:22:33:44:55:66", + "rx_rate": 0.0, + "tx_rate": 0.0, + }, + ], + "devices": [], + } +} diff --git a/tests/components/devolo_home_network/test_config_flow.py b/tests/components/devolo_home_network/test_config_flow.py new file mode 100644 index 00000000000..7d115a26b15 --- /dev/null +++ b/tests/components/devolo_home_network/test_config_flow.py @@ -0,0 +1,180 @@ +"""Test the devolo Home Network config flow.""" +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +from devolo_plc_api.exceptions.device import DeviceNotFound +import pytest + +from homeassistant import config_entries +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.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from .const import DISCOVERY_INFO, DISCOVERY_INFO_WRONG_DEVICE, IP + + +async def test_form(hass: HomeAssistant, info: dict[str, Any]): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + 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_IP_ADDRESS: IP, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["result"].unique_id == info["serial_number"] + assert result2["title"] == info["title"] + assert result2["data"] == { + CONF_IP_ADDRESS: IP, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "exception_type, expected_error", + [[DeviceNotFound, "cannot_connect"], [Exception, "unknown"]], +) +async def test_form_error(hass: HomeAssistant, exception_type, expected_error): + """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.devolo_home_network.config_flow.validate_input", + side_effect=exception_type, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: IP, + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {CONF_BASE: expected_error} + + +async def test_zeroconf(hass: HomeAssistant): + """Test that the zeroconf form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == RESULT_TYPE_FORM + assert result["description_placeholders"] == {"host_name": "test"} + + context = next( + flow["context"] + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + + assert ( + context["title_placeholders"][CONF_NAME] + == DISCOVERY_INFO.hostname.split(".", maxsplit=1)[0] + ) + + with patch( + "homeassistant.components.devolo_home_network.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["title"] == "test" + assert result2["data"] == { + CONF_IP_ADDRESS: IP, + } + + +async def test_abort_zeroconf_wrong_device(hass: HomeAssistant): + """Test we abort zeroconf for wrong devices.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO_WRONG_DEVICE, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "home_control" + + +@pytest.mark.usefixtures("info") +async def test_abort_if_configued(hass: HomeAssistant): + """Test we abort config flow if already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.devolo_home_network.async_setup_entry", + return_value=True, + ): + await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: IP, + }, + ) + await hass.async_block_till_done() + + # Abort on concurrent user flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: IP, + }, + ) + await hass.async_block_till_done() + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + # Abort on concurrent zeroconf discovery flow + result3 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + assert result3["type"] == RESULT_TYPE_ABORT + assert result3["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_validate_input(hass: HomeAssistant): + """Test input validaton.""" + 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_init.py b/tests/components/devolo_home_network/test_init.py new file mode 100644 index 00000000000..66d32e8974d --- /dev/null +++ b/tests/components/devolo_home_network/test_init.py @@ -0,0 +1,61 @@ +"""Test the devolo Home Network integration setup.""" +from unittest.mock import patch + +from devolo_plc_api.exceptions.device import DeviceNotFound +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_setup_entry(hass: HomeAssistant): + """Test setup entry.""" + entry = configure_integration(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 + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_setup_device_not_found(hass: HomeAssistant): + """Test setup entry.""" + entry = configure_integration(hass) + with patch( + "homeassistant.components.devolo_home_network.Device.async_connect", + side_effect=DeviceNotFound, + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_unload_entry(hass: HomeAssistant): + """Test unload entry.""" + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_hass_stop(hass: HomeAssistant): + """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) + assert 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 new file mode 100644 index 00000000000..6ce4c36cdb5 --- /dev/null +++ b/tests/components/devolo_home_network/test_sensor.py @@ -0,0 +1,156 @@ +"""Tests for the devolo Home Network sensors.""" +from unittest.mock import patch + +from devolo_plc_api.exceptions.device import DeviceUnavailable +import pytest + +from homeassistant.components.devolo_home_network.const import ( + LONG_UPDATE_INTERVAL, + SHORT_UPDATE_INTERVAL, +) +from homeassistant.components.sensor import DOMAIN, STATE_CLASS_MEASUREMENT +from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry +from homeassistant.util import dt + +from . import configure_integration + +from tests.common import async_fire_time_changed + + +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_sensor_setup(hass: HomeAssistant): + """Test default setup of the sensor component.""" + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(f"{DOMAIN}.connected_wifi_clients") is not None + assert hass.states.get(f"{DOMAIN}.connected_plc_devices") is None + assert hass.states.get(f"{DOMAIN}.neighboring_wifi_networks") is None + + await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_update_connected_wifi_clients(hass: HomeAssistant): + """Test state change of a connected_wifi_clients sensor device.""" + state_key = f"{DOMAIN}.connected_wifi_clients" + + entry = configure_integration(hass) + 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 == "1" + assert state.attributes["state_class"] == STATE_CLASS_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() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # Emulate state change + 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 == "1" + + await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_update_neighboring_wifi_networks(hass: HomeAssistant): + """Test state change of a neighboring_wifi_networks sensor device.""" + state_key = f"{DOMAIN}.neighboring_wifi_networks" + entry = configure_integration(hass) + with patch( + "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", + return_value=True, + ): + 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 == "1" + + er = entity_registry.async_get(hass) + assert er.async_get(state_key).entity_category == ENTITY_CATEGORY_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() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # Emulate state change + 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 == "1" + + await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_update_connected_plc_devices(hass: HomeAssistant): + """Test state change of a connected_plc_devices sensor device.""" + state_key = f"{DOMAIN}.connected_plc_devices" + entry = configure_integration(hass) + with patch( + "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", + return_value=True, + ): + 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 == "1" + + er = entity_registry.async_get(hass) + assert er.async_get(state_key).entity_category == ENTITY_CATEGORY_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() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # Emulate state change + 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 == "1" + + await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/dexcom/__init__.py b/tests/components/dexcom/__init__.py index 16c6f4b4d45..acf03725d32 100644 --- a/tests/components/dexcom/__init__.py +++ b/tests/components/dexcom/__init__.py @@ -16,7 +16,7 @@ CONFIG = { CONF_SERVER: SERVER_US, } -GLUCOSE_READING = GlucoseReading(json.loads(load_fixture("dexcom_data.json"))) +GLUCOSE_READING = GlucoseReading(json.loads(load_fixture("data.json", "dexcom"))) async def init_integration(hass) -> MockConfigEntry: diff --git a/tests/fixtures/dexcom_data.json b/tests/components/dexcom/fixtures/data.json similarity index 100% rename from tests/fixtures/dexcom_data.json rename to tests/components/dexcom/fixtures/data.json diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index dc50edbeb10..41059722113 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -171,11 +171,11 @@ async def test_dhcp_match_hostname_and_macaddress(hass): assert mock_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_DHCP } - assert mock_init.mock_calls[0][2]["data"] == { - dhcp.IP_ADDRESS: "192.168.210.56", - dhcp.HOSTNAME: "connect", - dhcp.MAC_ADDRESS: "b8b7f16db533", - } + assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( + ip="192.168.210.56", + hostname="connect", + macaddress="b8b7f16db533", + ) async def test_dhcp_renewal_match_hostname_and_macaddress(hass): @@ -199,11 +199,11 @@ async def test_dhcp_renewal_match_hostname_and_macaddress(hass): assert mock_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_DHCP } - assert mock_init.mock_calls[0][2]["data"] == { - dhcp.IP_ADDRESS: "192.168.1.120", - dhcp.HOSTNAME: "irobot-ae9ec12dd3b04885bcbfa36afb01e1cc", - dhcp.MAC_ADDRESS: "50147903852c", - } + assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( + ip="192.168.1.120", + hostname="irobot-ae9ec12dd3b04885bcbfa36afb01e1cc", + macaddress="50147903852c", + ) async def test_dhcp_match_hostname(hass): @@ -223,11 +223,11 @@ async def test_dhcp_match_hostname(hass): assert mock_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_DHCP } - assert mock_init.mock_calls[0][2]["data"] == { - dhcp.IP_ADDRESS: "192.168.210.56", - dhcp.HOSTNAME: "connect", - dhcp.MAC_ADDRESS: "b8b7f16db533", - } + assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( + ip="192.168.210.56", + hostname="connect", + macaddress="b8b7f16db533", + ) async def test_dhcp_match_macaddress(hass): @@ -247,11 +247,11 @@ async def test_dhcp_match_macaddress(hass): assert mock_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_DHCP } - assert mock_init.mock_calls[0][2]["data"] == { - dhcp.IP_ADDRESS: "192.168.210.56", - dhcp.HOSTNAME: "connect", - dhcp.MAC_ADDRESS: "b8b7f16db533", - } + assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( + ip="192.168.210.56", + hostname="connect", + macaddress="b8b7f16db533", + ) async def test_dhcp_match_macaddress_without_hostname(hass): @@ -271,11 +271,11 @@ async def test_dhcp_match_macaddress_without_hostname(hass): assert mock_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_DHCP } - assert mock_init.mock_calls[0][2]["data"] == { - dhcp.IP_ADDRESS: "192.168.107.151", - dhcp.HOSTNAME: "", - dhcp.MAC_ADDRESS: "606bbd59e4b4", - } + assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( + ip="192.168.107.151", + hostname="", + macaddress="606bbd59e4b4", + ) async def test_dhcp_nomatch(hass): @@ -548,11 +548,11 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start(hass): assert mock_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_DHCP } - assert mock_init.mock_calls[0][2]["data"] == { - dhcp.IP_ADDRESS: "192.168.210.56", - dhcp.HOSTNAME: "connect", - dhcp.MAC_ADDRESS: "b8b7f16db533", - } + assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( + ip="192.168.210.56", + hostname="connect", + macaddress="b8b7f16db533", + ) async def test_device_tracker_hostname_and_macaddress_after_start(hass): @@ -585,11 +585,11 @@ async def test_device_tracker_hostname_and_macaddress_after_start(hass): assert mock_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_DHCP } - assert mock_init.mock_calls[0][2]["data"] == { - dhcp.IP_ADDRESS: "192.168.210.56", - dhcp.HOSTNAME: "connect", - dhcp.MAC_ADDRESS: "b8b7f16db533", - } + assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( + ip="192.168.210.56", + hostname="connect", + macaddress="b8b7f16db533", + ) async def test_device_tracker_hostname_and_macaddress_after_start_not_home(hass): @@ -731,11 +731,11 @@ async def test_aiodiscover_finds_new_hosts(hass): assert mock_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_DHCP } - assert mock_init.mock_calls[0][2]["data"] == { - dhcp.IP_ADDRESS: "192.168.210.56", - dhcp.HOSTNAME: "connect", - dhcp.MAC_ADDRESS: "b8b7f16db533", - } + assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( + ip="192.168.210.56", + hostname="connect", + macaddress="b8b7f16db533", + ) async def test_aiodiscover_does_not_call_again_on_shorter_hostname(hass): @@ -786,20 +786,20 @@ async def test_aiodiscover_does_not_call_again_on_shorter_hostname(hass): assert mock_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_DHCP } - assert mock_init.mock_calls[0][2]["data"] == { - dhcp.IP_ADDRESS: "192.168.210.56", - dhcp.HOSTNAME: "irobot-abc", - dhcp.MAC_ADDRESS: "b8b7f16db533", - } + assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( + ip="192.168.210.56", + hostname="irobot-abc", + macaddress="b8b7f16db533", + ) assert mock_init.mock_calls[1][1][0] == "mock-domain" assert mock_init.mock_calls[1][2]["context"] == { "source": config_entries.SOURCE_DHCP } - assert mock_init.mock_calls[1][2]["data"] == { - dhcp.IP_ADDRESS: "192.168.210.56", - dhcp.HOSTNAME: "irobot-abcdef", - dhcp.MAC_ADDRESS: "b8b7f16db533", - } + assert mock_init.mock_calls[1][2]["data"] == dhcp.DhcpServiceInfo( + ip="192.168.210.56", + hostname="irobot-abcdef", + macaddress="b8b7f16db533", + ) async def test_aiodiscover_finds_new_hosts_after_interval(hass): @@ -838,8 +838,39 @@ async def test_aiodiscover_finds_new_hosts_after_interval(hass): assert mock_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_DHCP } - assert mock_init.mock_calls[0][2]["data"] == { - dhcp.IP_ADDRESS: "192.168.210.56", - dhcp.HOSTNAME: "connect", - dhcp.MAC_ADDRESS: "b8b7f16db533", - } + assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( + ip="192.168.210.56", + hostname="connect", + macaddress="b8b7f16db533", + ) + + +async def test_service_info_compatibility(hass, caplog): + """Test compatibility with old-style dict. + + To be removed in 2022.6 + """ + discovery_info = dhcp.DhcpServiceInfo( + ip="192.168.210.56", + hostname="connect", + macaddress="b8b7f16db533", + ) + + # Ensure first call get logged + assert discovery_info["ip"] == "192.168.210.56" + assert discovery_info.get("ip") == "192.168.210.56" + assert discovery_info.get("ip", "fallback_host") == "192.168.210.56" + assert discovery_info.get("invalid_key", "fallback_host") == "fallback_host" + assert "Detected code that accessed discovery_info['ip']" in caplog.text + assert "Detected code that accessed discovery_info.get('ip')" not in caplog.text + + # Ensure second call doesn't get logged + caplog.clear() + assert discovery_info["ip"] == "192.168.210.56" + assert discovery_info.get("ip") == "192.168.210.56" + assert "Detected code that accessed discovery_info['ip']" not in caplog.text + assert "Detected code that accessed discovery_info.get('ip')" not in caplog.text + + discovery_info._warning_logged = False # pylint: disable=[protected-access] + assert discovery_info.get("ip") == "192.168.210.56" + assert "Detected code that accessed discovery_info.get('ip')" in caplog.text diff --git a/tests/components/directv/__init__.py b/tests/components/directv/__init__.py index 790121ddca1..584a7c95509 100644 --- a/tests/components/directv/__init__.py +++ b/tests/components/directv/__init__.py @@ -1,8 +1,8 @@ """Tests for the DirecTV component.""" from http import HTTPStatus +from homeassistant.components import ssdp from homeassistant.components.directv.const import CONF_RECEIVER_ID, DOMAIN -from homeassistant.components.ssdp import ATTR_SSDP_LOCATION from homeassistant.const import CONF_HOST, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant @@ -15,7 +15,12 @@ SSDP_LOCATION = "http://127.0.0.1/" UPNP_SERIAL = "RID-028877455858" MOCK_CONFIG = {DOMAIN: [{CONF_HOST: HOST}]} -MOCK_SSDP_DISCOVERY_INFO = {ATTR_SSDP_LOCATION: SSDP_LOCATION} +MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=SSDP_LOCATION, + upnp={}, +) MOCK_USER_INPUT = {CONF_HOST: HOST} diff --git a/tests/fixtures/directv/info-get-locations.json b/tests/components/directv/fixtures/info-get-locations.json similarity index 100% rename from tests/fixtures/directv/info-get-locations.json rename to tests/components/directv/fixtures/info-get-locations.json diff --git a/tests/fixtures/directv/info-get-version.json b/tests/components/directv/fixtures/info-get-version.json similarity index 100% rename from tests/fixtures/directv/info-get-version.json rename to tests/components/directv/fixtures/info-get-version.json diff --git a/tests/fixtures/directv/info-mode-error.json b/tests/components/directv/fixtures/info-mode-error.json similarity index 100% rename from tests/fixtures/directv/info-mode-error.json rename to tests/components/directv/fixtures/info-mode-error.json diff --git a/tests/fixtures/directv/info-mode-standby.json b/tests/components/directv/fixtures/info-mode-standby.json similarity index 100% rename from tests/fixtures/directv/info-mode-standby.json rename to tests/components/directv/fixtures/info-mode-standby.json diff --git a/tests/fixtures/directv/info-mode.json b/tests/components/directv/fixtures/info-mode.json similarity index 100% rename from tests/fixtures/directv/info-mode.json rename to tests/components/directv/fixtures/info-mode.json diff --git a/tests/fixtures/directv/remote-process-key.json b/tests/components/directv/fixtures/remote-process-key.json similarity index 100% rename from tests/fixtures/directv/remote-process-key.json rename to tests/components/directv/fixtures/remote-process-key.json diff --git a/tests/fixtures/directv/tv-get-tuned-movie.json b/tests/components/directv/fixtures/tv-get-tuned-movie.json similarity index 100% rename from tests/fixtures/directv/tv-get-tuned-movie.json rename to tests/components/directv/fixtures/tv-get-tuned-movie.json diff --git a/tests/fixtures/directv/tv-get-tuned-music.json b/tests/components/directv/fixtures/tv-get-tuned-music.json similarity index 100% rename from tests/fixtures/directv/tv-get-tuned-music.json rename to tests/components/directv/fixtures/tv-get-tuned-music.json diff --git a/tests/fixtures/directv/tv-get-tuned-restricted.json b/tests/components/directv/fixtures/tv-get-tuned-restricted.json similarity index 100% rename from tests/fixtures/directv/tv-get-tuned-restricted.json rename to tests/components/directv/fixtures/tv-get-tuned-restricted.json diff --git a/tests/fixtures/directv/tv-get-tuned.json b/tests/components/directv/fixtures/tv-get-tuned.json similarity index 100% rename from tests/fixtures/directv/tv-get-tuned.json rename to tests/components/directv/fixtures/tv-get-tuned.json diff --git a/tests/fixtures/directv/tv-tune.json b/tests/components/directv/fixtures/tv-tune.json similarity index 100% rename from tests/fixtures/directv/tv-tune.json rename to tests/components/directv/fixtures/tv-tune.json diff --git a/tests/components/directv/test_config_flow.py b/tests/components/directv/test_config_flow.py index 646b863a114..f80e0d781ef 100644 --- a/tests/components/directv/test_config_flow.py +++ b/tests/components/directv/test_config_flow.py @@ -1,4 +1,5 @@ """Test the DirecTV config flow.""" +import dataclasses from unittest.mock import patch from aiohttp import ClientError as HTTPClientError @@ -43,7 +44,7 @@ async def test_show_ssdp_form( """Test that the ssdp confirmation form is served.""" mock_connection(aioclient_mock) - discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info ) @@ -77,7 +78,7 @@ async def test_ssdp_cannot_connect( """Test we abort SSDP flow on connection error.""" aioclient_mock.get("http://127.0.0.1:8080/info/getVersion", exc=HTTPClientError) - discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, @@ -94,7 +95,7 @@ async def test_ssdp_confirm_cannot_connect( """Test we abort SSDP flow on connection error.""" aioclient_mock.get("http://127.0.0.1:8080/info/getVersion", exc=HTTPClientError) - discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP, CONF_HOST: HOST, CONF_NAME: HOST}, @@ -128,7 +129,7 @@ async def test_ssdp_device_exists_abort( """Test we abort SSDP flow if DirecTV receiver already configured.""" await setup_integration(hass, aioclient_mock, skip_entry_setup=True) - discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, @@ -145,8 +146,8 @@ async def test_ssdp_with_receiver_id_device_exists_abort( """Test we abort SSDP flow if DirecTV receiver already configured.""" await setup_integration(hass, aioclient_mock, skip_entry_setup=True) - discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() - discovery_info[ATTR_UPNP_SERIAL] = UPNP_SERIAL + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) + discovery_info.upnp[ATTR_UPNP_SERIAL] = UPNP_SERIAL result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, @@ -180,7 +181,7 @@ async def test_ssdp_unknown_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort SSDP flow on unknown error.""" - discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) with patch( "homeassistant.components.directv.config_flow.DIRECTV.update", side_effect=Exception, @@ -199,7 +200,7 @@ async def test_ssdp_confirm_unknown_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort SSDP flow on unknown error.""" - discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) with patch( "homeassistant.components.directv.config_flow.DIRECTV.update", side_effect=Exception, @@ -249,7 +250,7 @@ async def test_full_ssdp_flow_implementation( """Test the full SSDP flow from start to finish.""" mock_connection(aioclient_mock) - discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info ) diff --git a/tests/components/directv/test_media_player.py b/tests/components/directv/test_media_player.py index 14bc121bf86..41431748d36 100644 --- a/tests/components/directv/test_media_player.py +++ b/tests/components/directv/test_media_player.py @@ -160,15 +160,15 @@ async def test_unique_id( entity_registry = er.async_get(hass) main = entity_registry.async_get(MAIN_ENTITY_ID) - assert main.device_class == DEVICE_CLASS_RECEIVER + assert main.original_device_class == DEVICE_CLASS_RECEIVER assert main.unique_id == "028877455858" client = entity_registry.async_get(CLIENT_ENTITY_ID) - assert client.device_class == DEVICE_CLASS_RECEIVER + assert client.original_device_class == DEVICE_CLASS_RECEIVER assert client.unique_id == "2CA17D1CD30X" unavailable_client = entity_registry.async_get(UNAVAILABLE_ENTITY_ID) - assert unavailable_client.device_class == DEVICE_CLASS_RECEIVER + assert unavailable_client.original_device_class == DEVICE_CLASS_RECEIVER assert unavailable_client.unique_id == "9XXXXXXXXXX9" diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py index e2d82d5b559..c497d45a4f2 100644 --- a/tests/components/dlna_dmr/test_config_flow.py +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -1,6 +1,7 @@ """Test the DLNA config flow.""" from __future__ import annotations +import dataclasses from unittest.mock import Mock from async_upnp_client import UpnpDevice, UpnpError @@ -23,7 +24,6 @@ from homeassistant.const import ( CONF_URL, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import DiscoveryInfoType from .conftest import ( MOCK_DEVICE_LOCATION, @@ -52,40 +52,43 @@ MOCK_CONFIG_IMPORT_DATA = { MOCK_ROOT_DEVICE_UDN = "ROOT_DEVICE" -MOCK_DISCOVERY: DiscoveryInfoType = { - ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, - ssdp.ATTR_SSDP_UDN: MOCK_DEVICE_UDN, - ssdp.ATTR_SSDP_ST: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, - ssdp.ATTR_UPNP_SERVICE_LIST: { - "service": [ - { - "SCPDURL": "/AVTransport/scpd.xml", - "controlURL": "/AVTransport/control.xml", - "eventSubURL": "/AVTransport/event.xml", - "serviceId": "urn:upnp-org:serviceId:AVTransport", - "serviceType": "urn:schemas-upnp-org:service:AVTransport:1", - }, - { - "SCPDURL": "/ConnectionManager/scpd.xml", - "controlURL": "/ConnectionManager/control.xml", - "eventSubURL": "/ConnectionManager/event.xml", - "serviceId": "urn:upnp-org:serviceId:ConnectionManager", - "serviceType": "urn:schemas-upnp-org:service:ConnectionManager:1", - }, - { - "SCPDURL": "/RenderingControl/scpd.xml", - "controlURL": "/RenderingControl/control.xml", - "eventSubURL": "/RenderingControl/event.xml", - "serviceId": "urn:upnp-org:serviceId:RenderingControl", - "serviceType": "urn:schemas-upnp-org:service:RenderingControl:1", - }, - ] +MOCK_DISCOVERY = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_location=MOCK_DEVICE_LOCATION, + ssdp_udn=MOCK_DEVICE_UDN, + ssdp_st=MOCK_DEVICE_TYPE, + upnp={ + ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ssdp.ATTR_UPNP_SERVICE_LIST: { + "service": [ + { + "SCPDURL": "/AVTransport/scpd.xml", + "controlURL": "/AVTransport/control.xml", + "eventSubURL": "/AVTransport/event.xml", + "serviceId": "urn:upnp-org:serviceId:AVTransport", + "serviceType": "urn:schemas-upnp-org:service:AVTransport:1", + }, + { + "SCPDURL": "/ConnectionManager/scpd.xml", + "controlURL": "/ConnectionManager/control.xml", + "eventSubURL": "/ConnectionManager/event.xml", + "serviceId": "urn:upnp-org:serviceId:ConnectionManager", + "serviceType": "urn:schemas-upnp-org:service:ConnectionManager:1", + }, + { + "SCPDURL": "/RenderingControl/scpd.xml", + "controlURL": "/RenderingControl/control.xml", + "eventSubURL": "/RenderingControl/event.xml", + "serviceId": "urn:upnp-org:serviceId:RenderingControl", + "serviceType": "urn:schemas-upnp-org:service:RenderingControl:1", + }, + ] + }, }, - ssdp.ATTR_HA_MATCHING_DOMAINS: {DLNA_DOMAIN}, -} + x_homeassistant_matching_domains=(DLNA_DOMAIN,), +) async def test_user_flow_undiscovered_manual(hass: HomeAssistant) -> None: @@ -545,19 +548,38 @@ async def test_ssdp_flow_existing( result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, - ssdp.ATTR_SSDP_UDN: MOCK_DEVICE_UDN, - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=NEW_DEVICE_LOCATION, + ssdp_udn=MOCK_DEVICE_UDN, + upnp={ + ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + }, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" assert config_entry_mock.data[CONF_URL] == NEW_DEVICE_LOCATION +async def test_ssdp_flow_duplicate_location( + hass: HomeAssistant, config_entry_mock: MockConfigEntry +) -> None: + """Test that discovery of device with URL matching existing entry gets aborted.""" + config_entry_mock.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert config_entry_mock.data[CONF_URL] == MOCK_DEVICE_LOCATION + + async def test_ssdp_flow_upnp_udn( hass: HomeAssistant, config_entry_mock: MockConfigEntry ) -> None: @@ -566,14 +588,17 @@ async def test_ssdp_flow_upnp_udn( result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, - ssdp.ATTR_SSDP_UDN: MOCK_DEVICE_UDN, - ssdp.ATTR_SSDP_ST: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_location=NEW_DEVICE_LOCATION, + ssdp_udn=MOCK_DEVICE_UDN, + ssdp_st=MOCK_DEVICE_TYPE, + upnp={ + ssdp.ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", + ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + }, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" @@ -583,8 +608,9 @@ async def test_ssdp_flow_upnp_udn( async def test_ssdp_missing_services(hass: HomeAssistant) -> None: """Test SSDP ignores devices that are missing required services.""" # No services defined at all - discovery = dict(MOCK_DISCOVERY) - del discovery[ssdp.ATTR_UPNP_SERVICE_LIST] + discovery = dataclasses.replace(MOCK_DISCOVERY) + discovery.upnp = discovery.upnp.copy() + del discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -594,11 +620,12 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: assert result["reason"] == "not_dmr" # AVTransport service is missing - discovery = dict(MOCK_DISCOVERY) - discovery[ssdp.ATTR_UPNP_SERVICE_LIST] = { + discovery = dataclasses.replace(MOCK_DISCOVERY) + discovery.upnp = discovery.upnp.copy() + discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = { "service": [ service - for service in discovery[ssdp.ATTR_UPNP_SERVICE_LIST]["service"] + for service in discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]["service"] if service.get("serviceId") != "urn:upnp-org:serviceId:AVTransport" ] } @@ -611,8 +638,9 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: """Test SSDP discovery ignores certain devices.""" - discovery = dict(MOCK_DISCOVERY) - discovery[ssdp.ATTR_HA_MATCHING_DOMAINS] = {DLNA_DOMAIN, "other_domain"} + discovery = dataclasses.replace(MOCK_DISCOVERY) + discovery.x_homeassistant_matching_domains = {DLNA_DOMAIN, "other_domain"} + assert discovery.x_homeassistant_matching_domains result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -621,8 +649,11 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "alternative_integration" - discovery = dict(MOCK_DISCOVERY) - discovery[ssdp.ATTR_UPNP_DEVICE_TYPE] = "urn:schemas-upnp-org:device:ZonePlayer:1" + discovery = dataclasses.replace(MOCK_DISCOVERY) + discovery.upnp = discovery.upnp.copy() + discovery.upnp[ + ssdp.ATTR_UPNP_DEVICE_TYPE + ] = "urn:schemas-upnp-org:device:ZonePlayer:1" result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -635,10 +666,12 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: ("XBMC Foundation", "Kodi"), ("Samsung", "Smart TV"), ("LG Electronics.", "LG TV"), + ("Royal Philips Electronics", "Philips TV DMR"), ]: - discovery = dict(MOCK_DISCOVERY) - discovery[ssdp.ATTR_UPNP_MANUFACTURER] = manufacturer - discovery[ssdp.ATTR_UPNP_MODEL_NAME] = model + discovery = dataclasses.replace(MOCK_DISCOVERY) + discovery.upnp = discovery.upnp.copy() + discovery.upnp[ssdp.ATTR_UPNP_MANUFACTURER] = manufacturer + discovery.upnp[ssdp.ATTR_UPNP_MODEL_NAME] = model result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index e12c23535fa..fe2a916fdcc 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -8,12 +8,13 @@ from types import MappingProxyType from typing import Any from unittest.mock import ANY, DEFAULT, Mock, patch +from async_upnp_client import UpnpService, UpnpStateVariable from async_upnp_client.exceptions import ( UpnpConnectionError, UpnpError, UpnpResponseError, ) -from async_upnp_client.profiles.dlna import TransportState +from async_upnp_client.profiles.dlna import PlayMode, TransportState import pytest from homeassistant import const as ha_const @@ -50,6 +51,8 @@ from .conftest import ( from tests.common import MockConfigEntry +MOCK_DEVICE_ST = "mock_st" + # Auto-use the domain_data_mock fixture for every test in this module pytestmark = pytest.mark.usefixtures("domain_data_mock") @@ -67,6 +70,16 @@ async def setup_mock_component(hass: HomeAssistant, mock_entry: MockConfigEntry) return entity_id +async def get_attrs(hass: HomeAssistant, entity_id: str) -> Mapping[str, Any]: + """Get updated device attributes.""" + await async_update_entity(hass, entity_id) + entity_state = hass.states.get(entity_id) + assert entity_state is not None + attrs = entity_state.attributes + assert attrs is not None + return attrs + + @pytest.fixture async def mock_entity_id( hass: HomeAssistant, @@ -335,9 +348,7 @@ async def test_setup_entry_with_options( async def test_event_subscribe_failure( - hass: HomeAssistant, - config_entry_mock: MockConfigEntry, - dmr_device_mock: Mock, + hass: HomeAssistant, config_entry_mock: MockConfigEntry, dmr_device_mock: Mock ) -> None: """Test _device_connect aborts when async_subscribe_services fails.""" dmr_device_mock.async_subscribe_services.side_effect = UpnpError @@ -389,9 +400,7 @@ async def test_event_subscribe_rejected( async def test_available_device( - hass: HomeAssistant, - dmr_device_mock: Mock, - mock_entity_id: str, + hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str ) -> None: """Test a DlnaDmrEntity with a connected DmrDevice.""" # Check hass device information is filled in @@ -429,19 +438,63 @@ async def test_available_device( assert entity_state is not None assert entity_state.state == ha_const.STATE_UNAVAILABLE - dmr_device_mock.profile_device.available = True - await async_update_entity(hass, mock_entity_id) +async def test_feature_flags( + hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str +) -> None: + """Test feature flags of a connected DlnaDmrEntity.""" + # Check supported feature flags, one at a time. + FEATURE_FLAGS: list[tuple[str, int]] = [ + ("has_volume_level", mp_const.SUPPORT_VOLUME_SET), + ("has_volume_mute", mp_const.SUPPORT_VOLUME_MUTE), + ("can_play", mp_const.SUPPORT_PLAY), + ("can_pause", mp_const.SUPPORT_PAUSE), + ("can_stop", mp_const.SUPPORT_STOP), + ("can_previous", mp_const.SUPPORT_PREVIOUS_TRACK), + ("can_next", mp_const.SUPPORT_NEXT_TRACK), + ("has_play_media", mp_const.SUPPORT_PLAY_MEDIA), + ("can_seek_rel_time", mp_const.SUPPORT_SEEK), + ("has_presets", mp_const.SUPPORT_SELECT_SOUND_MODE), + ] + + # Clear all feature properties + dmr_device_mock.valid_play_modes = set() + for feat_prop, _ in FEATURE_FLAGS: + setattr(dmr_device_mock, feat_prop, False) + attrs = await get_attrs(hass, mock_entity_id) + assert attrs[ha_const.ATTR_SUPPORTED_FEATURES] == 0 + + # Test the properties cumulatively + expected_features = 0 + for feat_prop, flag in FEATURE_FLAGS: + setattr(dmr_device_mock, feat_prop, True) + expected_features |= flag + attrs = await get_attrs(hass, mock_entity_id) + assert attrs[ha_const.ATTR_SUPPORTED_FEATURES] == expected_features + + # shuffle and repeat features depend on the available play modes + PLAY_MODE_FEATURE_FLAGS: list[tuple[PlayMode, int]] = [ + (PlayMode.NORMAL, 0), + (PlayMode.SHUFFLE, mp_const.SUPPORT_SHUFFLE_SET), + (PlayMode.REPEAT_ONE, mp_const.SUPPORT_REPEAT_SET), + (PlayMode.REPEAT_ALL, mp_const.SUPPORT_REPEAT_SET), + (PlayMode.RANDOM, mp_const.SUPPORT_SHUFFLE_SET), + (PlayMode.DIRECT_1, 0), + (PlayMode.INTRO, 0), + (PlayMode.VENDOR_DEFINED, 0), + ] + for play_modes, flag in PLAY_MODE_FEATURE_FLAGS: + dmr_device_mock.valid_play_modes = {play_modes} + attrs = await get_attrs(hass, mock_entity_id) + assert attrs[ha_const.ATTR_SUPPORTED_FEATURES] == expected_features | flag + + +async def test_attributes( + hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str +) -> None: + """Test attributes of a connected DlnaDmrEntity.""" # Check attributes come directly from the device - async def get_attrs() -> Mapping[str, Any]: - await async_update_entity(hass, mock_entity_id) - entity_state = hass.states.get(mock_entity_id) - assert entity_state is not None - attrs = entity_state.attributes - assert attrs is not None - return attrs - - attrs = await get_attrs() + attrs = await get_attrs(hass, mock_entity_id) assert attrs[mp_const.ATTR_MEDIA_VOLUME_LEVEL] is dmr_device_mock.volume_level assert attrs[mp_const.ATTR_MEDIA_VOLUME_MUTED] is dmr_device_mock.is_volume_muted assert attrs[mp_const.ATTR_MEDIA_DURATION] is dmr_device_mock.media_duration @@ -459,68 +512,65 @@ async def test_available_device( assert attrs[mp_const.ATTR_MEDIA_SEASON] is dmr_device_mock.media_season_number assert attrs[mp_const.ATTR_MEDIA_EPISODE] is dmr_device_mock.media_episode_number assert attrs[mp_const.ATTR_MEDIA_CHANNEL] is dmr_device_mock.media_channel_name + assert attrs[mp_const.ATTR_SOUND_MODE_LIST] is dmr_device_mock.preset_names + # Entity picture is cached, won't correspond to remote image assert isinstance(attrs[ha_const.ATTR_ENTITY_PICTURE], str) + # media_title depends on what is available assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_program_title dmr_device_mock.media_program_title = None - attrs = await get_attrs() + attrs = await get_attrs(hass, mock_entity_id) assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_title + # media_content_type is mapped from UPnP class to MediaPlayer type dmr_device_mock.media_class = "object.item.audioItem.musicTrack" - attrs = await get_attrs() + attrs = await get_attrs(hass, mock_entity_id) assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_MUSIC dmr_device_mock.media_class = "object.item.videoItem.movie" - attrs = await get_attrs() + attrs = await get_attrs(hass, mock_entity_id) assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_MOVIE dmr_device_mock.media_class = "object.item.videoItem.videoBroadcast" - attrs = await get_attrs() + attrs = await get_attrs(hass, mock_entity_id) assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == mp_const.MEDIA_TYPE_TVSHOW + # media_season & media_episode have a special case dmr_device_mock.media_season_number = "0" dmr_device_mock.media_episode_number = "123" - attrs = await get_attrs() + attrs = await get_attrs(hass, mock_entity_id) assert attrs[mp_const.ATTR_MEDIA_SEASON] == "1" assert attrs[mp_const.ATTR_MEDIA_EPISODE] == "23" dmr_device_mock.media_season_number = "0" dmr_device_mock.media_episode_number = "S1E23" # Unexpected and not parsed - attrs = await get_attrs() + attrs = await get_attrs(hass, mock_entity_id) assert attrs[mp_const.ATTR_MEDIA_SEASON] == "0" assert attrs[mp_const.ATTR_MEDIA_EPISODE] == "S1E23" - # Check supported feature flags, one at a time. - # tuple(async_upnp_client feature check property, HA feature flag) - FEATURE_FLAGS: list[tuple[str, int]] = [ - ("has_volume_level", mp_const.SUPPORT_VOLUME_SET), - ("has_volume_mute", mp_const.SUPPORT_VOLUME_MUTE), - ("can_play", mp_const.SUPPORT_PLAY), - ("can_pause", mp_const.SUPPORT_PAUSE), - ("can_stop", mp_const.SUPPORT_STOP), - ("can_previous", mp_const.SUPPORT_PREVIOUS_TRACK), - ("can_next", mp_const.SUPPORT_NEXT_TRACK), - ("has_play_media", mp_const.SUPPORT_PLAY_MEDIA), - ("can_seek_rel_time", mp_const.SUPPORT_SEEK), - ] - # Clear all feature properties - for feat_prop, _ in FEATURE_FLAGS: - setattr(dmr_device_mock, feat_prop, False) - await async_update_entity(hass, mock_entity_id) - entity_state = hass.states.get(mock_entity_id) - assert entity_state is not None - assert entity_state.attributes[ha_const.ATTR_SUPPORTED_FEATURES] == 0 - # Test the properties cumulatively - expected_features = 0 - for feat_prop, flag in FEATURE_FLAGS: - setattr(dmr_device_mock, feat_prop, True) - expected_features |= flag - await async_update_entity(hass, mock_entity_id) - entity_state = hass.states.get(mock_entity_id) - assert entity_state is not None - assert ( - entity_state.attributes[ha_const.ATTR_SUPPORTED_FEATURES] - == expected_features - ) + # shuffle and repeat is based on device's play mode + for play_mode, shuffle, repeat in [ + (PlayMode.NORMAL, False, mp_const.REPEAT_MODE_OFF), + (PlayMode.SHUFFLE, True, mp_const.REPEAT_MODE_OFF), + (PlayMode.REPEAT_ONE, False, mp_const.REPEAT_MODE_ONE), + (PlayMode.REPEAT_ALL, False, mp_const.REPEAT_MODE_ALL), + (PlayMode.RANDOM, True, mp_const.REPEAT_MODE_ALL), + (PlayMode.DIRECT_1, False, mp_const.REPEAT_MODE_OFF), + (PlayMode.INTRO, False, mp_const.REPEAT_MODE_OFF), + ]: + dmr_device_mock.play_mode = play_mode + attrs = await get_attrs(hass, mock_entity_id) + assert attrs[mp_const.ATTR_MEDIA_SHUFFLE] is shuffle + assert attrs[mp_const.ATTR_MEDIA_REPEAT] == repeat + for bad_play_mode in [None, PlayMode.VENDOR_DEFINED]: + dmr_device_mock.play_mode = bad_play_mode + attrs = await get_attrs(hass, mock_entity_id) + assert mp_const.ATTR_MEDIA_SHUFFLE not in attrs + assert mp_const.ATTR_MEDIA_REPEAT not in attrs + +async def test_services( + hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str +) -> None: + """Test service calls of a connected DlnaDmrEntity.""" # Check interface methods interact directly with the device await hass.services.async_call( MP_DOMAIN, @@ -578,15 +628,22 @@ async def test_available_device( blocking=True, ) dmr_device_mock.async_seek_rel_time.assert_awaited_once_with(timedelta(seconds=33)) + await hass.services.async_call( + MP_DOMAIN, + mp_const.SERVICE_SELECT_SOUND_MODE, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_SOUND_MODE: "Default"}, + blocking=True, + ) + dmr_device_mock.async_select_preset.assert_awaited_once_with("Default") + +async def test_play_media_stopped( + hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str +) -> None: + """Test play_media, starting from stopped and the device can stop.""" # play_media performs a few calls to the device for setup and play - # Start from stopped, and device can stop too dmr_device_mock.can_stop = True dmr_device_mock.transport_state = TransportState.STOPPED - dmr_device_mock.async_stop.reset_mock() - dmr_device_mock.async_set_transport_uri.reset_mock() - dmr_device_mock.async_wait_for_can_play.reset_mock() - dmr_device_mock.async_play.reset_mock() await hass.services.async_call( MP_DOMAIN, mp_const.SERVICE_PLAY_MEDIA, @@ -598,20 +655,27 @@ async def test_available_device( }, blocking=True, ) + + dmr_device_mock.construct_play_media_metadata.assert_awaited_once_with( + media_url="http://192.88.99.20:8200/MediaItems/17621.mp3", + media_title="Home Assistant", + override_upnp_class="object.item.audioItem.musicTrack", + meta_data={}, + ) dmr_device_mock.async_stop.assert_awaited_once_with() dmr_device_mock.async_set_transport_uri.assert_awaited_once_with( - "http://192.88.99.20:8200/MediaItems/17621.mp3", "Home Assistant" + "http://192.88.99.20:8200/MediaItems/17621.mp3", "Home Assistant", ANY ) dmr_device_mock.async_wait_for_can_play.assert_awaited_once_with() dmr_device_mock.async_play.assert_awaited_once_with() - # play_media again, while the device is already playing and can't stop + +async def test_play_media_playing( + hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str +) -> None: + """Test play_media, device is already playing and can't stop.""" dmr_device_mock.can_stop = False dmr_device_mock.transport_state = TransportState.PLAYING - dmr_device_mock.async_stop.reset_mock() - dmr_device_mock.async_set_transport_uri.reset_mock() - dmr_device_mock.async_wait_for_can_play.reset_mock() - dmr_device_mock.async_play.reset_mock() await hass.services.async_call( MP_DOMAIN, mp_const.SERVICE_PLAY_MEDIA, @@ -623,14 +687,232 @@ async def test_available_device( }, blocking=True, ) + + dmr_device_mock.construct_play_media_metadata.assert_awaited_once_with( + media_url="http://192.88.99.20:8200/MediaItems/17621.mp3", + media_title="Home Assistant", + override_upnp_class="object.item.audioItem.musicTrack", + meta_data={}, + ) dmr_device_mock.async_stop.assert_not_awaited() dmr_device_mock.async_set_transport_uri.assert_awaited_once_with( - "http://192.88.99.20:8200/MediaItems/17621.mp3", "Home Assistant" + "http://192.88.99.20:8200/MediaItems/17621.mp3", "Home Assistant", ANY ) - dmr_device_mock.async_wait_for_can_play.assert_awaited_once_with() + dmr_device_mock.async_wait_for_can_play.assert_not_awaited() dmr_device_mock.async_play.assert_not_awaited() +async def test_play_media_no_autoplay( + hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str +) -> None: + """Test play_media with autoplay=False.""" + # play_media performs a few calls to the device for setup and play + dmr_device_mock.can_stop = True + dmr_device_mock.transport_state = TransportState.STOPPED + await hass.services.async_call( + MP_DOMAIN, + mp_const.SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: mock_entity_id, + mp_const.ATTR_MEDIA_CONTENT_TYPE: mp_const.MEDIA_TYPE_MUSIC, + mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3", + mp_const.ATTR_MEDIA_ENQUEUE: False, + mp_const.ATTR_MEDIA_EXTRA: {"autoplay": False}, + }, + blocking=True, + ) + + dmr_device_mock.construct_play_media_metadata.assert_awaited_once_with( + media_url="http://192.88.99.20:8200/MediaItems/17621.mp3", + media_title="Home Assistant", + override_upnp_class="object.item.audioItem.musicTrack", + meta_data={}, + ) + dmr_device_mock.async_stop.assert_awaited_once_with() + dmr_device_mock.async_set_transport_uri.assert_awaited_once_with( + "http://192.88.99.20:8200/MediaItems/17621.mp3", "Home Assistant", ANY + ) + dmr_device_mock.async_wait_for_can_play.assert_not_awaited() + dmr_device_mock.async_play.assert_not_awaited() + + +async def test_play_media_metadata( + hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str +) -> None: + """Test play_media constructs useful metadata from user params.""" + await hass.services.async_call( + MP_DOMAIN, + mp_const.SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: mock_entity_id, + mp_const.ATTR_MEDIA_CONTENT_TYPE: mp_const.MEDIA_TYPE_MUSIC, + mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/17621.mp3", + mp_const.ATTR_MEDIA_ENQUEUE: False, + mp_const.ATTR_MEDIA_EXTRA: { + "title": "Mock song", + "thumb": "http://192.88.99.20:8200/MediaItems/17621.jpg", + "metadata": {"artist": "Mock artist", "album": "Mock album"}, + }, + }, + blocking=True, + ) + + dmr_device_mock.construct_play_media_metadata.assert_awaited_once_with( + media_url="http://192.88.99.20:8200/MediaItems/17621.mp3", + media_title="Mock song", + override_upnp_class="object.item.audioItem.musicTrack", + meta_data={ + "artist": "Mock artist", + "album": "Mock album", + "album_art_uri": "http://192.88.99.20:8200/MediaItems/17621.jpg", + }, + ) + + # Check again for a different media type + dmr_device_mock.construct_play_media_metadata.reset_mock() + await hass.services.async_call( + MP_DOMAIN, + mp_const.SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: mock_entity_id, + mp_const.ATTR_MEDIA_CONTENT_TYPE: mp_const.MEDIA_TYPE_TVSHOW, + mp_const.ATTR_MEDIA_CONTENT_ID: "http://192.88.99.20:8200/MediaItems/123.mkv", + mp_const.ATTR_MEDIA_ENQUEUE: False, + mp_const.ATTR_MEDIA_EXTRA: { + "title": "Mock show", + "metadata": {"season": 1, "episode": 12}, + }, + }, + blocking=True, + ) + + dmr_device_mock.construct_play_media_metadata.assert_awaited_once_with( + media_url="http://192.88.99.20:8200/MediaItems/123.mkv", + media_title="Mock show", + override_upnp_class="object.item.videoItem.videoBroadcast", + meta_data={"episodeSeason": 1, "episodeNumber": 12}, + ) + + +async def test_shuffle_repeat_modes( + hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str +) -> None: + """Test setting repeat and shuffle modes.""" + # Test shuffle with all variations of existing play mode + dmr_device_mock.valid_play_modes = {mode.value for mode in PlayMode} + for init_mode, shuffle_set, expect_mode in [ + (PlayMode.NORMAL, False, PlayMode.NORMAL), + (PlayMode.SHUFFLE, False, PlayMode.NORMAL), + (PlayMode.REPEAT_ONE, False, PlayMode.REPEAT_ONE), + (PlayMode.REPEAT_ALL, False, PlayMode.REPEAT_ALL), + (PlayMode.RANDOM, False, PlayMode.REPEAT_ALL), + (PlayMode.NORMAL, True, PlayMode.SHUFFLE), + (PlayMode.SHUFFLE, True, PlayMode.SHUFFLE), + (PlayMode.REPEAT_ONE, True, PlayMode.RANDOM), + (PlayMode.REPEAT_ALL, True, PlayMode.RANDOM), + (PlayMode.RANDOM, True, PlayMode.RANDOM), + ]: + dmr_device_mock.play_mode = init_mode + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_SHUFFLE_SET, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_SHUFFLE: shuffle_set}, + blocking=True, + ) + dmr_device_mock.async_set_play_mode.assert_awaited_with(expect_mode) + + # Test repeat with all variations of existing play mode + for init_mode, repeat_set, expect_mode in [ + (PlayMode.NORMAL, mp_const.REPEAT_MODE_OFF, PlayMode.NORMAL), + (PlayMode.SHUFFLE, mp_const.REPEAT_MODE_OFF, PlayMode.SHUFFLE), + (PlayMode.REPEAT_ONE, mp_const.REPEAT_MODE_OFF, PlayMode.NORMAL), + (PlayMode.REPEAT_ALL, mp_const.REPEAT_MODE_OFF, PlayMode.NORMAL), + (PlayMode.RANDOM, mp_const.REPEAT_MODE_OFF, PlayMode.SHUFFLE), + (PlayMode.NORMAL, mp_const.REPEAT_MODE_ONE, PlayMode.REPEAT_ONE), + (PlayMode.SHUFFLE, mp_const.REPEAT_MODE_ONE, PlayMode.REPEAT_ONE), + (PlayMode.REPEAT_ONE, mp_const.REPEAT_MODE_ONE, PlayMode.REPEAT_ONE), + (PlayMode.REPEAT_ALL, mp_const.REPEAT_MODE_ONE, PlayMode.REPEAT_ONE), + (PlayMode.RANDOM, mp_const.REPEAT_MODE_ONE, PlayMode.REPEAT_ONE), + (PlayMode.NORMAL, mp_const.REPEAT_MODE_ALL, PlayMode.REPEAT_ALL), + (PlayMode.SHUFFLE, mp_const.REPEAT_MODE_ALL, PlayMode.RANDOM), + (PlayMode.REPEAT_ONE, mp_const.REPEAT_MODE_ALL, PlayMode.REPEAT_ALL), + (PlayMode.REPEAT_ALL, mp_const.REPEAT_MODE_ALL, PlayMode.REPEAT_ALL), + (PlayMode.RANDOM, mp_const.REPEAT_MODE_ALL, PlayMode.RANDOM), + ]: + dmr_device_mock.play_mode = init_mode + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_REPEAT_SET, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_REPEAT: repeat_set}, + blocking=True, + ) + dmr_device_mock.async_set_play_mode.assert_awaited_with(expect_mode) + + # Test shuffle when the device doesn't support the desired play mode. + # Trying to go from RANDOM -> REPEAT_MODE_ALL, but nothing in the list is supported. + dmr_device_mock.async_set_play_mode.reset_mock() + dmr_device_mock.play_mode = PlayMode.RANDOM + dmr_device_mock.valid_play_modes = {PlayMode.SHUFFLE, PlayMode.RANDOM} + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_SHUFFLE_SET, + {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_SHUFFLE: False}, + blocking=True, + ) + dmr_device_mock.async_set_play_mode.assert_not_awaited() + + # Test repeat when the device doesn't support the desired play mode. + # Trying to go from RANDOM -> SHUFFLE, but nothing in the list is supported. + dmr_device_mock.async_set_play_mode.reset_mock() + dmr_device_mock.play_mode = PlayMode.RANDOM + dmr_device_mock.valid_play_modes = {PlayMode.REPEAT_ONE, PlayMode.REPEAT_ALL} + await hass.services.async_call( + MP_DOMAIN, + ha_const.SERVICE_REPEAT_SET, + { + ATTR_ENTITY_ID: mock_entity_id, + mp_const.ATTR_MEDIA_REPEAT: mp_const.REPEAT_MODE_OFF, + }, + blocking=True, + ) + dmr_device_mock.async_set_play_mode.assert_not_awaited() + + +async def test_playback_update_state( + hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str +) -> None: + """Test starting or pausing playback causes the state to be refreshed. + + This is necessary for responsive updates of the current track position and + total track time. + """ + on_event = dmr_device_mock.on_event + mock_service = Mock(UpnpService) + mock_service.service_id = "urn:upnp-org:serviceId:AVTransport" + mock_state_variable = Mock(UpnpStateVariable) + mock_state_variable.name = "TransportState" + + # Event update that device has started playing, device should get polled + mock_state_variable.value = TransportState.PLAYING + on_event(mock_service, [mock_state_variable]) + await hass.async_block_till_done() + dmr_device_mock.async_update.assert_awaited_once_with(do_ping=False) + + # Event update that device has paused playing, device should get polled + dmr_device_mock.async_update.reset_mock() + mock_state_variable.value = TransportState.PAUSED_PLAYBACK + on_event(mock_service, [mock_state_variable]) + await hass.async_block_till_done() + dmr_device_mock.async_update.assert_awaited_once_with(do_ping=False) + + # Different service shouldn't do anything + dmr_device_mock.async_update.reset_mock() + mock_service.service_id = "urn:upnp-org:serviceId:RenderingControl" + on_event(mock_service, [mock_state_variable]) + await hass.async_block_till_done() + dmr_device_mock.async_update.assert_not_awaited() + + async def test_unavailable_device( hass: HomeAssistant, domain_data_mock: Mock, @@ -691,6 +973,7 @@ async def test_unavailable_device( assert attrs[ha_const.ATTR_FRIENDLY_NAME] == MOCK_DEVICE_NAME assert attrs[ha_const.ATTR_SUPPORTED_FEATURES] == 0 + assert mp_const.ATTR_SOUND_MODE_LIST not in attrs # Check service calls do nothing SERVICES: list[tuple[str, dict]] = [ @@ -710,6 +993,9 @@ async def test_unavailable_device( mp_const.ATTR_MEDIA_ENQUEUE: False, }, ), + (mp_const.SERVICE_SELECT_SOUND_MODE, {mp_const.ATTR_SOUND_MODE: "Default"}), + (ha_const.SERVICE_SHUFFLE_SET, {mp_const.ATTR_MEDIA_SHUFFLE: True}), + (ha_const.SERVICE_REPEAT_SET, {mp_const.ATTR_MEDIA_REPEAT: "all"}), ] for service, data in SERVICES: await hass.services.async_call( @@ -768,10 +1054,12 @@ async def test_become_available( # Send an SSDP notification from the now alive device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] await ssdp_callback( - { - ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, - ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, - }, + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_location=NEW_DEVICE_LOCATION, + ssdp_st=MOCK_DEVICE_ST, + upnp={}, + ), ssdp.SsdpChange.ALIVE, ) await hass.async_block_till_done() @@ -830,10 +1118,12 @@ async def test_alive_but_gone( # Send an SSDP notification from the still missing device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] await ssdp_callback( - { - ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, - ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, - }, + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_location=NEW_DEVICE_LOCATION, + ssdp_st=MOCK_DEVICE_ST, + upnp={}, + ), ssdp.SsdpChange.ALIVE, ) await hass.async_block_till_done() @@ -869,17 +1159,21 @@ async def test_multiple_ssdp_alive( # Send two SSDP notifications with the new device URL ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] await ssdp_callback( - { - ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, - ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, - }, + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_location=NEW_DEVICE_LOCATION, + ssdp_st=MOCK_DEVICE_ST, + upnp={}, + ), ssdp.SsdpChange.ALIVE, ) await ssdp_callback( - { - ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, - ssdp.ATTR_SSDP_LOCATION: NEW_DEVICE_LOCATION, - }, + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_location=NEW_DEVICE_LOCATION, + ssdp_st=MOCK_DEVICE_ST, + upnp={}, + ), ssdp.SsdpChange.ALIVE, ) await hass.async_block_till_done() @@ -905,11 +1199,13 @@ async def test_ssdp_byebye( # First byebye will cause a disconnect ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] await ssdp_callback( - { - ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, - "_udn": MOCK_DEVICE_UDN, - "NTS": "ssdp:byebye", - }, + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_udn=MOCK_DEVICE_UDN, + ssdp_headers={"NTS": "ssdp:byebye"}, + ssdp_st=MOCK_DEVICE_ST, + upnp={}, + ), ssdp.SsdpChange.BYEBYE, ) @@ -922,11 +1218,13 @@ async def test_ssdp_byebye( # Second byebye will do nothing await ssdp_callback( - { - ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, - "_udn": MOCK_DEVICE_UDN, - "NTS": "ssdp:byebye", - }, + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_udn=MOCK_DEVICE_UDN, + ssdp_headers={"NTS": "ssdp:byebye"}, + ssdp_st=MOCK_DEVICE_ST, + upnp={}, + ), ssdp.SsdpChange.BYEBYE, ) @@ -953,24 +1251,30 @@ async def test_ssdp_update_seen_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] await ssdp_callback( - { - ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, - ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, - ssdp.ATTR_SSDP_BOOTID: "1", - }, + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_location=MOCK_DEVICE_LOCATION, + ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, + ssdp_st=MOCK_DEVICE_ST, + upnp={}, + ), ssdp.SsdpChange.ALIVE, ) await hass.async_block_till_done() # Send SSDP update with next boot ID await ssdp_callback( - { - ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, - "_udn": MOCK_DEVICE_UDN, - "NTS": "ssdp:update", - ssdp.ATTR_SSDP_BOOTID: "1", - ssdp.ATTR_SSDP_NEXTBOOTID: "2", - }, + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_udn=MOCK_DEVICE_UDN, + ssdp_headers={ + "NTS": "ssdp:update", + ssdp.ATTR_SSDP_BOOTID: "1", + ssdp.ATTR_SSDP_NEXTBOOTID: "2", + }, + ssdp_st=MOCK_DEVICE_ST, + upnp={}, + ), ssdp.SsdpChange.UPDATE, ) await hass.async_block_till_done() @@ -985,13 +1289,17 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with same next boot ID, again await ssdp_callback( - { - ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, - "_udn": MOCK_DEVICE_UDN, - "NTS": "ssdp:update", - ssdp.ATTR_SSDP_BOOTID: "1", - ssdp.ATTR_SSDP_NEXTBOOTID: "2", - }, + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_udn=MOCK_DEVICE_UDN, + ssdp_headers={ + "NTS": "ssdp:update", + ssdp.ATTR_SSDP_BOOTID: "1", + ssdp.ATTR_SSDP_NEXTBOOTID: "2", + }, + ssdp_st=MOCK_DEVICE_ST, + upnp={}, + ), ssdp.SsdpChange.UPDATE, ) await hass.async_block_till_done() @@ -1006,13 +1314,17 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with bad next boot ID await ssdp_callback( - { - ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, - "_udn": MOCK_DEVICE_UDN, - "NTS": "ssdp:update", - ssdp.ATTR_SSDP_BOOTID: "2", - ssdp.ATTR_SSDP_NEXTBOOTID: "7c848375-a106-4bd1-ac3c-8e50427c8e4f", - }, + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_udn=MOCK_DEVICE_UDN, + ssdp_headers={ + "NTS": "ssdp:update", + ssdp.ATTR_SSDP_BOOTID: "2", + ssdp.ATTR_SSDP_NEXTBOOTID: "7c848375-a106-4bd1-ac3c-8e50427c8e4f", + }, + ssdp_st=MOCK_DEVICE_ST, + upnp={}, + ), ssdp.SsdpChange.UPDATE, ) await hass.async_block_till_done() @@ -1027,11 +1339,13 @@ async def test_ssdp_update_seen_bootid( # Send a new SSDP alive with the new boot ID, device should not reconnect await ssdp_callback( - { - ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, - ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, - ssdp.ATTR_SSDP_BOOTID: "2", - }, + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_location=MOCK_DEVICE_LOCATION, + ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, + ssdp_st=MOCK_DEVICE_ST, + upnp={}, + ), ssdp.SsdpChange.ALIVE, ) await hass.async_block_till_done() @@ -1064,24 +1378,30 @@ async def test_ssdp_update_missed_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] await ssdp_callback( - { - ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, - ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, - ssdp.ATTR_SSDP_BOOTID: "1", - }, + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_location=MOCK_DEVICE_LOCATION, + ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, + ssdp_st=MOCK_DEVICE_ST, + upnp={}, + ), ssdp.SsdpChange.ALIVE, ) await hass.async_block_till_done() # Send SSDP update with skipped boot ID (not previously seen) await ssdp_callback( - { - ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, - "_udn": MOCK_DEVICE_UDN, - "NTS": "ssdp:update", - ssdp.ATTR_SSDP_BOOTID: "2", - ssdp.ATTR_SSDP_NEXTBOOTID: "3", - }, + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_udn=MOCK_DEVICE_UDN, + ssdp_headers={ + "NTS": "ssdp:update", + ssdp.ATTR_SSDP_BOOTID: "2", + ssdp.ATTR_SSDP_NEXTBOOTID: "3", + }, + ssdp_st=MOCK_DEVICE_ST, + upnp={}, + ), ssdp.SsdpChange.UPDATE, ) await hass.async_block_till_done() @@ -1096,11 +1416,13 @@ async def test_ssdp_update_missed_bootid( # Send a new SSDP alive with the new boot ID, device should reconnect await ssdp_callback( - { - ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, - ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, - ssdp.ATTR_SSDP_BOOTID: "3", - }, + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_location=MOCK_DEVICE_LOCATION, + ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "3"}, + ssdp_st=MOCK_DEVICE_ST, + upnp={}, + ), ssdp.SsdpChange.ALIVE, ) await hass.async_block_till_done() @@ -1133,11 +1455,13 @@ async def test_ssdp_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0] await ssdp_callback( - { - ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, - ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, - ssdp.ATTR_SSDP_BOOTID: "1", - }, + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_location=MOCK_DEVICE_LOCATION, + ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, + ssdp_st=MOCK_DEVICE_ST, + upnp={}, + ), ssdp.SsdpChange.ALIVE, ) await hass.async_block_till_done() @@ -1151,11 +1475,13 @@ async def test_ssdp_bootid( # Send SSDP alive with same boot ID, nothing should happen await ssdp_callback( - { - ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, - ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, - ssdp.ATTR_SSDP_BOOTID: "1", - }, + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_location=MOCK_DEVICE_LOCATION, + ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, + ssdp_st=MOCK_DEVICE_ST, + upnp={}, + ), ssdp.SsdpChange.ALIVE, ) await hass.async_block_till_done() @@ -1169,11 +1495,13 @@ async def test_ssdp_bootid( # Send a new SSDP alive with an incremented boot ID, device should be dis/reconnected await ssdp_callback( - { - ssdp.ATTR_SSDP_USN: MOCK_DEVICE_USN, - ssdp.ATTR_SSDP_LOCATION: MOCK_DEVICE_LOCATION, - ssdp.ATTR_SSDP_BOOTID: "2", - }, + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_location=MOCK_DEVICE_LOCATION, + ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, + ssdp_st=MOCK_DEVICE_ST, + upnp={}, + ), ssdp.SsdpChange.ALIVE, ) await hass.async_block_till_done() @@ -1312,6 +1640,9 @@ async def test_disappearing_device( # media_image_url is normally hidden by entity_picture, but we want a direct check assert entity.media_image_url is None + # Check attributes that are normally pre-checked + assert entity.sound_mode_list is None + # Test service calls await entity.async_set_volume_level(0.1) await entity.async_mute_volume(True) @@ -1322,6 +1653,9 @@ async def test_disappearing_device( await entity.async_play_media("", "") await entity.async_media_previous_track() await entity.async_media_next_track() + await entity.async_set_shuffle(True) + await entity.async_set_repeat(mp_const.REPEAT_MODE_ALL) + await entity.async_select_sound_mode("Default") async def test_resubscribe_failure( @@ -1335,7 +1669,8 @@ async def test_resubscribe_failure( dmr_device_mock.async_update.reset_mock() on_event = dmr_device_mock.on_event - on_event(None, []) + mock_service = Mock(UpnpService) + on_event(mock_service, []) await hass.async_block_till_done() await async_update_entity(hass, mock_entity_id) diff --git a/tests/components/doorbird/test_config_flow.py b/tests/components/doorbird/test_config_flow.py index e9f43bf7af2..ccb05b327ac 100644 --- a/tests/components/doorbird/test_config_flow.py +++ b/tests/components/doorbird/test_config_flow.py @@ -5,6 +5,7 @@ import pytest import requests from homeassistant import config_entries, data_entry_flow +from homeassistant.components import zeroconf from homeassistant.components.doorbird.const import CONF_EVENTS, DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME @@ -81,11 +82,14 @@ async def test_form_zeroconf_wrong_oui(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "properties": {"macaddress": "notdoorbirdoui"}, - "host": "192.168.1.8", - "name": "Doorstation - abc123._axis-video._tcp.local.", - }, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.8", + hostname="mock_hostname", + name="Doorstation - abc123._axis-video._tcp.local.", + port=None, + properties={"macaddress": "notdoorbirdoui"}, + type="mock_type", + ), ) assert result["type"] == "abort" assert result["reason"] == "not_doorbird_device" @@ -97,11 +101,14 @@ async def test_form_zeroconf_link_local_ignored(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "properties": {"macaddress": "1CCAE3DOORBIRD"}, - "host": "169.254.103.61", - "name": "Doorstation - abc123._axis-video._tcp.local.", - }, + data=zeroconf.ZeroconfServiceInfo( + host="169.254.103.61", + hostname="mock_hostname", + name="Doorstation - abc123._axis-video._tcp.local.", + port=None, + properties={"macaddress": "1CCAE3DOORBIRD"}, + type="mock_type", + ), ) assert result["type"] == "abort" assert result["reason"] == "link_local_address" @@ -120,11 +127,14 @@ async def test_form_zeroconf_correct_oui(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "properties": {"macaddress": "1CCAE3DOORBIRD"}, - "name": "Doorstation - abc123._axis-video._tcp.local.", - "host": "192.168.1.5", - }, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.5", + hostname="mock_hostname", + name="Doorstation - abc123._axis-video._tcp.local.", + port=None, + properties={"macaddress": "1CCAE3DOORBIRD"}, + type="mock_type", + ), ) await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -179,11 +189,14 @@ async def test_form_zeroconf_correct_oui_wrong_device(hass, doorbell_state_side_ result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "properties": {"macaddress": "1CCAE3DOORBIRD"}, - "name": "Doorstation - abc123._axis-video._tcp.local.", - "host": "192.168.1.5", - }, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.5", + hostname="mock_hostname", + name="Doorstation - abc123._axis-video._tcp.local.", + port=None, + properties={"macaddress": "1CCAE3DOORBIRD"}, + type="mock_type", + ), ) await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 6accf7c40da..19ac6dc5d1c 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -16,16 +16,13 @@ from homeassistant.components.dsmr.const import DOMAIN from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_GAS, - DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, STATE_UNKNOWN, VOLUME_CUBIC_METERS, @@ -134,9 +131,14 @@ async def test_default_setup(hass, dsmr_connection_fixture): # make sure entities have been created and return 'unknown' state power_consumption = hass.states.get("sensor.power_consumption") assert power_consumption.state == STATE_UNKNOWN - assert power_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ( + power_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER + ) assert power_consumption.attributes.get(ATTR_ICON) is None - assert power_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + power_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.MEASUREMENT + ) assert power_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser @@ -163,9 +165,10 @@ async def test_default_setup(hass, dsmr_connection_fixture): # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" - assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( - gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + gas_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING ) assert ( gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS @@ -261,10 +264,11 @@ async def test_v4_meter(hass, dsmr_connection_fixture): # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" - assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS assert ( - gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + gas_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING ) assert ( gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS @@ -331,9 +335,10 @@ async def test_v5_meter(hass, dsmr_connection_fixture): # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" - assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( - gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + gas_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING ) assert ( gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS @@ -397,9 +402,12 @@ async def test_luxembourg_meter(hass, dsmr_connection_fixture): power_tariff = hass.states.get("sensor.energy_consumption_total") assert power_tariff.state == "123.456" - assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert power_tariff.attributes.get(ATTR_ICON) is None - assert power_tariff.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert ( + power_tariff.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) assert ( power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR ) @@ -411,9 +419,10 @@ async def test_luxembourg_meter(hass, dsmr_connection_fixture): # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" - assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( - gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + gas_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING ) assert ( gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS @@ -480,9 +489,10 @@ async def test_belgian_meter(hass, dsmr_connection_fixture): # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" - assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) is DEVICE_CLASS_GAS + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( - gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + gas_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING ) assert ( gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS @@ -586,16 +596,22 @@ async def test_swedish_meter(hass, dsmr_connection_fixture): power_tariff = hass.states.get("sensor.energy_consumption_total") assert power_tariff.state == "123.456" - assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert power_tariff.attributes.get(ATTR_ICON) is None - assert power_tariff.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert ( + power_tariff.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) assert ( power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR ) power_tariff = hass.states.get("sensor.energy_production_total") assert power_tariff.state == "654.321" - assert power_tariff.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert ( + power_tariff.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) assert ( power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR ) diff --git a/tests/components/dunehd/test_config_flow.py b/tests/components/dunehd/test_config_flow.py index c8e7b0f7f3e..73732236077 100644 --- a/tests/components/dunehd/test_config_flow.py +++ b/tests/components/dunehd/test_config_flow.py @@ -16,7 +16,9 @@ DUNEHD_STATE = {"protocol_version": "4", "player_state": "navigator"} async def test_import(hass): """Test that the import works.""" - with patch("pdunehd.DuneHDPlayer.update_state", return_value=DUNEHD_STATE): + with patch("homeassistant.components.dunehd.async_setup_entry"), patch( + "pdunehd.DuneHDPlayer.update_state", return_value=DUNEHD_STATE + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=CONFIG_HOSTNAME ) @@ -108,7 +110,9 @@ async def test_duplicate_error(hass): async def test_create_entry(hass): """Test that the user step works.""" - with patch("pdunehd.DuneHDPlayer.update_state", return_value=DUNEHD_STATE): + with patch("homeassistant.components.dunehd.async_setup_entry"), patch( + "pdunehd.DuneHDPlayer.update_state", return_value=DUNEHD_STATE + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_HOSTNAME ) @@ -120,7 +124,9 @@ async def test_create_entry(hass): async def test_create_entry_with_ipv6_address(hass): """Test that the user step works with device IPv6 address..""" - with patch("pdunehd.DuneHDPlayer.update_state", return_value=DUNEHD_STATE): + with patch("homeassistant.components.dunehd.async_setup_entry"), patch( + "pdunehd.DuneHDPlayer.update_state", return_value=DUNEHD_STATE + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, diff --git a/tests/components/dyson/__init__.py b/tests/components/dyson/__init__.py deleted file mode 100644 index d4c814a37db..00000000000 --- a/tests/components/dyson/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the dyson component.""" diff --git a/tests/components/dyson/common.py b/tests/components/dyson/common.py deleted file mode 100644 index 4fde47183d2..00000000000 --- a/tests/components/dyson/common.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Common utils for Dyson tests.""" -from __future__ import annotations - -from unittest import mock -from unittest.mock import MagicMock - -from libpurecool.const import SLEEP_TIMER_OFF, Dyson360EyeMode, FanMode, PowerMode -from libpurecool.dyson_360_eye import Dyson360Eye -from libpurecool.dyson_device import DysonDevice -from libpurecool.dyson_pure_cool import DysonPureCool, FanSpeed -from libpurecool.dyson_pure_cool_link import DysonPureCoolLink - -from homeassistant.components.dyson import CONF_LANGUAGE, DOMAIN -from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant, callback - -SERIAL = "XX-XXXXX-XX" -NAME = "Temp Name" -ENTITY_NAME = "temp_name" -IP_ADDRESS = "0.0.0.0" - -BASE_PATH = "homeassistant.components.dyson" - -CONFIG = { - DOMAIN: { - CONF_USERNAME: "user@example.com", - CONF_PASSWORD: "password", - CONF_LANGUAGE: "US", - CONF_DEVICES: [ - { - "device_id": SERIAL, - "device_ip": IP_ADDRESS, - } - ], - } -} - - -@callback -def async_get_basic_device(spec: type[DysonDevice]) -> DysonDevice: - """Return a basic device with common fields filled out.""" - device = MagicMock(spec=spec) - device.serial = SERIAL - device.name = NAME - device.connect = mock.Mock(return_value=True) - device.auto_connect = mock.Mock(return_value=True) - return device - - -@callback -def async_get_360eye_device(state=Dyson360EyeMode.FULL_CLEAN_RUNNING) -> Dyson360Eye: - """Return a Dyson 360 Eye device.""" - device = async_get_basic_device(Dyson360Eye) - device.state.state = state - device.state.battery_level = 85 - device.state.power_mode = PowerMode.QUIET - device.state.position = (0, 0) - return device - - -@callback -def async_get_purecoollink_device() -> DysonPureCoolLink: - """Return a Dyson Pure Cool Link device.""" - device = async_get_basic_device(DysonPureCoolLink) - device.state.fan_mode = FanMode.FAN.value - device.state.speed = FanSpeed.FAN_SPEED_1.value - device.state.night_mode = "ON" - device.state.oscillation = "ON" - return device - - -@callback -def async_get_purecool_device() -> DysonPureCool: - """Return a Dyson Pure Cool device.""" - device = async_get_basic_device(DysonPureCool) - device.state.fan_power = "ON" - device.state.speed = FanSpeed.FAN_SPEED_1.value - device.state.night_mode = "ON" - device.state.oscillation = "OION" - device.state.oscillation_angle_low = "0024" - device.state.oscillation_angle_high = "0254" - device.state.auto_mode = "OFF" - device.state.front_direction = "ON" - device.state.sleep_timer = SLEEP_TIMER_OFF - device.state.hepa_filter_state = "0100" - device.state.carbon_filter_state = "0100" - return device - - -async def async_update_device( - hass: HomeAssistant, device: DysonDevice, state_type: type | None = None -) -> None: - """Update the device using callback function.""" - callbacks = [args[0][0] for args in device.add_message_listener.call_args_list] - message = MagicMock(spec=state_type) - - # Combining sync calls to avoid multiple executors - def _run_callbacks(): - for callback_fn in callbacks: - callback_fn(message) - - await hass.async_add_executor_job(_run_callbacks) - await hass.async_block_till_done() diff --git a/tests/components/dyson/conftest.py b/tests/components/dyson/conftest.py deleted file mode 100644 index 300c80f3a73..00000000000 --- a/tests/components/dyson/conftest.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Configure pytest for Dyson tests.""" -from unittest.mock import patch - -from libpurecool.dyson_device import DysonDevice -import pytest - -from homeassistant.components.dyson import DOMAIN -from homeassistant.core import HomeAssistant - -from .common import BASE_PATH, CONFIG - -from tests.common import async_setup_component - - -@pytest.fixture() -async def device(hass: HomeAssistant, request) -> DysonDevice: - """Fixture to provide Dyson 360 Eye device.""" - platform = request.module.PLATFORM_DOMAIN - get_device = request.module.async_get_device - if hasattr(request, "param"): - if isinstance(request.param, list): - device = get_device(*request.param) - else: - device = get_device(request.param) - else: - device = get_device() - with patch(f"{BASE_PATH}.DysonAccount.login", return_value=True), patch( - f"{BASE_PATH}.DysonAccount.devices", return_value=[device] - ), patch(f"{BASE_PATH}.PLATFORMS", [platform]): - # PLATFORMS is patched so that only the platform being tested is set up - await async_setup_component( - hass, - DOMAIN, - CONFIG, - ) - await hass.async_block_till_done() - - return device diff --git a/tests/components/dyson/test_air_quality.py b/tests/components/dyson/test_air_quality.py deleted file mode 100644 index 51b38303a58..00000000000 --- a/tests/components/dyson/test_air_quality.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Test the Dyson air quality component.""" - -from libpurecool.dyson_pure_cool import DysonPureCool -from libpurecool.dyson_pure_state_v2 import DysonEnvironmentalSensorV2State - -from homeassistant.components.air_quality import ( - ATTR_AQI, - ATTR_NO2, - ATTR_PM_2_5, - ATTR_PM_10, - DOMAIN as PLATFORM_DOMAIN, -) -from homeassistant.components.dyson.air_quality import ATTR_VOC -from homeassistant.core import HomeAssistant, callback - -from .common import ENTITY_NAME, async_get_purecool_device, async_update_device - -ENTITY_ID = f"{PLATFORM_DOMAIN}.{ENTITY_NAME}" - -MOCKED_VALUES = { - ATTR_PM_2_5: 10, - ATTR_PM_10: 20, - ATTR_NO2: 30, - ATTR_VOC: 40, -} - -MOCKED_UPDATED_VALUES = { - ATTR_PM_2_5: 60, - ATTR_PM_10: 50, - ATTR_NO2: 40, - ATTR_VOC: 30, -} - - -def _async_assign_values(device: DysonPureCool, values=MOCKED_VALUES) -> None: - """Assign mocked environmental states to the device.""" - device.environmental_state.particulate_matter_25 = values[ATTR_PM_2_5] - device.environmental_state.particulate_matter_10 = values[ATTR_PM_10] - device.environmental_state.nitrogen_dioxide = values[ATTR_NO2] - device.environmental_state.volatile_organic_compounds = values[ATTR_VOC] - - -@callback -def async_get_device() -> DysonPureCool: - """Return a device of the given type.""" - device = async_get_purecool_device() - _async_assign_values(device) - return device - - -async def test_air_quality(hass: HomeAssistant, device: DysonPureCool) -> None: - """Test the state and attributes of the air quality entity.""" - state = hass.states.get(ENTITY_ID) - assert state.state == str(MOCKED_VALUES[ATTR_PM_2_5]) - attributes = state.attributes - for attr, value in MOCKED_VALUES.items(): - assert attributes[attr] == value - assert attributes[ATTR_AQI] == 40 - - _async_assign_values(device, MOCKED_UPDATED_VALUES) - await async_update_device(hass, device, DysonEnvironmentalSensorV2State) - state = hass.states.get(ENTITY_ID) - assert state.state == str(MOCKED_UPDATED_VALUES[ATTR_PM_2_5]) - attributes = state.attributes - for attr, value in MOCKED_UPDATED_VALUES.items(): - assert attributes[attr] == value - assert attributes[ATTR_AQI] == 60 diff --git a/tests/components/dyson/test_climate.py b/tests/components/dyson/test_climate.py deleted file mode 100644 index 2591b90f596..00000000000 --- a/tests/components/dyson/test_climate.py +++ /dev/null @@ -1,348 +0,0 @@ -"""Test the Dyson fan component.""" -from __future__ import annotations - -from libpurecool.const import ( - AutoMode, - FanPower, - FanSpeed, - FanState, - FocusMode, - HeatMode, - HeatState, -) -from libpurecool.dyson_device import DysonDevice -from libpurecool.dyson_pure_hotcool import DysonPureHotCool -from libpurecool.dyson_pure_hotcool_link import DysonPureHotCoolLink -from libpurecool.dyson_pure_state import DysonPureHotCoolState -from libpurecool.dyson_pure_state_v2 import DysonPureHotCoolV2State -import pytest - -from homeassistant.components.climate import DOMAIN as PLATFORM_DOMAIN -from homeassistant.components.climate.const import ( - ATTR_CURRENT_HUMIDITY, - ATTR_CURRENT_TEMPERATURE, - ATTR_FAN_MODE, - ATTR_FAN_MODES, - ATTR_HVAC_ACTION, - ATTR_HVAC_MODE, - ATTR_HVAC_MODES, - ATTR_MAX_TEMP, - ATTR_MIN_TEMP, - CURRENT_HVAC_COOL, - CURRENT_HVAC_HEAT, - CURRENT_HVAC_IDLE, - CURRENT_HVAC_OFF, - FAN_AUTO, - FAN_DIFFUSE, - FAN_FOCUS, - FAN_HIGH, - FAN_LOW, - FAN_MEDIUM, - FAN_OFF, - HVAC_MODE_COOL, - HVAC_MODE_HEAT, - HVAC_MODE_OFF, - SERVICE_SET_FAN_MODE, - SERVICE_SET_HVAC_MODE, - SERVICE_SET_TEMPERATURE, -) -from homeassistant.components.dyson.climate import ( - SUPPORT_FAN, - SUPPORT_FAN_PCOOL, - SUPPORT_FLAGS, - SUPPORT_HVAC, - SUPPORT_HVAC_PCOOL, -) -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - ATTR_TEMPERATURE, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er - -from .common import ( - ENTITY_NAME, - NAME, - SERIAL, - async_get_basic_device, - async_update_device, -) - -ENTITY_ID = f"{PLATFORM_DOMAIN}.{ENTITY_NAME}" - - -@callback -def async_get_device(spec: type[DysonDevice]) -> DysonDevice: - """Return a Dyson climate device.""" - device = async_get_basic_device(spec) - device.state.heat_target = 2900 - device.environmental_state.temperature = 275 - device.environmental_state.humidity = 50 - if spec == DysonPureHotCoolLink: - device.state.heat_mode = HeatMode.HEAT_ON.value - device.state.heat_state = HeatState.HEAT_STATE_ON.value - device.state.focus_mode = FocusMode.FOCUS_ON.value - else: - device.state.fan_power = FanPower.POWER_ON.value - device.state.heat_mode = HeatMode.HEAT_ON.value - device.state.heat_state = HeatState.HEAT_STATE_ON.value - device.state.auto_mode = AutoMode.AUTO_ON.value - device.state.fan_state = FanState.FAN_OFF.value - device.state.speed = FanSpeed.FAN_SPEED_AUTO.value - return device - - -@pytest.mark.parametrize( - "device", [DysonPureHotCoolLink, DysonPureHotCool], indirect=True -) -async def test_state_common(hass: HomeAssistant, device: DysonDevice) -> None: - """Test common state and attributes of two types of climate entities.""" - entity_registry = er.async_get(hass) - assert entity_registry.async_get(ENTITY_ID).unique_id == SERIAL - - state = hass.states.get(ENTITY_ID) - assert state.name == NAME - attributes = state.attributes - assert attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_FLAGS - assert attributes[ATTR_CURRENT_TEMPERATURE] == 2 - assert attributes[ATTR_CURRENT_HUMIDITY] == 50 - assert attributes[ATTR_TEMPERATURE] == 17 - assert attributes[ATTR_MIN_TEMP] == 1 - assert attributes[ATTR_MAX_TEMP] == 37 - - device.state.heat_target = 2800 - device.environmental_state.temperature = 0 - device.environmental_state.humidity = 0 - await async_update_device( - hass, - device, - DysonPureHotCoolState - if isinstance(device, DysonPureHotCoolLink) - else DysonPureHotCoolV2State, - ) - attributes = hass.states.get(ENTITY_ID).attributes - assert attributes[ATTR_CURRENT_TEMPERATURE] is None - assert ATTR_CURRENT_HUMIDITY not in attributes - assert attributes[ATTR_TEMPERATURE] == 7 - - -@pytest.mark.parametrize("device", [DysonPureHotCoolLink], indirect=True) -async def test_state_purehotcoollink( - hass: HomeAssistant, device: DysonPureHotCoolLink -) -> None: - """Test common state and attributes of a PureHotCoolLink entity.""" - state = hass.states.get(ENTITY_ID) - assert state.state == HVAC_MODE_HEAT - attributes = state.attributes - assert attributes[ATTR_HVAC_MODES] == SUPPORT_HVAC - assert attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT - assert attributes[ATTR_FAN_MODE] == FAN_FOCUS - assert attributes[ATTR_FAN_MODES] == SUPPORT_FAN - - device.state.heat_state = HeatState.HEAT_STATE_OFF.value - device.state.focus_mode = FocusMode.FOCUS_OFF - await async_update_device(hass, device, DysonPureHotCoolState) - state = hass.states.get(ENTITY_ID) - assert state.state == HVAC_MODE_HEAT - attributes = state.attributes - assert attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE - assert attributes[ATTR_FAN_MODE] == FAN_DIFFUSE - - device.state.heat_mode = HeatMode.HEAT_OFF.value - await async_update_device(hass, device, DysonPureHotCoolState) - state = hass.states.get(ENTITY_ID) - assert state.state == HVAC_MODE_COOL - attributes = state.attributes - assert attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_COOL - - -@pytest.mark.parametrize("device", [DysonPureHotCool], indirect=True) -async def test_state_purehotcool(hass: HomeAssistant, device: DysonPureHotCool) -> None: - """Test common state and attributes of a PureHotCool entity.""" - state = hass.states.get(ENTITY_ID) - assert state.state == HVAC_MODE_HEAT - attributes = state.attributes - assert attributes[ATTR_HVAC_MODES] == SUPPORT_HVAC_PCOOL - assert attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT - assert attributes[ATTR_FAN_MODE] == FAN_AUTO - assert attributes[ATTR_FAN_MODES] == SUPPORT_FAN_PCOOL - - device.state.heat_state = HeatState.HEAT_STATE_OFF.value - device.state.auto_mode = AutoMode.AUTO_OFF.value - await async_update_device(hass, device, DysonPureHotCoolV2State) - state = hass.states.get(ENTITY_ID) - assert state.state == HVAC_MODE_HEAT - attributes = state.attributes - assert attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE - assert attributes[ATTR_FAN_MODE] == FAN_OFF - - device.state.heat_mode = HeatMode.HEAT_OFF.value - device.state.fan_state = FanState.FAN_ON.value - device.state.speed = FanSpeed.FAN_SPEED_1.value - await async_update_device(hass, device, DysonPureHotCoolV2State) - state = hass.states.get(ENTITY_ID) - assert state.state == HVAC_MODE_COOL - attributes = state.attributes - assert attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_COOL - assert attributes[ATTR_FAN_MODE] == FAN_LOW - - device.state.fan_power = FanPower.POWER_OFF.value - await async_update_device(hass, device, DysonPureHotCoolV2State) - state = hass.states.get(ENTITY_ID) - assert state.state == HVAC_MODE_OFF - attributes = state.attributes - assert attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF - - -@pytest.mark.parametrize( - "service,service_data,configuration_data", - [ - ( - SERVICE_SET_TEMPERATURE, - {ATTR_TEMPERATURE: -5}, - {"heat_target": "2740", "heat_mode": HeatMode.HEAT_ON}, - ), - ( - SERVICE_SET_TEMPERATURE, - {ATTR_TEMPERATURE: 40}, - {"heat_target": "3100", "heat_mode": HeatMode.HEAT_ON}, - ), - ( - SERVICE_SET_TEMPERATURE, - {ATTR_TEMPERATURE: 20}, - {"heat_target": "2930", "heat_mode": HeatMode.HEAT_ON}, - ), - ( - SERVICE_SET_FAN_MODE, - {ATTR_FAN_MODE: FAN_FOCUS}, - {"focus_mode": FocusMode.FOCUS_ON}, - ), - ( - SERVICE_SET_FAN_MODE, - {ATTR_FAN_MODE: FAN_DIFFUSE}, - {"focus_mode": FocusMode.FOCUS_OFF}, - ), - ( - SERVICE_SET_HVAC_MODE, - {ATTR_HVAC_MODE: HVAC_MODE_HEAT}, - {"heat_mode": HeatMode.HEAT_ON}, - ), - ( - SERVICE_SET_HVAC_MODE, - {ATTR_HVAC_MODE: HVAC_MODE_COOL}, - {"heat_mode": HeatMode.HEAT_OFF}, - ), - ], -) -@pytest.mark.parametrize("device", [DysonPureHotCoolLink], indirect=True) -async def test_commands_purehotcoollink( - hass: HomeAssistant, - device: DysonPureHotCoolLink, - service: str, - service_data: dict, - configuration_data: dict, -) -> None: - """Test sending commands to a PureHotCoolLink entity.""" - await hass.services.async_call( - PLATFORM_DOMAIN, - service, - { - ATTR_ENTITY_ID: ENTITY_ID, - **service_data, - }, - blocking=True, - ) - device.set_configuration.assert_called_once_with(**configuration_data) - - -@pytest.mark.parametrize( - "service,service_data,command,command_args", - [ - (SERVICE_SET_TEMPERATURE, {ATTR_TEMPERATURE: 20}, "set_heat_target", ["2930"]), - (SERVICE_SET_FAN_MODE, {ATTR_FAN_MODE: FAN_OFF}, "turn_off", []), - ( - SERVICE_SET_FAN_MODE, - {ATTR_FAN_MODE: FAN_LOW}, - "set_fan_speed", - [FanSpeed.FAN_SPEED_4], - ), - ( - SERVICE_SET_FAN_MODE, - {ATTR_FAN_MODE: FAN_MEDIUM}, - "set_fan_speed", - [FanSpeed.FAN_SPEED_7], - ), - ( - SERVICE_SET_FAN_MODE, - {ATTR_FAN_MODE: FAN_HIGH}, - "set_fan_speed", - [FanSpeed.FAN_SPEED_10], - ), - (SERVICE_SET_FAN_MODE, {ATTR_FAN_MODE: FAN_AUTO}, "enable_auto_mode", []), - (SERVICE_SET_HVAC_MODE, {ATTR_HVAC_MODE: HVAC_MODE_OFF}, "turn_off", []), - ( - SERVICE_SET_HVAC_MODE, - {ATTR_HVAC_MODE: HVAC_MODE_HEAT}, - "enable_heat_mode", - [], - ), - ( - SERVICE_SET_HVAC_MODE, - {ATTR_HVAC_MODE: HVAC_MODE_COOL}, - "disable_heat_mode", - [], - ), - ], -) -@pytest.mark.parametrize("device", [DysonPureHotCool], indirect=True) -async def test_commands_purehotcool( - hass: HomeAssistant, - device: DysonPureHotCoolLink, - service: str, - service_data: dict, - command: str, - command_args: list, -) -> None: - """Test sending commands to a PureHotCool entity.""" - await hass.services.async_call( - PLATFORM_DOMAIN, - service, - { - ATTR_ENTITY_ID: ENTITY_ID, - **service_data, - }, - blocking=True, - ) - getattr(device, command).assert_called_once_with(*command_args) - - -@pytest.mark.parametrize("hvac_mode", [HVAC_MODE_HEAT, HVAC_MODE_COOL]) -@pytest.mark.parametrize( - "fan_power,turn_on_call_count", - [ - (FanPower.POWER_ON.value, 0), - (FanPower.POWER_OFF.value, 1), - ], -) -@pytest.mark.parametrize("device", [DysonPureHotCool], indirect=True) -async def test_set_hvac_mode_purehotcool( - hass: HomeAssistant, - device: DysonPureHotCoolLink, - hvac_mode: str, - fan_power: str, - turn_on_call_count: int, -) -> None: - """Test setting HVAC mode of a PureHotCool entity turns on the device when it's off.""" - device.state.fan_power = fan_power - await async_update_device(hass, device) - await hass.services.async_call( - PLATFORM_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: ENTITY_ID, - ATTR_HVAC_MODE: hvac_mode, - }, - blocking=True, - ) - assert device.turn_on.call_count == turn_on_call_count diff --git a/tests/components/dyson/test_fan.py b/tests/components/dyson/test_fan.py deleted file mode 100644 index 67149ff7f2e..00000000000 --- a/tests/components/dyson/test_fan.py +++ /dev/null @@ -1,441 +0,0 @@ -"""Test the Dyson fan component.""" -from __future__ import annotations - -from libpurecool.const import FanMode, FanSpeed, NightMode, Oscillation -from libpurecool.dyson_pure_cool import DysonPureCool, DysonPureCoolLink -from libpurecool.dyson_pure_state import DysonPureCoolState -from libpurecool.dyson_pure_state_v2 import DysonPureCoolV2State -import pytest - -from homeassistant.components.dyson import DOMAIN -from homeassistant.components.dyson.fan import ( - ATTR_ANGLE_HIGH, - ATTR_ANGLE_LOW, - ATTR_AUTO_MODE, - ATTR_CARBON_FILTER, - ATTR_DYSON_SPEED, - ATTR_DYSON_SPEED_LIST, - ATTR_FLOW_DIRECTION_FRONT, - ATTR_HEPA_FILTER, - ATTR_NIGHT_MODE, - ATTR_TIMER, - PRESET_MODE_AUTO, - SERVICE_SET_ANGLE, - SERVICE_SET_AUTO_MODE, - SERVICE_SET_DYSON_SPEED, - SERVICE_SET_FLOW_DIRECTION_FRONT, - SERVICE_SET_NIGHT_MODE, - SERVICE_SET_TIMER, -) -from homeassistant.components.fan import ( - ATTR_OSCILLATING, - ATTR_PERCENTAGE, - ATTR_PRESET_MODE, - ATTR_SPEED, - ATTR_SPEED_LIST, - DOMAIN as PLATFORM_DOMAIN, - SERVICE_OSCILLATE, - SERVICE_SET_SPEED, - SERVICE_TURN_OFF, - SERVICE_TURN_ON, - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, - SUPPORT_OSCILLATE, - SUPPORT_SET_SPEED, -) -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - STATE_OFF, - STATE_ON, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er - -from .common import ( - ENTITY_NAME, - NAME, - SERIAL, - async_get_purecool_device, - async_get_purecoollink_device, - async_update_device, -) - -ENTITY_ID = f"{PLATFORM_DOMAIN}.{ENTITY_NAME}" - - -@callback -def async_get_device(spec: type[DysonPureCoolLink]) -> DysonPureCoolLink: - """Return a Dyson fan device.""" - if spec == DysonPureCoolLink: - return async_get_purecoollink_device() - return async_get_purecool_device() - - -@pytest.mark.parametrize("device", [DysonPureCoolLink], indirect=True) -async def test_state_purecoollink( - hass: HomeAssistant, device: DysonPureCoolLink -) -> None: - """Test the state of a PureCoolLink fan.""" - entity_registry = er.async_get(hass) - assert entity_registry.async_get(ENTITY_ID).unique_id == SERIAL - - state = hass.states.get(ENTITY_ID) - assert state.state == STATE_ON - assert state.name == NAME - attributes = state.attributes - assert attributes[ATTR_NIGHT_MODE] is True - assert attributes[ATTR_OSCILLATING] is True - assert attributes[ATTR_PERCENTAGE] == 10 - assert attributes[ATTR_PRESET_MODE] is None - assert attributes[ATTR_SPEED] == SPEED_LOW - assert attributes[ATTR_SPEED_LIST] == [ - SPEED_OFF, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_HIGH, - PRESET_MODE_AUTO, - ] - assert attributes[ATTR_DYSON_SPEED] == 1 - assert attributes[ATTR_DYSON_SPEED_LIST] == list(range(1, 11)) - assert attributes[ATTR_AUTO_MODE] is False - assert attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_OSCILLATE | SUPPORT_SET_SPEED - - device.state.fan_mode = FanMode.OFF.value - await async_update_device(hass, device, DysonPureCoolState) - state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF - - device.state.fan_mode = FanMode.AUTO.value - device.state.speed = FanSpeed.FAN_SPEED_AUTO.value - device.state.night_mode = "OFF" - device.state.oscillation = "OFF" - await async_update_device(hass, device, DysonPureCoolState) - state = hass.states.get(ENTITY_ID) - assert state.state == STATE_ON - attributes = state.attributes - assert attributes[ATTR_NIGHT_MODE] is False - assert attributes[ATTR_OSCILLATING] is False - assert attributes[ATTR_PERCENTAGE] is None - assert attributes[ATTR_PRESET_MODE] == "auto" - assert attributes[ATTR_SPEED] == PRESET_MODE_AUTO - assert attributes[ATTR_DYSON_SPEED] == "AUTO" - assert attributes[ATTR_AUTO_MODE] is True - - -@pytest.mark.parametrize("device", [DysonPureCool], indirect=True) -async def test_state_purecool(hass: HomeAssistant, device: DysonPureCool) -> None: - """Test the state of a PureCool fan.""" - entity_registry = er.async_get(hass) - assert entity_registry.async_get(ENTITY_ID).unique_id == SERIAL - - state = hass.states.get(ENTITY_ID) - assert state.state == STATE_ON - assert state.name == NAME - attributes = state.attributes - assert attributes[ATTR_NIGHT_MODE] is True - assert attributes[ATTR_OSCILLATING] is True - assert attributes[ATTR_ANGLE_LOW] == 24 - assert attributes[ATTR_ANGLE_HIGH] == 254 - assert attributes[ATTR_PERCENTAGE] == 10 - assert attributes[ATTR_PRESET_MODE] is None - assert attributes[ATTR_SPEED] == SPEED_LOW - assert attributes[ATTR_SPEED_LIST] == [ - SPEED_OFF, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_HIGH, - PRESET_MODE_AUTO, - ] - assert attributes[ATTR_DYSON_SPEED] == 1 - assert attributes[ATTR_DYSON_SPEED_LIST] == list(range(1, 11)) - assert attributes[ATTR_AUTO_MODE] is False - assert attributes[ATTR_FLOW_DIRECTION_FRONT] is True - assert attributes[ATTR_TIMER] == "OFF" - assert attributes[ATTR_HEPA_FILTER] == 100 - assert attributes[ATTR_CARBON_FILTER] == 100 - assert attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_OSCILLATE | SUPPORT_SET_SPEED - - device.state.auto_mode = "ON" - device.state.night_mode = "OFF" - device.state.oscillation = "OIOF" - device.state.speed = "AUTO" - device.state.front_direction = "OFF" - device.state.sleep_timer = "0120" - device.state.carbon_filter_state = "INV" - await async_update_device(hass, device, DysonPureCoolV2State) - state = hass.states.get(ENTITY_ID) - attributes = state.attributes - assert attributes[ATTR_NIGHT_MODE] is False - assert attributes[ATTR_OSCILLATING] is False - assert attributes[ATTR_PERCENTAGE] is None - assert attributes[ATTR_PRESET_MODE] == "auto" - assert attributes[ATTR_SPEED] == PRESET_MODE_AUTO - assert attributes[ATTR_DYSON_SPEED] == "AUTO" - assert attributes[ATTR_AUTO_MODE] is True - assert attributes[ATTR_FLOW_DIRECTION_FRONT] is False - assert attributes[ATTR_TIMER] == "0120" - assert attributes[ATTR_CARBON_FILTER] == "INV" - - device.state.fan_power = "OFF" - await async_update_device(hass, device, DysonPureCoolV2State) - state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF - - -@pytest.mark.parametrize( - "service,service_data,configuration_args", - [ - (SERVICE_TURN_ON, {}, {"fan_mode": FanMode.FAN}), - ( - SERVICE_TURN_ON, - {ATTR_SPEED: SPEED_LOW}, - {"fan_mode": FanMode.FAN, "fan_speed": FanSpeed.FAN_SPEED_4}, - ), - ( - SERVICE_TURN_ON, - {ATTR_PERCENTAGE: 40}, - {"fan_mode": FanMode.FAN, "fan_speed": FanSpeed.FAN_SPEED_4}, - ), - (SERVICE_TURN_OFF, {}, {"fan_mode": FanMode.OFF}), - ( - SERVICE_OSCILLATE, - {ATTR_OSCILLATING: True}, - {"oscillation": Oscillation.OSCILLATION_ON}, - ), - ( - SERVICE_OSCILLATE, - {ATTR_OSCILLATING: False}, - {"oscillation": Oscillation.OSCILLATION_OFF}, - ), - ( - SERVICE_SET_SPEED, - {ATTR_SPEED: SPEED_LOW}, - {"fan_mode": FanMode.FAN, "fan_speed": FanSpeed.FAN_SPEED_4}, - ), - ( - SERVICE_SET_SPEED, - {ATTR_SPEED: SPEED_MEDIUM}, - {"fan_mode": FanMode.FAN, "fan_speed": FanSpeed.FAN_SPEED_7}, - ), - ( - SERVICE_SET_SPEED, - {ATTR_SPEED: SPEED_HIGH}, - {"fan_mode": FanMode.FAN, "fan_speed": FanSpeed.FAN_SPEED_10}, - ), - ], -) -@pytest.mark.parametrize("device", [DysonPureCoolLink], indirect=True) -async def test_commands_purecoollink( - hass: HomeAssistant, - device: DysonPureCoolLink, - service: str, - service_data: dict, - configuration_args: dict, -) -> None: - """Test sending commands to a PureCoolLink fan.""" - await hass.services.async_call( - PLATFORM_DOMAIN, - service, - { - ATTR_ENTITY_ID: ENTITY_ID, - **service_data, - }, - blocking=True, - ) - device.set_configuration.assert_called_once_with(**configuration_args) - - -@pytest.mark.parametrize( - "service,service_data,command,command_args", - [ - (SERVICE_TURN_ON, {}, "turn_on", []), - ( - SERVICE_TURN_ON, - {ATTR_SPEED: SPEED_LOW}, - "set_fan_speed", - [FanSpeed.FAN_SPEED_4], - ), - ( - SERVICE_TURN_ON, - {ATTR_PERCENTAGE: 40}, - "set_fan_speed", - [FanSpeed.FAN_SPEED_4], - ), - ( - SERVICE_TURN_ON, - {ATTR_PRESET_MODE: "auto"}, - "enable_auto_mode", - [], - ), - (SERVICE_TURN_OFF, {}, "turn_off", []), - (SERVICE_OSCILLATE, {ATTR_OSCILLATING: True}, "enable_oscillation", []), - (SERVICE_OSCILLATE, {ATTR_OSCILLATING: False}, "disable_oscillation", []), - ( - SERVICE_SET_SPEED, - {ATTR_SPEED: SPEED_LOW}, - "set_fan_speed", - [FanSpeed.FAN_SPEED_4], - ), - ( - SERVICE_SET_SPEED, - {ATTR_SPEED: SPEED_MEDIUM}, - "set_fan_speed", - [FanSpeed.FAN_SPEED_7], - ), - ( - SERVICE_SET_SPEED, - {ATTR_SPEED: SPEED_HIGH}, - "set_fan_speed", - [FanSpeed.FAN_SPEED_10], - ), - ], -) -@pytest.mark.parametrize("device", [DysonPureCool], indirect=True) -async def test_commands_purecool( - hass: HomeAssistant, - device: DysonPureCool, - service: str, - service_data: dict, - command: str, - command_args: list, -) -> None: - """Test sending commands to a PureCool fan.""" - await hass.services.async_call( - PLATFORM_DOMAIN, - service, - { - ATTR_ENTITY_ID: ENTITY_ID, - **service_data, - }, - blocking=True, - ) - getattr(device, command).assert_called_once_with(*command_args) - - -@pytest.mark.parametrize( - "service,service_data,configuration_args", - [ - ( - SERVICE_SET_NIGHT_MODE, - {ATTR_NIGHT_MODE: True}, - {"night_mode": NightMode.NIGHT_MODE_ON}, - ), - ( - SERVICE_SET_NIGHT_MODE, - {ATTR_NIGHT_MODE: False}, - {"night_mode": NightMode.NIGHT_MODE_OFF}, - ), - (SERVICE_SET_AUTO_MODE, {"auto_mode": True}, {"fan_mode": FanMode.AUTO}), - (SERVICE_SET_AUTO_MODE, {"auto_mode": False}, {"fan_mode": FanMode.FAN}), - ( - SERVICE_SET_DYSON_SPEED, - {ATTR_DYSON_SPEED: "4"}, - {"fan_mode": FanMode.FAN, "fan_speed": FanSpeed.FAN_SPEED_4}, - ), - ], -) -@pytest.mark.parametrize("device", [DysonPureCoolLink], indirect=True) -async def test_custom_services_purecoollink( - hass: HomeAssistant, - device: DysonPureCoolLink, - service: str, - service_data: dict, - configuration_args: dict, -) -> None: - """Test custom services of a PureCoolLink fan.""" - await hass.services.async_call( - DOMAIN, - service, - { - ATTR_ENTITY_ID: ENTITY_ID, - **service_data, - }, - blocking=True, - ) - device.set_configuration.assert_called_once_with(**configuration_args) - - -@pytest.mark.parametrize( - "service,service_data,command,command_args", - [ - (SERVICE_SET_NIGHT_MODE, {ATTR_NIGHT_MODE: True}, "enable_night_mode", []), - (SERVICE_SET_NIGHT_MODE, {ATTR_NIGHT_MODE: False}, "disable_night_mode", []), - (SERVICE_SET_AUTO_MODE, {ATTR_AUTO_MODE: True}, "enable_auto_mode", []), - (SERVICE_SET_AUTO_MODE, {ATTR_AUTO_MODE: False}, "disable_auto_mode", []), - (SERVICE_SET_AUTO_MODE, {ATTR_AUTO_MODE: False}, "disable_auto_mode", []), - ( - SERVICE_SET_ANGLE, - {ATTR_ANGLE_LOW: 10, ATTR_ANGLE_HIGH: 200}, - "enable_oscillation", - [10, 200], - ), - ( - SERVICE_SET_FLOW_DIRECTION_FRONT, - {ATTR_FLOW_DIRECTION_FRONT: True}, - "enable_frontal_direction", - [], - ), - ( - SERVICE_SET_FLOW_DIRECTION_FRONT, - {ATTR_FLOW_DIRECTION_FRONT: False}, - "disable_frontal_direction", - [], - ), - (SERVICE_SET_TIMER, {ATTR_TIMER: 0}, "disable_sleep_timer", []), - (SERVICE_SET_TIMER, {ATTR_TIMER: 10}, "enable_sleep_timer", [10]), - ( - SERVICE_SET_DYSON_SPEED, - {ATTR_DYSON_SPEED: "4"}, - "set_fan_speed", - [FanSpeed("0004")], - ), - ], -) -@pytest.mark.parametrize("device", [DysonPureCool], indirect=True) -async def test_custom_services_purecool( - hass: HomeAssistant, - device: DysonPureCool, - service: str, - service_data: dict, - command: str, - command_args: list, -) -> None: - """Test custom services of a PureCool fan.""" - await hass.services.async_call( - DOMAIN, - service, - { - ATTR_ENTITY_ID: ENTITY_ID, - **service_data, - }, - blocking=True, - ) - getattr(device, command).assert_called_once_with(*command_args) - - -@pytest.mark.parametrize( - "domain,service,data", - [ - (PLATFORM_DOMAIN, SERVICE_TURN_ON, {ATTR_SPEED: "AUTO"}), - (PLATFORM_DOMAIN, SERVICE_SET_SPEED, {ATTR_SPEED: "AUTO"}), - (DOMAIN, SERVICE_SET_DYSON_SPEED, {ATTR_DYSON_SPEED: "11"}), - ], -) -@pytest.mark.parametrize("device", [DysonPureCool], indirect=True) -async def test_custom_services_invalid_data( - hass: HomeAssistant, device: DysonPureCool, domain: str, service: str, data: dict -) -> None: - """Test custom services calling with invalid data.""" - with pytest.raises(ValueError): - await hass.services.async_call( - domain, - service, - { - ATTR_ENTITY_ID: ENTITY_ID, - **data, - }, - blocking=True, - ) diff --git a/tests/components/dyson/test_init.py b/tests/components/dyson/test_init.py deleted file mode 100644 index 714ac919c19..00000000000 --- a/tests/components/dyson/test_init.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Test the parent Dyson component.""" -import copy -from unittest.mock import MagicMock, patch - -from homeassistant.components.dyson import DOMAIN -from homeassistant.const import CONF_DEVICES -from homeassistant.core import HomeAssistant - -from .common import ( - BASE_PATH, - CONFIG, - ENTITY_NAME, - IP_ADDRESS, - async_get_360eye_device, - async_get_purecool_device, - async_get_purecoollink_device, -) - -from tests.common import async_setup_component - - -async def test_setup_manual(hass: HomeAssistant): - """Test set up the component with manually configured device IPs.""" - SERIAL_TEMPLATE = "XX-XXXXX-X{}" - - # device1 works - device1 = async_get_purecoollink_device() - device1.serial = SERIAL_TEMPLATE.format(1) - - # device2 failed to connect - device2 = async_get_purecool_device() - device2.serial = SERIAL_TEMPLATE.format(2) - device2.connect = MagicMock(return_value=False) - - # device3 throws exception during connection - device3 = async_get_360eye_device() - device3.serial = SERIAL_TEMPLATE.format(3) - device3.connect = MagicMock(side_effect=OSError) - - # device4 not configured in configuration - device4 = async_get_360eye_device() - device4.serial = SERIAL_TEMPLATE.format(4) - - devices = [device1, device2, device3, device4] - config = copy.deepcopy(CONFIG) - config[DOMAIN][CONF_DEVICES] = [ - { - "device_id": SERIAL_TEMPLATE.format(i), - "device_ip": IP_ADDRESS, - } - for i in [1, 2, 3, 5] # 1 device missing and 1 device not existed - ] - - with patch(f"{BASE_PATH}.DysonAccount.login", return_value=True) as login, patch( - f"{BASE_PATH}.DysonAccount.devices", return_value=devices - ) as devices_method, patch( - f"{BASE_PATH}.PLATFORMS", ["fan", "vacuum"] - ): # Patch platforms to get rid of sensors - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - login.assert_called_once_with() - devices_method.assert_called_once_with() - - # Only one fan and zero vacuum is set up successfully - assert hass.states.async_entity_ids() == [f"fan.{ENTITY_NAME}"] - device1.connect.assert_called_once_with(IP_ADDRESS) - device2.connect.assert_called_once_with(IP_ADDRESS) - device3.connect.assert_called_once_with(IP_ADDRESS) - device4.connect.assert_not_called() - - -async def test_setup_autoconnect(hass: HomeAssistant): - """Test set up the component with auto connect.""" - # device1 works - device1 = async_get_purecoollink_device() - - # device2 failed to auto connect - device2 = async_get_purecool_device() - device2.auto_connect = MagicMock(return_value=False) - - devices = [device1, device2] - config = copy.deepcopy(CONFIG) - config[DOMAIN].pop(CONF_DEVICES) - - with patch(f"{BASE_PATH}.DysonAccount.login", return_value=True), patch( - f"{BASE_PATH}.DysonAccount.devices", return_value=devices - ), patch( - f"{BASE_PATH}.PLATFORMS", ["fan"] - ): # Patch platforms to get rid of sensors - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - assert hass.states.async_entity_ids_count() == 1 - - -async def test_login_failed(hass: HomeAssistant): - """Test login failure during setup.""" - with patch(f"{BASE_PATH}.DysonAccount.login", return_value=False): - assert not await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() diff --git a/tests/components/dyson/test_sensor.py b/tests/components/dyson/test_sensor.py deleted file mode 100644 index 5bd6fd85c3a..00000000000 --- a/tests/components/dyson/test_sensor.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Test the Dyson sensor(s) component.""" -from __future__ import annotations - -from unittest.mock import patch - -from libpurecool.dyson_pure_cool import DysonPureCool -from libpurecool.dyson_pure_cool_link import DysonPureCoolLink -import pytest - -from homeassistant.components.dyson import DOMAIN -from homeassistant.components.dyson.sensor import SENSOR_ATTRIBUTES, SENSOR_NAMES -from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - STATE_OFF, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem - -from .common import ( - BASE_PATH, - CONFIG, - ENTITY_NAME, - NAME, - SERIAL, - async_get_basic_device, - async_update_device, -) - -from tests.common import async_setup_component - -ENTITY_ID_PREFIX = f"{PLATFORM_DOMAIN}.{ENTITY_NAME}" - -MOCKED_VALUES = { - "filter_life": 100, - "dust": 5, - "humidity": 45, - "temperature_kelvin": 295, - "temperature": 21.9, - "air_quality": 5, - "hepa_filter_state": 50, - "combi_filter_state": 50, - "carbon_filter_state": 10, -} - -MOCKED_UPDATED_VALUES = { - "filter_life": 30, - "dust": 2, - "humidity": 80, - "temperature_kelvin": 240, - "temperature": -33.1, - "air_quality": 3, - "hepa_filter_state": 30, - "combi_filter_state": 30, - "carbon_filter_state": 20, -} - - -@callback -def _async_assign_values( - device: DysonPureCoolLink, values=MOCKED_VALUES, combi=False -) -> None: - """Assign mocked values to the device.""" - if isinstance(device, DysonPureCool): - device.state.hepa_filter_state = values["hepa_filter_state"] - device.state.carbon_filter_state = ( - "INV" if combi else values["carbon_filter_state"] - ) - device.environmental_state.humidity = values["humidity"] - device.environmental_state.temperature = values["temperature_kelvin"] - else: # DysonPureCoolLink - device.state.filter_life = values["filter_life"] - device.environmental_state.dust = values["dust"] - device.environmental_state.humidity = values["humidity"] - device.environmental_state.temperature = values["temperature_kelvin"] - device.environmental_state.volatil_organic_compounds = values["air_quality"] - - -@callback -def async_get_device(spec: type[DysonPureCoolLink], combi=False) -> DysonPureCoolLink: - """Return a device of the given type.""" - device = async_get_basic_device(spec) - _async_assign_values(device, combi=combi) - return device - - -@callback -def _async_get_entity_id(sensor_type: str) -> str: - """Get the expected entity id from the type of the sensor.""" - sensor_name = SENSOR_NAMES[sensor_type] - entity_id_suffix = sensor_name.lower().replace(" ", "_") - return f"{ENTITY_ID_PREFIX}_{entity_id_suffix}" - - -@pytest.mark.parametrize( - "device,sensors", - [ - ( - DysonPureCoolLink, - ["filter_life", "dust", "humidity", "temperature", "air_quality"], - ), - ( - DysonPureCool, - ["hepa_filter_state", "carbon_filter_state", "humidity", "temperature"], - ), - ( - [DysonPureCool, True], - ["combi_filter_state", "humidity", "temperature"], - ), - ], - indirect=["device"], -) -async def test_sensors( - hass: HomeAssistant, device: DysonPureCoolLink, sensors: list[str] -) -> None: - """Test the sensors.""" - # Temperature is given by the device in kelvin - # Make sure no other sensors are set up - assert len(hass.states.async_all()) == len(sensors) - - entity_registry = er.async_get(hass) - for sensor in sensors: - entity_id = _async_get_entity_id(sensor) - - # Test unique id - assert entity_registry.async_get(entity_id).unique_id == f"{SERIAL}-{sensor}" - - # Test state - state = hass.states.get(entity_id) - assert state.state == str(MOCKED_VALUES[sensor]) - assert state.name == f"{NAME} {SENSOR_NAMES[sensor]}" - - # Test attributes - attributes = state.attributes - for attr, value in SENSOR_ATTRIBUTES[sensor].items(): - assert attributes[attr] == value - - # Test data update - _async_assign_values(device, MOCKED_UPDATED_VALUES) - await async_update_device(hass, device) - for sensor in sensors: - state = hass.states.get(_async_get_entity_id(sensor)) - assert state.state == str(MOCKED_UPDATED_VALUES[sensor]) - - -@pytest.mark.parametrize("device", [DysonPureCoolLink], indirect=True) -async def test_sensors_off(hass: HomeAssistant, device: DysonPureCoolLink) -> None: - """Test the case where temperature and humidity are not available.""" - device.environmental_state.temperature = 0 - device.environmental_state.humidity = 0 - await async_update_device(hass, device) - assert hass.states.get(f"{ENTITY_ID_PREFIX}_temperature").state == STATE_OFF - assert hass.states.get(f"{ENTITY_ID_PREFIX}_humidity").state == STATE_OFF - - -@pytest.mark.parametrize( - "unit_system,temp_unit,temperature", - [(METRIC_SYSTEM, TEMP_CELSIUS, 21.9), (IMPERIAL_SYSTEM, TEMP_FAHRENHEIT, 71.3)], -) -async def test_temperature( - hass: HomeAssistant, unit_system: UnitSystem, temp_unit: str, temperature: float -) -> None: - """Test the temperature sensor in different units.""" - hass.config.units = unit_system - - device = async_get_device(DysonPureCoolLink) - with patch(f"{BASE_PATH}.DysonAccount.login", return_value=True), patch( - f"{BASE_PATH}.DysonAccount.devices", return_value=[device] - ), patch(f"{BASE_PATH}.PLATFORMS", [PLATFORM_DOMAIN]): - # PLATFORMS is patched so that only the platform being tested is set up - await async_setup_component( - hass, - DOMAIN, - CONFIG, - ) - await hass.async_block_till_done() - - state = hass.states.get(f"{ENTITY_ID_PREFIX}_temperature") - assert state.state == str(temperature) - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == temp_unit diff --git a/tests/components/dyson/test_vacuum.py b/tests/components/dyson/test_vacuum.py deleted file mode 100644 index b77dee3270f..00000000000 --- a/tests/components/dyson/test_vacuum.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Test the Dyson 360 eye robot vacuum component.""" -from libpurecool.const import Dyson360EyeMode, PowerMode -from libpurecool.dyson_360_eye import Dyson360Eye -import pytest - -from homeassistant.components.dyson.vacuum import ATTR_POSITION, SUPPORT_DYSON -from homeassistant.components.vacuum import ( - ATTR_FAN_SPEED, - ATTR_FAN_SPEED_LIST, - ATTR_STATUS, - DOMAIN as PLATFORM_DOMAIN, - SERVICE_RETURN_TO_BASE, - SERVICE_SET_FAN_SPEED, - SERVICE_START_PAUSE, - SERVICE_STOP, - SERVICE_TURN_OFF, - SERVICE_TURN_ON, -) -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - STATE_OFF, - STATE_ON, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er - -from .common import ( - ENTITY_NAME, - NAME, - SERIAL, - async_get_360eye_device, - async_update_device, -) - -ENTITY_ID = f"{PLATFORM_DOMAIN}.{ENTITY_NAME}" - - -@callback -def async_get_device(state=Dyson360EyeMode.FULL_CLEAN_RUNNING) -> Dyson360Eye: - """Return a Dyson 360 Eye device.""" - return async_get_360eye_device(state) - - -async def test_state(hass: HomeAssistant, device: Dyson360Eye) -> None: - """Test the state of the vacuum.""" - entity_registry = er.async_get(hass) - assert entity_registry.async_get(ENTITY_ID).unique_id == SERIAL - - state = hass.states.get(ENTITY_ID) - assert state.name == NAME - assert state.state == STATE_ON - attributes = state.attributes - assert attributes[ATTR_STATUS] == "Cleaning" - assert attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_DYSON - assert attributes[ATTR_BATTERY_LEVEL] == 85 - assert attributes[ATTR_POSITION] == "(0, 0)" - assert attributes[ATTR_FAN_SPEED] == "Quiet" - assert attributes[ATTR_FAN_SPEED_LIST] == ["Quiet", "Max"] - - device.state.state = Dyson360EyeMode.INACTIVE_CHARGING - device.state.power_mode = PowerMode.MAX - await async_update_device(hass, device) - state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF - assert state.attributes[ATTR_STATUS] == "Stopped - Charging" - assert state.attributes[ATTR_FAN_SPEED] == "Max" - - device.state.state = Dyson360EyeMode.FULL_CLEAN_PAUSED - await async_update_device(hass, device) - state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF - assert state.attributes[ATTR_STATUS] == "Paused" - - -@pytest.mark.parametrize( - "service,command,device", - [ - (SERVICE_TURN_ON, "start", Dyson360EyeMode.INACTIVE_CHARGED), - (SERVICE_TURN_ON, "resume", Dyson360EyeMode.FULL_CLEAN_PAUSED), - (SERVICE_TURN_OFF, "pause", Dyson360EyeMode.FULL_CLEAN_RUNNING), - (SERVICE_STOP, "pause", Dyson360EyeMode.FULL_CLEAN_RUNNING), - (SERVICE_START_PAUSE, "pause", Dyson360EyeMode.FULL_CLEAN_RUNNING), - (SERVICE_START_PAUSE, "pause", Dyson360EyeMode.FULL_CLEAN_RUNNING), - (SERVICE_START_PAUSE, "start", Dyson360EyeMode.INACTIVE_CHARGED), - (SERVICE_START_PAUSE, "resume", Dyson360EyeMode.FULL_CLEAN_PAUSED), - (SERVICE_RETURN_TO_BASE, "abort", Dyson360EyeMode.FULL_CLEAN_PAUSED), - ], - indirect=["device"], -) -async def test_commands( - hass: HomeAssistant, device: Dyson360Eye, service: str, command: str -) -> None: - """Test sending commands to the vacuum.""" - await hass.services.async_call( - PLATFORM_DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True - ) - getattr(device, command).assert_called_once_with() - - -async def test_set_fan_speed(hass: HomeAssistant, device: Dyson360Eye): - """Test setting fan speed of the vacuum.""" - fan_speed_map = { - "Max": PowerMode.MAX, - "Quiet": PowerMode.QUIET, - } - for service_speed, command_speed in fan_speed_map.items(): - await hass.services.async_call( - PLATFORM_DOMAIN, - SERVICE_SET_FAN_SPEED, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_SPEED: service_speed}, - blocking=True, - ) - device.set_power_mode.assert_called_with(command_speed) diff --git a/tests/fixtures/ecobee/ecobee-data.json b/tests/components/ecobee/fixtures/ecobee-data.json similarity index 100% rename from tests/fixtures/ecobee/ecobee-data.json rename to tests/components/ecobee/fixtures/ecobee-data.json diff --git a/tests/fixtures/ecobee/ecobee-token.json b/tests/components/ecobee/fixtures/ecobee-token.json similarity index 100% rename from tests/fixtures/ecobee/ecobee-token.json rename to tests/components/ecobee/fixtures/ecobee-token.json diff --git a/tests/fixtures/efergy/budget.json b/tests/components/efergy/fixtures/budget.json similarity index 100% rename from tests/fixtures/efergy/budget.json rename to tests/components/efergy/fixtures/budget.json diff --git a/tests/fixtures/efergy/current_values_multi.json b/tests/components/efergy/fixtures/current_values_multi.json similarity index 100% rename from tests/fixtures/efergy/current_values_multi.json rename to tests/components/efergy/fixtures/current_values_multi.json diff --git a/tests/fixtures/efergy/current_values_single.json b/tests/components/efergy/fixtures/current_values_single.json similarity index 100% rename from tests/fixtures/efergy/current_values_single.json rename to tests/components/efergy/fixtures/current_values_single.json diff --git a/tests/fixtures/efergy/daily_cost.json b/tests/components/efergy/fixtures/daily_cost.json similarity index 100% rename from tests/fixtures/efergy/daily_cost.json rename to tests/components/efergy/fixtures/daily_cost.json diff --git a/tests/fixtures/efergy/daily_energy.json b/tests/components/efergy/fixtures/daily_energy.json similarity index 100% rename from tests/fixtures/efergy/daily_energy.json rename to tests/components/efergy/fixtures/daily_energy.json diff --git a/tests/fixtures/efergy/instant.json b/tests/components/efergy/fixtures/instant.json similarity index 100% rename from tests/fixtures/efergy/instant.json rename to tests/components/efergy/fixtures/instant.json diff --git a/tests/fixtures/efergy/monthly_cost.json b/tests/components/efergy/fixtures/monthly_cost.json similarity index 100% rename from tests/fixtures/efergy/monthly_cost.json rename to tests/components/efergy/fixtures/monthly_cost.json diff --git a/tests/fixtures/efergy/monthly_energy.json b/tests/components/efergy/fixtures/monthly_energy.json similarity index 100% rename from tests/fixtures/efergy/monthly_energy.json rename to tests/components/efergy/fixtures/monthly_energy.json diff --git a/tests/fixtures/efergy/status.json b/tests/components/efergy/fixtures/status.json similarity index 100% rename from tests/fixtures/efergy/status.json rename to tests/components/efergy/fixtures/status.json diff --git a/tests/fixtures/efergy/weekly_cost.json b/tests/components/efergy/fixtures/weekly_cost.json similarity index 100% rename from tests/fixtures/efergy/weekly_cost.json rename to tests/components/efergy/fixtures/weekly_cost.json diff --git a/tests/fixtures/efergy/weekly_energy.json b/tests/components/efergy/fixtures/weekly_energy.json similarity index 100% rename from tests/fixtures/efergy/weekly_energy.json rename to tests/components/efergy/fixtures/weekly_energy.json diff --git a/tests/fixtures/efergy/yearly_cost.json b/tests/components/efergy/fixtures/yearly_cost.json similarity index 100% rename from tests/fixtures/efergy/yearly_cost.json rename to tests/components/efergy/fixtures/yearly_cost.json diff --git a/tests/fixtures/efergy/yearly_energy.json b/tests/components/efergy/fixtures/yearly_energy.json similarity index 100% rename from tests/fixtures/efergy/yearly_energy.json rename to tests/components/efergy/fixtures/yearly_energy.json diff --git a/tests/fixtures/elgato/info.json b/tests/components/elgato/fixtures/info.json similarity index 100% rename from tests/fixtures/elgato/info.json rename to tests/components/elgato/fixtures/info.json diff --git a/tests/fixtures/elgato/settings-color.json b/tests/components/elgato/fixtures/settings-color.json similarity index 100% rename from tests/fixtures/elgato/settings-color.json rename to tests/components/elgato/fixtures/settings-color.json diff --git a/tests/fixtures/elgato/settings.json b/tests/components/elgato/fixtures/settings.json similarity index 100% rename from tests/fixtures/elgato/settings.json rename to tests/components/elgato/fixtures/settings.json diff --git a/tests/fixtures/elgato/state-color.json b/tests/components/elgato/fixtures/state-color.json similarity index 100% rename from tests/fixtures/elgato/state-color.json rename to tests/components/elgato/fixtures/state-color.json diff --git a/tests/fixtures/elgato/state.json b/tests/components/elgato/fixtures/state.json similarity index 100% rename from tests/fixtures/elgato/state.json rename to tests/components/elgato/fixtures/state.json diff --git a/tests/components/elgato/test_button.py b/tests/components/elgato/test_button.py new file mode 100644 index 00000000000..21211596d0c --- /dev/null +++ b/tests/components/elgato/test_button.py @@ -0,0 +1,77 @@ +"""Tests for the Elgato Light button platform.""" +from unittest.mock import patch + +from elgato import ElgatoError +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ICON, + ENTITY_CATEGORY_CONFIG, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.components.elgato import init_integration +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.mark.freeze_time("2021-11-13 11:48:00") +async def test_button_identify( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the Elgato identify button.""" + await init_integration(hass, aioclient_mock) + + entity_registry = er.async_get(hass) + + state = hass.states.get("button.identify") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:help" + assert state.state == STATE_UNKNOWN + + entry = entity_registry.async_get("button.identify") + assert entry + assert entry.unique_id == "CN11A1A00001_identify" + assert entry.entity_category == ENTITY_CATEGORY_CONFIG + + with patch( + "homeassistant.components.elgato.light.Elgato.identify" + ) as mock_identify: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.identify"}, + blocking=True, + ) + + assert len(mock_identify.mock_calls) == 1 + mock_identify.assert_called_with() + + state = hass.states.get("button.identify") + assert state + assert state.state == "2021-11-13T11:48:00+00:00" + + +async def test_button_identify_error( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog +) -> None: + """Test an error occurs with the Elgato identify button.""" + await init_integration(hass, aioclient_mock) + + with patch( + "homeassistant.components.elgato.light.Elgato.identify", + side_effect=ElgatoError, + ) as mock_identify: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.identify"}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(mock_identify.mock_calls) == 1 + assert "An error occurred while identifying the Elgato Light" in caplog.text diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index 49c85c5f2a2..2ea2dab6acf 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -2,6 +2,7 @@ import aiohttp from homeassistant import data_entry_flow +from homeassistant.components import zeroconf from homeassistant.components.elgato.const import CONF_SERIAL_NUMBER, DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SOURCE, CONTENT_TYPE_JSON @@ -27,12 +28,14 @@ async def test_full_user_flow_implementation( await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, - data={ - "host": "127.0.0.1", - "hostname": "example.local.", - "port": 9123, - "properties": {}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="example.local.", + name="mock_name", + port=9123, + properties={}, + type="mock_type", + ), ) result = await hass.config_entries.flow.async_init( @@ -70,12 +73,14 @@ async def test_full_zeroconf_flow_implementation( result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, - data={ - "host": "127.0.0.1", - "hostname": "example.local.", - "port": 9123, - "properties": {}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="example.local.", + name="mock_name", + port=9123, + properties={}, + type="mock_type", + ), ) assert result["description_placeholders"] == {CONF_SERIAL_NUMBER: "CN11A1A00001"} @@ -127,7 +132,14 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={"host": "127.0.0.1", "port": 9123}, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="mock_hostname", + name="mock_name", + port=9123, + properties={}, + type="mock_type", + ), ) assert result["reason"] == "cannot_connect" @@ -158,7 +170,14 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, - data={"host": "127.0.0.1", "port": 9123}, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="mock_hostname", + name="mock_name", + port=9123, + properties={}, + type="mock_type", + ), ) assert result["reason"] == "already_configured" @@ -167,7 +186,14 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, - data={"host": "127.0.0.2", "port": 9123}, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.2", + hostname="mock_hostname", + name="mock_name", + port=9123, + properties={}, + type="mock_type", + ), ) assert result["reason"] == "already_configured" diff --git a/tests/components/emonitor/test_config_flow.py b/tests/components/emonitor/test_config_flow.py index a030f242d8d..0314ce7420e 100644 --- a/tests/components/emonitor/test_config_flow.py +++ b/tests/components/emonitor/test_config_flow.py @@ -5,7 +5,7 @@ from aioemonitor.monitor import EmonitorNetwork, EmonitorStatus import aiohttp from homeassistant import config_entries -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp from homeassistant.components.emonitor.const import DOMAIN from homeassistant.const import CONF_HOST @@ -102,11 +102,11 @@ async def test_dhcp_can_confirm(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - HOSTNAME: "emonitor", - IP_ADDRESS: "1.2.3.4", - MAC_ADDRESS: "aa:bb:cc:dd:ee:ff", - }, + data=dhcp.DhcpServiceInfo( + hostname="emonitor", + ip="1.2.3.4", + macaddress="aa:bb:cc:dd:ee:ff", + ), ) await hass.async_block_till_done() @@ -145,11 +145,11 @@ async def test_dhcp_fails_to_connect(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - HOSTNAME: "emonitor", - IP_ADDRESS: "1.2.3.4", - MAC_ADDRESS: "aa:bb:cc:dd:ee:ff", - }, + data=dhcp.DhcpServiceInfo( + hostname="emonitor", + ip="1.2.3.4", + macaddress="aa:bb:cc:dd:ee:ff", + ), ) await hass.async_block_till_done() @@ -174,11 +174,11 @@ async def test_dhcp_already_exists(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - HOSTNAME: "emonitor", - IP_ADDRESS: "1.2.3.4", - MAC_ADDRESS: "aa:bb:cc:dd:ee:ff", - }, + data=dhcp.DhcpServiceInfo( + hostname="emonitor", + ip="1.2.3.4", + macaddress="aa:bb:cc:dd:ee:ff", + ), ) await hass.async_block_till_done() diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py index 2b0d6fe06c6..93bf8c0631f 100644 --- a/tests/components/emulated_hue/test_init.py +++ b/tests/components/emulated_hue/test_init.py @@ -1,5 +1,6 @@ """Test the Emulated Hue component.""" from datetime import timedelta +from unittest.mock import patch from homeassistant.components.emulated_hue import ( DATA_KEY, @@ -7,6 +8,7 @@ from homeassistant.components.emulated_hue import ( SAVE_DELAY, Config, ) +from homeassistant.setup import async_setup_component from homeassistant.util import utcnow from tests.common import async_fire_time_changed @@ -113,3 +115,12 @@ def test_config_alexa_entity_id_to_number(): entity_id = conf.number_to_entity_id("light.test") assert entity_id == "light.test" + + +async def test_setup_works(hass): + """Test setup works.""" + hass.config.components.add("network") + with patch( + "homeassistant.components.emulated_hue.create_upnp_datagram_endpoint" + ), patch("homeassistant.components.emulated_hue.async_get_source_ip"): + assert await async_setup_component(hass, "emulated_hue", {}) diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 5e3ad5c4aff..78a61b1bf69 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -26,11 +26,19 @@ def mock_get_metadata(): """Mock recorder.statistics.get_metadata.""" mocks = {} + def _get_metadata(_hass, *, statistic_ids): + result = {} + for statistic_id in statistic_ids: + if statistic_id in mocks: + if mocks[statistic_id] is not None: + result[statistic_id] = mocks[statistic_id] + else: + result[statistic_id] = (1, {}) + return result + with patch( "homeassistant.components.recorder.statistics.get_metadata", - side_effect=lambda hass, statistic_ids: mocks.get( - statistic_ids[0], {statistic_ids[0]: (1, {})} - ), + wraps=_get_metadata, ): yield mocks @@ -361,8 +369,8 @@ async def test_validation_grid( """Test validating grid with sensors for energy and cost/compensation.""" mock_is_entity_recorded["sensor.grid_cost_1"] = False mock_is_entity_recorded["sensor.grid_compensation_1"] = False - mock_get_metadata["sensor.grid_cost_1"] = {} - mock_get_metadata["sensor.grid_compensation_1"] = {} + mock_get_metadata["sensor.grid_cost_1"] = None + mock_get_metadata["sensor.grid_compensation_1"] = None await mock_energy_manager.async_update( { "energy_sources": [ @@ -456,8 +464,8 @@ async def test_validation_grid_external_cost_compensation( hass, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata ): """Test validating grid with non entity stats for energy and cost/compensation.""" - mock_get_metadata["external:grid_cost_1"] = {} - mock_get_metadata["external:grid_compensation_1"] = {} + mock_get_metadata["external:grid_cost_1"] = None + mock_get_metadata["external:grid_compensation_1"] = None await mock_energy_manager.async_update( { "energy_sources": [ diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index 09a3b7aed94..c1dc195a63e 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -1,12 +1,21 @@ """Test the Energy websocket API.""" -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch import pytest from homeassistant.components.energy import data, is_configured +from homeassistant.components.recorder import statistics +from homeassistant.components.recorder.statistics import async_add_external_statistics from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry, flush_store, mock_platform +from tests.common import ( + MockConfigEntry, + flush_store, + init_recorder_component, + mock_platform, +) +from tests.components.recorder.common import async_wait_recording_done_without_instance @pytest.fixture(autouse=True) @@ -289,3 +298,932 @@ 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): + """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")) + + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "history", {}) + await async_setup_component(hass, "sensor", {}) + + period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) + period2_day_start = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 00:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) + period4_day_start = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 00:00:00")) + + external_energy_statistics_1 = ( + { + "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": 5, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 8, + }, + ) + external_energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + external_energy_statistics_2 = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 20, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 30, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 50, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 80, + }, + ) + external_energy_metadata_2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_2", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_1 + ) + async_add_external_statistics( + hass, external_energy_metadata_2, external_energy_statistics_2 + ) + await async_wait_recording_done_without_instance(hass) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import_tariff_1", + "test:total_energy_import_tariff_2", + ], + "co2_statistic_id": "test:co2_ratio_missing", + "period": "hour", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + period2.isoformat(): pytest.approx(33.0 - 22.0), + period3.isoformat(): pytest.approx(55.0 - 33.0), + period4.isoformat(): pytest.approx(88.0 - 55.0), + } + + await client.send_json( + { + "id": 2, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import_tariff_1", + "test:total_energy_import_tariff_2", + ], + "co2_statistic_id": "test:co2_ratio_missing", + "period": "day", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + period2_day_start.isoformat(): pytest.approx(33.0 - 22.0), + period3.isoformat(): pytest.approx(55.0 - 33.0), + period4_day_start.isoformat(): pytest.approx(88.0 - 55.0), + } + + await client.send_json( + { + "id": 3, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import_tariff_1", + "test:total_energy_import_tariff_2", + ], + "co2_statistic_id": "test:co2_ratio_missing", + "period": "month", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + period1.isoformat(): pytest.approx(33.0 - 22.0), + period3.isoformat(): pytest.approx((55.0 - 33.0) + (88.0 - 55.0)), + } + + +@pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") +async def test_fossil_energy_consumption_hole(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")) + + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "history", {}) + await async_setup_component(hass, "sensor", {}) + + period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) + period2_day_start = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 00:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) + period4_day_start = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 00:00:00")) + + external_energy_statistics_1 = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": None, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 5, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 8, + }, + ) + external_energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + external_energy_statistics_2 = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 20, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": None, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 50, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 80, + }, + ) + external_energy_metadata_2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_2", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_1 + ) + async_add_external_statistics( + hass, external_energy_metadata_2, external_energy_statistics_2 + ) + await async_wait_recording_done_without_instance(hass) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import_tariff_1", + "test:total_energy_import_tariff_2", + ], + "co2_statistic_id": "test:co2_ratio_missing", + "period": "hour", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + period2.isoformat(): pytest.approx(3.0 - 20.0), + period3.isoformat(): pytest.approx(55.0 - 3.0), + period4.isoformat(): pytest.approx(88.0 - 55.0), + } + + await client.send_json( + { + "id": 2, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import_tariff_1", + "test:total_energy_import_tariff_2", + ], + "co2_statistic_id": "test:co2_ratio_missing", + "period": "day", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + period2_day_start.isoformat(): pytest.approx(3.0 - 20.0), + period3.isoformat(): pytest.approx(55.0 - 3.0), + period4_day_start.isoformat(): pytest.approx(88.0 - 55.0), + } + + await client.send_json( + { + "id": 3, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import_tariff_1", + "test:total_energy_import_tariff_2", + ], + "co2_statistic_id": "test:co2_ratio_missing", + "period": "month", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + period1.isoformat(): pytest.approx(3.0 - 20.0), + period3.isoformat(): pytest.approx((55.0 - 3.0) + (88.0 - 55.0)), + } + + +@pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") +async def test_fossil_energy_consumption_no_data(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")) + + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "history", {}) + await async_setup_component(hass, "sensor", {}) + + period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) + + external_energy_statistics_1 = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": None, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 5, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 8, + }, + ) + external_energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + external_energy_statistics_2 = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 20, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": None, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 50, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 80, + }, + ) + external_energy_metadata_2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_2", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_1 + ) + async_add_external_statistics( + hass, external_energy_metadata_2, external_energy_statistics_2 + ) + await async_wait_recording_done_without_instance(hass) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import_tariff_1_missing", + "test:total_energy_import_tariff_2_missing", + ], + "co2_statistic_id": "test:co2_ratio_missing", + "period": "hour", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} + + await client.send_json( + { + "id": 2, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import_tariff_1_missing", + "test:total_energy_import_tariff_2_missing", + ], + "co2_statistic_id": "test:co2_ratio_missing", + "period": "day", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} + + await client.send_json( + { + "id": 3, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import_tariff_1_missing", + "test:total_energy_import_tariff_2_missing", + ], + "co2_statistic_id": "test:co2_ratio_missing", + "period": "month", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} + + +@pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") +async def test_fossil_energy_consumption(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")) + + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "history", {}) + await async_setup_component(hass, "sensor", {}) + + period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) + period2_day_start = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 00:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) + period4_day_start = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 00:00:00")) + + external_energy_statistics_1 = ( + { + "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_energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + external_energy_statistics_2 = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 20, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 30, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 40, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 50, + }, + ) + external_energy_metadata_2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_2", + "unit_of_measurement": "kWh", + } + external_co2_statistics = ( + { + "start": period1, + "last_reset": None, + "mean": 10, + }, + { + "start": period2, + "last_reset": None, + "mean": 30, + }, + { + "start": period3, + "last_reset": None, + "mean": 60, + }, + { + "start": period4, + "last_reset": None, + "mean": 90, + }, + ) + external_co2_metadata = { + "has_mean": True, + "has_sum": False, + "name": "Fossil percentage", + "source": "test", + "statistic_id": "test:fossil_percentage", + "unit_of_measurement": "%", + } + + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_1 + ) + async_add_external_statistics( + hass, external_energy_metadata_2, external_energy_statistics_2 + ) + async_add_external_statistics(hass, external_co2_metadata, external_co2_statistics) + await async_wait_recording_done_without_instance(hass) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import_tariff_1", + "test:total_energy_import_tariff_2", + ], + "co2_statistic_id": "test:fossil_percentage", + "period": "hour", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + period2.isoformat(): pytest.approx((33.0 - 22.0) * 0.3), + period3.isoformat(): pytest.approx((44.0 - 33.0) * 0.6), + period4.isoformat(): pytest.approx((55.0 - 44.0) * 0.9), + } + + await client.send_json( + { + "id": 2, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import_tariff_1", + "test:total_energy_import_tariff_2", + ], + "co2_statistic_id": "test:fossil_percentage", + "period": "day", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + period2_day_start.isoformat(): pytest.approx((33.0 - 22.0) * 0.3), + period3.isoformat(): pytest.approx((44.0 - 33.0) * 0.6), + period4_day_start.isoformat(): pytest.approx((55.0 - 44.0) * 0.9), + } + + await client.send_json( + { + "id": 3, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import_tariff_1", + "test:total_energy_import_tariff_2", + ], + "co2_statistic_id": "test:fossil_percentage", + "period": "month", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + period1.isoformat(): pytest.approx((33.0 - 22.0) * 0.3), + period3.isoformat(): pytest.approx( + ((44.0 - 33.0) * 0.6) + ((55.0 - 44.0) * 0.9) + ), + } + + +@pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") +async def test_fossil_energy_consumption_duplicate(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")) + + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "history", {}) + await async_setup_component(hass, "sensor", {}) + + period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) + period2_day_start = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 00:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) + period4_day_start = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 00:00:00")) + + external_energy_statistics_1 = ( + { + "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, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + }, + ) + external_energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + external_energy_statistics_2 = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 20, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 30, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 40, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 50, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 50, + }, + ) + external_energy_metadata_2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_2", + "unit_of_measurement": "kWh", + } + external_co2_statistics = ( + { + "start": period1, + "last_reset": None, + "mean": 10, + }, + { + "start": period2, + "last_reset": None, + "mean": 30, + }, + { + "start": period3, + "last_reset": None, + "mean": 60, + }, + { + "start": period4, + "last_reset": None, + "mean": 90, + }, + ) + external_co2_metadata = { + "has_mean": True, + "has_sum": False, + "name": "Fossil percentage", + "source": "test", + "statistic_id": "test:fossil_percentage", + "unit_of_measurement": "%", + } + + with patch.object( + statistics, "_statistics_exists", return_value=False + ), patch.object( + statistics, "_insert_statistics", wraps=statistics._insert_statistics + ) as insert_statistics_mock: + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_1 + ) + async_add_external_statistics( + hass, external_energy_metadata_2, external_energy_statistics_2 + ) + async_add_external_statistics( + hass, external_co2_metadata, external_co2_statistics + ) + await async_wait_recording_done_without_instance(hass) + assert insert_statistics_mock.call_count == 14 + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import_tariff_1", + "test:total_energy_import_tariff_2", + ], + "co2_statistic_id": "test:fossil_percentage", + "period": "hour", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + period2.isoformat(): pytest.approx((33.0 - 22.0) * 0.3), + period3.isoformat(): pytest.approx((44.0 - 33.0) * 0.6), + period4.isoformat(): pytest.approx((55.0 - 44.0) * 0.9), + } + + await client.send_json( + { + "id": 2, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import_tariff_1", + "test:total_energy_import_tariff_2", + ], + "co2_statistic_id": "test:fossil_percentage", + "period": "day", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + period2_day_start.isoformat(): pytest.approx((33.0 - 22.0) * 0.3), + period3.isoformat(): pytest.approx((44.0 - 33.0) * 0.6), + period4_day_start.isoformat(): pytest.approx((55.0 - 44.0) * 0.9), + } + + await client.send_json( + { + "id": 3, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import_tariff_1", + "test:total_energy_import_tariff_2", + ], + "co2_statistic_id": "test:fossil_percentage", + "period": "month", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + period1.isoformat(): pytest.approx((33.0 - 22.0) * 0.3), + period3.isoformat(): pytest.approx( + ((44.0 - 33.0) * 0.6) + ((55.0 - 44.0) * 0.9) + ), + } + + +async def test_fossil_energy_consumption_checks(hass, hass_ws_client): + """Test fossil_energy_consumption parameter validation.""" + client = await hass_ws_client(hass) + now = dt_util.utcnow() + + await client.send_json( + { + "id": 1, + "type": "energy/fossil_energy_consumption", + "start_time": "donald_duck", + "end_time": now.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import_tariff_1", + "test:total_energy_import_tariff_2", + ], + "co2_statistic_id": "test:fossil_percentage", + "period": "hour", + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 1 + assert not msg["success"] + assert msg["error"] == { + "code": "invalid_start_time", + "message": "Invalid start_time", + } + + await client.send_json( + { + "id": 2, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": "donald_duck", + "energy_statistic_ids": [ + "test:total_energy_import_tariff_1", + "test:total_energy_import_tariff_2", + ], + "co2_statistic_id": "test:fossil_percentage", + "period": "hour", + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 2 + assert not msg["success"] + assert msg["error"] == {"code": "invalid_end_time", "message": "Invalid end_time"} diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index 6e32cf88975..41a49a7b245 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import httpx from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.components.enphase_envoy.const import DOMAIN from homeassistant.core import HomeAssistant @@ -22,6 +23,91 @@ async def test_form(hass: HomeAssistant) -> None: 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() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Envoy 1234" + assert result2["data"] == { + "host": "1.1.1.1", + "name": "Envoy 1234", + "username": "test-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_no_serial_number(hass: HomeAssistant) -> 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() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Envoy" + assert result2["data"] == { + "host": "1.1.1.1", + "name": "Envoy", + "username": "test-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_fetching_serial_fails(hass: HomeAssistant) -> 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, @@ -124,6 +210,9 @@ async def test_import(hass: HomeAssistant) -> None: 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, @@ -157,10 +246,14 @@ async def test_zeroconf(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "properties": {"serialnum": "1234"}, - "host": "1.1.1.1", - }, + data=zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={"serialnum": "1234"}, + type="mock_type", + ), ) await hass.async_block_till_done() @@ -253,10 +346,14 @@ async def test_zeroconf_serial_already_exists(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "properties": {"serialnum": "1234"}, - "host": "1.1.1.1", - }, + data=zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={"serialnum": "1234"}, + type="mock_type", + ), ) assert result["type"] == "abort" @@ -288,10 +385,14 @@ async def test_zeroconf_host_already_exists(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "properties": {"serialnum": "1234"}, - "host": "1.1.1.1", - }, + data=zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={"serialnum": "1234"}, + type="mock_type", + ), ) await hass.async_block_till_done() diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py new file mode 100644 index 00000000000..91368044723 --- /dev/null +++ b/tests/components/esphome/conftest.py @@ -0,0 +1,8 @@ +"""esphome session fixtures.""" + +import pytest + + +@pytest.fixture(autouse=True) +def esphome_mock_async_zeroconf(mock_async_zeroconf): + """Auto mock zeroconf.""" diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 6a96e88cab0..25103c4fe2a 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -12,6 +12,7 @@ from aioesphomeapi import ( import pytest from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN, DomainData from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.data_entry_flow import ( @@ -215,12 +216,14 @@ async def test_discovery_initiation(hass, mock_client, mock_zeroconf): """Test discovery importing works.""" mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(False, "test8266")) - service_info = { - "host": "192.168.43.183", - "port": 6053, - "hostname": "test8266.local.", - "properties": {}, - } + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.43.183", + hostname="test8266.local.", + name="mock_name", + port=6053, + properties={}, + type="mock_type", + ) flow = await hass.config_entries.flow.async_init( "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) @@ -247,12 +250,14 @@ async def test_discovery_already_configured_hostname(hass, mock_client): entry.add_to_hass(hass) - service_info = { - "host": "192.168.43.183", - "port": 6053, - "hostname": "test8266.local.", - "properties": {}, - } + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.43.183", + hostname="test8266.local.", + name="mock_name", + port=6053, + properties={}, + type="mock_type", + ) result = await hass.config_entries.flow.async_init( "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) @@ -272,12 +277,14 @@ async def test_discovery_already_configured_ip(hass, mock_client): entry.add_to_hass(hass) - service_info = { - "host": "192.168.43.183", - "port": 6053, - "hostname": "test8266.local.", - "properties": {"address": "192.168.43.183"}, - } + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.43.183", + hostname="test8266.local.", + name="mock_name", + port=6053, + properties={"address": "192.168.43.183"}, + type="mock_type", + ) result = await hass.config_entries.flow.async_init( "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) @@ -301,12 +308,14 @@ async def test_discovery_already_configured_name(hass, mock_client): domain_data = DomainData.get(hass) domain_data.set_entry_data(entry, mock_entry_data) - service_info = { - "host": "192.168.43.184", - "port": 6053, - "hostname": "test8266.local.", - "properties": {"address": "test8266.local"}, - } + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.43.184", + hostname="test8266.local.", + name="mock_name", + port=6053, + properties={"address": "test8266.local"}, + type="mock_type", + ) result = await hass.config_entries.flow.async_init( "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) @@ -320,12 +329,14 @@ async def test_discovery_already_configured_name(hass, mock_client): async def test_discovery_duplicate_data(hass, mock_client): """Test discovery aborts if same mDNS packet arrives.""" - service_info = { - "host": "192.168.43.183", - "port": 6053, - "hostname": "test8266.local.", - "properties": {"address": "test8266.local"}, - } + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.43.183", + hostname="test8266.local.", + name="mock_name", + port=6053, + properties={"address": "test8266.local"}, + type="mock_type", + ) mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(False, "test8266")) @@ -351,12 +362,14 @@ async def test_discovery_updates_unique_id(hass, mock_client): entry.add_to_hass(hass) - service_info = { - "host": "192.168.43.183", - "port": 6053, - "hostname": "test8266.local.", - "properties": {"address": "test8266.local"}, - } + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.43.183", + hostname="test8266.local.", + name="mock_name", + port=6053, + properties={"address": "test8266.local"}, + type="mock_type", + ) result = await hass.config_entries.flow.async_init( "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) diff --git a/tests/components/evil_genius_labs/__init__.py b/tests/components/evil_genius_labs/__init__.py new file mode 100644 index 00000000000..70b122eb460 --- /dev/null +++ b/tests/components/evil_genius_labs/__init__.py @@ -0,0 +1 @@ +"""Tests for the Evil Genius Labs integration.""" diff --git a/tests/components/evil_genius_labs/conftest.py b/tests/components/evil_genius_labs/conftest.py new file mode 100644 index 00000000000..063e31704a5 --- /dev/null +++ b/tests/components/evil_genius_labs/conftest.py @@ -0,0 +1,49 @@ +"""Test helpers for Evil Genius Labs.""" +import json +from unittest.mock import patch + +import pytest + +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(scope="session") +def data_fixture(): + """Fixture data.""" + data = json.loads(load_fixture("data.json", "evil_genius_labs")) + return {item["name"]: item for item in data} + + +@pytest.fixture(scope="session") +def info_fixture(): + """Fixture info.""" + return json.loads(load_fixture("info.json", "evil_genius_labs")) + + +@pytest.fixture +def config_entry(hass): + """Evil genius labs config entry.""" + entry = MockConfigEntry(domain="evil_genius_labs", data={"host": "192.168.1.113"}) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def setup_evil_genius_labs( + hass, config_entry, data_fixture, info_fixture, platforms +): + """Test up Evil Genius Labs instance.""" + with patch( + "pyevilgenius.EvilGeniusDevice.get_data", + return_value=data_fixture, + ), patch( + "pyevilgenius.EvilGeniusDevice.get_info", + return_value=info_fixture, + ), patch( + "homeassistant.components.evil_genius_labs.PLATFORMS", platforms + ): + assert await async_setup_component(hass, "evil_genius_labs", {}) + await hass.async_block_till_done() + yield diff --git a/tests/components/evil_genius_labs/fixtures/data.json b/tests/components/evil_genius_labs/fixtures/data.json new file mode 100644 index 00000000000..555fe38da7e --- /dev/null +++ b/tests/components/evil_genius_labs/fixtures/data.json @@ -0,0 +1,331 @@ +[ + { + "name": "name", + "label": "Name", + "type": "Label", + "value": "Fibonacci256-23D4" + }, + { "name": "power", "label": "Power", "type": "Boolean", "value": 1 }, + { + "name": "brightness", + "label": "Brightness", + "type": "Number", + "value": 128, + "min": 1, + "max": 255 + }, + { + "name": "pattern", + "label": "Pattern", + "type": "Select", + "value": 70, + "options": [ + "Pride", + "Pride Fibonacci", + "Color Waves", + "Color Waves Fibonacci", + "Pride Playground", + "Pride Playground Fibonacci", + "Color Waves Playground", + "Color Waves Playground Fibonacci", + "Wheel", + "Swirl Fibonacci", + "Fire Fibonacci", + "Water Fibonacci", + "Emitter Fibonacci", + "Pacifica", + "Pacifica Fibonacci", + "Angle Palette", + "Radius Palette", + "X Axis Palette", + "Y Axis Palette", + "XY Axis Palette", + "Angle Gradient Palette", + "Radius Gradient Palette", + "X Axis Gradient Palette", + "Y Axis Gradient Palette", + "XY Axis Gradient Palette", + "Fire Noise", + "Fire Noise 2", + "Lava Noise", + "Rainbow Noise", + "Rainbow Stripe Noise", + "Party Noise", + "Forest Noise", + "Cloud Noise", + "Ocean Noise", + "Black & White Noise", + "Black & Blue Noise", + "Analog Clock", + "Spiral Analog Clock 13", + "Spiral Analog Clock 21", + "Spiral Analog Clock 34", + "Spiral Analog Clock 55", + "Spiral Analog Clock 89", + "Spiral Analog Clock 21 & 34", + "Spiral Analog Clock 13, 21 & 34", + "Spiral Analog Clock 34, 21 & 13", + "Pride Playground", + "Color Waves Playground", + "Rainbow Twinkles", + "Snow Twinkles", + "Cloud Twinkles", + "Incandescent Twinkles", + "Retro C9 Twinkles", + "Red & White Twinkles", + "Blue & White Twinkles", + "Red, Green & White Twinkles", + "Fairy Light Twinkles", + "Snow 2 Twinkles", + "Holly Twinkles", + "Ice Twinkles", + "Party Twinkles", + "Forest Twinkles", + "Lava Twinkles", + "Fire Twinkles", + "Cloud 2 Twinkles", + "Ocean Twinkles", + "Rainbow", + "Rainbow With Glitter", + "Solid Rainbow", + "Confetti", + "Sinelon", + "Beat", + "Juggle", + "Fire", + "Water", + "Strand Test", + "Solid Color" + ] + }, + { + "name": "palette", + "label": "Palette", + "type": "Select", + "value": 0, + "options": [ + "Rainbow", + "Rainbow Stripe", + "Cloud", + "Lava", + "Ocean", + "Forest", + "Party", + "Heat" + ] + }, + { + "name": "speed", + "label": "Speed", + "type": "Number", + "value": 30, + "min": 1, + "max": 255 + }, + { "name": "autoplaySection", "label": "Autoplay", "type": "Section" }, + { "name": "autoplay", "label": "Autoplay", "type": "Boolean", "value": 0 }, + { + "name": "autoplayDuration", + "label": "Autoplay Duration", + "type": "Number", + "value": 10, + "min": 0, + "max": 255 + }, + { "name": "clock", "label": "Clock", "type": "Section" }, + { "name": "showClock", "label": "Show Clock", "type": "Boolean", "value": 0 }, + { + "name": "clockBackgroundFade", + "label": "Background Fade", + "type": "Number", + "value": 240, + "min": 0, + "max": 255 + }, + { "name": "solidColorSection", "label": "Solid Color", "type": "Section" }, + { + "name": "solidColor", + "label": "Color", + "type": "Color", + "value": "0,0,255" + }, + { "name": "prideSection", "label": "Pride & ColorWaves", "type": "Section" }, + { + "name": "saturationBpm", + "label": "Saturation BPM", + "type": "Number", + "value": 87, + "min": 0, + "max": 255 + }, + { + "name": "saturationMin", + "label": "Saturation Min", + "type": "Number", + "value": 220, + "min": 0, + "max": 255 + }, + { + "name": "saturationMax", + "label": "Saturation Max", + "type": "Number", + "value": 250, + "min": 0, + "max": 255 + }, + { + "name": "brightDepthBpm", + "label": "Brightness Depth BPM", + "type": "Number", + "value": 1, + "min": 0, + "max": 255 + }, + { + "name": "brightDepthMin", + "label": "Brightness Depth Min", + "type": "Number", + "value": 96, + "min": 0, + "max": 255 + }, + { + "name": "brightDepthMax", + "label": "Brightness Depth Max", + "type": "Number", + "value": 224, + "min": 0, + "max": 255 + }, + { + "name": "brightThetaIncBpm", + "label": "Bright Theta Inc BPM", + "type": "Number", + "value": 203, + "min": 0, + "max": 255 + }, + { + "name": "brightThetaIncMin", + "label": "Bright Theta Inc Min", + "type": "Number", + "value": 25, + "min": 0, + "max": 255 + }, + { + "name": "brightThetaIncMax", + "label": "Bright Theta Inc Max", + "type": "Number", + "value": 40, + "min": 0, + "max": 255 + }, + { + "name": "msMultiplierBpm", + "label": "Time Multiplier BPM", + "type": "Number", + "value": 147, + "min": 0, + "max": 255 + }, + { + "name": "msMultiplierMin", + "label": "Time Multiplier Min", + "type": "Number", + "value": 23, + "min": 0, + "max": 255 + }, + { + "name": "msMultiplierMax", + "label": "Time Multiplier Max", + "type": "Number", + "value": 60, + "min": 0, + "max": 255 + }, + { + "name": "hueIncBpm", + "label": "Hue Inc BPM", + "type": "Number", + "value": 113, + "min": 0, + "max": 255 + }, + { + "name": "hueIncMin", + "label": "Hue Inc Min", + "type": "Number", + "value": 1, + "min": 0, + "max": 255 + }, + { + "name": "hueIncMax", + "label": "Hue Inc Max", + "type": "Number", + "value": 12, + "min": 0, + "max": 255 + }, + { + "name": "sHueBpm", + "label": "S Hue BPM", + "type": "Number", + "value": 2, + "min": 0, + "max": 255 + }, + { + "name": "sHueMin", + "label": "S Hue Min", + "type": "Number", + "value": 5, + "min": 0, + "max": 255 + }, + { + "name": "sHueMax", + "label": "S Hue Max", + "type": "Number", + "value": 9, + "min": 0, + "max": 255 + }, + { "name": "fireSection", "label": "Fire & Water", "type": "Section" }, + { + "name": "cooling", + "label": "Cooling", + "type": "Number", + "value": 49, + "min": 0, + "max": 255 + }, + { + "name": "sparking", + "label": "Sparking", + "type": "Number", + "value": 60, + "min": 0, + "max": 255 + }, + { "name": "twinklesSection", "label": "Twinkles", "type": "Section" }, + { + "name": "twinkleSpeed", + "label": "Twinkle Speed", + "type": "Number", + "value": 4, + "min": 0, + "max": 8 + }, + { + "name": "twinkleDensity", + "label": "Twinkle Density", + "type": "Number", + "value": 5, + "min": 0, + "max": 8 + } +] diff --git a/tests/components/evil_genius_labs/fixtures/info.json b/tests/components/evil_genius_labs/fixtures/info.json new file mode 100644 index 00000000000..c11ab369316 --- /dev/null +++ b/tests/components/evil_genius_labs/fixtures/info.json @@ -0,0 +1,30 @@ +{ + "millis": 62099724, + "vcc": 3005, + "wiFiChipId": "1923d4", + "flashChipId": "1640d8", + "flashChipSize": 4194304, + "flashChipRealSize": 4194304, + "sdkVersion": "2.2.2-dev(38a443e)", + "coreVersion": "2_7_4", + "bootVersion": 6, + "cpuFreqMHz": 160, + "freeHeap": 21936, + "sketchSize": 476352, + "freeSketchSpace": 1617920, + "resetReason": "External System", + "isConnected": true, + "wiFiSsidDefault": "My Wi-Fi", + "wiFiSSID": "My Wi-Fi", + "localIP": "192.168.1.113", + "gatewayIP": "192.168.1.1", + "subnetMask": "255.255.255.0", + "dnsIP": "192.168.1.1", + "hostname": "ESP-1923D4", + "macAddress": "BC:FF:4D:19:23:D4", + "autoConnect": true, + "softAPSSID": "FaryLink_1923D4", + "softAPIP": "(IP unset)", + "BSSID": "FC:EC:DA:77:1A:CE", + "softAPmacAddress": "BE:FF:4D:19:23:D4" +} diff --git a/tests/components/evil_genius_labs/test_config_flow.py b/tests/components/evil_genius_labs/test_config_flow.py new file mode 100644 index 00000000000..55e207ba7e0 --- /dev/null +++ b/tests/components/evil_genius_labs/test_config_flow.py @@ -0,0 +1,85 @@ +"""Test the Evil Genius Labs config flow.""" +from unittest.mock import patch + +import aiohttp + +from homeassistant import config_entries +from homeassistant.components.evil_genius_labs.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +async def test_form(hass: HomeAssistant, data_fixture, info_fixture) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "pyevilgenius.EvilGeniusDevice.get_data", + return_value=data_fixture, + ), patch( + "pyevilgenius.EvilGeniusDevice.get_info", + return_value=info_fixture, + ), patch( + "homeassistant.components.evil_genius_labs.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", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Fibonacci256-23D4" + assert result2["data"] == { + "host": "1.1.1.1", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyevilgenius.EvilGeniusDevice.get_data", + side_effect=aiohttp.ClientError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyevilgenius.EvilGeniusDevice.get_data", + side_effect=ValueError("BOOM"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/evil_genius_labs/test_init.py b/tests/components/evil_genius_labs/test_init.py new file mode 100644 index 00000000000..c0c7d00e1e5 --- /dev/null +++ b/tests/components/evil_genius_labs/test_init.py @@ -0,0 +1,13 @@ +"""Test evil genius labs init.""" +import pytest + +from homeassistant import config_entries +from homeassistant.components.evil_genius_labs import PLATFORMS + + +@pytest.mark.parametrize("platforms", [PLATFORMS]) +async def test_setup_unload_entry(hass, setup_evil_genius_labs, config_entry): + """Test setting up and unloading a config entry.""" + assert len(hass.states.async_entity_ids()) == 1 + assert await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state == config_entries.ConfigEntryState.NOT_LOADED diff --git a/tests/components/evil_genius_labs/test_light.py b/tests/components/evil_genius_labs/test_light.py new file mode 100644 index 00000000000..de053c58037 --- /dev/null +++ b/tests/components/evil_genius_labs/test_light.py @@ -0,0 +1,76 @@ +"""Test Evil Genius Labs light.""" +from unittest.mock import patch + +import pytest + + +@pytest.mark.parametrize("platforms", [("light",)]) +async def test_works(hass, setup_evil_genius_labs): + """Test it works.""" + state = hass.states.get("light.fibonacci256_23d4") + assert state is not None + assert state.state == "on" + assert state.attributes["brightness"] == 128 + + +@pytest.mark.parametrize("platforms", [("light",)]) +async def test_turn_on_color(hass, setup_evil_genius_labs): + """Test turning on with a color.""" + with patch( + "pyevilgenius.EvilGeniusDevice.set_path_value" + ) as mock_set_path_value, patch( + "pyevilgenius.EvilGeniusDevice.set_rgb_color" + ) as mock_set_rgb_color: + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.fibonacci256_23d4", + "brightness": 100, + "rgb_color": (10, 20, 30), + }, + blocking=True, + ) + + assert len(mock_set_path_value.mock_calls) == 2 + mock_set_path_value.mock_calls[0][1] == ("brightness", 100) + mock_set_path_value.mock_calls[1][1] == ("power", 1) + + assert len(mock_set_rgb_color.mock_calls) == 1 + mock_set_rgb_color.mock_calls[0][1] == (10, 20, 30) + + +@pytest.mark.parametrize("platforms", [("light",)]) +async def test_turn_on_effect(hass, setup_evil_genius_labs): + """Test turning on with an effect.""" + with patch("pyevilgenius.EvilGeniusDevice.set_path_value") as mock_set_path_value: + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.fibonacci256_23d4", + "effect": "Pride Playground", + }, + blocking=True, + ) + + assert len(mock_set_path_value.mock_calls) == 2 + mock_set_path_value.mock_calls[0][1] == ("pattern", 4) + mock_set_path_value.mock_calls[1][1] == ("power", 1) + + +@pytest.mark.parametrize("platforms", [("light",)]) +async def test_turn_off(hass, setup_evil_genius_labs): + """Test turning off.""" + with patch("pyevilgenius.EvilGeniusDevice.set_path_value") as mock_set_path_value: + await hass.services.async_call( + "light", + "turn_off", + { + "entity_id": "light.fibonacci256_23d4", + }, + blocking=True, + ) + + assert len(mock_set_path_value.mock_calls) == 1 + mock_set_path_value.mock_calls[0][1] == ("power", 0) diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index 05ced3b8be7..3167cb16e67 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -65,3 +65,22 @@ async def test_async_fanentity(hass): await fan.async_increase_speed() with pytest.raises(NotImplementedError): await fan.async_decrease_speed() + + +@pytest.mark.parametrize( + "attribute_name, attribute_value", + [ + ("current_direction", "forward"), + ("oscillating", True), + ("percentage", 50), + ("preset_mode", "medium"), + ("preset_modes", ["low", "medium", "high"]), + ("speed_count", 50), + ("supported_features", 1), + ], +) +def test_fanentity_attributes(attribute_name, attribute_value): + """Test fan entity attribute shorthand.""" + fan = BaseFan() + setattr(fan, f"_attr_{attribute_name}", attribute_value) + assert getattr(fan, attribute_name) == attribute_value diff --git a/tests/fixtures/filesize/configuration.yaml b/tests/components/filesize/fixtures/configuration.yaml similarity index 100% rename from tests/fixtures/filesize/configuration.yaml rename to tests/components/filesize/fixtures/configuration.yaml diff --git a/tests/components/filesize/test_sensor.py b/tests/components/filesize/test_sensor.py index 8bb79a8088a..72d0d112f17 100644 --- a/tests/components/filesize/test_sensor.py +++ b/tests/components/filesize/test_sensor.py @@ -10,6 +10,8 @@ from homeassistant.components.filesize.sensor import CONF_FILE_PATHS from homeassistant.const import SERVICE_RELOAD from homeassistant.setup import async_setup_component +from tests.common import get_fixture_path + TEST_DIR = os.path.join(os.path.dirname(__file__)) TEST_FILE = os.path.join(TEST_DIR, "mock_file_test_filesize.txt") @@ -70,11 +72,7 @@ async def test_reload(hass, tmpdir): assert hass.states.get("sensor.file") - yaml_path = os.path.join( - _get_fixtures_base_path(), - "fixtures", - "filesize/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "filesize") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path), patch.object( hass.config, "is_allowed_path", return_value=True ): @@ -87,7 +85,3 @@ async def test_reload(hass, tmpdir): await hass.async_block_till_done() assert hass.states.get("sensor.file") is None - - -def _get_fixtures_base_path(): - return os.path.dirname(os.path.dirname(os.path.dirname(__file__))) diff --git a/tests/fixtures/filter/configuration.yaml b/tests/components/filter/fixtures/configuration.yaml similarity index 100% rename from tests/fixtures/filter/configuration.yaml rename to tests/components/filter/fixtures/configuration.yaml diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index 89e8758c661..ec831b79670 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -1,6 +1,5 @@ """The test for the data filter sensor platform.""" from datetime import timedelta -from os import path from unittest.mock import patch from pytest import fixture @@ -30,7 +29,11 @@ import homeassistant.core as ha from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import assert_setup_component, async_init_recorder_component +from tests.common import ( + assert_setup_component, + async_init_recorder_component, + get_fixture_path, +) @fixture @@ -184,11 +187,7 @@ async def test_source_state_none(hass, values): assert state.state == "0.0" # Force Template Reload - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "template/sensor_configuration.yaml", - ) + yaml_path = get_fixture_path("sensor_configuration.yaml", "template") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( "template", @@ -478,11 +477,8 @@ async def test_reload(hass): assert hass.states.get("sensor.test") - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "filter/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "filter") + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( DOMAIN, @@ -496,7 +492,3 @@ async def test_reload(hass): assert hass.states.get("sensor.test") is None assert hass.states.get("sensor.filtered_realistic_humidity") - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/components/flo/conftest.py b/tests/components/flo/conftest.py index 6a82c0f4791..1484b10eae2 100644 --- a/tests/components/flo/conftest.py +++ b/tests/components/flo/conftest.py @@ -44,6 +44,13 @@ def aioclient_mock_fixture(aioclient_mock): headers={"Content-Type": CONTENT_TYPE_JSON}, status=HTTPStatus.OK, ) + # Mocks the presence ping response for flo. + aioclient_mock.post( + "https://api-gw.meetflo.com/api/v2/presence/me", + text=load_fixture("flo/ping_response.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + status=HTTPStatus.OK, + ) # Mocks the devices for flo. aioclient_mock.get( "https://api-gw.meetflo.com/api/v2/devices/98765", diff --git a/tests/fixtures/flo/device_info_response.json b/tests/components/flo/fixtures/device_info_response.json similarity index 100% rename from tests/fixtures/flo/device_info_response.json rename to tests/components/flo/fixtures/device_info_response.json diff --git a/tests/fixtures/flo/device_info_response_closed.json b/tests/components/flo/fixtures/device_info_response_closed.json similarity index 100% rename from tests/fixtures/flo/device_info_response_closed.json rename to tests/components/flo/fixtures/device_info_response_closed.json diff --git a/tests/fixtures/flo/device_info_response_detector.json b/tests/components/flo/fixtures/device_info_response_detector.json similarity index 100% rename from tests/fixtures/flo/device_info_response_detector.json rename to tests/components/flo/fixtures/device_info_response_detector.json diff --git a/tests/fixtures/flo/location_info_base_response.json b/tests/components/flo/fixtures/location_info_base_response.json similarity index 100% rename from tests/fixtures/flo/location_info_base_response.json rename to tests/components/flo/fixtures/location_info_base_response.json diff --git a/tests/fixtures/flo/location_info_expand_devices_response.json b/tests/components/flo/fixtures/location_info_expand_devices_response.json similarity index 100% rename from tests/fixtures/flo/location_info_expand_devices_response.json rename to tests/components/flo/fixtures/location_info_expand_devices_response.json diff --git a/tests/components/flo/fixtures/ping_response.json b/tests/components/flo/fixtures/ping_response.json new file mode 100644 index 00000000000..950519289e6 --- /dev/null +++ b/tests/components/flo/fixtures/ping_response.json @@ -0,0 +1,8 @@ +{ + "ipAddress": "11.111.111.111", + "userId": "12345abcde", + "action": "report", + "type": "user", + "appName": "legacy", + "userData": { "account": { "type": "personal" } } +} diff --git a/tests/fixtures/flo/user_info_base_response.json b/tests/components/flo/fixtures/user_info_base_response.json similarity index 100% rename from tests/fixtures/flo/user_info_base_response.json rename to tests/components/flo/fixtures/user_info_base_response.json diff --git a/tests/fixtures/flo/user_info_expand_locations_response.json b/tests/components/flo/fixtures/user_info_expand_locations_response.json similarity index 100% rename from tests/fixtures/flo/user_info_expand_locations_response.json rename to tests/components/flo/fixtures/user_info_expand_locations_response.json diff --git a/tests/fixtures/flo/water_consumption_info_response.json b/tests/components/flo/fixtures/water_consumption_info_response.json similarity index 100% rename from tests/fixtures/flo/water_consumption_info_response.json rename to tests/components/flo/fixtures/water_consumption_info_response.json diff --git a/tests/components/flo/test_device.py b/tests/components/flo/test_device.py index 5ac31a2bff9..3e08d289aef 100644 --- a/tests/components/flo/test_device.py +++ b/tests/components/flo/test_device.py @@ -1,9 +1,14 @@ """Define tests for device-related endpoints.""" from datetime import timedelta +from unittest.mock import patch + +from aioflo.errors import RequestError +import pytest from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN from homeassistant.components.flo.device import FloDeviceDataUpdateCoordinator from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component from homeassistant.util import dt @@ -67,10 +72,19 @@ async def test_device(hass, config_entry, aioclient_mock_fixture, aioclient_mock assert detector.model == "puck_v1" assert detector.manufacturer == "Flo by Moen" assert detector.device_name == "Kitchen Sink" + assert detector.serial_number == "111111111112" call_count = aioclient_mock.call_count async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=90)) await hass.async_block_till_done() - assert aioclient_mock.call_count == call_count + 4 + assert aioclient_mock.call_count == call_count + 6 + + # test error sending device ping + with patch( + "homeassistant.components.flo.device.FloDeviceDataUpdateCoordinator.send_presence_ping", + side_effect=RequestError, + ): + with pytest.raises(UpdateFailed): + await valve._async_update_data() diff --git a/tests/components/flo/test_sensor.py b/tests/components/flo/test_sensor.py index f1572dae02c..ec044286b0d 100644 --- a/tests/components/flo/test_sensor.py +++ b/tests/components/flo/test_sensor.py @@ -50,4 +50,4 @@ async def test_manual_update_entity( {ATTR_ENTITY_ID: ["sensor.current_system_mode"]}, blocking=True, ) - assert aioclient_mock.call_count == call_count + 2 + assert aioclient_mock.call_count == call_count + 3 diff --git a/tests/components/flo/test_services.py b/tests/components/flo/test_services.py index 4941a118e48..699bd97ebed 100644 --- a/tests/components/flo/test_services.py +++ b/tests/components/flo/test_services.py @@ -26,7 +26,7 @@ async def test_services(hass, config_entry, aioclient_mock_fixture, aioclient_mo await hass.async_block_till_done() assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2 - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 8 await hass.services.async_call( FLO_DOMAIN, @@ -35,7 +35,7 @@ async def test_services(hass, config_entry, aioclient_mock_fixture, aioclient_mo blocking=True, ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 7 + assert aioclient_mock.call_count == 9 await hass.services.async_call( FLO_DOMAIN, @@ -44,7 +44,7 @@ async def test_services(hass, config_entry, aioclient_mock_fixture, aioclient_mo blocking=True, ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 8 + assert aioclient_mock.call_count == 10 await hass.services.async_call( FLO_DOMAIN, @@ -53,7 +53,7 @@ async def test_services(hass, config_entry, aioclient_mock_fixture, aioclient_mo blocking=True, ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 9 + assert aioclient_mock.call_count == 11 await hass.services.async_call( FLO_DOMAIN, @@ -66,4 +66,4 @@ async def test_services(hass, config_entry, aioclient_mock_fixture, aioclient_mo blocking=True, ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 10 + assert aioclient_mock.call_count == 12 diff --git a/tests/components/flux_led/__init__.py b/tests/components/flux_led/__init__.py index 8f484ac1989..6bfe02990a2 100644 --- a/tests/components/flux_led/__init__.py +++ b/tests/components/flux_led/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations import asyncio +from contextlib import contextmanager +import datetime from typing import Callable from unittest.mock import AsyncMock, MagicMock, patch @@ -11,31 +13,51 @@ from flux_led.const import ( COLOR_MODE_CCT as FLUX_COLOR_MODE_CCT, COLOR_MODE_RGB as FLUX_COLOR_MODE_RGB, ) +from flux_led.models_db import MODEL_MAP from flux_led.protocol import LEDENETRawState +from flux_led.scanner import FluxLEDDiscovery -from homeassistant.components.dhcp import ( - HOSTNAME as DHCP_HOSTNAME, - IP_ADDRESS as DHCP_IP_ADDRESS, - MAC_ADDRESS as DHCP_MAC_ADDRESS, -) -from homeassistant.components.flux_led.const import FLUX_HOST, FLUX_MAC, FLUX_MODEL +from homeassistant.components import dhcp from homeassistant.core import HomeAssistant MODULE = "homeassistant.components.flux_led" MODULE_CONFIG_FLOW = "homeassistant.components.flux_led.config_flow" IP_ADDRESS = "127.0.0.1" +MODEL_NUM_HEX = "0x35" MODEL = "AZ120444" +MODEL_DESCRIPTION = "Bulb RGBCW" MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" FLUX_MAC_ADDRESS = "aabbccddeeff" +SHORT_MAC_ADDRESS = "ddeeff" -DEFAULT_ENTRY_TITLE = f"{MODEL} {FLUX_MAC_ADDRESS}" +DEFAULT_ENTRY_TITLE = f"{MODEL_DESCRIPTION} {SHORT_MAC_ADDRESS}" -DHCP_DISCOVERY = { - DHCP_HOSTNAME: MODEL, - DHCP_IP_ADDRESS: IP_ADDRESS, - DHCP_MAC_ADDRESS: MAC_ADDRESS, -} -FLUX_DISCOVERY = {FLUX_HOST: IP_ADDRESS, FLUX_MODEL: MODEL, FLUX_MAC: FLUX_MAC_ADDRESS} + +DHCP_DISCOVERY = dhcp.DhcpServiceInfo( + hostname=MODEL, + ip=IP_ADDRESS, + macaddress=MAC_ADDRESS, +) +FLUX_DISCOVERY_PARTIAL = FluxLEDDiscovery( + ipaddr=IP_ADDRESS, + model=MODEL, + id=FLUX_MAC_ADDRESS, + model_num=None, + version_num=None, + firmware_date=None, + model_info=None, + model_description=None, +) +FLUX_DISCOVERY = FluxLEDDiscovery( + ipaddr=IP_ADDRESS, + model=MODEL, + id=FLUX_MAC_ADDRESS, + model_num=0x25, + version_num=0x04, + firmware_date=datetime.date(2021, 5, 5), + model_info=MODEL, + model_description=MODEL_DESCRIPTION, +) def _mocked_bulb() -> AIOWifiLedBulb: @@ -45,10 +67,14 @@ def _mocked_bulb() -> AIOWifiLedBulb: bulb.data_receive_callback = callback bulb.device_type = DeviceType.Bulb + bulb.requires_turn_on = True bulb.async_setup = AsyncMock(side_effect=_save_setup_callback) + bulb.effect_list = ["some_effect"] bulb.async_set_custom_pattern = AsyncMock() bulb.async_set_preset_pattern = AsyncMock() + bulb.async_set_effect = AsyncMock() bulb.async_set_white_temp = AsyncMock() + bulb.async_set_brightness = AsyncMock() bulb.async_stop = AsyncMock() bulb.async_update = AsyncMock() bulb.async_turn_off = AsyncMock() @@ -61,6 +87,7 @@ def _mocked_bulb() -> AIOWifiLedBulb: bulb.getRgbww = MagicMock(return_value=[255, 0, 0, 50, 0]) bulb.getRgbcw = MagicMock(return_value=[255, 0, 0, 0, 50]) bulb.rgb = (255, 0, 0) + bulb.rgb_unscaled = (255, 0, 0) bulb.rgbw = (255, 0, 0, 50) bulb.rgbww = (255, 0, 0, 50, 0) bulb.rgbcw = (255, 0, 0, 0, 50) @@ -68,8 +95,12 @@ def _mocked_bulb() -> AIOWifiLedBulb: bulb.getWhiteTemperature = MagicMock(return_value=(2700, 128)) bulb.brightness = 128 bulb.model_num = 0x35 - bulb.model = "Smart Bulb (0x35)" + bulb.model_data = MODEL_MAP[0x35] + bulb.effect = None + bulb.speed = 50 + bulb.model = "Bulb RGBCW (0x35)" bulb.version_num = 8 + bulb.speed_adjust_off = True bulb.rgbwcapable = True bulb.color_modes = {FLUX_COLOR_MODE_RGB, FLUX_COLOR_MODE_CCT} bulb.color_mode = FLUX_COLOR_MODE_RGB @@ -86,13 +117,15 @@ def _mocked_switch() -> AIOWifiLedBulb: switch.data_receive_callback = callback switch.device_type = DeviceType.Switch + switch.requires_turn_on = True switch.async_setup = AsyncMock(side_effect=_save_setup_callback) switch.async_stop = AsyncMock() switch.async_update = AsyncMock() switch.async_turn_off = AsyncMock() switch.async_turn_on = AsyncMock() switch.model_num = 0x97 - switch.model = "Smart Switch (0x97)" + switch.model_data = MODEL_MAP[0x97] + switch.model = "Switch (0x97)" switch.version_num = 0x97 switch.raw_state = LEDENETRawState( 0, 0x97, 0, 0x61, 0x97, 50, 255, 0, 0, 50, 8, 0, 0, 0 @@ -116,15 +149,34 @@ async def async_mock_device_turn_on(hass: HomeAssistant, bulb: AIOWifiLedBulb) - await hass.async_block_till_done() +async def async_mock_effect_speed( + hass: HomeAssistant, bulb: AIOWifiLedBulb, effect: str, speed: int +) -> None: + """Mock the device being on with an effect.""" + bulb.speed = speed + bulb.effect = effect + bulb.data_receive_callback() + await hass.async_block_till_done() + + def _patch_discovery(device=None, no_device=False): async def _discovery(*args, **kwargs): if no_device: raise OSError - return [FLUX_DISCOVERY] + return [] if no_device else [device or FLUX_DISCOVERY] - return patch( - "homeassistant.components.flux_led.AIOBulbScanner.async_scan", new=_discovery - ) + @contextmanager + def _patcher(): + with patch( + "homeassistant.components.flux_led.AIOBulbScanner.async_scan", + new=_discovery, + ), patch( + "homeassistant.components.flux_led.AIOBulbScanner.getBulbInfo", + return_value=[] if no_device else [device or FLUX_DISCOVERY], + ): + yield + + return _patcher() def _patch_wifibulb(device=None, no_device=False): diff --git a/tests/components/flux_led/conftest.py b/tests/components/flux_led/conftest.py new file mode 100644 index 00000000000..abac297da2d --- /dev/null +++ b/tests/components/flux_led/conftest.py @@ -0,0 +1,11 @@ +"""Tests for the flux_led integration.""" + +import pytest + +from tests.common import mock_device_registry + + +@pytest.fixture(name="device_reg") +def device_reg_fixture(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) diff --git a/tests/components/flux_led/test_config_flow.py b/tests/components/flux_led/test_config_flow.py index 582823439f6..a546120ae41 100644 --- a/tests/components/flux_led/test_config_flow.py +++ b/tests/components/flux_led/test_config_flow.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from homeassistant import config_entries +from homeassistant.components import dhcp from homeassistant.components.flux_led.const import ( CONF_CUSTOM_EFFECT_COLORS, CONF_CUSTOM_EFFECT_SPEED_PCT, @@ -29,10 +30,8 @@ from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM from . import ( DEFAULT_ENTRY_TITLE, DHCP_DISCOVERY, - DHCP_HOSTNAME, - DHCP_IP_ADDRESS, - DHCP_MAC_ADDRESS, FLUX_DISCOVERY, + FLUX_DISCOVERY_PARTIAL, IP_ADDRESS, MAC_ADDRESS, MODULE, @@ -42,6 +41,8 @@ from . import ( from tests.common import MockConfigEntry +MAC_ADDRESS_DIFFERENT = "ff:bb:ff:dd:ee:ff" + async def test_discovery(hass: HomeAssistant): """Test setting up discovery.""" @@ -341,30 +342,25 @@ async def test_discovered_by_discovery_and_dhcp(hass): result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - DHCP_HOSTNAME: "any", - DHCP_IP_ADDRESS: IP_ADDRESS, - DHCP_MAC_ADDRESS: "00:00:00:00:00:00", - }, + data=dhcp.DhcpServiceInfo( + hostname="any", + ip=IP_ADDRESS, + macaddress="00:00:00:00:00:00", + ), ) await hass.async_block_till_done() assert result3["type"] == RESULT_TYPE_ABORT assert result3["reason"] == "already_in_progress" -@pytest.mark.parametrize( - "source, data", - [ - (config_entries.SOURCE_DHCP, DHCP_DISCOVERY), - (config_entries.SOURCE_DISCOVERY, FLUX_DISCOVERY), - ], -) -async def test_discovered_by_dhcp_or_discovery(hass, source, data): - """Test we can setup when discovered from dhcp or discovery.""" +async def test_discovered_by_discovery(hass): + """Test we can setup when discovered from discovery.""" with _patch_discovery(), _patch_wifibulb(): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": source}, data=data + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data=FLUX_DISCOVERY, ) await hass.async_block_till_done() @@ -385,6 +381,103 @@ async def test_discovered_by_dhcp_or_discovery(hass, source, data): assert mock_async_setup_entry.called +async def test_discovered_by_dhcp_udp_responds(hass): + """Test we can setup when discovered from dhcp but with udp response.""" + + with _patch_discovery(), _patch_wifibulb(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with _patch_discovery(), _patch_wifibulb(), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_async_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_async_setup_entry: + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["data"] == {CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE} + assert mock_async_setup.called + assert mock_async_setup_entry.called + + +async def test_discovered_by_dhcp_no_udp_response(hass): + """Test we can setup when discovered from dhcp but no udp response.""" + + with _patch_discovery(no_device=True), _patch_wifibulb(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with _patch_discovery(no_device=True), _patch_wifibulb(), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_async_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_async_setup_entry: + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_NAME: DEFAULT_ENTRY_TITLE, + } + assert mock_async_setup.called + assert mock_async_setup_entry.called + + +async def test_discovered_by_dhcp_partial_udp_response_fallback_tcp(hass): + """Test we can setup when discovered from dhcp but part of the udp response is missing.""" + + with _patch_discovery(no_device=True), _patch_wifibulb(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with _patch_discovery(device=FLUX_DISCOVERY_PARTIAL), _patch_wifibulb(), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_async_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_async_setup_entry: + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_NAME: DEFAULT_ENTRY_TITLE, + } + assert mock_async_setup.called + assert mock_async_setup_entry.called + + +async def test_discovered_by_dhcp_no_udp_response_or_tcp_response(hass): + """Test we can setup when discovered from dhcp but no udp response or tcp response.""" + + with _patch_discovery(no_device=True), _patch_wifibulb(no_device=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + @pytest.mark.parametrize( "source, data", [ @@ -411,6 +504,34 @@ async def test_discovered_by_dhcp_or_discovery_adds_missing_unique_id( assert config_entry.unique_id == MAC_ADDRESS +@pytest.mark.parametrize( + "source, data", + [ + (config_entries.SOURCE_DHCP, DHCP_DISCOVERY), + (config_entries.SOURCE_DISCOVERY, FLUX_DISCOVERY), + ], +) +async def test_discovered_by_dhcp_or_discovery_mac_address_mismatch_host_already_configured( + hass, source, data +): + """Test we abort if the host is already configured but the mac does not match.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=MAC_ADDRESS_DIFFERENT + ) + config_entry.add_to_hass(hass) + + with _patch_discovery(), _patch_wifibulb(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + assert config_entry.unique_id == MAC_ADDRESS_DIFFERENT + + async def test_options(hass: HomeAssistant): """Test options flow.""" config_entry = MockConfigEntry( @@ -447,4 +568,4 @@ async def test_options(hass: HomeAssistant): assert result2["type"] == "create_entry" assert result2["data"] == user_input assert result2["data"] == config_entry.options - assert hass.states.get("light.az120444_aabbccddeeff") is not None + assert hass.states.get("light.bulb_rgbcw_ddeeff") is not None diff --git a/tests/components/flux_led/test_init.py b/tests/components/flux_led/test_init.py index db4ddffbc3f..23a238fa812 100644 --- a/tests/components/flux_led/test_init.py +++ b/tests/components/flux_led/test_init.py @@ -3,6 +3,8 @@ from __future__ import annotations from unittest.mock import patch +import pytest + from homeassistant.components import flux_led from homeassistant.components.flux_led.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -14,6 +16,7 @@ from homeassistant.util.dt import utcnow from . import ( DEFAULT_ENTRY_TITLE, FLUX_DISCOVERY, + FLUX_DISCOVERY_PARTIAL, IP_ADDRESS, MAC_ADDRESS, _patch_discovery, @@ -67,8 +70,15 @@ async def test_config_entry_retry(hass: HomeAssistant) -> None: assert config_entry.state == ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize( + "discovery,title", + [ + (FLUX_DISCOVERY, DEFAULT_ENTRY_TITLE), + (FLUX_DISCOVERY_PARTIAL, "AZ120444 ddeeff"), + ], +) async def test_config_entry_fills_unique_id_with_directed_discovery( - hass: HomeAssistant, + hass: HomeAssistant, discovery: dict[str, str], title: str ) -> None: """Test that the unique id is added if its missing via directed (not broadcast) discovery.""" config_entry = MockConfigEntry( @@ -78,7 +88,7 @@ async def test_config_entry_fills_unique_id_with_directed_discovery( async def _discovery(self, *args, address=None, **kwargs): # Only return discovery results when doing directed discovery - return [FLUX_DISCOVERY] if address == IP_ADDRESS else [] + return [discovery] if address == IP_ADDRESS else [] with patch( "homeassistant.components.flux_led.AIOBulbScanner.async_scan", new=_discovery @@ -88,5 +98,5 @@ async def test_config_entry_fills_unique_id_with_directed_discovery( assert config_entry.state == ConfigEntryState.LOADED assert config_entry.unique_id == MAC_ADDRESS - assert config_entry.data[CONF_NAME] == DEFAULT_ENTRY_TITLE - assert config_entry.title == DEFAULT_ENTRY_TITLE + assert config_entry.data[CONF_NAME] == title + assert config_entry.title == title diff --git a/tests/components/flux_led/test_light.py b/tests/components/flux_led/test_light.py index 01dfff85528..92ea0fd8d39 100644 --- a/tests/components/flux_led/test_light.py +++ b/tests/components/flux_led/test_light.py @@ -27,7 +27,6 @@ from homeassistant.components.flux_led.const import ( MODE_AUTO, TRANSITION_JUMP, ) -from homeassistant.components.flux_led.light import EFFECT_CUSTOM_CODE, FLUX_EFFECT_LIST from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, @@ -81,11 +80,11 @@ async def test_light_unique_id(hass: HomeAssistant) -> None: ) config_entry.add_to_hass(hass) bulb = _mocked_bulb() - with _patch_discovery(device=bulb), _patch_wifibulb(device=bulb): + with _patch_discovery(), _patch_wifibulb(device=bulb): await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.az120444_aabbccddeeff" + entity_id = "light.bulb_rgbcw_ddeeff" entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == MAC_ADDRESS state = hass.states.get(entity_id) @@ -101,11 +100,11 @@ async def test_light_goes_unavailable_and_recovers(hass: HomeAssistant) -> None: ) config_entry.add_to_hass(hass) bulb = _mocked_bulb() - with _patch_discovery(device=bulb), _patch_wifibulb(device=bulb): + with _patch_discovery(), _patch_wifibulb(device=bulb): await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.az120444_aabbccddeeff" + entity_id = "light.bulb_rgbcw_ddeeff" entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == MAC_ADDRESS state = hass.states.get(entity_id) @@ -137,7 +136,7 @@ async def test_light_no_unique_id(hass: HomeAssistant) -> None: await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.az120444_aabbccddeeff" + entity_id = "light.bulb_rgbcw_ddeeff" entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id) is None state = hass.states.get(entity_id) @@ -191,18 +190,18 @@ async def test_rgb_light(hass: HomeAssistant) -> None: bulb.raw_state = bulb.raw_state._replace(model_num=0x33) # RGB only model bulb.color_modes = {FLUX_COLOR_MODE_RGB} bulb.color_mode = FLUX_COLOR_MODE_RGB - with _patch_discovery(device=bulb), _patch_wifibulb(device=bulb): + with _patch_discovery(no_device=True), _patch_wifibulb(device=bulb): await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.az120444_aabbccddeeff" + entity_id = "light.bulb_rgbcw_ddeeff" state = hass.states.get(entity_id) assert state.state == STATE_ON attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "rgb" - assert attributes[ATTR_EFFECT_LIST] == FLUX_EFFECT_LIST + assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["rgb"] assert attributes[ATTR_HS_COLOR] == (0, 100) @@ -227,22 +226,19 @@ async def test_rgb_light(hass: HomeAssistant) -> None: bulb.async_set_levels.reset_mock() bulb.async_turn_on.reset_mock() - await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True - ) - bulb.async_turn_on.assert_called_once() - bulb.async_turn_on.reset_mock() - await async_mock_device_turn_on(hass, bulb) - assert hass.states.get(entity_id).state == STATE_ON - await hass.services.async_call( LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) - bulb.async_set_levels.assert_called_with(255, 0, 0, brightness=100) - bulb.async_set_levels.reset_mock() + # If its off and the device requires the turn on + # command before setting brightness we need to make sure its called + bulb.async_turn_on.assert_called_once() + bulb.async_set_brightness.assert_called_with(100) + bulb.async_set_brightness.reset_mock() + await async_mock_device_turn_on(hass, bulb) + assert hass.states.get(entity_id).state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, @@ -272,8 +268,8 @@ async def test_rgb_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "random"}, blocking=True, ) - bulb.async_set_levels.assert_called_once() - bulb.async_set_levels.reset_mock() + bulb.async_set_effect.assert_called_once() + bulb.async_set_effect.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -281,16 +277,122 @@ async def test_rgb_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "purple_fade"}, blocking=True, ) - bulb.async_set_preset_pattern.assert_called_with(43, 50) - bulb.async_set_preset_pattern.reset_mock() + bulb.async_set_effect.assert_called_with("purple_fade", 50, 50) + bulb.async_set_effect.reset_mock() - with pytest.raises(ValueError): - await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "does not exist"}, - blocking=True, - ) + +async def test_rgb_light_auto_on(hass: HomeAssistant) -> None: + """Test an rgb light that does not need the turn on command sent.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.requires_turn_on = False + bulb.raw_state = bulb.raw_state._replace(model_num=0x33) # RGB only model + bulb.color_modes = {FLUX_COLOR_MODE_RGB} + bulb.color_mode = FLUX_COLOR_MODE_RGB + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.bulb_rgbcw_ddeeff" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_COLOR_MODE] == "rgb" + assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["rgb"] + assert attributes[ATTR_HS_COLOR] == (0, 100) + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_turn_off.assert_called_once() + + await async_mock_device_turn_off(hass, bulb) + assert hass.states.get(entity_id).state == STATE_OFF + + bulb.brightness = 0 + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: (10, 10, 30)}, + blocking=True, + ) + # If the bulb is off and we are using existing brightness + # it has to be at least 1 or the bulb won't turn on + bulb.async_turn_on.assert_not_called() + bulb.async_set_levels.assert_called_with(10, 10, 30, brightness=1) + bulb.async_set_levels.reset_mock() + bulb.async_turn_on.reset_mock() + + # Should still be called with no kwargs + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_turn_on.assert_called_once() + await async_mock_device_turn_on(hass, bulb) + assert hass.states.get(entity_id).state == STATE_ON + bulb.async_turn_on.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_brightness.assert_called_with(100) + bulb.async_set_brightness.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: (10, 10, 30)}, + blocking=True, + ) + # If the bulb is on and we are using existing brightness + # and brightness was 0 it means we could not read it because + # an effect is in progress so we use 255 + bulb.async_turn_on.assert_not_called() + bulb.async_set_levels.assert_called_with(10, 10, 30, brightness=255) + bulb.async_set_levels.reset_mock() + + bulb.brightness = 128 + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_levels.assert_called_with(255, 191, 178, brightness=128) + bulb.async_set_levels.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "random"}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_effect.assert_called_once() + bulb.async_set_effect.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "purple_fade"}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_effect.assert_called_with("purple_fade", 50, 50) + bulb.async_set_effect.reset_mock() async def test_rgb_cct_light(hass: HomeAssistant) -> None: @@ -305,18 +407,18 @@ async def test_rgb_cct_light(hass: HomeAssistant) -> None: bulb.raw_state = bulb.raw_state._replace(model_num=0x35) # RGB & CCT model bulb.color_modes = {FLUX_COLOR_MODE_RGB, FLUX_COLOR_MODE_CCT} bulb.color_mode = FLUX_COLOR_MODE_RGB - with _patch_discovery(device=bulb), _patch_wifibulb(device=bulb): + with _patch_discovery(), _patch_wifibulb(device=bulb): await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.az120444_aabbccddeeff" + entity_id = "light.bulb_rgbcw_ddeeff" state = hass.states.get(entity_id) assert state.state == STATE_ON attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "rgb" - assert attributes[ATTR_EFFECT_LIST] == FLUX_EFFECT_LIST + assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "rgb"] assert attributes[ATTR_HS_COLOR] == (0, 100) @@ -340,8 +442,8 @@ async def test_rgb_cct_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) - bulb.async_set_levels.assert_called_with(255, 0, 0, brightness=100) - bulb.async_set_levels.reset_mock() + bulb.async_set_brightness.assert_called_with(100) + bulb.async_set_brightness.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -358,8 +460,8 @@ async def test_rgb_cct_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "random"}, blocking=True, ) - bulb.async_set_levels.assert_called_once() - bulb.async_set_levels.reset_mock() + bulb.async_set_effect.assert_called_once() + bulb.async_set_effect.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -367,8 +469,8 @@ async def test_rgb_cct_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "purple_fade"}, blocking=True, ) - bulb.async_set_preset_pattern.assert_called_with(43, 50) - bulb.async_set_preset_pattern.reset_mock() + bulb.async_set_effect.assert_called_with("purple_fade", 50, 50) + bulb.async_set_effect.reset_mock() bulb.color_mode = FLUX_COLOR_MODE_CCT bulb.getWhiteTemperature = Mock(return_value=(5000, 128)) bulb.color_temp = 5000 @@ -400,8 +502,8 @@ async def test_rgb_cct_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 255}, blocking=True, ) - bulb.async_set_white_temp.assert_called_with(5000, 255) - bulb.async_set_white_temp.reset_mock() + bulb.async_set_brightness.assert_called_with(255) + bulb.async_set_brightness.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -409,8 +511,8 @@ async def test_rgb_cct_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, blocking=True, ) - bulb.async_set_white_temp.assert_called_with(5000, 128) - bulb.async_set_white_temp.reset_mock() + bulb.async_set_brightness.assert_called_with(128) + bulb.async_set_brightness.reset_mock() async def test_rgbw_light(hass: HomeAssistant) -> None: @@ -424,18 +526,18 @@ async def test_rgbw_light(hass: HomeAssistant) -> None: bulb = _mocked_bulb() bulb.color_modes = {FLUX_COLOR_MODE_RGBW} bulb.color_mode = FLUX_COLOR_MODE_RGBW - with _patch_discovery(device=bulb), _patch_wifibulb(device=bulb): + with _patch_discovery(), _patch_wifibulb(device=bulb): await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.az120444_aabbccddeeff" + entity_id = "light.bulb_rgbcw_ddeeff" state = hass.states.get(entity_id) assert state.state == STATE_ON attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "rgbw" - assert attributes[ATTR_EFFECT_LIST] == FLUX_EFFECT_LIST + assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["rgbw"] assert attributes[ATTR_RGB_COLOR] == (255, 42, 42) @@ -460,8 +562,8 @@ async def test_rgbw_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) - bulb.async_set_levels.assert_called_with(168, 0, 0, 33) - bulb.async_set_levels.reset_mock() + bulb.async_set_brightness.assert_called_with(100) + bulb.async_set_brightness.reset_mock() state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -502,17 +604,17 @@ async def test_rgbw_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "random"}, blocking=True, ) - bulb.async_set_levels.assert_called_once() - bulb.async_set_levels.reset_mock() + bulb.async_set_effect.assert_called_once() + bulb.async_set_effect.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "purple_fade"}, + {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "purple_fade", ATTR_BRIGHTNESS: 255}, blocking=True, ) - bulb.async_set_preset_pattern.assert_called_with(43, 50) - bulb.async_set_preset_pattern.reset_mock() + bulb.async_set_effect.assert_called_with("purple_fade", 50, 100) + bulb.async_set_effect.reset_mock() async def test_rgb_or_w_light(hass: HomeAssistant) -> None: @@ -526,18 +628,18 @@ async def test_rgb_or_w_light(hass: HomeAssistant) -> None: bulb = _mocked_bulb() bulb.color_modes = FLUX_COLOR_MODES_RGB_W bulb.color_mode = FLUX_COLOR_MODE_RGB - with _patch_discovery(device=bulb), _patch_wifibulb(device=bulb): + with _patch_discovery(), _patch_wifibulb(device=bulb): await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.az120444_aabbccddeeff" + entity_id = "light.bulb_rgbcw_ddeeff" state = hass.states.get(entity_id) assert state.state == STATE_ON attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "rgb" - assert attributes[ATTR_EFFECT_LIST] == FLUX_EFFECT_LIST + assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["rgb", "white"] assert attributes[ATTR_RGB_COLOR] == (255, 0, 0) @@ -562,8 +664,8 @@ async def test_rgb_or_w_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) - bulb.async_set_levels.assert_called_with(255, 0, 0, brightness=100) - bulb.async_set_levels.reset_mock() + bulb.async_set_brightness.assert_called_with(100) + bulb.async_set_brightness.reset_mock() state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -586,17 +688,17 @@ async def test_rgb_or_w_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "random"}, blocking=True, ) - bulb.async_set_levels.assert_called_once() - bulb.async_set_levels.reset_mock() + bulb.async_set_effect.assert_called_once() + bulb.async_set_effect.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "purple_fade"}, + {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "purple_fade", ATTR_BRIGHTNESS: 255}, blocking=True, ) - bulb.async_set_preset_pattern.assert_called_with(43, 50) - bulb.async_set_preset_pattern.reset_mock() + bulb.async_set_effect.assert_called_with("purple_fade", 50, 100) + bulb.async_set_effect.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -621,8 +723,8 @@ async def test_rgb_or_w_light(hass: HomeAssistant) -> None: }, blocking=True, ) - bulb.async_set_levels.assert_called_with(w=100) - bulb.async_set_levels.reset_mock() + bulb.async_set_brightness.assert_called_with(100) + bulb.async_set_brightness.reset_mock() async def test_rgbcw_light(hass: HomeAssistant) -> None: @@ -637,18 +739,18 @@ async def test_rgbcw_light(hass: HomeAssistant) -> None: bulb.raw_state = bulb.raw_state._replace(warm_white=1, cool_white=2) bulb.color_modes = {FLUX_COLOR_MODE_RGBWW, FLUX_COLOR_MODE_CCT} bulb.color_mode = FLUX_COLOR_MODE_RGBWW - with _patch_discovery(device=bulb), _patch_wifibulb(device=bulb): + with _patch_discovery(), _patch_wifibulb(device=bulb): await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.az120444_aabbccddeeff" + entity_id = "light.bulb_rgbcw_ddeeff" state = hass.states.get(entity_id) assert state.state == STATE_ON attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "rgbww" - assert attributes[ATTR_EFFECT_LIST] == FLUX_EFFECT_LIST + assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "rgbww"] assert attributes[ATTR_HS_COLOR] == (3.237, 94.51) @@ -672,8 +774,8 @@ async def test_rgbcw_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) - bulb.async_set_levels.assert_called_with(250, 0, 0, 49, 0) - bulb.async_set_levels.reset_mock() + bulb.async_set_brightness.assert_called_with(100) + bulb.async_set_brightness.reset_mock() bulb.is_on = True await hass.services.async_call( @@ -740,8 +842,8 @@ async def test_rgbcw_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "random"}, blocking=True, ) - bulb.async_set_levels.assert_called_once() - bulb.async_set_levels.reset_mock() + bulb.async_set_effect.assert_called_once() + bulb.async_set_effect.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -749,8 +851,19 @@ async def test_rgbcw_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "purple_fade"}, blocking=True, ) - bulb.async_set_preset_pattern.assert_called_with(43, 50) - bulb.async_set_preset_pattern.reset_mock() + bulb.async_set_effect.assert_called_with("purple_fade", 50, 50) + bulb.async_set_effect.reset_mock() + bulb.effect = "purple_fade" + bulb.brightness = 128 + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + bulb.async_set_brightness.assert_called_with(255) + bulb.async_set_brightness.reset_mock() async def test_white_light(hass: HomeAssistant) -> None: @@ -766,11 +879,11 @@ async def test_white_light(hass: HomeAssistant) -> None: bulb.protocol = None bulb.color_modes = {FLUX_COLOR_MODE_DIM} bulb.color_mode = FLUX_COLOR_MODE_DIM - with _patch_discovery(device=bulb), _patch_wifibulb(device=bulb): + with _patch_discovery(), _patch_wifibulb(device=bulb): await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.az120444_aabbccddeeff" + entity_id = "light.bulb_rgbcw_ddeeff" state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -778,7 +891,7 @@ async def test_white_light(hass: HomeAssistant) -> None: assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "brightness" assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness"] - assert ATTR_EFFECT_LIST not in attributes # single channel does not support effects + assert ATTR_EFFECT_LIST in attributes # single channel now supports effects await hass.services.async_call( LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -800,8 +913,48 @@ async def test_white_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) - bulb.async_set_levels.assert_called_with(w=100) - bulb.async_set_levels.reset_mock() + bulb.async_set_brightness.assert_called_with(100) + bulb.async_set_brightness.reset_mock() + + +async def test_no_color_modes(hass: HomeAssistant) -> None: + """Test a light that has no color modes defined in the database.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.mode = "ww" + bulb.protocol = None + bulb.color_modes = set() + bulb.color_mode = None + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.bulb_rgbcw_ddeeff" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attributes = state.attributes + assert attributes[ATTR_COLOR_MODE] == "onoff" + assert ATTR_EFFECT_LIST in attributes # single channel now supports effects + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_turn_off.assert_called_once() + await async_mock_device_turn_off(hass, bulb) + + assert hass.states.get(entity_id).state == STATE_OFF + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_turn_on.assert_called_once() + bulb.async_turn_on.reset_mock() async def test_rgb_light_custom_effects(hass: HomeAssistant) -> None: @@ -821,18 +974,18 @@ async def test_rgb_light_custom_effects(hass: HomeAssistant) -> None: bulb = _mocked_bulb() bulb.color_modes = {FLUX_COLOR_MODE_RGB} bulb.color_mode = FLUX_COLOR_MODE_RGB - with _patch_discovery(device=bulb), _patch_wifibulb(device=bulb): + with _patch_discovery(), _patch_wifibulb(device=bulb): await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.az120444_aabbccddeeff" + entity_id = "light.bulb_rgbcw_ddeeff" state = hass.states.get(entity_id) assert state.state == STATE_ON attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "rgb" - assert attributes[ATTR_EFFECT_LIST] == [*FLUX_EFFECT_LIST, "custom"] + assert attributes[ATTR_EFFECT_LIST] == [*bulb.effect_list, "custom"] assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["rgb"] assert attributes[ATTR_HS_COLOR] == (0, 100) @@ -850,11 +1003,11 @@ async def test_rgb_light_custom_effects(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "custom"}, blocking=True, ) + bulb.effect = "custom" bulb.async_set_custom_pattern.assert_called_with( [[0, 0, 255], [255, 0, 0]], 88, "jump" ) bulb.async_set_custom_pattern.reset_mock() - bulb.preset_pattern_num = EFFECT_CUSTOM_CODE await async_mock_device_turn_on(hass, bulb) state = hass.states.get(entity_id) @@ -868,11 +1021,11 @@ async def test_rgb_light_custom_effects(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 55, ATTR_EFFECT: "custom"}, blocking=True, ) + bulb.effect = "custom" bulb.async_set_custom_pattern.assert_called_with( [[0, 0, 255], [255, 0, 0]], 88, "jump" ) bulb.async_set_custom_pattern.reset_mock() - bulb.preset_pattern_num = EFFECT_CUSTOM_CODE await async_mock_device_turn_on(hass, bulb) state = hass.states.get(entity_id) @@ -903,18 +1056,18 @@ async def test_rgb_light_custom_effects_invalid_colors( bulb = _mocked_bulb() bulb.color_modes = {FLUX_COLOR_MODE_RGB} bulb.color_mode = FLUX_COLOR_MODE_RGB - with _patch_discovery(device=bulb), _patch_wifibulb(device=bulb): + with _patch_discovery(), _patch_wifibulb(device=bulb): await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.az120444_aabbccddeeff" + entity_id = "light.bulb_rgbcw_ddeeff" state = hass.states.get(entity_id) assert state.state == STATE_ON attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "rgb" - assert attributes[ATTR_EFFECT_LIST] == FLUX_EFFECT_LIST + assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["rgb"] assert attributes[ATTR_HS_COLOR] == (0, 100) @@ -932,18 +1085,18 @@ async def test_rgb_light_custom_effect_via_service( bulb = _mocked_bulb() bulb.color_modes = {FLUX_COLOR_MODE_RGB} bulb.color_mode = FLUX_COLOR_MODE_RGB - with _patch_discovery(device=bulb), _patch_wifibulb(device=bulb): + with _patch_discovery(), _patch_wifibulb(device=bulb): await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.az120444_aabbccddeeff" + entity_id = "light.bulb_rgbcw_ddeeff" state = hass.states.get(entity_id) assert state.state == STATE_ON attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "rgb" - assert attributes[ATTR_EFFECT_LIST] == [*FLUX_EFFECT_LIST] + assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["rgb"] assert attributes[ATTR_HS_COLOR] == (0, 100) @@ -1077,19 +1230,17 @@ async def test_addressable_light(hass: HomeAssistant) -> None: bulb.raw_state = bulb.raw_state._replace(model_num=0x33) # RGB only model bulb.color_modes = {FLUX_COLOR_MODE_ADDRESSABLE} bulb.color_mode = FLUX_COLOR_MODE_ADDRESSABLE - with _patch_discovery(device=bulb), _patch_wifibulb(device=bulb): + with _patch_discovery(), _patch_wifibulb(device=bulb): await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.az120444_aabbccddeeff" + entity_id = "light.bulb_rgbcw_ddeeff" state = hass.states.get(entity_id) assert state.state == STATE_ON attributes = state.attributes assert attributes[ATTR_COLOR_MODE] == "onoff" - assert ( - ATTR_EFFECT_LIST not in attributes - ) # no support for effects with addressable yet + assert ATTR_EFFECT_LIST in attributes assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] await hass.services.async_call( @@ -1106,11 +1257,3 @@ async def test_addressable_light(hass: HomeAssistant) -> None: bulb.async_turn_on.assert_called_once() bulb.async_turn_on.reset_mock() await async_mock_device_turn_on(hass, bulb) - - with pytest.raises(ValueError): - await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, - blocking=True, - ) diff --git a/tests/components/flux_led/test_number.py b/tests/components/flux_led/test_number.py new file mode 100644 index 00000000000..325307f1f32 --- /dev/null +++ b/tests/components/flux_led/test_number.py @@ -0,0 +1,227 @@ +"""Tests for the flux_led number platform.""" + + +from flux_led.const import COLOR_MODE_RGB as FLUX_COLOR_MODE_RGB +import pytest + +from homeassistant.components import flux_led +from homeassistant.components.flux_led.const import DOMAIN +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from . import ( + DEFAULT_ENTRY_TITLE, + IP_ADDRESS, + MAC_ADDRESS, + _mocked_bulb, + _patch_discovery, + _patch_wifibulb, + async_mock_device_turn_off, + async_mock_device_turn_on, + async_mock_effect_speed, +) + +from tests.common import MockConfigEntry + + +async def test_number_unique_id(hass: HomeAssistant) -> None: + """Test a number unique id.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "number.bulb_rgbcw_ddeeff_effect_speed" + entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == MAC_ADDRESS + + +async def test_rgb_light_effect_speed(hass: HomeAssistant) -> None: + """Test an rgb light with an effect.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.raw_state = bulb.raw_state._replace(model_num=0x33) # RGB only model + + bulb.color_modes = {FLUX_COLOR_MODE_RGB} + bulb.color_mode = FLUX_COLOR_MODE_RGB + + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + await async_mock_device_turn_on(hass, bulb) + + light_entity_id = "light.bulb_rgbcw_ddeeff" + number_entity_id = "number.bulb_rgbcw_ddeeff_effect_speed" + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: number_entity_id, ATTR_VALUE: 100}, + blocking=True, + ) + + state = hass.states.get(light_entity_id) + assert state.state == STATE_ON + + bulb.effect = "colorloop" + bulb.speed = 50 + await async_mock_device_turn_off(hass, bulb) + state = hass.states.get(number_entity_id) + assert state.state == "50" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: number_entity_id, ATTR_VALUE: 100}, + blocking=True, + ) + bulb.async_set_effect.assert_called_with("colorloop", 100, 50) + bulb.async_set_effect.reset_mock() + + await async_mock_effect_speed(hass, bulb, "red_fade", 50) + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: number_entity_id, ATTR_VALUE: 50}, + blocking=True, + ) + bulb.async_set_effect.assert_called_with("red_fade", 50, 50) + bulb.async_set_effect.reset_mock() + + state = hass.states.get(number_entity_id) + assert state.state == "50" + + +async def test_original_addressable_light_effect_speed(hass: HomeAssistant) -> None: + """Test an original addressable light with an effect.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.speed_adjust_off = False + bulb.raw_state = bulb.raw_state._replace( + model_num=0xA1 + ) # Original addressable model + bulb.color_modes = {FLUX_COLOR_MODE_RGB} + bulb.color_mode = FLUX_COLOR_MODE_RGB + bulb.effect = "7 colors change gradually" + bulb.speed = 50 + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + await async_mock_device_turn_on(hass, bulb) + + light_entity_id = "light.bulb_rgbcw_ddeeff" + number_entity_id = "number.bulb_rgbcw_ddeeff_effect_speed" + + state = hass.states.get(light_entity_id) + assert state.state == STATE_ON + + state = hass.states.get(number_entity_id) + assert state.state == "50" + + await async_mock_device_turn_off(hass, bulb) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: number_entity_id, ATTR_VALUE: 100}, + blocking=True, + ) + + await async_mock_device_turn_on(hass, bulb) + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: number_entity_id, ATTR_VALUE: 100}, + blocking=True, + ) + bulb.async_set_effect.assert_called_with("7 colors change gradually", 100, 50) + bulb.async_set_effect.reset_mock() + await async_mock_effect_speed(hass, bulb, "7 colors run in olivary", 100) + + state = hass.states.get(number_entity_id) + assert state.state == "100" + + +async def test_addressable_light_effect_speed(hass: HomeAssistant) -> None: + """Test an addressable light with an effect.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.addressable = True + bulb.raw_state = bulb.raw_state._replace( + model_num=0xA2 + ) # Original addressable model + bulb.color_modes = {FLUX_COLOR_MODE_RGB} + bulb.color_mode = FLUX_COLOR_MODE_RGB + bulb.effect = "RBM 1" + bulb.speed = 50 + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + await async_mock_device_turn_on(hass, bulb) + + light_entity_id = "light.bulb_rgbcw_ddeeff" + number_entity_id = "number.bulb_rgbcw_ddeeff_effect_speed" + + state = hass.states.get(light_entity_id) + assert state.state == STATE_ON + + state = hass.states.get(number_entity_id) + assert state.state == "50" + + await async_mock_device_turn_off(hass, bulb) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: number_entity_id, ATTR_VALUE: 100}, + blocking=True, + ) + bulb.async_set_effect.assert_called_with("RBM 1", 100, 50) + bulb.async_set_effect.reset_mock() + + await async_mock_device_turn_on(hass, bulb) + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: number_entity_id, ATTR_VALUE: 100}, + blocking=True, + ) + bulb.async_set_effect.assert_called_with("RBM 1", 100, 50) + bulb.async_set_effect.reset_mock() + await async_mock_effect_speed(hass, bulb, "RBM 2", 100) + + state = hass.states.get(number_entity_id) + assert state.state == "100" diff --git a/tests/components/flux_led/test_switch.py b/tests/components/flux_led/test_switch.py index e41a10807c7..b569d51e13a 100644 --- a/tests/components/flux_led/test_switch.py +++ b/tests/components/flux_led/test_switch.py @@ -35,11 +35,11 @@ async def test_switch_on_off(hass: HomeAssistant) -> None: ) config_entry.add_to_hass(hass) switch = _mocked_switch() - with _patch_discovery(device=switch), _patch_wifibulb(device=switch): + with _patch_discovery(), _patch_wifibulb(device=switch): await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "switch.az120444_aabbccddeeff" + entity_id = "switch.bulb_rgbcw_ddeeff" state = hass.states.get(entity_id) assert state.state == STATE_ON diff --git a/tests/fixtures/foobot_data.json b/tests/components/foobot/fixtures/data.json similarity index 100% rename from tests/fixtures/foobot_data.json rename to tests/components/foobot/fixtures/data.json diff --git a/tests/fixtures/foobot_devices.json b/tests/components/foobot/fixtures/devices.json similarity index 100% rename from tests/fixtures/foobot_devices.json rename to tests/components/foobot/fixtures/devices.json diff --git a/tests/components/foobot/test_sensor.py b/tests/components/foobot/test_sensor.py index ec19fb7b94f..7ffa3987110 100644 --- a/tests/components/foobot/test_sensor.py +++ b/tests/components/foobot/test_sensor.py @@ -32,10 +32,11 @@ async def test_default_setup(hass, aioclient_mock): """Test the default setup.""" aioclient_mock.get( re.compile("api.foobot.io/v2/owner/.*"), - text=load_fixture("foobot_devices.json"), + text=load_fixture("devices.json", "foobot"), ) aioclient_mock.get( - re.compile("api.foobot.io/v2/device/.*"), text=load_fixture("foobot_data.json") + re.compile("api.foobot.io/v2/device/.*"), + text=load_fixture("data.json", "foobot"), ) assert await async_setup_component(hass, sensor.DOMAIN, {"sensor": VALID_CONFIG}) await hass.async_block_till_done() diff --git a/tests/components/forecast_solar/test_config_flow.py b/tests/components/forecast_solar/test_config_flow.py index ac950d38b51..d175be986ca 100644 --- a/tests/components/forecast_solar/test_config_flow.py +++ b/tests/components/forecast_solar/test_config_flow.py @@ -61,6 +61,11 @@ async def test_options_flow( ) -> None: """Test config flow options.""" mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.forecast_solar.async_setup_entry", return_value=True + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) diff --git a/tests/components/forecast_solar/test_sensor.py b/tests/components/forecast_solar/test_sensor.py index 6c910d699c4..a6fb45158f8 100644 --- a/tests/components/forecast_solar/test_sensor.py +++ b/tests/components/forecast_solar/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock import pytest -from homeassistant.components.forecast_solar.const import DOMAIN, ENTRY_TYPE_SERVICE +from homeassistant.components.forecast_solar.const import DOMAIN from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, @@ -141,7 +141,7 @@ async def test_sensors( assert device_entry.identifiers == {(DOMAIN, f"{entry_id}")} assert device_entry.manufacturer == "Forecast.Solar" assert device_entry.name == "Solar Production Forecast" - assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert device_entry.entry_type is dr.DeviceEntryType.SERVICE assert device_entry.model == "public" assert not device_entry.sw_version diff --git a/tests/components/forked_daapd/test_config_flow.py b/tests/components/forked_daapd/test_config_flow.py index 668f1be0a4f..abd2f1ab3a2 100644 --- a/tests/components/forked_daapd/test_config_flow.py +++ b/tests/components/forked_daapd/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant import data_entry_flow +from homeassistant.components import zeroconf from homeassistant.components.forked_daapd.const import ( CONF_LIBRESPOT_JAVA_PORT, CONF_MAX_PLAYLISTS, @@ -98,11 +99,14 @@ async def test_zeroconf_updates_title(hass, config_entry): MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "different host"}).add_to_hass(hass) config_entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 2 - discovery_info = { - "host": "192.168.1.1", - "port": 23, - "properties": {"mtd-version": "27.0", "Machine Name": "zeroconf_test"}, - } + discovery_info = zeroconf.ZeroconfServiceInfo( + host="192.168.1.1", + hostname="mock_hostname", + name="mock_name", + port=23, + properties={"mtd-version": "27.0", "Machine Name": "zeroconf_test"}, + type="mock_type", + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) @@ -129,40 +133,56 @@ async def test_config_flow_no_websocket(hass, config_entry): async def test_config_flow_zeroconf_invalid(hass): """Test that an invalid zeroconf entry doesn't work.""" # test with no discovery properties - discovery_info = {"host": "127.0.0.1", "port": 23} + discovery_info = zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="mock_hostname", + name="mock_name", + port=23, + properties={}, + type="mock_type", + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) # doesn't create the entry, tries to show form but gets abort assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "not_forked_daapd" # test with forked-daapd version < 27 - discovery_info = { - "host": "127.0.0.1", - "port": 23, - "properties": {"mtd-version": "26.3", "Machine Name": "forked-daapd"}, - } + discovery_info = zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="mock_hostname", + name="mock_name", + port=23, + properties={"mtd-version": "26.3", "Machine Name": "forked-daapd"}, + type="mock_type", + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) # doesn't create the entry, tries to show form but gets abort assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "not_forked_daapd" # test with verbose mtd-version from Firefly - discovery_info = { - "host": "127.0.0.1", - "port": 23, - "properties": {"mtd-version": "0.2.4.1", "Machine Name": "firefly"}, - } + discovery_info = zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="mock_hostname", + name="mock_name", + port=23, + properties={"mtd-version": "0.2.4.1", "Machine Name": "firefly"}, + type="mock_type", + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) # doesn't create the entry, tries to show form but gets abort assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "not_forked_daapd" # test with svn mtd-version from Firefly - discovery_info = { - "host": "127.0.0.1", - "port": 23, - "properties": {"mtd-version": "svn-1676", "Machine Name": "firefly"}, - } + discovery_info = zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="mock_hostname", + name="mock_name", + port=23, + properties={"mtd-version": "svn-1676", "Machine Name": "firefly"}, + type="mock_type", + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) # doesn't create the entry, tries to show form but gets abort @@ -172,15 +192,18 @@ async def test_config_flow_zeroconf_invalid(hass): async def test_config_flow_zeroconf_valid(hass): """Test that a valid zeroconf entry works.""" - discovery_info = { - "host": "192.168.1.1", - "port": 23, - "properties": { + discovery_info = zeroconf.ZeroconfServiceInfo( + host="192.168.1.1", + hostname="mock_hostname", + name="mock_name", + port=23, + properties={ "mtd-version": "27.0", "Machine Name": "zeroconf_test", "Machine ID": "5E55EEFF", }, - } + type="mock_type", + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) diff --git a/tests/components/freebox/test_config_flow.py b/tests/components/freebox/test_config_flow.py index 2d86ee1de20..3bffd213b72 100644 --- a/tests/components/freebox/test_config_flow.py +++ b/tests/components/freebox/test_config_flow.py @@ -8,6 +8,7 @@ from freebox_api.exceptions import ( ) from homeassistant import data_entry_flow +from homeassistant.components import zeroconf from homeassistant.components.freebox.const import DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PORT @@ -17,13 +18,13 @@ from .const import MOCK_HOST, MOCK_PORT from tests.common import MockConfigEntry -MOCK_ZEROCONF_DATA = { - "host": "192.168.0.254", - "port": 80, - "hostname": "Freebox-Server.local.", - "type": "_fbx-api._tcp.local.", - "name": "Freebox Server._fbx-api._tcp.local.", - "properties": { +MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( + host="192.168.0.254", + port=80, + hostname="Freebox-Server.local.", + type="_fbx-api._tcp.local.", + name="Freebox Server._fbx-api._tcp.local.", + properties={ "api_version": "8.0", "device_type": "FreeboxServer1,2", "api_base_url": "/api/", @@ -34,7 +35,7 @@ MOCK_ZEROCONF_DATA = { "box_model_name": "Freebox Server (r2)", "api_domain": MOCK_HOST, }, -} +) async def test_user(hass: HomeAssistant): diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index 2d276293baa..3981a3d2685 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -1,9 +1,11 @@ """Tests for AVM Fritz!Box config flow.""" +import dataclasses from unittest.mock import patch from fritzconnection.core.exceptions import FritzConnectionException, FritzSecurityError import pytest +from homeassistant.components import ssdp from homeassistant.components.device_tracker.const import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, @@ -14,11 +16,7 @@ from homeassistant.components.fritz.const import ( ERROR_CANNOT_CONNECT, ERROR_UNKNOWN, ) -from homeassistant.components.ssdp import ( - ATTR_SSDP_LOCATION, - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_UDN, -) +from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN from homeassistant.config_entries import ( SOURCE_IMPORT, SOURCE_REAUTH, @@ -51,11 +49,15 @@ MOCK_DEVICE_INFO = { ATTR_NEW_SERIAL_NUMBER: MOCK_SERIAL_NUMBER, } MOCK_IMPORT_CONFIG = {CONF_HOST: MOCK_HOST, CONF_USERNAME: "username"} -MOCK_SSDP_DATA = { - ATTR_SSDP_LOCATION: f"https://{MOCK_IP}:12345/test", - ATTR_UPNP_FRIENDLY_NAME: "fake_name", - ATTR_UPNP_UDN: "uuid:only-a-test", -} +MOCK_SSDP_DATA = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=f"https://{MOCK_IP}:12345/test", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: "fake_name", + ATTR_UPNP_UDN: "uuid:only-a-test", + }, +) MOCK_REQUEST = b'xxxxxxxxxxxxxxxxxxxxxxxx0Dial2App2HomeAuto2BoxAdmin2Phone2NAS2FakeFritzUser\n' @@ -407,8 +409,9 @@ async def test_ssdp_already_in_progress_host( assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" - MOCK_NO_UNIQUE_ID = MOCK_SSDP_DATA.copy() - del MOCK_NO_UNIQUE_ID[ATTR_UPNP_UDN] + MOCK_NO_UNIQUE_ID = dataclasses.replace(MOCK_SSDP_DATA) + MOCK_NO_UNIQUE_ID.upnp = MOCK_NO_UNIQUE_ID.upnp.copy() + del MOCK_NO_UNIQUE_ID.upnp[ATTR_UPNP_UDN] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_NO_UNIQUE_ID ) diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 27abd38f8cb..05003ccdf51 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any from unittest.mock import Mock +from homeassistant.components.climate.const import PRESET_COMFORT, PRESET_ECO from homeassistant.components.fritzbox.const import DOMAIN from homeassistant.core import HomeAssistant @@ -67,6 +68,7 @@ class FritzDeviceClimateMock(FritzDeviceBaseMock): """Mock of a AVM Fritz!Box climate device.""" actual_temperature = 18.0 + temperature = 18.0 alert_state = "fake_state" battery_level = 23 battery_low = True @@ -78,7 +80,7 @@ class FritzDeviceClimateMock(FritzDeviceBaseMock): has_powermeter = False has_lightbulb = False has_switch = False - has_temperature_sensor = False + has_temperature_sensor = True has_thermostat = True holiday_active = "fake_holiday" lock = "fake_locked" @@ -86,6 +88,10 @@ class FritzDeviceClimateMock(FritzDeviceBaseMock): summer_active = "fake_summer" target_temperature = 19.5 window_open = "fake_window" + nextchange_temperature = 22.0 + nextchange_endperiod = 0 + nextchange_preset = PRESET_COMFORT + scheduled_preset = PRESET_ECO class FritzDeviceSensorMock(FritzDeviceBaseMock): diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 627c82f617a..fc3b15c4199 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -39,6 +39,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, PERCENTAGE, + TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util @@ -85,6 +86,65 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE assert ATTR_STATE_CLASS not in state.attributes + state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_comfort_temperature") + assert state + assert state.state == "22.0" + assert ( + state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Comfort Temperature" + ) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + assert ATTR_STATE_CLASS not in state.attributes + + state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_eco_temperature") + assert state + assert state.state == "16.0" + assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Eco Temperature" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + assert ATTR_STATE_CLASS not in state.attributes + + state = hass.states.get( + f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_temperature" + ) + assert state + assert state.state == "22.0" + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == f"{CONF_FAKE_NAME} Next Scheduled Temperature" + ) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + assert ATTR_STATE_CLASS not in state.attributes + + state = hass.states.get( + f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_change_time" + ) + assert state + assert state.state == "1970-01-01T00:00:00+00:00" + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == f"{CONF_FAKE_NAME} Next Scheduled Change Time" + ) + assert ATTR_STATE_CLASS not in state.attributes + + state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_preset") + assert state + assert state.state == PRESET_COMFORT + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == f"{CONF_FAKE_NAME} Next Scheduled Preset" + ) + assert ATTR_STATE_CLASS not in state.attributes + + state = hass.states.get( + f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current_scheduled_preset" + ) + assert state + assert state.state == PRESET_ECO + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == f"{CONF_FAKE_NAME} Current Scheduled Preset" + ) + assert ATTR_STATE_CLASS not in state.attributes + async def test_target_temperature_on(hass: HomeAssistant, fritz: Mock): """Test turn device on.""" @@ -126,7 +186,7 @@ async def test_update(hass: HomeAssistant, fritz: Mock): assert state.attributes[ATTR_MIN_TEMP] == 8 assert state.attributes[ATTR_TEMPERATURE] == 19.5 - device.actual_temperature = 19 + device.temperature = 19 device.target_temperature = 20 next_update = dt_util.utcnow() + timedelta(seconds=200) @@ -140,6 +200,24 @@ async def test_update(hass: HomeAssistant, fritz: Mock): assert state.attributes[ATTR_TEMPERATURE] == 20 +async def test_automatic_offset(hass: HomeAssistant, fritz: Mock): + """Test when automtaic offset is configured on fritz!box device.""" + device = FritzDeviceClimateMock() + device.temperature = 18 + device.actual_temperature = 19 + device.target_temperature = 20 + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 18 + assert state.attributes[ATTR_MAX_TEMP] == 28 + assert state.attributes[ATTR_MIN_TEMP] == 8 + assert state.attributes[ATTR_TEMPERATURE] == 20 + + async def test_update_error(hass: HomeAssistant, fritz: Mock): """Test update with error.""" device = FritzDeviceClimateMock() diff --git a/tests/components/fritzbox/test_config_flow.py b/tests/components/fritzbox/test_config_flow.py index 6d62122a871..a3b258d405e 100644 --- a/tests/components/fritzbox/test_config_flow.py +++ b/tests/components/fritzbox/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for AVM Fritz!Box config flow.""" +import dataclasses from unittest import mock from unittest.mock import Mock, patch @@ -6,12 +7,9 @@ from pyfritzhome import LoginError import pytest from requests.exceptions import HTTPError +from homeassistant.components import ssdp from homeassistant.components.fritzbox.const import DOMAIN -from homeassistant.components.ssdp import ( - ATTR_SSDP_LOCATION, - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_UDN, -) +from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -26,17 +24,23 @@ from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import MockConfigEntry MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] -MOCK_SSDP_DATA = { - ATTR_SSDP_LOCATION: "https://fake_host:12345/test", - ATTR_UPNP_FRIENDLY_NAME: CONF_FAKE_NAME, - ATTR_UPNP_UDN: "uuid:only-a-test", -} +MOCK_SSDP_DATA = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="https://fake_host:12345/test", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: CONF_FAKE_NAME, + ATTR_UPNP_UDN: "uuid:only-a-test", + }, +) @pytest.fixture(name="fritz") def fritz_fixture() -> Mock: """Patch libraries.""" - with patch("homeassistant.components.fritzbox.config_flow.Fritzhome") as fritz: + with patch("homeassistant.components.fritzbox.async_setup_entry"), patch( + "homeassistant.components.fritzbox.config_flow.Fritzhome" + ) as fritz: yield fritz @@ -201,8 +205,9 @@ async def test_ssdp(hass: HomeAssistant, fritz: Mock): async def test_ssdp_no_friendly_name(hass: HomeAssistant, fritz: Mock): """Test starting a flow from discovery without friendly name.""" - MOCK_NO_NAME = MOCK_SSDP_DATA.copy() - del MOCK_NO_NAME[ATTR_UPNP_FRIENDLY_NAME] + MOCK_NO_NAME = dataclasses.replace(MOCK_SSDP_DATA) + MOCK_NO_NAME.upnp = MOCK_NO_NAME.upnp.copy() + del MOCK_NO_NAME.upnp[ATTR_UPNP_FRIENDLY_NAME] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_NO_NAME ) @@ -300,8 +305,9 @@ async def test_ssdp_already_in_progress_host(hass: HomeAssistant, fritz: Mock): assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "confirm" - MOCK_NO_UNIQUE_ID = MOCK_SSDP_DATA.copy() - del MOCK_NO_UNIQUE_ID[ATTR_UPNP_UDN] + MOCK_NO_UNIQUE_ID = dataclasses.replace(MOCK_SSDP_DATA) + MOCK_NO_UNIQUE_ID.upnp = MOCK_NO_UNIQUE_ID.upnp.copy() + del MOCK_NO_UNIQUE_ID.upnp[ATTR_UPNP_UDN] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_NO_UNIQUE_ID ) diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py new file mode 100644 index 00000000000..7930f6c01f4 --- /dev/null +++ b/tests/components/fronius/__init__.py @@ -0,0 +1,98 @@ +"""Tests for the Fronius integration.""" +from __future__ import annotations + +from homeassistant.components.fronius.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt + +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +MOCK_HOST = "http://fronius" +MOCK_UID = "123.4567890" + + +async def setup_fronius_integration( + hass: HomeAssistant, is_logger: bool = True, unique_id: str = MOCK_UID +) -> ConfigEntry: + """Create the Fronius integration.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=unique_id, # has to match mocked logger unique_id + data={ + CONF_HOST: MOCK_HOST, + "is_logger": is_logger, + }, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +def mock_responses( + aioclient_mock: AiohttpClientMocker, + host: str = MOCK_HOST, + fixture_set: str = "symo", + inverter_ids: list[str | int] = [1], + night: bool = False, +) -> None: + """Mock responses for Fronius devices.""" + aioclient_mock.clear_requests() + _night = "_night" if night else "" + + aioclient_mock.get( + f"{host}/solar_api/GetAPIVersion.cgi", + text=load_fixture(f"{fixture_set}/GetAPIVersion.json", "fronius"), + ) + for inverter_id in inverter_ids: + aioclient_mock.get( + f"{host}/solar_api/v1/GetInverterRealtimeData.cgi?Scope=Device&" + f"DeviceId={inverter_id}&DataCollection=CommonInverterData", + text=load_fixture( + f"{fixture_set}/GetInverterRealtimeData_Device_{inverter_id}{_night}.json", + "fronius", + ), + ) + aioclient_mock.get( + f"{host}/solar_api/v1/GetInverterInfo.cgi", + text=load_fixture(f"{fixture_set}/GetInverterInfo.json", "fronius"), + ) + aioclient_mock.get( + f"{host}/solar_api/v1/GetLoggerInfo.cgi", + text=load_fixture(f"{fixture_set}/GetLoggerInfo.json", "fronius"), + ) + aioclient_mock.get( + f"{host}/solar_api/v1/GetMeterRealtimeData.cgi?Scope=System", + text=load_fixture(f"{fixture_set}/GetMeterRealtimeData.json", "fronius"), + ) + aioclient_mock.get( + f"{host}/solar_api/v1/GetPowerFlowRealtimeData.fcgi", + text=load_fixture( + f"{fixture_set}/GetPowerFlowRealtimeData{_night}.json", "fronius" + ), + ) + aioclient_mock.get( + f"{host}/solar_api/v1/GetStorageRealtimeData.cgi?Scope=System", + text=load_fixture(f"{fixture_set}/GetStorageRealtimeData.json", "fronius"), + ) + aioclient_mock.get( + f"{host}/solar_api/v1/GetOhmPilotRealtimeData.cgi?Scope=System", + text=load_fixture(f"{fixture_set}/GetOhmPilotRealtimeData.json", "fronius"), + ) + + +async def enable_all_entities(hass, config_entry_id, time_till_next_update): + """Enable all entities for a config entry and fast forward time to receive data.""" + registry = er.async_get(hass) + entities = er.async_entries_for_config_entry(registry, config_entry_id) + for entry in [ + entry for entry in entities if entry.disabled_by == er.DISABLED_INTEGRATION + ]: + registry.async_update_entity(entry.entity_id, **{"disabled_by": None}) + await hass.async_block_till_done() + async_fire_time_changed(hass, dt.utcnow() + time_till_next_update) + await hass.async_block_till_done() diff --git a/tests/components/fronius/fixtures/gen24/GetAPIVersion.json b/tests/components/fronius/fixtures/gen24/GetAPIVersion.json new file mode 100644 index 00000000000..a76e4813e5f --- /dev/null +++ b/tests/components/fronius/fixtures/gen24/GetAPIVersion.json @@ -0,0 +1,5 @@ +{ + "APIVersion" : 1, + "BaseURL" : "/solar_api/v1/", + "CompatibilityRange" : "1.7-3" +} diff --git a/tests/components/fronius/fixtures/gen24/GetInverterInfo.json b/tests/components/fronius/fixtures/gen24/GetInverterInfo.json new file mode 100644 index 00000000000..8a20c6c806b --- /dev/null +++ b/tests/components/fronius/fixtures/gen24/GetInverterInfo.json @@ -0,0 +1,25 @@ +{ + "Body" : { + "Data" : { + "1" : { + "CustomName" : "Inverter name", + "DT" : 1, + "ErrorCode" : 0, + "InverterState" : "Running", + "PVPower" : 9360, + "Show" : 1, + "StatusCode" : 7, + "UniqueID" : "12345678" + } + } + }, + "Head" : { + "RequestArguments" : {}, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-11-26T11:07:53+00:00" + } +} diff --git a/tests/components/fronius/fixtures/gen24/GetInverterRealtimeData_Device_1.json b/tests/components/fronius/fixtures/gen24/GetInverterRealtimeData_Device_1.json new file mode 100644 index 00000000000..35e7b84f452 --- /dev/null +++ b/tests/components/fronius/fixtures/gen24/GetInverterRealtimeData_Device_1.json @@ -0,0 +1,80 @@ +{ + "Body" : { + "Data" : { + "DAY_ENERGY" : { + "Unit" : "Wh", + "Value" : null + }, + "DeviceStatus" : { + "ErrorCode" : 0, + "InverterState" : "Running", + "StatusCode" : 7 + }, + "FAC" : { + "Unit" : "Hz", + "Value" : 49.99169921875 + }, + "IAC" : { + "Unit" : "A", + "Value" : 0.15894582867622375 + }, + "IDC" : { + "Unit" : "A", + "Value" : 0.078323781490325928 + }, + "IDC_2" : { + "Unit" : "A", + "Value" : 0.075399093329906464 + }, + "IDC_3" : { + "Unit" : "A", + "Value" : null + }, + "PAC" : { + "Unit" : "W", + "Value" : 37.320449829101562 + }, + "SAC" : { + "Unit" : "VA", + "Value" : 37.40447998046875 + }, + "TOTAL_ENERGY" : { + "Unit" : "Wh", + "Value" : 1530193.4199999999 + }, + "UAC" : { + "Unit" : "V", + "Value" : 234.91676330566406 + }, + "UDC" : { + "Unit" : "V", + "Value" : 411.38107299804688 + }, + "UDC_2" : { + "Unit" : "V", + "Value" : 403.43124389648438 + }, + "UDC_3" : { + "Unit" : "V", + "Value" : null + }, + "YEAR_ENERGY" : { + "Unit" : "Wh", + "Value" : null + } + } + }, + "Head" : { + "RequestArguments" : { + "DataCollection" : "CommonInverterData", + "DeviceId" : "1", + "Scope" : "Device" + }, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-11-26T11:07:53+00:00" + } +} diff --git a/tests/components/fronius/fixtures/gen24/GetLoggerInfo.json b/tests/components/fronius/fixtures/gen24/GetLoggerInfo.json new file mode 100644 index 00000000000..103da09d9ba --- /dev/null +++ b/tests/components/fronius/fixtures/gen24/GetLoggerInfo.json @@ -0,0 +1,14 @@ +{ + "Body" : { + "Data" : {} + }, + "Head" : { + "RequestArguments" : {}, + "Status" : { + "Code" : 11, + "Reason" : "v1/GetLoggerInfo.cgi request is not supported.", + "UserMessage" : "" + }, + "Timestamp" : "2021-11-26T11:07:53+00:00" + } +} diff --git a/tests/components/fronius/fixtures/gen24/GetMeterRealtimeData.json b/tests/components/fronius/fixtures/gen24/GetMeterRealtimeData.json new file mode 100644 index 00000000000..2fcc208468a --- /dev/null +++ b/tests/components/fronius/fixtures/gen24/GetMeterRealtimeData.json @@ -0,0 +1,61 @@ +{ + "Body" : { + "Data" : { + "0" : { + "Current_AC_Phase_1" : 1.145, + "Current_AC_Phase_2" : 2.3300000000000001, + "Current_AC_Phase_3" : 1.825, + "Current_AC_Sum" : 5.2999999999999998, + "Details" : { + "Manufacturer" : "Fronius", + "Model" : "Smart Meter TS 65A-3", + "Serial" : "1234567890" + }, + "Enable" : 1, + "EnergyReactive_VArAC_Sum_Consumed" : 88221.0, + "EnergyReactive_VArAC_Sum_Produced" : 1989125.0, + "EnergyReal_WAC_Minus_Absolute" : 3863340.0, + "EnergyReal_WAC_Plus_Absolute" : 2013105.0, + "EnergyReal_WAC_Sum_Consumed" : 2013105.0, + "EnergyReal_WAC_Sum_Produced" : 3863340.0, + "Frequency_Phase_Average" : 49.899999999999999, + "Meter_Location_Current" : 0.0, + "PowerApparent_S_Phase_1" : 243.30000000000001, + "PowerApparent_S_Phase_2" : 323.39999999999998, + "PowerApparent_S_Phase_3" : 301.19999999999999, + "PowerApparent_S_Sum" : 868.0, + "PowerFactor_Phase_1" : 0.441, + "PowerFactor_Phase_2" : 0.93400000000000005, + "PowerFactor_Phase_3" : 0.83199999999999996, + "PowerFactor_Sum" : 0.82799999999999996, + "PowerReactive_Q_Phase_1" : -218.59999999999999, + "PowerReactive_Q_Phase_2" : -132.80000000000001, + "PowerReactive_Q_Phase_3" : -166.0, + "PowerReactive_Q_Sum" : -517.39999999999998, + "PowerReal_P_Phase_1" : 106.8, + "PowerReal_P_Phase_2" : 294.89999999999998, + "PowerReal_P_Phase_3" : 251.30000000000001, + "PowerReal_P_Sum" : 653.10000000000002, + "TimeStamp" : 1637924872.0, + "Visible" : 1.0, + "Voltage_AC_PhaseToPhase_12" : 408.69999999999999, + "Voltage_AC_PhaseToPhase_23" : 409.60000000000002, + "Voltage_AC_PhaseToPhase_31" : 409.39999999999998, + "Voltage_AC_Phase_1" : 235.90000000000001, + "Voltage_AC_Phase_2" : 236.09999999999999, + "Voltage_AC_Phase_3" : 236.90000000000001 + } + } + }, + "Head" : { + "RequestArguments" : { + "Scope" : "System" + }, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-11-26T11:07:52+00:00" + } +} diff --git a/tests/components/fronius/fixtures/gen24/GetOhmPilotRealtimeData.json b/tests/components/fronius/fixtures/gen24/GetOhmPilotRealtimeData.json new file mode 100644 index 00000000000..87d53b13d8b --- /dev/null +++ b/tests/components/fronius/fixtures/gen24/GetOhmPilotRealtimeData.json @@ -0,0 +1,16 @@ +{ + "Body" : { + "Data" : {} + }, + "Head" : { + "RequestArguments" : { + "Scope" : "System" + }, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-11-26T11:07:53+00:00" + } +} diff --git a/tests/components/fronius/fixtures/gen24/GetPowerFlowRealtimeData.json b/tests/components/fronius/fixtures/gen24/GetPowerFlowRealtimeData.json new file mode 100644 index 00000000000..86fd9f12aff --- /dev/null +++ b/tests/components/fronius/fixtures/gen24/GetPowerFlowRealtimeData.json @@ -0,0 +1,45 @@ +{ + "Body" : { + "Data" : { + "Inverters" : { + "1" : { + "Battery_Mode" : "disabled", + "DT" : 1, + "E_Day" : null, + "E_Total" : 1530193.4199999999, + "E_Year" : null, + "P" : 37.320449829101562, + "SOC" : 0.0 + } + }, + "Site" : { + "BackupMode" : false, + "BatteryStandby" : false, + "E_Day" : null, + "E_Total" : 1530193.4199999999, + "E_Year" : null, + "Meter_Location" : "grid", + "Mode" : "meter", + "P_Akku" : null, + "P_Grid" : 658.39999999999998, + "P_Load" : -695.68274917602537, + "P_PV" : 62.948148727416992, + "rel_Autonomy" : 5.3591596485874495, + "rel_SelfConsumption" : 100.0 + }, + "Smartloads" : { + "Ohmpilots" : {} + }, + "Version" : "12" + } + }, + "Head" : { + "RequestArguments" : {}, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-11-26T11:07:53+00:00" + } +} diff --git a/tests/components/fronius/fixtures/gen24/GetStorageRealtimeData.json b/tests/components/fronius/fixtures/gen24/GetStorageRealtimeData.json new file mode 100644 index 00000000000..573a97b7a61 --- /dev/null +++ b/tests/components/fronius/fixtures/gen24/GetStorageRealtimeData.json @@ -0,0 +1,16 @@ +{ + "Body" : { + "Data" : {} + }, + "Head" : { + "RequestArguments" : { + "Scope" : "System" + }, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-11-26T11:07:52+00:00" + } +} diff --git a/tests/components/fronius/fixtures/gen24_storage/GetAPIVersion.json b/tests/components/fronius/fixtures/gen24_storage/GetAPIVersion.json new file mode 100644 index 00000000000..a76e4813e5f --- /dev/null +++ b/tests/components/fronius/fixtures/gen24_storage/GetAPIVersion.json @@ -0,0 +1,5 @@ +{ + "APIVersion" : 1, + "BaseURL" : "/solar_api/v1/", + "CompatibilityRange" : "1.7-3" +} diff --git a/tests/components/fronius/fixtures/gen24_storage/GetInverterInfo.json b/tests/components/fronius/fixtures/gen24_storage/GetInverterInfo.json new file mode 100644 index 00000000000..f96f13ae0e8 --- /dev/null +++ b/tests/components/fronius/fixtures/gen24_storage/GetInverterInfo.json @@ -0,0 +1,25 @@ +{ + "Body" : { + "Data" : { + "1" : { + "CustomName" : "Gen24 Storage", + "DT" : 1, + "ErrorCode" : 0, + "InverterState" : "Running", + "PVPower" : 13930, + "Show" : 1, + "StatusCode" : 7, + "UniqueID" : "12345678" + } + } + }, + "Head" : { + "RequestArguments" : {}, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-11-28T13:12:59+00:00" + } +} diff --git a/tests/components/fronius/fixtures/gen24_storage/GetInverterRealtimeData_Device_1.json b/tests/components/fronius/fixtures/gen24_storage/GetInverterRealtimeData_Device_1.json new file mode 100644 index 00000000000..0b7c01f56eb --- /dev/null +++ b/tests/components/fronius/fixtures/gen24_storage/GetInverterRealtimeData_Device_1.json @@ -0,0 +1,80 @@ +{ + "Body" : { + "Data" : { + "DAY_ENERGY" : { + "Unit" : "Wh", + "Value" : null + }, + "DeviceStatus" : { + "ErrorCode" : 0, + "InverterState" : "Running", + "StatusCode" : 7 + }, + "FAC" : { + "Unit" : "Hz", + "Value" : 49.981552124023438 + }, + "IAC" : { + "Unit" : "A", + "Value" : 1.1086627244949341 + }, + "IDC" : { + "Unit" : "A", + "Value" : 0.39519637823104858 + }, + "IDC_2" : { + "Unit" : "A", + "Value" : 0.35640031099319458 + }, + "IDC_3" : { + "Unit" : "A", + "Value" : null + }, + "PAC" : { + "Unit" : "W", + "Value" : 250.90925598144531 + }, + "SAC" : { + "Unit" : "VA", + "Value" : 250.9410400390625 + }, + "TOTAL_ENERGY" : { + "Unit" : "Wh", + "Value" : 7512794.0116666667 + }, + "UAC" : { + "Unit" : "V", + "Value" : 227.35398864746094 + }, + "UDC" : { + "Unit" : "V", + "Value" : 419.10092163085938 + }, + "UDC_2" : { + "Unit" : "V", + "Value" : 318.81027221679688 + }, + "UDC_3" : { + "Unit" : "V", + "Value" : null + }, + "YEAR_ENERGY" : { + "Unit" : "Wh", + "Value" : null + } + } + }, + "Head" : { + "RequestArguments" : { + "DataCollection" : "CommonInverterData", + "DeviceId" : "1", + "Scope" : "Device" + }, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-11-28T13:51:45+00:00" + } +} diff --git a/tests/components/fronius/fixtures/gen24_storage/GetLoggerInfo.json b/tests/components/fronius/fixtures/gen24_storage/GetLoggerInfo.json new file mode 100644 index 00000000000..55ad726cd83 --- /dev/null +++ b/tests/components/fronius/fixtures/gen24_storage/GetLoggerInfo.json @@ -0,0 +1,14 @@ +{ + "Body" : { + "Data" : {} + }, + "Head" : { + "RequestArguments" : {}, + "Status" : { + "Code" : 11, + "Reason" : "v1/GetLoggerInfo.cgi request is not supported.", + "UserMessage" : "" + }, + "Timestamp" : "2021-11-28T13:17:43+00:00" + } +} diff --git a/tests/components/fronius/fixtures/gen24_storage/GetMeterRealtimeData.json b/tests/components/fronius/fixtures/gen24_storage/GetMeterRealtimeData.json new file mode 100644 index 00000000000..f32ba740eaa --- /dev/null +++ b/tests/components/fronius/fixtures/gen24_storage/GetMeterRealtimeData.json @@ -0,0 +1,61 @@ +{ + "Body" : { + "Data" : { + "0" : { + "Current_AC_Phase_1" : 1.7010000000000001, + "Current_AC_Phase_2" : 1.8320000000000001, + "Current_AC_Phase_3" : 0.64500000000000002, + "Current_AC_Sum" : 4.1780000000000008, + "Details" : { + "Manufacturer" : "Fronius", + "Model" : "Smart Meter TS 65A-3", + "Serial" : "1234567890" + }, + "Enable" : 1, + "EnergyReactive_VArAC_Sum_Consumed" : 5482.0, + "EnergyReactive_VArAC_Sum_Produced" : 3266105.0, + "EnergyReal_WAC_Minus_Absolute" : 1705128.0, + "EnergyReal_WAC_Plus_Absolute" : 1247204.0, + "EnergyReal_WAC_Sum_Consumed" : 1247204.0, + "EnergyReal_WAC_Sum_Produced" : 1705128.0, + "Frequency_Phase_Average" : 49.899999999999999, + "Meter_Location_Current" : 0.0, + "PowerApparent_S_Phase_1" : 319.5, + "PowerApparent_S_Phase_2" : 383.89999999999998, + "PowerApparent_S_Phase_3" : 118.40000000000001, + "PowerApparent_S_Sum" : 821.89999999999998, + "PowerFactor_Phase_1" : 0.995, + "PowerFactor_Phase_2" : 0.38900000000000001, + "PowerFactor_Phase_3" : 0.16300000000000001, + "PowerFactor_Sum" : 0.69799999999999995, + "PowerReactive_Q_Phase_1" : -31.300000000000001, + "PowerReactive_Q_Phase_2" : -353.39999999999998, + "PowerReactive_Q_Phase_3" : -116.7, + "PowerReactive_Q_Sum" : -501.5, + "PowerReal_P_Phase_1" : 317.89999999999998, + "PowerReal_P_Phase_2" : 150.0, + "PowerReal_P_Phase_3" : 19.600000000000001, + "PowerReal_P_Sum" : 487.69999999999999, + "TimeStamp" : 1638104813.0, + "Visible" : 1.0, + "Voltage_AC_PhaseToPhase_12" : 396.0, + "Voltage_AC_PhaseToPhase_23" : 393.0, + "Voltage_AC_PhaseToPhase_31" : 394.30000000000001, + "Voltage_AC_Phase_1" : 229.40000000000001, + "Voltage_AC_Phase_2" : 225.59999999999999, + "Voltage_AC_Phase_3" : 228.30000000000001 + } + } + }, + "Head" : { + "RequestArguments" : { + "Scope" : "System" + }, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-11-28T13:06:54+00:00" + } +} diff --git a/tests/components/fronius/fixtures/gen24_storage/GetOhmPilotRealtimeData.json b/tests/components/fronius/fixtures/gen24_storage/GetOhmPilotRealtimeData.json new file mode 100644 index 00000000000..0215201ad6e --- /dev/null +++ b/tests/components/fronius/fixtures/gen24_storage/GetOhmPilotRealtimeData.json @@ -0,0 +1,30 @@ +{ + "Body" : { + "Data" : { + "0" : { + "CodeOfState" : 0.0, + "Details" : { + "Hardware" : "6", + "Manufacturer" : "Fronius", + "Model" : "Ohmpilot", + "Serial" : "23456789", + "Software" : "1.0.25-3" + }, + "EnergyReal_WAC_Sum_Consumed" : 1233295.0, + "PowerReal_PAC_Sum" : 0.0, + "Temperature_Channel_1" : 38.899999999999999 + } + } + }, + "Head" : { + "RequestArguments" : { + "Scope" : "System" + }, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-11-28T13:11:42+00:00" + } +} diff --git a/tests/components/fronius/fixtures/gen24_storage/GetPowerFlowRealtimeData.json b/tests/components/fronius/fixtures/gen24_storage/GetPowerFlowRealtimeData.json new file mode 100644 index 00000000000..fcd82a9bf1d --- /dev/null +++ b/tests/components/fronius/fixtures/gen24_storage/GetPowerFlowRealtimeData.json @@ -0,0 +1,51 @@ +{ + "Body" : { + "Data" : { + "Inverters" : { + "1" : { + "Battery_Mode" : "suspended", + "DT" : 1, + "E_Day" : null, + "E_Total" : 7512664.4041666668, + "E_Year" : null, + "P" : 186.54914855957031, + "SOC" : 4.5999999999999996 + } + }, + "Site" : { + "BackupMode" : true, + "BatteryStandby" : false, + "E_Day" : null, + "E_Total" : 7512664.4041666668, + "E_Year" : null, + "Meter_Location" : "grid", + "Mode" : "bidirectional", + "P_Akku" : 0.15907810628414154, + "P_Grid" : 2274.9000000000001, + "P_Load" : -2459.3092254638673, + "P_PV" : 216.43276786804199, + "rel_Autonomy" : 7.4984155532163506, + "rel_SelfConsumption" : 100.0 + }, + "Smartloads" : { + "Ohmpilots" : { + "0" : { + "P_AC_Total" : 0.0, + "State" : "normal", + "Temperature" : 38.799999999999997 + } + } + }, + "Version" : "12" + } + }, + "Head" : { + "RequestArguments" : {}, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-11-28T13:12:20+00:00" + } +} diff --git a/tests/components/fronius/fixtures/gen24_storage/GetStorageRealtimeData.json b/tests/components/fronius/fixtures/gen24_storage/GetStorageRealtimeData.json new file mode 100644 index 00000000000..d810962c66c --- /dev/null +++ b/tests/components/fronius/fixtures/gen24_storage/GetStorageRealtimeData.json @@ -0,0 +1,36 @@ +{ + "Body" : { + "Data" : { + "0" : { + "Controller" : { + "Capacity_Maximum" : 16588, + "Current_DC" : 0.0, + "DesignedCapacity" : 16588, + "Details" : { + "Manufacturer" : "BYD", + "Model" : "BYD Battery-Box Premium HV", + "Serial" : "P030T020Z2001234567 " + }, + "Enable" : 1, + "StateOfCharge_Relative" : 4.5999999999999996, + "Status_BatteryCell" : 0.0, + "Temperature_Cell" : 21.5, + "TimeStamp" : 1638105056.0, + "Voltage_DC" : 0.0 + }, + "Modules" : [] + } + } + }, + "Head" : { + "RequestArguments" : { + "Scope" : "System" + }, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-11-28T13:10:57+00:00" + } +} diff --git a/tests/components/fronius/fixtures/primo_s0/GetAPIVersion.json b/tests/components/fronius/fixtures/primo_s0/GetAPIVersion.json new file mode 100644 index 00000000000..2051b4d58e3 --- /dev/null +++ b/tests/components/fronius/fixtures/primo_s0/GetAPIVersion.json @@ -0,0 +1,5 @@ +{ + "APIVersion" : 1, + "BaseURL" : "/solar_api/v1/", + "CompatibilityRange" : "1.6-3" +} diff --git a/tests/components/fronius/fixtures/primo_s0/GetInverterInfo.json b/tests/components/fronius/fixtures/primo_s0/GetInverterInfo.json new file mode 100644 index 00000000000..5ac293653c0 --- /dev/null +++ b/tests/components/fronius/fixtures/primo_s0/GetInverterInfo.json @@ -0,0 +1,33 @@ +{ + "Body" : { + "Data" : { + "1" : { + "CustomName" : "Primo 5.0-1", + "DT" : 76, + "ErrorCode" : 0, + "PVPower" : 5160, + "Show" : 1, + "StatusCode" : 7, + "UniqueID" : "123456" + }, + "2" : { + "CustomName" : "Primo 3.0-1", + "DT" : 81, + "ErrorCode" : 0, + "PVPower" : 3240, + "Show" : 1, + "StatusCode" : 7, + "UniqueID" : "234567" + } + } + }, + "Head" : { + "RequestArguments" : {}, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-12-09T15:34:06-03:00" + } +} diff --git a/tests/components/fronius/fixtures/primo_s0/GetInverterRealtimeData_Device_1.json b/tests/components/fronius/fixtures/primo_s0/GetInverterRealtimeData_Device_1.json new file mode 100644 index 00000000000..e54366a5008 --- /dev/null +++ b/tests/components/fronius/fixtures/primo_s0/GetInverterRealtimeData_Device_1.json @@ -0,0 +1,64 @@ +{ + "Body" : { + "Data" : { + "DAY_ENERGY" : { + "Unit" : "Wh", + "Value" : 22504 + }, + "DeviceStatus" : { + "ErrorCode" : 0, + "LEDColor" : 2, + "LEDState" : 0, + "MgmtTimerRemainingTime" : -1, + "StateToReset" : false, + "StatusCode" : 7 + }, + "FAC" : { + "Unit" : "Hz", + "Value" : 60 + }, + "IAC" : { + "Unit" : "A", + "Value" : 3.8500000000000001 + }, + "IDC" : { + "Unit" : "A", + "Value" : 4.2300000000000004 + }, + "PAC" : { + "Unit" : "W", + "Value" : 862 + }, + "TOTAL_ENERGY" : { + "Unit" : "Wh", + "Value" : 17114940 + }, + "UAC" : { + "Unit" : "V", + "Value" : 223.90000000000001 + }, + "UDC" : { + "Unit" : "V", + "Value" : 452.30000000000001 + }, + "YEAR_ENERGY" : { + "Unit" : "Wh", + "Value" : 7532755.5 + } + } + }, + "Head" : { + "RequestArguments" : { + "DataCollection" : "CommonInverterData", + "DeviceClass" : "Inverter", + "DeviceId" : "1", + "Scope" : "Device" + }, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-12-09T15:34:08-03:00" + } +} diff --git a/tests/components/fronius/fixtures/primo_s0/GetInverterRealtimeData_Device_2.json b/tests/components/fronius/fixtures/primo_s0/GetInverterRealtimeData_Device_2.json new file mode 100644 index 00000000000..dd1e22c0a7a --- /dev/null +++ b/tests/components/fronius/fixtures/primo_s0/GetInverterRealtimeData_Device_2.json @@ -0,0 +1,64 @@ +{ + "Body" : { + "Data" : { + "DAY_ENERGY" : { + "Unit" : "Wh", + "Value" : 14237 + }, + "DeviceStatus" : { + "ErrorCode" : 0, + "LEDColor" : 2, + "LEDState" : 0, + "MgmtTimerRemainingTime" : -1, + "StateToReset" : false, + "StatusCode" : 7 + }, + "FAC" : { + "Unit" : "Hz", + "Value" : 60.009999999999998 + }, + "IAC" : { + "Unit" : "A", + "Value" : 1.3200000000000001 + }, + "IDC" : { + "Unit" : "A", + "Value" : 0.96999999999999997 + }, + "PAC" : { + "Unit" : "W", + "Value" : 296 + }, + "TOTAL_ENERGY" : { + "Unit" : "Wh", + "Value" : 5796010 + }, + "UAC" : { + "Unit" : "V", + "Value" : 223.59999999999999 + }, + "UDC" : { + "Unit" : "V", + "Value" : 329.5 + }, + "YEAR_ENERGY" : { + "Unit" : "Wh", + "Value" : 3596193.25 + } + } + }, + "Head" : { + "RequestArguments" : { + "DataCollection" : "CommonInverterData", + "DeviceClass" : "Inverter", + "DeviceId" : "2", + "Scope" : "Device" + }, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-12-09T15:36:15-03:00" + } +} diff --git a/tests/components/fronius/fixtures/primo_s0/GetLoggerInfo.json b/tests/components/fronius/fixtures/primo_s0/GetLoggerInfo.json new file mode 100644 index 00000000000..1fb0bbc8577 --- /dev/null +++ b/tests/components/fronius/fixtures/primo_s0/GetLoggerInfo.json @@ -0,0 +1,29 @@ +{ + "Body" : { + "LoggerInfo" : { + "CO2Factor" : 0.52999997138977051, + "CO2Unit" : "kg", + "CashCurrency" : "BRL", + "CashFactor" : 1, + "DefaultLanguage" : "en", + "DeliveryFactor" : 1, + "HWVersion" : "2.4E", + "PlatformID" : "wilma", + "ProductID" : "fronius-datamanager-card", + "SWVersion" : "3.18.7-1", + "TimezoneLocation" : "Sao_Paulo", + "TimezoneName" : "-03", + "UTCOffset" : 4294956496, + "UniqueID" : "123.4567890" + } + }, + "Head" : { + "RequestArguments" : {}, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-12-09T15:34:09-03:00" + } +} diff --git a/tests/components/fronius/fixtures/primo_s0/GetMeterRealtimeData.json b/tests/components/fronius/fixtures/primo_s0/GetMeterRealtimeData.json new file mode 100644 index 00000000000..aa308bb3b69 --- /dev/null +++ b/tests/components/fronius/fixtures/primo_s0/GetMeterRealtimeData.json @@ -0,0 +1,31 @@ +{ + "Body" : { + "Data" : { + "0" : { + "Details" : { + "Manufacturer" : "Fronius", + "Model" : "S0 Meter at inverter 1", + "Serial" : "n.a." + }, + "Enable" : 1, + "EnergyReal_WAC_Minus_Relative" : 191.25, + "Meter_Location_Current" : 1, + "PowerReal_P_Sum" : -2216.7486858112229, + "TimeStamp" : 1639074843, + "Visible" : 1 + } + } + }, + "Head" : { + "RequestArguments" : { + "DeviceClass" : "Meter", + "Scope" : "System" + }, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-12-09T15:34:04-03:00" + } +} diff --git a/tests/components/fronius/fixtures/primo_s0/GetOhmPilotRealtimeData.json b/tests/components/fronius/fixtures/primo_s0/GetOhmPilotRealtimeData.json new file mode 100644 index 00000000000..4562b45efb0 --- /dev/null +++ b/tests/components/fronius/fixtures/primo_s0/GetOhmPilotRealtimeData.json @@ -0,0 +1,17 @@ +{ + "Body" : { + "Data" : {} + }, + "Head" : { + "RequestArguments" : { + "DeviceClass" : "OhmPilot", + "Scope" : "System" + }, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-12-09T15:34:05-03:00" + } +} diff --git a/tests/components/fronius/fixtures/primo_s0/GetPowerFlowRealtimeData.json b/tests/components/fronius/fixtures/primo_s0/GetPowerFlowRealtimeData.json new file mode 100644 index 00000000000..4bbee2aec28 --- /dev/null +++ b/tests/components/fronius/fixtures/primo_s0/GetPowerFlowRealtimeData.json @@ -0,0 +1,45 @@ +{ + "Body" : { + "Data" : { + "Inverters" : { + "1" : { + "DT" : 76, + "E_Day" : 22502, + "E_Total" : 17114930, + "E_Year" : 7532753.5, + "P" : 886 + }, + "2" : { + "DT" : 81, + "E_Day" : 14222, + "E_Total" : 5795989.5, + "E_Year" : 3596179.75, + "P" : 948 + } + }, + "Site" : { + "E_Day" : 36724, + "E_Total" : 22910919.5, + "E_Year" : 11128933.25, + "Meter_Location" : "load", + "Mode" : "vague-meter", + "P_Akku" : null, + "P_Grid" : 384.93491437299008, + "P_Load" : -2218.9349143729901, + "P_PV" : 1834, + "rel_Autonomy" : 82.652266550064084, + "rel_SelfConsumption" : 100 + }, + "Version" : "12" + } + }, + "Head" : { + "RequestArguments" : {}, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-12-09T15:34:06-03:00" + } +} diff --git a/tests/components/fronius/fixtures/primo_s0/GetStorageRealtimeData.json b/tests/components/fronius/fixtures/primo_s0/GetStorageRealtimeData.json new file mode 100644 index 00000000000..8743a2c6d68 --- /dev/null +++ b/tests/components/fronius/fixtures/primo_s0/GetStorageRealtimeData.json @@ -0,0 +1,14 @@ +{ + "Body" : { + "Data" : {} + }, + "Head" : { + "RequestArguments" : {}, + "Status" : { + "Code" : 255, + "Reason" : "GetStorageRealtimeData request is not supported by this device.", + "UserMessage" : "" + }, + "Timestamp" : "2021-12-09T15:34:05-03:00" + } +} diff --git a/tests/components/fronius/fixtures/symo/GetAPIVersion.json b/tests/components/fronius/fixtures/symo/GetAPIVersion.json new file mode 100644 index 00000000000..59c1dbb1c5c --- /dev/null +++ b/tests/components/fronius/fixtures/symo/GetAPIVersion.json @@ -0,0 +1,5 @@ +{ + "APIVersion": 1, + "BaseURL": "/solar_api/v1/", + "CompatibilityRange": "1.6-3" +} \ No newline at end of file diff --git a/tests/components/fronius/fixtures/symo/GetInverterInfo.json b/tests/components/fronius/fixtures/symo/GetInverterInfo.json new file mode 100644 index 00000000000..8bbf01a6cb4 --- /dev/null +++ b/tests/components/fronius/fixtures/symo/GetInverterInfo.json @@ -0,0 +1,24 @@ +{ + "Body": { + "Data": { + "1": { + "CustomName": "Symo 20", + "DT": 121, + "ErrorCode": 0, + "PVPower": 23100, + "Show": 1, + "StatusCode": 7, + "UniqueID": "1234567" + } + } + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2021-10-07T13:41:00+02:00" + } +} \ No newline at end of file diff --git a/tests/components/fronius/fixtures/symo/GetInverterRealtimeData_Device_1.json b/tests/components/fronius/fixtures/symo/GetInverterRealtimeData_Device_1.json new file mode 100644 index 00000000000..d504b125a62 --- /dev/null +++ b/tests/components/fronius/fixtures/symo/GetInverterRealtimeData_Device_1.json @@ -0,0 +1,64 @@ +{ + "Body": { + "Data": { + "DAY_ENERGY": { + "Unit": "Wh", + "Value": 1113 + }, + "DeviceStatus": { + "ErrorCode": 0, + "LEDColor": 2, + "LEDState": 0, + "MgmtTimerRemainingTime": -1, + "StateToReset": false, + "StatusCode": 7 + }, + "FAC": { + "Unit": "Hz", + "Value": 49.939999999999998 + }, + "IAC": { + "Unit": "A", + "Value": 5.1900000000000004 + }, + "IDC": { + "Unit": "A", + "Value": 2.1899999999999999 + }, + "PAC": { + "Unit": "W", + "Value": 1190 + }, + "TOTAL_ENERGY": { + "Unit": "Wh", + "Value": 44188000 + }, + "UAC": { + "Unit": "V", + "Value": 227.90000000000001 + }, + "UDC": { + "Unit": "V", + "Value": 518 + }, + "YEAR_ENERGY": { + "Unit": "Wh", + "Value": 25508798 + } + } + }, + "Head": { + "RequestArguments": { + "DataCollection": "CommonInverterData", + "DeviceClass": "Inverter", + "DeviceId": "1", + "Scope": "Device" + }, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2021-10-07T10:01:17+02:00" + } +} \ No newline at end of file diff --git a/tests/components/fronius/fixtures/symo/GetInverterRealtimeData_Device_1_night.json b/tests/components/fronius/fixtures/symo/GetInverterRealtimeData_Device_1_night.json new file mode 100644 index 00000000000..d3c5091fc00 --- /dev/null +++ b/tests/components/fronius/fixtures/symo/GetInverterRealtimeData_Device_1_night.json @@ -0,0 +1,48 @@ +{ + "Body": { + "Data": { + "DAY_ENERGY": { + "Unit": "Wh", + "Value": 10828 + }, + "DeviceStatus": { + "ErrorCode": 307, + "LEDColor": 1, + "LEDState": 0, + "MgmtTimerRemainingTime": 17, + "StateToReset": false, + "StatusCode": 3 + }, + "IDC": { + "Unit": "A", + "Value": 0 + }, + "TOTAL_ENERGY": { + "Unit": "Wh", + "Value": 44186900 + }, + "UDC": { + "Unit": "V", + "Value": 16 + }, + "YEAR_ENERGY": { + "Unit": "Wh", + "Value": 25507686 + } + } + }, + "Head": { + "RequestArguments": { + "DataCollection": "CommonInverterData", + "DeviceClass": "Inverter", + "DeviceId": "1", + "Scope": "Device" + }, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2021-10-06T21:16:59+02:00" + } +} \ No newline at end of file diff --git a/tests/components/fronius/fixtures/symo/GetLoggerInfo.json b/tests/components/fronius/fixtures/symo/GetLoggerInfo.json new file mode 100644 index 00000000000..f977823674e --- /dev/null +++ b/tests/components/fronius/fixtures/symo/GetLoggerInfo.json @@ -0,0 +1,29 @@ +{ + "Body": { + "LoggerInfo": { + "CO2Factor": 0.52999997138977051, + "CO2Unit": "kg", + "CashCurrency": "EUR", + "CashFactor": 0.078000001609325409, + "DefaultLanguage": "en", + "DeliveryFactor": 0.15000000596046448, + "HWVersion": "2.4E", + "PlatformID": "wilma", + "ProductID": "fronius-datamanager-card", + "SWVersion": "3.18.7-1", + "TimezoneLocation": "Vienna", + "TimezoneName": "CEST", + "UTCOffset": 7200, + "UniqueID": "123.4567890" + } + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2021-10-06T23:56:32+02:00" + } +} \ No newline at end of file diff --git a/tests/components/fronius/fixtures/symo/GetMeterRealtimeData.json b/tests/components/fronius/fixtures/symo/GetMeterRealtimeData.json new file mode 100644 index 00000000000..6c13116f351 --- /dev/null +++ b/tests/components/fronius/fixtures/symo/GetMeterRealtimeData.json @@ -0,0 +1,61 @@ +{ + "Body": { + "Data": { + "0": { + "Current_AC_Phase_1": 7.7549999999999999, + "Current_AC_Phase_2": 6.6799999999999997, + "Current_AC_Phase_3": 10.102, + "Details": { + "Manufacturer": "Fronius", + "Model": "Smart Meter 63A", + "Serial": "12345678" + }, + "Enable": 1, + "EnergyReactive_VArAC_Sum_Consumed": 59960790, + "EnergyReactive_VArAC_Sum_Produced": 723160, + "EnergyReal_WAC_Minus_Absolute": 35623065, + "EnergyReal_WAC_Plus_Absolute": 15303334, + "EnergyReal_WAC_Sum_Consumed": 15303334, + "EnergyReal_WAC_Sum_Produced": 35623065, + "Frequency_Phase_Average": 50, + "Meter_Location_Current": 0, + "PowerApparent_S_Phase_1": 1772.7929999999999, + "PowerApparent_S_Phase_2": 1527.048, + "PowerApparent_S_Phase_3": 2333.5619999999999, + "PowerApparent_S_Sum": 5592.5699999999997, + "PowerFactor_Phase_1": -0.98999999999999999, + "PowerFactor_Phase_2": -0.98999999999999999, + "PowerFactor_Phase_3": 0.98999999999999999, + "PowerFactor_Sum": 1, + "PowerReactive_Q_Phase_1": 51.479999999999997, + "PowerReactive_Q_Phase_2": 115.63, + "PowerReactive_Q_Phase_3": -164.24000000000001, + "PowerReactive_Q_Sum": 2.8700000000000001, + "PowerReal_P_Phase_1": 1765.55, + "PowerReal_P_Phase_2": 1515.8, + "PowerReal_P_Phase_3": 2311.2199999999998, + "PowerReal_P_Sum": 5592.5699999999997, + "TimeStamp": 1633977078, + "Visible": 1, + "Voltage_AC_PhaseToPhase_12": 395.89999999999998, + "Voltage_AC_PhaseToPhase_23": 398, + "Voltage_AC_PhaseToPhase_31": 398, + "Voltage_AC_Phase_1": 228.59999999999999, + "Voltage_AC_Phase_2": 228.59999999999999, + "Voltage_AC_Phase_3": 231 + } + } + }, + "Head": { + "RequestArguments": { + "DeviceClass": "Meter", + "Scope": "System" + }, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2021-10-11T20:31:18+02:00" + } +} \ No newline at end of file diff --git a/tests/components/fronius/fixtures/symo/GetOhmPilotRealtimeData.json b/tests/components/fronius/fixtures/symo/GetOhmPilotRealtimeData.json new file mode 100644 index 00000000000..38cbde318ab --- /dev/null +++ b/tests/components/fronius/fixtures/symo/GetOhmPilotRealtimeData.json @@ -0,0 +1,17 @@ +{ + "Body" : { + "Data" : {} + }, + "Head" : { + "RequestArguments" : { + "DeviceClass" : "OhmPilot", + "Scope" : "System" + }, + "Status" : { + "Code" : 0, + "Reason" : "", + "UserMessage" : "" + }, + "Timestamp" : "2021-11-28T22:36:04+01:00" + } +} diff --git a/tests/components/fronius/fixtures/symo/GetPowerFlowRealtimeData.json b/tests/components/fronius/fixtures/symo/GetPowerFlowRealtimeData.json new file mode 100644 index 00000000000..03e6a74ecf1 --- /dev/null +++ b/tests/components/fronius/fixtures/symo/GetPowerFlowRealtimeData.json @@ -0,0 +1,38 @@ +{ + "Body": { + "Data": { + "Inverters": { + "1": { + "DT": 121, + "E_Day": 1101.7000732421875, + "E_Total": 44188000, + "E_Year": 25508788, + "P": 1111 + } + }, + "Site": { + "E_Day": 1101.7000732421875, + "E_Total": 44188000, + "E_Year": 25508788, + "Meter_Location": "grid", + "Mode": "meter", + "P_Akku": null, + "P_Grid": 1703.74, + "P_Load": -2814.7399999999998, + "P_PV": 1111, + "rel_Autonomy": 39.4707859340472, + "rel_SelfConsumption": 100 + }, + "Version": "12" + } + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2021-10-07T10:00:43+02:00" + } +} \ No newline at end of file diff --git a/tests/components/fronius/fixtures/symo/GetPowerFlowRealtimeData_night.json b/tests/components/fronius/fixtures/symo/GetPowerFlowRealtimeData_night.json new file mode 100644 index 00000000000..141033fe4f2 --- /dev/null +++ b/tests/components/fronius/fixtures/symo/GetPowerFlowRealtimeData_night.json @@ -0,0 +1,38 @@ +{ + "Body": { + "Data": { + "Inverters": { + "1": { + "DT": 121, + "E_Day": 10828, + "E_Total": 44186900, + "E_Year": 25507686, + "P": 0 + } + }, + "Site": { + "E_Day": 10828, + "E_Total": 44186900, + "E_Year": 25507686, + "Meter_Location": "grid", + "Mode": "meter", + "P_Akku": null, + "P_Grid": 975.30999999999995, + "P_Load": -975.30999999999995, + "P_PV": null, + "rel_Autonomy": 0, + "rel_SelfConsumption": null + }, + "Version": "12" + } + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2021-10-06T23:49:54+02:00" + } +} \ No newline at end of file diff --git a/tests/components/fronius/fixtures/symo/GetStorageRealtimeData.json b/tests/components/fronius/fixtures/symo/GetStorageRealtimeData.json new file mode 100644 index 00000000000..db4ab288683 --- /dev/null +++ b/tests/components/fronius/fixtures/symo/GetStorageRealtimeData.json @@ -0,0 +1,14 @@ +{ + "Body": { + "Data": {} + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 255, + "Reason": "GetStorageRealtimeData request is not supported by this device.", + "UserMessage": "" + }, + "Timestamp": "2021-10-22T06:50:22+02:00" + } +} \ No newline at end of file diff --git a/tests/components/fronius/test_config_flow.py b/tests/components/fronius/test_config_flow.py new file mode 100644 index 00000000000..427c8e4a163 --- /dev/null +++ b/tests/components/fronius/test_config_flow.py @@ -0,0 +1,328 @@ +"""Test the Fronius config flow.""" +from unittest.mock import patch + +from pyfronius import FroniusError + +from homeassistant import config_entries +from homeassistant.components.dhcp import DhcpServiceInfo +from homeassistant.components.fronius.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_HOST, CONF_RESOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) +from homeassistant.setup import async_setup_component + +from . import MOCK_HOST, mock_responses + +from tests.common import MockConfigEntry + +INVERTER_INFO_RETURN_VALUE = { + "inverters": [ + { + "device_id": {"value": "1"}, + "unique_id": {"value": "1234567"}, + } + ] +} +LOGGER_INFO_RETURN_VALUE = {"unique_identifier": {"value": "123.4567"}} +MOCK_DHCP_DATA = DhcpServiceInfo( + hostname="fronius", + ip="10.2.3.4", + macaddress="00:03:ac:11:22:33", +) + + +async def test_form_with_logger(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "pyfronius.Fronius.current_logger_info", + return_value=LOGGER_INFO_RETURN_VALUE, + ), patch( + "homeassistant.components.fronius.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "10.9.8.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "SolarNet Datalogger at 10.9.8.1" + assert result2["data"] == { + "host": "10.9.8.1", + "is_logger": True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_with_inverter(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "pyfronius.Fronius.current_logger_info", + side_effect=FroniusError, + ), patch( + "pyfronius.Fronius.inverter_info", + return_value=INVERTER_INFO_RETURN_VALUE, + ), patch( + "homeassistant.components.fronius.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "10.9.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "SolarNet Inverter at 10.9.1.1" + assert result2["data"] == { + "host": "10.9.1.1", + "is_logger": False, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyfronius.Fronius.current_logger_info", + side_effect=FroniusError, + ), patch( + "pyfronius.Fronius.inverter_info", + side_effect=FroniusError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_no_device(hass: HomeAssistant) -> None: + """Test we handle no device found error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyfronius.Fronius.current_logger_info", + side_effect=FroniusError, + ), patch( + "pyfronius.Fronius.inverter_info", + return_value={"inverters": []}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unexpected(hass: HomeAssistant) -> None: + """Test we handle unexpected error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyfronius.Fronius.current_logger_info", + side_effect=KeyError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_already_existing(hass): + """Test existing entry.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="123.4567", + data={CONF_HOST: "10.9.8.1", "is_logger": True}, + ).add_to_hass(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "pyfronius.Fronius.current_logger_info", + return_value=LOGGER_INFO_RETURN_VALUE, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "10.9.8.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + +async def test_form_updates_host(hass, aioclient_mock): + """Test existing entry gets updated.""" + old_host = "http://10.1.0.1" + new_host = "http://10.1.0.2" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123.4567890", # has to match mocked logger unique_id + data={ + CONF_HOST: old_host, + "is_logger": True, + }, + ) + entry.add_to_hass(hass) + mock_responses(aioclient_mock, host=old_host) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + 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_USER} + ) + + mock_responses(aioclient_mock, host=new_host) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": new_host, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].data == { + "host": new_host, + "is_logger": True, + } + + +async def test_import(hass, aioclient_mock): + """Test import step.""" + mock_responses(aioclient_mock) + assert await async_setup_component( + hass, + SENSOR_DOMAIN, + { + SENSOR_DOMAIN: { + "platform": DOMAIN, + CONF_RESOURCE: MOCK_HOST, + } + }, + ) + await hass.async_block_till_done() + + fronius_entries = hass.config_entries.async_entries(DOMAIN) + assert len(fronius_entries) == 1 + + test_entry = fronius_entries[0] + assert test_entry.unique_id == "123.4567890" # has to match mocked logger unique_id + assert test_entry.data == { + "host": MOCK_HOST, + "is_logger": True, + } + + +async def test_dhcp(hass, aioclient_mock): + """Test starting a flow from discovery.""" + with patch( + "homeassistant.components.fronius.config_flow.DHCP_REQUEST_DELAY", 0 + ), patch( + "pyfronius.Fronius.current_logger_info", + return_value=LOGGER_INFO_RETURN_VALUE, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=MOCK_DHCP_DATA + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm_discovery" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == f"SolarNet Datalogger at {MOCK_DHCP_DATA.ip}" + assert result["data"] == { + "host": MOCK_DHCP_DATA.ip, + "is_logger": True, + } + + +async def test_dhcp_already_configured(hass, aioclient_mock): + """Test starting a flow from discovery.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123.4567890", + data={ + CONF_HOST: f"http://{MOCK_DHCP_DATA.ip}/", + "is_logger": True, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=MOCK_DHCP_DATA + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_invalid(hass, aioclient_mock): + """Test starting a flow from discovery.""" + with patch( + "homeassistant.components.fronius.config_flow.DHCP_REQUEST_DELAY", 0 + ), patch("pyfronius.Fronius.current_logger_info", side_effect=FroniusError,), patch( + "pyfronius.Fronius.inverter_info", + side_effect=FroniusError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=MOCK_DHCP_DATA + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "invalid_host" diff --git a/tests/components/fronius/test_coordinator.py b/tests/components/fronius/test_coordinator.py new file mode 100644 index 00000000000..b729c4d97ac --- /dev/null +++ b/tests/components/fronius/test_coordinator.py @@ -0,0 +1,55 @@ +"""Test the Fronius update coordinators.""" +from unittest.mock import patch + +from pyfronius import FroniusError + +from homeassistant.components.fronius.coordinator import ( + FroniusInverterUpdateCoordinator, +) +from homeassistant.util import dt + +from . import mock_responses, setup_fronius_integration + +from tests.common import async_fire_time_changed + + +async def test_adaptive_update_interval(hass, aioclient_mock): + """Test coordinators changing their update interval when inverter not available.""" + with patch("pyfronius.Fronius.current_inverter_data") as mock_inverter_data: + mock_responses(aioclient_mock) + await setup_fronius_integration(hass) + assert mock_inverter_data.call_count == 1 + + async_fire_time_changed( + hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval + ) + await hass.async_block_till_done() + assert mock_inverter_data.call_count == 2 + + mock_inverter_data.side_effect = FroniusError + # first 3 requests at default interval - 4th has different interval + for _ in range(4): + async_fire_time_changed( + hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval + ) + await hass.async_block_till_done() + assert mock_inverter_data.call_count == 5 + async_fire_time_changed( + hass, dt.utcnow() + FroniusInverterUpdateCoordinator.error_interval + ) + await hass.async_block_till_done() + assert mock_inverter_data.call_count == 6 + + mock_inverter_data.side_effect = None + # next successful request resets to default interval + async_fire_time_changed( + hass, dt.utcnow() + FroniusInverterUpdateCoordinator.error_interval + ) + await hass.async_block_till_done() + assert mock_inverter_data.call_count == 7 + + async_fire_time_changed( + hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval + ) + await hass.async_block_till_done() + assert mock_inverter_data.call_count == 8 diff --git a/tests/components/fronius/test_init.py b/tests/components/fronius/test_init.py new file mode 100644 index 00000000000..bfbd631ff37 --- /dev/null +++ b/tests/components/fronius/test_init.py @@ -0,0 +1,46 @@ +"""Test the Fronius integration.""" +from unittest.mock import patch + +from pyfronius import FroniusError + +from homeassistant.components.fronius.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState + +from . import mock_responses, setup_fronius_integration + + +async def test_unload_config_entry(hass, aioclient_mock): + """Test that configuration entry supports unloading.""" + mock_responses(aioclient_mock) + await setup_fronius_integration(hass) + + fronius_entries = hass.config_entries.async_entries(DOMAIN) + assert len(fronius_entries) == 1 + + test_entry = fronius_entries[0] + assert test_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(test_entry.entry_id) + await hass.async_block_till_done() + + assert test_entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + +async def test_logger_error(hass, aioclient_mock): + """Test setup when logger reports an error.""" + # gen24 dataset will raise FroniusError when logger is called + mock_responses(aioclient_mock, fixture_set="gen24") + config_entry = await setup_fronius_integration(hass, is_logger=True) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_inverter_error(hass, aioclient_mock): + """Test setup when inverter_info reports an error.""" + mock_responses(aioclient_mock) + with patch( + "pyfronius.Fronius.inverter_info", + side_effect=FroniusError, + ): + config_entry = await setup_fronius_integration(hass) + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py new file mode 100644 index 00000000000..2e48faf606a --- /dev/null +++ b/tests/components/fronius/test_sensor.py @@ -0,0 +1,605 @@ +"""Tests for the Fronius sensor platform.""" +from homeassistant.components.fronius.const import DOMAIN +from homeassistant.components.fronius.coordinator import ( + FroniusInverterUpdateCoordinator, + FroniusMeterUpdateCoordinator, + FroniusPowerFlowUpdateCoordinator, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import STATE_UNKNOWN +from homeassistant.helpers import device_registry as dr +from homeassistant.util import dt + +from . import enable_all_entities, mock_responses, setup_fronius_integration + +from tests.common import async_fire_time_changed + + +async def test_symo_inverter(hass, aioclient_mock): + """Test Fronius Symo inverter entities.""" + + def assert_state(entity_id, expected_state): + state = hass.states.get(entity_id) + assert state.state == str(expected_state) + + # Init at night + mock_responses(aioclient_mock, night=True) + config_entry = await setup_fronius_integration(hass) + + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 23 + await enable_all_entities( + hass, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval + ) + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 55 + assert_state("sensor.current_dc_fronius_inverter_1_http_fronius", 0) + assert_state("sensor.energy_day_fronius_inverter_1_http_fronius", 10828) + assert_state("sensor.energy_total_fronius_inverter_1_http_fronius", 44186900) + assert_state("sensor.energy_year_fronius_inverter_1_http_fronius", 25507686) + assert_state("sensor.voltage_dc_fronius_inverter_1_http_fronius", 16) + + # Second test at daytime when inverter is producing + mock_responses(aioclient_mock, night=False) + async_fire_time_changed( + hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval + ) + await hass.async_block_till_done() + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 57 + await enable_all_entities( + hass, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval + ) + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 59 + # 4 additional AC entities + assert_state("sensor.current_dc_fronius_inverter_1_http_fronius", 2.19) + assert_state("sensor.energy_day_fronius_inverter_1_http_fronius", 1113) + assert_state("sensor.energy_total_fronius_inverter_1_http_fronius", 44188000) + assert_state("sensor.energy_year_fronius_inverter_1_http_fronius", 25508798) + assert_state("sensor.voltage_dc_fronius_inverter_1_http_fronius", 518) + assert_state("sensor.current_ac_fronius_inverter_1_http_fronius", 5.19) + assert_state("sensor.frequency_ac_fronius_inverter_1_http_fronius", 49.94) + assert_state("sensor.power_ac_fronius_inverter_1_http_fronius", 1190) + assert_state("sensor.voltage_ac_fronius_inverter_1_http_fronius", 227.90) + + # Third test at nighttime - additional AC entities aren't changed + mock_responses(aioclient_mock, night=True) + async_fire_time_changed( + hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval + ) + await hass.async_block_till_done() + assert_state("sensor.current_ac_fronius_inverter_1_http_fronius", 5.19) + assert_state("sensor.frequency_ac_fronius_inverter_1_http_fronius", 49.94) + assert_state("sensor.power_ac_fronius_inverter_1_http_fronius", 1190) + assert_state("sensor.voltage_ac_fronius_inverter_1_http_fronius", 227.90) + + +async def test_symo_logger(hass, aioclient_mock): + """Test Fronius Symo logger entities.""" + + def assert_state(entity_id, expected_state): + state = hass.states.get(entity_id) + assert state + assert state.state == str(expected_state) + + mock_responses(aioclient_mock) + await setup_fronius_integration(hass) + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 25 + + # ignored constant entities: + # hardware_platform, hardware_version, product_type + # software_version, time_zone, time_zone_location + # time_stamp, unique_identifier, utc_offset + # + # states are rounded to 4 decimals + assert_state( + "sensor.cash_factor_fronius_logger_info_0_http_fronius", + 0.078, + ) + assert_state( + "sensor.co2_factor_fronius_logger_info_0_http_fronius", + 0.53, + ) + assert_state( + "sensor.delivery_factor_fronius_logger_info_0_http_fronius", + 0.15, + ) + + +async def test_symo_meter(hass, aioclient_mock): + """Test Fronius Symo meter entities.""" + + def assert_state(entity_id, expected_state): + state = hass.states.get(entity_id) + assert state + assert state.state == str(expected_state) + + mock_responses(aioclient_mock) + config_entry = await setup_fronius_integration(hass) + + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 25 + await enable_all_entities( + hass, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval + ) + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 59 + # ignored entities: + # manufacturer, model, serial, enable, timestamp, visible, meter_location + # + # states are rounded to 4 decimals + assert_state("sensor.current_ac_phase_1_fronius_meter_0_http_fronius", 7.755) + assert_state("sensor.current_ac_phase_2_fronius_meter_0_http_fronius", 6.68) + assert_state("sensor.current_ac_phase_3_fronius_meter_0_http_fronius", 10.102) + assert_state( + "sensor.energy_reactive_ac_consumed_fronius_meter_0_http_fronius", 59960790 + ) + assert_state( + "sensor.energy_reactive_ac_produced_fronius_meter_0_http_fronius", 723160 + ) + assert_state("sensor.energy_real_ac_minus_fronius_meter_0_http_fronius", 35623065) + assert_state("sensor.energy_real_ac_plus_fronius_meter_0_http_fronius", 15303334) + assert_state("sensor.energy_real_consumed_fronius_meter_0_http_fronius", 15303334) + assert_state("sensor.energy_real_produced_fronius_meter_0_http_fronius", 35623065) + assert_state("sensor.frequency_phase_average_fronius_meter_0_http_fronius", 50) + assert_state("sensor.power_apparent_phase_1_fronius_meter_0_http_fronius", 1772.793) + assert_state("sensor.power_apparent_phase_2_fronius_meter_0_http_fronius", 1527.048) + assert_state("sensor.power_apparent_phase_3_fronius_meter_0_http_fronius", 2333.562) + assert_state("sensor.power_apparent_fronius_meter_0_http_fronius", 5592.57) + assert_state("sensor.power_factor_phase_1_fronius_meter_0_http_fronius", -0.99) + assert_state("sensor.power_factor_phase_2_fronius_meter_0_http_fronius", -0.99) + assert_state("sensor.power_factor_phase_3_fronius_meter_0_http_fronius", 0.99) + assert_state("sensor.power_factor_fronius_meter_0_http_fronius", 1) + assert_state("sensor.power_reactive_phase_1_fronius_meter_0_http_fronius", 51.48) + assert_state("sensor.power_reactive_phase_2_fronius_meter_0_http_fronius", 115.63) + assert_state("sensor.power_reactive_phase_3_fronius_meter_0_http_fronius", -164.24) + assert_state("sensor.power_reactive_fronius_meter_0_http_fronius", 2.87) + assert_state("sensor.power_real_phase_1_fronius_meter_0_http_fronius", 1765.55) + assert_state("sensor.power_real_phase_2_fronius_meter_0_http_fronius", 1515.8) + assert_state("sensor.power_real_phase_3_fronius_meter_0_http_fronius", 2311.22) + assert_state("sensor.power_real_fronius_meter_0_http_fronius", 5592.57) + assert_state("sensor.voltage_ac_phase_1_fronius_meter_0_http_fronius", 228.6) + assert_state("sensor.voltage_ac_phase_2_fronius_meter_0_http_fronius", 228.6) + assert_state("sensor.voltage_ac_phase_3_fronius_meter_0_http_fronius", 231) + assert_state( + "sensor.voltage_ac_phase_to_phase_12_fronius_meter_0_http_fronius", 395.9 + ) + assert_state( + "sensor.voltage_ac_phase_to_phase_23_fronius_meter_0_http_fronius", 398 + ) + assert_state( + "sensor.voltage_ac_phase_to_phase_31_fronius_meter_0_http_fronius", 398 + ) + + +async def test_symo_power_flow(hass, aioclient_mock): + """Test Fronius Symo power flow entities.""" + async_fire_time_changed(hass, dt.utcnow()) + + def assert_state(entity_id, expected_state): + state = hass.states.get(entity_id) + assert state.state == str(expected_state) + + # First test at night + mock_responses(aioclient_mock, night=True) + config_entry = await setup_fronius_integration(hass) + + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 23 + await enable_all_entities( + hass, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval + ) + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 55 + # ignored: location, mode, timestamp + # + # states are rounded to 4 decimals + assert_state( + "sensor.energy_day_fronius_power_flow_0_http_fronius", + 10828, + ) + assert_state( + "sensor.energy_total_fronius_power_flow_0_http_fronius", + 44186900, + ) + assert_state( + "sensor.energy_year_fronius_power_flow_0_http_fronius", + 25507686, + ) + assert_state( + "sensor.power_battery_fronius_power_flow_0_http_fronius", + STATE_UNKNOWN, + ) + assert_state( + "sensor.power_grid_fronius_power_flow_0_http_fronius", + 975.31, + ) + assert_state( + "sensor.power_load_fronius_power_flow_0_http_fronius", + -975.31, + ) + assert_state( + "sensor.power_photovoltaics_fronius_power_flow_0_http_fronius", + STATE_UNKNOWN, + ) + assert_state( + "sensor.relative_autonomy_fronius_power_flow_0_http_fronius", + 0, + ) + assert_state( + "sensor.relative_self_consumption_fronius_power_flow_0_http_fronius", + STATE_UNKNOWN, + ) + + # Second test at daytime when inverter is producing + mock_responses(aioclient_mock, night=False) + async_fire_time_changed( + hass, dt.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval + ) + await hass.async_block_till_done() + # still 55 because power_flow update interval is shorter than others + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 55 + assert_state( + "sensor.energy_day_fronius_power_flow_0_http_fronius", + 1101.7001, + ) + assert_state( + "sensor.energy_total_fronius_power_flow_0_http_fronius", + 44188000, + ) + assert_state( + "sensor.energy_year_fronius_power_flow_0_http_fronius", + 25508788, + ) + assert_state( + "sensor.power_battery_fronius_power_flow_0_http_fronius", + STATE_UNKNOWN, + ) + assert_state( + "sensor.power_grid_fronius_power_flow_0_http_fronius", + 1703.74, + ) + assert_state( + "sensor.power_load_fronius_power_flow_0_http_fronius", + -2814.74, + ) + assert_state( + "sensor.power_photovoltaics_fronius_power_flow_0_http_fronius", + 1111, + ) + assert_state( + "sensor.relative_autonomy_fronius_power_flow_0_http_fronius", + 39.4708, + ) + assert_state( + "sensor.relative_self_consumption_fronius_power_flow_0_http_fronius", + 100, + ) + + +async def test_gen24(hass, aioclient_mock): + """Test Fronius Gen24 inverter entities.""" + + def assert_state(entity_id, expected_state): + state = hass.states.get(entity_id) + assert state + assert state.state == str(expected_state) + + mock_responses(aioclient_mock, fixture_set="gen24") + config_entry = await setup_fronius_integration(hass, is_logger=False) + + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 25 + await enable_all_entities( + hass, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval + ) + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 57 + # inverter 1 + assert_state("sensor.energy_year_fronius_inverter_1_http_fronius", STATE_UNKNOWN) + assert_state("sensor.current_ac_fronius_inverter_1_http_fronius", 0.1589) + assert_state("sensor.current_dc_2_fronius_inverter_1_http_fronius", 0.0754) + assert_state("sensor.status_code_fronius_inverter_1_http_fronius", 7) + assert_state("sensor.energy_day_fronius_inverter_1_http_fronius", STATE_UNKNOWN) + assert_state("sensor.current_dc_fronius_inverter_1_http_fronius", 0.0783) + assert_state("sensor.voltage_dc_2_fronius_inverter_1_http_fronius", 403.4312) + assert_state("sensor.power_ac_fronius_inverter_1_http_fronius", 37.3204) + assert_state("sensor.error_code_fronius_inverter_1_http_fronius", 0) + assert_state("sensor.voltage_dc_fronius_inverter_1_http_fronius", 411.3811) + assert_state("sensor.energy_total_fronius_inverter_1_http_fronius", 1530193.42) + assert_state("sensor.inverter_state_fronius_inverter_1_http_fronius", "Running") + assert_state("sensor.voltage_ac_fronius_inverter_1_http_fronius", 234.9168) + assert_state("sensor.frequency_ac_fronius_inverter_1_http_fronius", 49.9917) + # meter + assert_state("sensor.energy_real_produced_fronius_meter_0_http_fronius", 3863340.0) + assert_state("sensor.energy_real_consumed_fronius_meter_0_http_fronius", 2013105.0) + assert_state("sensor.power_real_fronius_meter_0_http_fronius", 653.1) + assert_state("sensor.frequency_phase_average_fronius_meter_0_http_fronius", 49.9) + assert_state("sensor.meter_location_fronius_meter_0_http_fronius", 0.0) + assert_state("sensor.power_factor_fronius_meter_0_http_fronius", 0.828) + assert_state( + "sensor.energy_reactive_ac_consumed_fronius_meter_0_http_fronius", 88221.0 + ) + assert_state("sensor.energy_real_ac_minus_fronius_meter_0_http_fronius", 3863340.0) + assert_state("sensor.current_ac_phase_2_fronius_meter_0_http_fronius", 2.33) + assert_state("sensor.voltage_ac_phase_1_fronius_meter_0_http_fronius", 235.9) + assert_state( + "sensor.voltage_ac_phase_to_phase_12_fronius_meter_0_http_fronius", 408.7 + ) + assert_state("sensor.power_real_phase_2_fronius_meter_0_http_fronius", 294.9) + assert_state("sensor.energy_real_ac_plus_fronius_meter_0_http_fronius", 2013105.0) + assert_state("sensor.voltage_ac_phase_2_fronius_meter_0_http_fronius", 236.1) + assert_state( + "sensor.energy_reactive_ac_produced_fronius_meter_0_http_fronius", 1989125.0 + ) + assert_state("sensor.voltage_ac_phase_3_fronius_meter_0_http_fronius", 236.9) + assert_state("sensor.power_factor_phase_1_fronius_meter_0_http_fronius", 0.441) + assert_state( + "sensor.voltage_ac_phase_to_phase_23_fronius_meter_0_http_fronius", 409.6 + ) + assert_state("sensor.current_ac_phase_3_fronius_meter_0_http_fronius", 1.825) + assert_state("sensor.power_factor_phase_3_fronius_meter_0_http_fronius", 0.832) + assert_state("sensor.power_apparent_phase_1_fronius_meter_0_http_fronius", 243.3) + assert_state( + "sensor.voltage_ac_phase_to_phase_31_fronius_meter_0_http_fronius", 409.4 + ) + assert_state("sensor.power_apparent_phase_2_fronius_meter_0_http_fronius", 323.4) + assert_state("sensor.power_apparent_phase_3_fronius_meter_0_http_fronius", 301.2) + assert_state("sensor.power_real_phase_1_fronius_meter_0_http_fronius", 106.8) + assert_state("sensor.power_factor_phase_2_fronius_meter_0_http_fronius", 0.934) + assert_state("sensor.power_real_phase_3_fronius_meter_0_http_fronius", 251.3) + assert_state("sensor.power_reactive_phase_1_fronius_meter_0_http_fronius", -218.6) + assert_state("sensor.power_reactive_phase_2_fronius_meter_0_http_fronius", -132.8) + assert_state("sensor.power_reactive_phase_3_fronius_meter_0_http_fronius", -166.0) + assert_state("sensor.power_apparent_fronius_meter_0_http_fronius", 868.0) + assert_state("sensor.power_reactive_fronius_meter_0_http_fronius", -517.4) + assert_state("sensor.current_ac_phase_1_fronius_meter_0_http_fronius", 1.145) + # power_flow + assert_state("sensor.power_grid_fronius_power_flow_0_http_fronius", 658.4) + assert_state( + "sensor.relative_self_consumption_fronius_power_flow_0_http_fronius", 100.0 + ) + assert_state( + "sensor.power_photovoltaics_fronius_power_flow_0_http_fronius", 62.9481 + ) + assert_state("sensor.power_load_fronius_power_flow_0_http_fronius", -695.6827) + assert_state("sensor.meter_mode_fronius_power_flow_0_http_fronius", "meter") + assert_state("sensor.relative_autonomy_fronius_power_flow_0_http_fronius", 5.3592) + assert_state( + "sensor.power_battery_fronius_power_flow_0_http_fronius", STATE_UNKNOWN + ) + assert_state("sensor.energy_year_fronius_power_flow_0_http_fronius", STATE_UNKNOWN) + assert_state("sensor.energy_day_fronius_power_flow_0_http_fronius", STATE_UNKNOWN) + assert_state("sensor.energy_total_fronius_power_flow_0_http_fronius", 1530193.42) + + +async def test_gen24_storage(hass, aioclient_mock): + """Test Fronius Gen24 inverter with BYD battery and Ohmpilot entities.""" + + def assert_state(entity_id, expected_state): + state = hass.states.get(entity_id) + assert state + assert state.state == str(expected_state) + + mock_responses(aioclient_mock, fixture_set="gen24_storage") + config_entry = await setup_fronius_integration( + hass, is_logger=False, unique_id="12345678" + ) + + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 36 + await enable_all_entities( + hass, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval + ) + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 68 + # inverter 1 + assert_state("sensor.current_dc_fronius_inverter_1_http_fronius", 0.3952) + assert_state("sensor.voltage_dc_2_fronius_inverter_1_http_fronius", 318.8103) + assert_state("sensor.current_dc_2_fronius_inverter_1_http_fronius", 0.3564) + assert_state("sensor.energy_year_fronius_inverter_1_http_fronius", STATE_UNKNOWN) + assert_state("sensor.current_ac_fronius_inverter_1_http_fronius", 1.1087) + assert_state("sensor.power_ac_fronius_inverter_1_http_fronius", 250.9093) + assert_state("sensor.energy_day_fronius_inverter_1_http_fronius", STATE_UNKNOWN) + assert_state("sensor.error_code_fronius_inverter_1_http_fronius", 0) + assert_state("sensor.status_code_fronius_inverter_1_http_fronius", 7) + assert_state("sensor.energy_total_fronius_inverter_1_http_fronius", 7512794.0117) + assert_state("sensor.inverter_state_fronius_inverter_1_http_fronius", "Running") + assert_state("sensor.voltage_dc_fronius_inverter_1_http_fronius", 419.1009) + assert_state("sensor.voltage_ac_fronius_inverter_1_http_fronius", 227.354) + assert_state("sensor.frequency_ac_fronius_inverter_1_http_fronius", 49.9816) + # meter + assert_state("sensor.energy_real_produced_fronius_meter_0_http_fronius", 1705128.0) + assert_state("sensor.power_real_fronius_meter_0_http_fronius", 487.7) + assert_state("sensor.power_factor_fronius_meter_0_http_fronius", 0.698) + assert_state("sensor.energy_real_consumed_fronius_meter_0_http_fronius", 1247204.0) + assert_state("sensor.frequency_phase_average_fronius_meter_0_http_fronius", 49.9) + assert_state("sensor.meter_location_fronius_meter_0_http_fronius", 0.0) + assert_state("sensor.power_reactive_fronius_meter_0_http_fronius", -501.5) + assert_state( + "sensor.energy_reactive_ac_produced_fronius_meter_0_http_fronius", 3266105.0 + ) + assert_state("sensor.power_real_phase_3_fronius_meter_0_http_fronius", 19.6) + assert_state("sensor.current_ac_phase_3_fronius_meter_0_http_fronius", 0.645) + assert_state("sensor.energy_real_ac_minus_fronius_meter_0_http_fronius", 1705128.0) + assert_state("sensor.power_apparent_phase_2_fronius_meter_0_http_fronius", 383.9) + assert_state("sensor.current_ac_phase_1_fronius_meter_0_http_fronius", 1.701) + assert_state("sensor.current_ac_phase_2_fronius_meter_0_http_fronius", 1.832) + assert_state("sensor.power_apparent_phase_1_fronius_meter_0_http_fronius", 319.5) + assert_state("sensor.voltage_ac_phase_1_fronius_meter_0_http_fronius", 229.4) + assert_state("sensor.power_real_phase_2_fronius_meter_0_http_fronius", 150.0) + assert_state( + "sensor.voltage_ac_phase_to_phase_31_fronius_meter_0_http_fronius", 394.3 + ) + assert_state("sensor.voltage_ac_phase_2_fronius_meter_0_http_fronius", 225.6) + assert_state( + "sensor.energy_reactive_ac_consumed_fronius_meter_0_http_fronius", 5482.0 + ) + assert_state("sensor.energy_real_ac_plus_fronius_meter_0_http_fronius", 1247204.0) + assert_state("sensor.power_factor_phase_1_fronius_meter_0_http_fronius", 0.995) + assert_state("sensor.power_factor_phase_3_fronius_meter_0_http_fronius", 0.163) + assert_state("sensor.power_factor_phase_2_fronius_meter_0_http_fronius", 0.389) + assert_state("sensor.power_reactive_phase_1_fronius_meter_0_http_fronius", -31.3) + assert_state("sensor.power_reactive_phase_3_fronius_meter_0_http_fronius", -116.7) + assert_state( + "sensor.voltage_ac_phase_to_phase_12_fronius_meter_0_http_fronius", 396.0 + ) + assert_state( + "sensor.voltage_ac_phase_to_phase_23_fronius_meter_0_http_fronius", 393.0 + ) + assert_state("sensor.power_reactive_phase_2_fronius_meter_0_http_fronius", -353.4) + assert_state("sensor.power_real_phase_1_fronius_meter_0_http_fronius", 317.9) + assert_state("sensor.voltage_ac_phase_3_fronius_meter_0_http_fronius", 228.3) + assert_state("sensor.power_apparent_fronius_meter_0_http_fronius", 821.9) + assert_state("sensor.power_apparent_phase_3_fronius_meter_0_http_fronius", 118.4) + # ohmpilot + assert_state( + "sensor.energy_real_ac_consumed_fronius_ohmpilot_0_http_fronius", 1233295.0 + ) + assert_state("sensor.power_real_ac_fronius_ohmpilot_0_http_fronius", 0.0) + assert_state("sensor.temperature_channel_1_fronius_ohmpilot_0_http_fronius", 38.9) + assert_state("sensor.state_code_fronius_ohmpilot_0_http_fronius", 0.0) + assert_state( + "sensor.state_message_fronius_ohmpilot_0_http_fronius", "Up and running" + ) + # power_flow + assert_state("sensor.power_grid_fronius_power_flow_0_http_fronius", 2274.9) + assert_state("sensor.power_battery_fronius_power_flow_0_http_fronius", 0.1591) + assert_state("sensor.power_load_fronius_power_flow_0_http_fronius", -2459.3092) + assert_state( + "sensor.relative_self_consumption_fronius_power_flow_0_http_fronius", 100.0 + ) + assert_state( + "sensor.power_photovoltaics_fronius_power_flow_0_http_fronius", 216.4328 + ) + assert_state("sensor.relative_autonomy_fronius_power_flow_0_http_fronius", 7.4984) + assert_state("sensor.meter_mode_fronius_power_flow_0_http_fronius", "bidirectional") + assert_state("sensor.energy_year_fronius_power_flow_0_http_fronius", STATE_UNKNOWN) + assert_state("sensor.energy_day_fronius_power_flow_0_http_fronius", STATE_UNKNOWN) + assert_state("sensor.energy_total_fronius_power_flow_0_http_fronius", 7512664.4042) + # storage + assert_state("sensor.current_dc_fronius_storage_0_http_fronius", 0.0) + assert_state("sensor.state_of_charge_fronius_storage_0_http_fronius", 4.6) + assert_state("sensor.capacity_maximum_fronius_storage_0_http_fronius", 16588) + assert_state("sensor.temperature_cell_fronius_storage_0_http_fronius", 21.5) + assert_state("sensor.capacity_designed_fronius_storage_0_http_fronius", 16588) + assert_state("sensor.voltage_dc_fronius_storage_0_http_fronius", 0.0) + + # Devices + device_registry = dr.async_get(hass) + + solar_net = device_registry.async_get_device( + identifiers={(DOMAIN, "solar_net_12345678")} + ) + assert solar_net.configuration_url == "http://fronius" + assert solar_net.manufacturer == "Fronius" + assert solar_net.name == "SolarNet" + + inverter_1 = device_registry.async_get_device(identifiers={(DOMAIN, "12345678")}) + assert inverter_1.manufacturer == "Fronius" + assert inverter_1.model == "Gen24" + assert inverter_1.name == "Gen24 Storage" + + meter = device_registry.async_get_device(identifiers={(DOMAIN, "1234567890")}) + assert meter.manufacturer == "Fronius" + assert meter.model == "Smart Meter TS 65A-3" + assert meter.name == "Smart Meter TS 65A-3" + + ohmpilot = device_registry.async_get_device(identifiers={(DOMAIN, "23456789")}) + assert ohmpilot.manufacturer == "Fronius" + assert ohmpilot.model == "Ohmpilot 6" + assert ohmpilot.name == "Ohmpilot" + assert ohmpilot.sw_version == "1.0.25-3" + + storage = device_registry.async_get_device( + identifiers={(DOMAIN, "P030T020Z2001234567 ")} + ) + assert storage.manufacturer == "BYD" + assert storage.model == "BYD Battery-Box Premium HV" + assert storage.name == "BYD Battery-Box Premium HV" + + +async def test_primo_s0(hass, aioclient_mock): + """Test Fronius Primo dual inverter with S0 meter entities.""" + + def assert_state(entity_id, expected_state): + state = hass.states.get(entity_id) + assert state + assert state.state == str(expected_state) + + mock_responses(aioclient_mock, fixture_set="primo_s0", inverter_ids=[1, 2]) + config_entry = await setup_fronius_integration(hass, is_logger=True) + + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 30 + await enable_all_entities( + hass, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval + ) + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 41 + # logger + assert_state("sensor.cash_factor_fronius_logger_info_0_http_fronius", 1) + assert_state("sensor.co2_factor_fronius_logger_info_0_http_fronius", 0.53) + assert_state("sensor.delivery_factor_fronius_logger_info_0_http_fronius", 1) + # inverter 1 + assert_state("sensor.energy_total_fronius_inverter_1_http_fronius", 17114940) + assert_state("sensor.energy_day_fronius_inverter_1_http_fronius", 22504) + assert_state("sensor.voltage_dc_fronius_inverter_1_http_fronius", 452.3) + assert_state("sensor.power_ac_fronius_inverter_1_http_fronius", 862) + assert_state("sensor.error_code_fronius_inverter_1_http_fronius", 0) + assert_state("sensor.current_dc_fronius_inverter_1_http_fronius", 4.23) + assert_state("sensor.status_code_fronius_inverter_1_http_fronius", 7) + assert_state("sensor.energy_year_fronius_inverter_1_http_fronius", 7532755.5) + assert_state("sensor.current_ac_fronius_inverter_1_http_fronius", 3.85) + assert_state("sensor.voltage_ac_fronius_inverter_1_http_fronius", 223.9) + assert_state("sensor.frequency_ac_fronius_inverter_1_http_fronius", 60) + assert_state("sensor.led_color_fronius_inverter_1_http_fronius", 2) + assert_state("sensor.led_state_fronius_inverter_1_http_fronius", 0) + # inverter 2 + assert_state("sensor.energy_total_fronius_inverter_2_http_fronius", 5796010) + assert_state("sensor.energy_day_fronius_inverter_2_http_fronius", 14237) + assert_state("sensor.voltage_dc_fronius_inverter_2_http_fronius", 329.5) + assert_state("sensor.power_ac_fronius_inverter_2_http_fronius", 296) + assert_state("sensor.error_code_fronius_inverter_2_http_fronius", 0) + assert_state("sensor.current_dc_fronius_inverter_2_http_fronius", 0.97) + assert_state("sensor.status_code_fronius_inverter_2_http_fronius", 7) + assert_state("sensor.energy_year_fronius_inverter_2_http_fronius", 3596193.25) + assert_state("sensor.current_ac_fronius_inverter_2_http_fronius", 1.32) + assert_state("sensor.voltage_ac_fronius_inverter_2_http_fronius", 223.6) + assert_state("sensor.frequency_ac_fronius_inverter_2_http_fronius", 60.01) + assert_state("sensor.led_color_fronius_inverter_2_http_fronius", 2) + assert_state("sensor.led_state_fronius_inverter_2_http_fronius", 0) + # meter + assert_state("sensor.meter_location_fronius_meter_0_http_fronius", 1) + assert_state("sensor.power_real_fronius_meter_0_http_fronius", -2216.7487) + # power_flow + assert_state("sensor.power_load_fronius_power_flow_0_http_fronius", -2218.9349) + assert_state( + "sensor.power_battery_fronius_power_flow_0_http_fronius", STATE_UNKNOWN + ) + assert_state("sensor.meter_mode_fronius_power_flow_0_http_fronius", "vague-meter") + assert_state("sensor.power_photovoltaics_fronius_power_flow_0_http_fronius", 1834) + assert_state("sensor.power_grid_fronius_power_flow_0_http_fronius", 384.9349) + assert_state( + "sensor.relative_self_consumption_fronius_power_flow_0_http_fronius", 100 + ) + assert_state("sensor.relative_autonomy_fronius_power_flow_0_http_fronius", 82.6523) + assert_state("sensor.energy_total_fronius_power_flow_0_http_fronius", 22910919.5) + assert_state("sensor.energy_day_fronius_power_flow_0_http_fronius", 36724) + assert_state("sensor.energy_year_fronius_power_flow_0_http_fronius", 11128933.25) + + # Devices + device_registry = dr.async_get(hass) + + solar_net = device_registry.async_get_device( + identifiers={(DOMAIN, "solar_net_123.4567890")} + ) + assert solar_net.configuration_url == "http://fronius" + assert solar_net.manufacturer == "Fronius" + assert solar_net.model == "fronius-datamanager-card" + assert solar_net.name == "SolarNet" + assert solar_net.sw_version == "3.18.7-1" + + inverter_1 = device_registry.async_get_device(identifiers={(DOMAIN, "123456")}) + assert inverter_1.manufacturer == "Fronius" + assert inverter_1.model == "Primo 5.0-1" + assert inverter_1.name == "Primo 5.0-1" + + inverter_2 = device_registry.async_get_device(identifiers={(DOMAIN, "234567")}) + assert inverter_2.manufacturer == "Fronius" + assert inverter_2.model == "Primo 3.0-1" + assert inverter_2.name == "Primo 3.0-1" + + meter = device_registry.async_get_device( + identifiers={(DOMAIN, "solar_net_123.4567890:S0 Meter at inverter 1")} + ) + assert meter.manufacturer == "Fronius" + assert meter.model == "S0 Meter at inverter 1" + assert meter.name == "S0 Meter at inverter 1" diff --git a/tests/fixtures/generic/configuration.yaml b/tests/components/generic/fixtures/configuration.yaml similarity index 100% rename from tests/fixtures/generic/configuration.yaml rename to tests/components/generic/fixtures/configuration.yaml diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index 36a9304cdc1..c52b4bf6e8f 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -1,10 +1,11 @@ """The tests for generic camera component.""" import asyncio from http import HTTPStatus -from os import path from unittest.mock import patch +import aiohttp import httpx +import pytest import respx from homeassistant import config as hass_config @@ -13,6 +14,8 @@ from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import SERVICE_RELOAD from homeassistant.setup import async_setup_component +from tests.common import get_fixture_path + @respx.mock async def test_fetching_url(hass, hass_client): @@ -131,10 +134,13 @@ async def test_limit_refetch(hass, hass_client): hass.states.async_set("sensor.temp", "5") - with patch("async_timeout.timeout", side_effect=asyncio.TimeoutError()): + with pytest.raises(aiohttp.ServerTimeoutError), patch( + "async_timeout.timeout", side_effect=asyncio.TimeoutError() + ): resp = await client.get("/api/camera_proxy/camera.config_test") - assert respx.calls.call_count == 0 - assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + + assert respx.calls.call_count == 0 + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR hass.states.async_set("sensor.temp", "10") @@ -379,11 +385,8 @@ async def test_reloading(hass, hass_client): body = await resp.text() assert body == "hello world" - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "generic/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "generic") + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( DOMAIN, @@ -456,7 +459,3 @@ async def test_timeout_cancelled(hass, hass_client): assert respx.calls.call_count == total_calls assert resp.status == HTTPStatus.OK assert await resp.text() == "hello world" - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/fixtures/generic_thermostat/configuration.yaml b/tests/components/generic_thermostat/fixtures/configuration.yaml similarity index 100% rename from tests/fixtures/generic_thermostat/configuration.yaml rename to tests/components/generic_thermostat/fixtures/configuration.yaml diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 4015887efbb..a1896c94d2f 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -1,6 +1,5 @@ """The tests for the generic_thermostat.""" import datetime -from os import path from unittest.mock import patch import pytest @@ -43,6 +42,7 @@ from tests.common import ( assert_setup_component, async_fire_time_changed, async_mock_service, + get_fixture_path, mock_restore_cache, ) from tests.components.climate import common @@ -1486,11 +1486,7 @@ async def test_reload(hass): assert len(hass.states.async_all()) == 1 assert hass.states.get("climate.test") is not None - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "generic_thermostat/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "generic_thermostat") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( GENERIC_THERMOSTAT_DOMAIN, @@ -1503,7 +1499,3 @@ async def test_reload(hass): assert len(hass.states.async_all()) == 1 assert hass.states.get("climate.test") is None assert hass.states.get("climate.reload") - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/fixtures/gios/indexes.json b/tests/components/gios/fixtures/indexes.json similarity index 100% rename from tests/fixtures/gios/indexes.json rename to tests/components/gios/fixtures/indexes.json diff --git a/tests/fixtures/gios/sensors.json b/tests/components/gios/fixtures/sensors.json similarity index 100% rename from tests/fixtures/gios/sensors.json rename to tests/components/gios/fixtures/sensors.json diff --git a/tests/fixtures/gios/station.json b/tests/components/gios/fixtures/station.json similarity index 100% rename from tests/fixtures/gios/station.json rename to tests/components/gios/fixtures/station.json diff --git a/tests/components/goalzero/__init__.py b/tests/components/goalzero/__init__.py index 1b5302dbc1b..8ffbbc11825 100644 --- a/tests/components/goalzero/__init__.py +++ b/tests/components/goalzero/__init__.py @@ -1,42 +1,95 @@ """Tests for the Goal Zero Yeti integration.""" - from unittest.mock import AsyncMock, patch -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp +from homeassistant.components.goalzero import DOMAIN +from homeassistant.components.goalzero.const import DEFAULT_NAME from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker HOST = "1.2.3.4" -NAME = "Yeti" +MAC = "aa:bb:cc:dd:ee:ff" CONF_DATA = { CONF_HOST: HOST, - CONF_NAME: NAME, + CONF_NAME: DEFAULT_NAME, } -CONF_CONFIG_FLOW = { - CONF_HOST: HOST, - CONF_NAME: NAME, -} - -CONF_DHCP_FLOW = { - IP_ADDRESS: "1.1.1.1", - MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", - HOSTNAME: "any", -} +CONF_DHCP_FLOW = dhcp.DhcpServiceInfo( + ip=HOST, + macaddress=format_mac("AA:BB:CC:DD:EE:FF"), + hostname="yeti", +) -async def _create_mocked_yeti(raise_exception=False): +def create_entry(hass: HomeAssistant): + """Add config entry in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_DATA, + unique_id=MAC, + ) + entry.add_to_hass(hass) + return entry + + +async def create_mocked_yeti(): + """Create mocked yeti device.""" mocked_yeti = AsyncMock() - mocked_yeti.get_state = AsyncMock() + mocked_yeti.data = {} + mocked_yeti.data["firmwareVersion"] = "1.0.0" + mocked_yeti.sysdata = {} + mocked_yeti.sysdata["model"] = "test_model" + mocked_yeti.sysdata["macAddress"] = MAC return mocked_yeti -def _patch_init_yeti(mocked_yeti): - return patch("homeassistant.components.goalzero.Yeti", return_value=mocked_yeti) - - -def _patch_config_flow_yeti(mocked_yeti): +def patch_config_flow_yeti(mocked_yeti): + """Patch Goal Zero config flow.""" return patch( "homeassistant.components.goalzero.config_flow.Yeti", return_value=mocked_yeti, ) + + +async def async_init_integration( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + skip_setup: bool = False, +) -> MockConfigEntry: + """Set up the Goal Zero integration in Home Assistant.""" + entry = create_entry(hass) + base_url = f"http://{HOST}/" + aioclient_mock.get( + f"{base_url}state", + text=load_fixture("goalzero/state_data.json"), + ) + aioclient_mock.get( + f"{base_url}sysinfo", + text=load_fixture("goalzero/info_data.json"), + ) + + if not skip_setup: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +async def async_setup_platform( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + platform: str, +): + """Set up the platform.""" + entry = await async_init_integration(hass, aioclient_mock) + + with patch("homeassistant.components.goalzero.PLATFORMS", [platform]): + assert await async_setup_component(hass, DOMAIN, {}) + + return entry diff --git a/tests/components/goalzero/fixtures/info_data.json b/tests/components/goalzero/fixtures/info_data.json new file mode 100644 index 00000000000..6be95e6c482 --- /dev/null +++ b/tests/components/goalzero/fixtures/info_data.json @@ -0,0 +1,7 @@ +{ + "name":"yeti123456789012", + "model":"Yeti 1400", + "firmwareVersion":"1.5.7", + "macAddress":"123456789012", + "platform":"esp32" +} \ No newline at end of file diff --git a/tests/components/goalzero/fixtures/state_change.json b/tests/components/goalzero/fixtures/state_change.json new file mode 100644 index 00000000000..301a27da954 --- /dev/null +++ b/tests/components/goalzero/fixtures/state_change.json @@ -0,0 +1,38 @@ +{ + "thingName":"yeti123456789012", + "v12PortStatus":1, + "usbPortStatus":0, + "acPortStatus":1, + "backlight":1, + "app_online":0, + "wattsIn":0.0, + "ampsIn":0.0, + "wattsOut":50.5, + "ampsOut":2.1, + "whOut":5.23, + "whStored":1330, + "volts":12.0, + "socPercent":95, + "isCharging":0, + "inputDetected":0, + "timeToEmptyFull":-1, + "temperature":25, + "wifiStrength":-62, + "ssid":"wifi", + "ipAddr":"1.2.3.4", + "timestamp":1720984, + "firmwareVersion":"1.5.7", + "version":3, + "ota":{ + "delay":0, + "status":"000-000-100_001-000-100_002-000-100_003-000-100" + }, + "notify":{ + "enabled":1048575, + "trigger":0 + }, + "foreignAcsry":{ + "model":"Yeti MPPT", + "firmwareVersion":"1.1.2" + } +} \ No newline at end of file diff --git a/tests/components/goalzero/fixtures/state_data.json b/tests/components/goalzero/fixtures/state_data.json new file mode 100644 index 00000000000..455524584f7 --- /dev/null +++ b/tests/components/goalzero/fixtures/state_data.json @@ -0,0 +1,38 @@ +{ + "thingName":"yeti123456789012", + "v12PortStatus":0, + "usbPortStatus":0, + "acPortStatus":1, + "backlight":1, + "app_online":0, + "wattsIn":0.0, + "ampsIn":0.0, + "wattsOut":50.5, + "ampsOut":2.1, + "whOut":5.23, + "whStored":1330, + "volts":12.0, + "socPercent":95, + "isCharging":0, + "inputDetected":0, + "timeToEmptyFull":-1, + "temperature":25, + "wifiStrength":-62, + "ssid":"wifi", + "ipAddr":"1.2.3.4", + "timestamp":1720984, + "firmwareVersion":"1.5.7", + "version":3, + "ota":{ + "delay":0, + "status":"000-000-100_001-000-100_002-000-100_003-000-100" + }, + "notify":{ + "enabled":1048575, + "trigger":0 + }, + "foreignAcsry":{ + "model":"Yeti MPPT", + "firmwareVersion":"1.1.2" + } +} \ No newline at end of file diff --git a/tests/components/goalzero/test_binary_sensor.py b/tests/components/goalzero/test_binary_sensor.py new file mode 100644 index 00000000000..1c130013283 --- /dev/null +++ b/tests/components/goalzero/test_binary_sensor.py @@ -0,0 +1,36 @@ +"""Binary sensor tests for the Goalzero integration.""" +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_CONNECTIVITY, + DOMAIN, +) +from homeassistant.components.goalzero.const import DEFAULT_NAME +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + DEVICE_CLASS_POWER, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant + +from . import async_setup_platform + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_binary_sensors(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + """Test we get sensor data.""" + await async_setup_platform(hass, aioclient_mock, DOMAIN) + + state = hass.states.get(f"binary_sensor.{DEFAULT_NAME}_backlight") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + state = hass.states.get(f"binary_sensor.{DEFAULT_NAME}_app_online") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CONNECTIVITY + state = hass.states.get(f"binary_sensor.{DEFAULT_NAME}_charging") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_BATTERY_CHARGING + state = hass.states.get(f"binary_sensor.{DEFAULT_NAME}_input_detected") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER diff --git a/tests/components/goalzero/test_config_flow.py b/tests/components/goalzero/test_config_flow.py index 838a0f8a124..669cace729b 100644 --- a/tests/components/goalzero/test_config_flow.py +++ b/tests/components/goalzero/test_config_flow.py @@ -3,181 +3,150 @@ from unittest.mock import patch from goalzero import exceptions -from homeassistant.components.goalzero.const import DOMAIN +from homeassistant import data_entry_flow +from homeassistant.components.goalzero.const import DEFAULT_NAME, DOMAIN, MANUFACTURER from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER -from homeassistant.data_entry_flow import ( - RESULT_TYPE_ABORT, - RESULT_TYPE_CREATE_ENTRY, - RESULT_TYPE_FORM, -) +from homeassistant.core import HomeAssistant from . import ( - CONF_CONFIG_FLOW, CONF_DATA, CONF_DHCP_FLOW, - CONF_HOST, - CONF_NAME, - NAME, - _create_mocked_yeti, - _patch_config_flow_yeti, + MAC, + create_entry, + create_mocked_yeti, + patch_config_flow_yeti, ) -from tests.common import MockConfigEntry - - -def _flow_next(hass, flow_id): - return next( - flow - for flow in hass.config_entries.flow.async_progress() - if flow["flow_id"] == flow_id - ) - def _patch_setup(): - return patch( - "homeassistant.components.goalzero.async_setup_entry", - return_value=True, - ) + return patch("homeassistant.components.goalzero.async_setup_entry") -async def test_flow_user(hass): +async def test_flow_user(hass: HomeAssistant): """Test user initialized flow.""" - mocked_yeti = await _create_mocked_yeti() - with _patch_config_flow_yeti(mocked_yeti), _patch_setup(): + mocked_yeti = await create_mocked_yeti() + with patch_config_flow_yeti(mocked_yeti), _patch_setup(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=CONF_CONFIG_FLOW, + user_input=CONF_DATA, ) - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == NAME + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DEFAULT_NAME assert result["data"] == CONF_DATA + assert result["result"].unique_id == MAC -async def test_flow_user_already_configured(hass): +async def test_flow_user_already_configured(hass: HomeAssistant): """Test user initialized flow with duplicate server.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "1.2.3.4", CONF_NAME: "Yeti"}, - ) - - entry.add_to_hass(hass) - - service_info = { - "host": "1.2.3.4", - "name": "Yeti", - } + create_entry(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=service_info + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA ) - assert result["type"] == RESULT_TYPE_ABORT + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" -async def test_flow_user_cannot_connect(hass): +async def test_flow_user_cannot_connect(hass: HomeAssistant): """Test user initialized flow with unreachable server.""" - mocked_yeti = await _create_mocked_yeti(True) - with _patch_config_flow_yeti(mocked_yeti) as yetimock: + with patch_config_flow_yeti(await create_mocked_yeti()) as yetimock: yetimock.side_effect = exceptions.ConnectError result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA ) - assert result["type"] == RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"]["base"] == "cannot_connect" -async def test_flow_user_invalid_host(hass): +async def test_flow_user_invalid_host(hass: HomeAssistant): """Test user initialized flow with invalid server.""" - mocked_yeti = await _create_mocked_yeti(True) - with _patch_config_flow_yeti(mocked_yeti) as yetimock: + with patch_config_flow_yeti(await create_mocked_yeti()) as yetimock: yetimock.side_effect = exceptions.InvalidHost result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA ) - assert result["type"] == RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_host"} + assert result["errors"]["base"] == "invalid_host" -async def test_flow_user_unknown_error(hass): +async def test_flow_user_unknown_error(hass: HomeAssistant): """Test user initialized flow with unreachable server.""" - mocked_yeti = await _create_mocked_yeti(True) - with _patch_config_flow_yeti(mocked_yeti) as yetimock: + with patch_config_flow_yeti(await create_mocked_yeti()) as yetimock: yetimock.side_effect = Exception result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA ) - assert result["type"] == RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - assert result["errors"] == {"base": "unknown"} + assert result["errors"]["base"] == "unknown" -async def test_dhcp_discovery(hass): +async def test_dhcp_discovery(hass: HomeAssistant): """Test we can process the discovery from dhcp.""" - mocked_yeti = await _create_mocked_yeti() - with _patch_config_flow_yeti(mocked_yeti), _patch_setup(): + mocked_yeti = await create_mocked_yeti() + with patch_config_flow_yeti(mocked_yeti), _patch_setup(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=CONF_DHCP_FLOW, ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM result = await hass.config_entries.flow.async_configure( result["flow_id"], {}, ) await hass.async_block_till_done() - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_NAME: "Yeti", - } + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MANUFACTURER + assert result["data"] == CONF_DATA + assert result["result"].unique_id == MAC result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=CONF_DHCP_FLOW, ) - assert result["type"] == RESULT_TYPE_ABORT + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" -async def test_dhcp_discovery_failed(hass): +async def test_dhcp_discovery_failed(hass: HomeAssistant): """Test failed setup from dhcp.""" - mocked_yeti = await _create_mocked_yeti(True) - with _patch_config_flow_yeti(mocked_yeti) as yetimock: + mocked_yeti = await create_mocked_yeti() + with patch_config_flow_yeti(mocked_yeti) as yetimock: yetimock.side_effect = exceptions.ConnectError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=CONF_DHCP_FLOW, ) - assert result["type"] == RESULT_TYPE_ABORT + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "cannot_connect" - with _patch_config_flow_yeti(mocked_yeti) as yetimock: + with patch_config_flow_yeti(mocked_yeti) as yetimock: yetimock.side_effect = exceptions.InvalidHost result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=CONF_DHCP_FLOW, ) - assert result["type"] == RESULT_TYPE_ABORT + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "invalid_host" - with _patch_config_flow_yeti(mocked_yeti) as yetimock: + with patch_config_flow_yeti(mocked_yeti) as yetimock: yetimock.side_effect = Exception result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=CONF_DHCP_FLOW, ) - assert result["type"] == RESULT_TYPE_ABORT + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "unknown" diff --git a/tests/components/goalzero/test_init.py b/tests/components/goalzero/test_init.py new file mode 100644 index 00000000000..a436b491d48 --- /dev/null +++ b/tests/components/goalzero/test_init.py @@ -0,0 +1,80 @@ +"""Test Goal Zero integration.""" +from datetime import timedelta +from unittest.mock import patch + +from goalzero import exceptions + +from homeassistant.components.goalzero.const import DEFAULT_NAME, DOMAIN, MANUFACTURER +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +import homeassistant.util.dt as dt_util + +from . import CONF_DATA, async_init_integration, create_entry, create_mocked_yeti + +from tests.common import async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup_config_and_unload(hass: HomeAssistant): + """Test Goal Zero setup and unload.""" + entry = create_entry(hass) + mocked_yeti = await create_mocked_yeti() + with patch("homeassistant.components.goalzero.Yeti", return_value=mocked_yeti): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.data == CONF_DATA + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_entry_not_ready(hass: HomeAssistant): + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + entry = create_entry(hass) + with patch( + "homeassistant.components.goalzero.Yeti.init_connect", + side_effect=exceptions.ConnectError, + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_update_failed( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test data update failure.""" + await async_init_integration(hass, aioclient_mock) + assert hass.states.get(f"switch.{DEFAULT_NAME}_ac_port_status").state == STATE_ON + with patch( + "homeassistant.components.goalzero.Yeti.get_state", + side_effect=exceptions.ConnectError, + ) as updater: + next_update = dt_util.utcnow() + timedelta(seconds=30) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + updater.assert_called_once() + state = hass.states.get(f"switch.{DEFAULT_NAME}_ac_port_status") + assert state.state == STATE_UNAVAILABLE + + +async def test_device_info(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + """Test device info.""" + entry = await async_init_integration(hass, aioclient_mock) + device_registry = await dr.async_get_registry(hass) + + device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + + assert device.connections == {("mac", "12:34:56:78:90:12")} + assert device.identifiers == {(DOMAIN, entry.entry_id)} + assert device.manufacturer == MANUFACTURER + assert device.model == "Yeti 1400" + assert device.name == DEFAULT_NAME + assert device.sw_version == "1.5.7" diff --git a/tests/components/goalzero/test_sensor.py b/tests/components/goalzero/test_sensor.py new file mode 100644 index 00000000000..592c43b5d43 --- /dev/null +++ b/tests/components/goalzero/test_sensor.py @@ -0,0 +1,112 @@ +"""Sensor tests for the Goalzero integration.""" +from homeassistant.components.goalzero.const import DEFAULT_NAME +from homeassistant.components.goalzero.sensor import SENSOR_TYPES +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_WATT_HOUR, + PERCENTAGE, + POWER_WATT, + SIGNAL_STRENGTH_DECIBELS, + TEMP_CELSIUS, + TIME_MINUTES, + TIME_SECONDS, +) +from homeassistant.core import HomeAssistant + +from . import async_setup_platform + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_sensors(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + """Test we get sensor data.""" + for description in SENSOR_TYPES: + description.entity_registry_enabled_default = True + await async_setup_platform(hass, aioclient_mock, DOMAIN) + + state = hass.states.get(f"sensor.{DEFAULT_NAME}_watts_in") + assert state.state == "0.0" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + state = hass.states.get(f"sensor.{DEFAULT_NAME}_amps_in") + assert state.state == "0.0" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CURRENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_CURRENT_AMPERE + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + state = hass.states.get(f"sensor.{DEFAULT_NAME}_watts_out") + assert state.state == "50.5" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + state = hass.states.get(f"sensor.{DEFAULT_NAME}_amps_out") + assert state.state == "2.1" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CURRENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_CURRENT_AMPERE + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + state = hass.states.get(f"sensor.{DEFAULT_NAME}_wh_out") + assert state.state == "5.23" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_WATT_HOUR + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + state = hass.states.get(f"sensor.{DEFAULT_NAME}_wh_stored") + assert state.state == "1330" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_WATT_HOUR + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + state = hass.states.get(f"sensor.{DEFAULT_NAME}_volts") + assert state.state == "12.0" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_VOLTAGE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_POTENTIAL_VOLT + assert state.attributes.get(ATTR_STATE_CLASS) is None + state = hass.states.get(f"sensor.{DEFAULT_NAME}_state_of_charge_percent") + assert state.state == "95" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_BATTERY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + assert state.attributes.get(ATTR_STATE_CLASS) is None + state = hass.states.get(f"sensor.{DEFAULT_NAME}_time_to_empty_full") + assert state.state == "-1" + assert state.attributes.get(ATTR_DEVICE_CLASS) == TIME_MINUTES + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TIME_MINUTES + assert state.attributes.get(ATTR_STATE_CLASS) is None + state = hass.states.get(f"sensor.{DEFAULT_NAME}_temperature") + assert state.state == "25" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is None + state = hass.states.get(f"sensor.{DEFAULT_NAME}_wifi_strength") + assert state.state == "-62" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_SIGNAL_STRENGTH + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SIGNAL_STRENGTH_DECIBELS + assert state.attributes.get(ATTR_STATE_CLASS) is None + state = hass.states.get(f"sensor.{DEFAULT_NAME}_total_run_time") + assert state.state == "1720984" + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TIME_SECONDS + assert state.attributes.get(ATTR_STATE_CLASS) is None + state = hass.states.get(f"sensor.{DEFAULT_NAME}_wi_fi_ssid") + assert state.state == "wifi" + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_STATE_CLASS) is None + state = hass.states.get(f"sensor.{DEFAULT_NAME}_ip_address") + assert state.state == "1.2.3.4" + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_STATE_CLASS) is None diff --git a/tests/components/goalzero/test_switch.py b/tests/components/goalzero/test_switch.py new file mode 100644 index 00000000000..4c625653033 --- /dev/null +++ b/tests/components/goalzero/test_switch.py @@ -0,0 +1,51 @@ +"""Switch tests for the Goalzero integration.""" +from homeassistant.components.goalzero.const import DEFAULT_NAME +from homeassistant.components.switch import DOMAIN as DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant + +from . import async_setup_platform + +from tests.common import load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_switches_states( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +): + """Test we get sensor data.""" + await async_setup_platform(hass, aioclient_mock, DOMAIN) + + assert hass.states.get(f"switch.{DEFAULT_NAME}_usb_port_status").state == STATE_OFF + assert hass.states.get(f"switch.{DEFAULT_NAME}_ac_port_status").state == STATE_ON + entity_id = f"switch.{DEFAULT_NAME}_12v_port_status" + assert hass.states.get(entity_id).state == STATE_OFF + aioclient_mock.post( + "http://1.2.3.4/state", + text=load_fixture("goalzero/state_change.json"), + ) + 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 + aioclient_mock.clear_requests() + aioclient_mock.post( + "http://1.2.3.4/state", + text=load_fixture("goalzero/state_data.json"), + ) + 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 diff --git a/tests/components/gogogate2/test_config_flow.py b/tests/components/gogogate2/test_config_flow.py index 18ed7334b8d..a20687c5f3d 100644 --- a/tests/components/gogogate2/test_config_flow.py +++ b/tests/components/gogogate2/test_config_flow.py @@ -6,6 +6,7 @@ from ismartgate.common import ApiError from ismartgate.const import GogoGate2ApiErrorCode from homeassistant import config_entries +from homeassistant.components import dhcp, zeroconf from homeassistant.components.gogogate2.const import ( DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, @@ -106,7 +107,14 @@ async def test_form_homekit_unique_id_already_setup(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data={"host": "1.2.3.4", "properties": {"id": MOCK_MAC_ADDR}}, + data=zeroconf.ZeroconfServiceInfo( + host="1.2.3.4", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + type="mock_type", + ), ) assert result["type"] == RESULT_TYPE_FORM assert result["errors"] == {} @@ -126,7 +134,14 @@ async def test_form_homekit_unique_id_already_setup(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data={"host": "1.2.3.4", "properties": {"id": MOCK_MAC_ADDR}}, + data=zeroconf.ZeroconfServiceInfo( + host="1.2.3.4", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + type="mock_type", + ), ) assert result["type"] == RESULT_TYPE_ABORT @@ -143,7 +158,14 @@ async def test_form_homekit_ip_address_already_setup(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data={"host": "1.2.3.4", "properties": {"id": MOCK_MAC_ADDR}}, + data=zeroconf.ZeroconfServiceInfo( + host="1.2.3.4", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + type="mock_type", + ), ) assert result["type"] == RESULT_TYPE_ABORT @@ -154,7 +176,14 @@ async def test_form_homekit_ip_address(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data={"host": "1.2.3.4", "properties": {"id": MOCK_MAC_ADDR}}, + data=zeroconf.ZeroconfServiceInfo( + host="1.2.3.4", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + type="mock_type", + ), ) assert result["type"] == RESULT_TYPE_FORM assert result["errors"] == {} @@ -182,7 +211,9 @@ async def test_discovered_dhcp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={"ip": "1.2.3.4", "macaddress": MOCK_MAC_ADDR}, + data=dhcp.DhcpServiceInfo( + ip="1.2.3.4", macaddress=MOCK_MAC_ADDR, hostname="mock_hostname" + ), ) assert result["type"] == RESULT_TYPE_FORM assert result["errors"] == {} @@ -227,7 +258,14 @@ async def test_discovered_by_homekit_and_dhcp(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data={"host": "1.2.3.4", "properties": {"id": MOCK_MAC_ADDR}}, + data=zeroconf.ZeroconfServiceInfo( + host="1.2.3.4", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + type="mock_type", + ), ) assert result["type"] == RESULT_TYPE_FORM assert result["errors"] == {} @@ -235,7 +273,9 @@ async def test_discovered_by_homekit_and_dhcp(hass): result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={"ip": "1.2.3.4", "macaddress": MOCK_MAC_ADDR}, + data=dhcp.DhcpServiceInfo( + ip="1.2.3.4", macaddress=MOCK_MAC_ADDR, hostname="mock_hostname" + ), ) assert result2["type"] == RESULT_TYPE_ABORT assert result2["reason"] == "already_in_progress" @@ -243,7 +283,9 @@ async def test_discovered_by_homekit_and_dhcp(hass): result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={"ip": "1.2.3.4", "macaddress": "00:00:00:00:00:00"}, + data=dhcp.DhcpServiceInfo( + ip="1.2.3.4", macaddress="00:00:00:00:00:00", hostname="mock_hostname" + ), ) assert result3["type"] == RESULT_TYPE_ABORT assert result3["reason"] == "already_in_progress" diff --git a/tests/components/gogogate2/test_sensor.py b/tests/components/gogogate2/test_sensor.py index 1a59c5fd3c5..129fcac504f 100644 --- a/tests/components/gogogate2/test_sensor.py +++ b/tests/components/gogogate2/test_sensor.py @@ -172,6 +172,7 @@ async def test_sensor_update(gogogate2api_mock, hass: HomeAssistant) -> None: "friendly_name": "Door1 battery", "sensor_id": "ABCD", "state_class": "measurement", + "unit_of_measurement": "%", } temp_attributes = { "device_class": "temperature", @@ -248,6 +249,7 @@ async def test_availability(ismartgateapi_mock, hass: HomeAssistant) -> None: "friendly_name": "Door1 battery", "sensor_id": "ABCD", "state_class": "measurement", + "unit_of_measurement": "%", } temp_attributes = { "device_class": "temperature", diff --git a/tests/fixtures/google_maps_elevation.json b/tests/components/google/fixtures/maps_elevation.json similarity index 100% rename from tests/fixtures/google_maps_elevation.json rename to tests/components/google/fixtures/maps_elevation.json diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index f7537db18de..562bd4e16cd 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -20,25 +20,27 @@ class MockConfig(helpers.AbstractConfig): def __init__( self, *, - secure_devices_pin=None, - should_expose=None, - should_2fa=None, + agent_user_ids=None, + enabled=True, entity_config=None, hass=None, - local_sdk_webhook_id=None, local_sdk_user_id=None, - enabled=True, - agent_user_ids=None, + local_sdk_webhook_id=None, + secure_devices_pin=None, + should_2fa=None, + should_expose=None, + should_report_state=False, ): """Initialize config.""" super().__init__(hass) - self._should_expose = should_expose - self._should_2fa = should_2fa - self._secure_devices_pin = secure_devices_pin - self._entity_config = entity_config or {} - self._local_sdk_webhook_id = local_sdk_webhook_id - self._local_sdk_user_id = local_sdk_user_id self._enabled = enabled + self._entity_config = entity_config or {} + self._local_sdk_user_id = local_sdk_user_id + self._local_sdk_webhook_id = local_sdk_webhook_id + self._secure_devices_pin = secure_devices_pin + self._should_2fa = should_2fa + self._should_expose = should_expose + self._should_report_state = should_report_state self._store = mock_google_config_store(agent_user_ids) @property @@ -74,6 +76,11 @@ class MockConfig(helpers.AbstractConfig): """Expose it all.""" return self._should_expose is None or self._should_expose(state) + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + return self._should_report_state + def should_2fa(self, state): """Expose it all.""" return self._should_2fa is None or self._should_2fa(state) diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index ded7429bdee..0a916c1e184 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -144,10 +144,18 @@ async def test_sync_request(hass_fixture, assistant_client, auth_header): suggested_object_id="diagnostic_switch", entity_category="diagnostic", ) + entity_entry3 = entity_registry.async_get_or_create( + "switch", + "test", + "switch_system_id", + suggested_object_id="system_switch", + entity_category="system", + ) # These should not show up in the sync request hass_fixture.states.async_set(entity_entry1.entity_id, "on") hass_fixture.states.async_set(entity_entry2.entity_id, "something_else") + hass_fixture.states.async_set(entity_entry3.entity_id, "blah") reqid = "5711642932632160983" data = {"requestId": reqid, "inputs": [{"intent": "action.devices.SYNC"}]} diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 9e54e6cff3f..a260ef03948 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -169,6 +169,7 @@ async def test_agent_user_id_storage(hass, hass_storage): hass_storage["google_assistant"] = { "version": 1, + "minor_version": 1, "key": "google_assistant", "data": {"agent_user_ids": {"agent_1": {}}}, } @@ -178,6 +179,7 @@ async def test_agent_user_id_storage(hass, hass_storage): assert hass_storage["google_assistant"] == { "version": 1, + "minor_version": 1, "key": "google_assistant", "data": {"agent_user_ids": {"agent_1": {}}}, } @@ -188,6 +190,7 @@ async def test_agent_user_id_storage(hass, hass_storage): assert hass_storage["google_assistant"] == { "version": 1, + "minor_version": 1, "key": "google_assistant", "data": data, } diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 161b4a6f3cb..911f66bb428 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1,5 +1,6 @@ """Test Google Smart Home.""" -from unittest.mock import patch +import asyncio +from unittest.mock import ANY, call, patch import pytest @@ -25,7 +26,7 @@ from homeassistant.components.google_assistant import ( from homeassistant.config import async_process_ha_core_config from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, __version__ from homeassistant.core import EVENT_CALL_SERVICE, State -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry, entity_platform from homeassistant.setup import async_setup_component from . import BASIC_CONFIG, MockConfig @@ -367,7 +368,10 @@ async def test_query_message(hass): } -async def test_execute(hass): +@pytest.mark.parametrize( + "report_state,on,brightness,value", [(False, True, 20, 0.2), (True, ANY, ANY, ANY)] +) +async def test_execute(hass, report_state, on, brightness, value): """Test an execute command.""" await async_setup_component(hass, "light", {"light": {"platform": "demo"}}) await hass.async_block_till_done() @@ -375,45 +379,82 @@ async def test_execute(hass): await hass.services.async_call( "light", "turn_off", {"entity_id": "light.ceiling_lights"}, blocking=True ) + await hass.async_block_till_done() events = async_capture_events(hass, EVENT_COMMAND_RECEIVED) service_events = async_capture_events(hass, EVENT_CALL_SERVICE) - result = await sh.async_handle_message( - hass, - BASIC_CONFIG, - None, - { - "requestId": REQ_ID, - "inputs": [ - { - "intent": "action.devices.EXECUTE", - "payload": { - "commands": [ - { - "devices": [ - {"id": "light.non_existing"}, - {"id": "light.ceiling_lights"}, - {"id": "light.kitchen_lights"}, - ], - "execution": [ - { - "command": "action.devices.commands.OnOff", - "params": {"on": True}, - }, - { - "command": "action.devices.commands.BrightnessAbsolute", - "params": {"brightness": 20}, - }, - ], - } - ] - }, - } - ], - }, - const.SOURCE_CLOUD, - ) + with patch.object( + hass.services, "async_call", wraps=hass.services.async_call + ) as call_service_mock: + result = await sh.async_handle_message( + hass, + MockConfig(should_report_state=report_state), + None, + { + "requestId": REQ_ID, + "inputs": [ + { + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [ + { + "devices": [ + {"id": "light.non_existing"}, + {"id": "light.ceiling_lights"}, + {"id": "light.kitchen_lights"}, + ], + "execution": [ + { + "command": "action.devices.commands.OnOff", + "params": {"on": True}, + }, + { + "command": "action.devices.commands.BrightnessAbsolute", + "params": {"brightness": 20}, + }, + ], + } + ] + }, + } + ], + }, + const.SOURCE_CLOUD, + ) + assert call_service_mock.call_count == 4 + expected_calls = [ + call( + "light", + "turn_on", + {"entity_id": "light.ceiling_lights"}, + blocking=not report_state, + context=ANY, + ), + call( + "light", + "turn_on", + {"entity_id": "light.kitchen_lights"}, + blocking=not report_state, + context=ANY, + ), + call( + "light", + "turn_on", + {"entity_id": "light.ceiling_lights", "brightness_pct": 20}, + blocking=not report_state, + context=ANY, + ), + call( + "light", + "turn_on", + {"entity_id": "light.kitchen_lights", "brightness_pct": 20}, + blocking=not report_state, + context=ANY, + ), + ] + call_service_mock.assert_has_awaits(expected_calls, any_order=True) + await hass.async_block_till_done() assert result == { "requestId": REQ_ID, @@ -428,9 +469,9 @@ async def test_execute(hass): "ids": ["light.ceiling_lights"], "status": "SUCCESS", "states": { - "on": True, + "on": on, "online": True, - "brightness": 20, + "brightness": brightness, "color": {"temperatureK": 2631}, }, }, @@ -440,12 +481,12 @@ async def test_execute(hass): "states": { "on": True, "online": True, - "brightness": 20, + "brightness": brightness, "color": { "spectrumHsv": { "hue": 345, "saturation": 0.75, - "value": 0.2, + "value": value, }, }, }, @@ -506,6 +547,209 @@ async def test_execute(hass): assert service_events[3].context == events[0].context +@pytest.mark.parametrize("report_state,on,brightness,value", [(False, False, ANY, ANY)]) +async def test_execute_times_out(hass, report_state, on, brightness, value): + """Test an execute command which times out.""" + orig_execute_limit = sh.EXECUTE_LIMIT + sh.EXECUTE_LIMIT = 0.02 # Decrease timeout to 20ms + await async_setup_component(hass, "light", {"light": {"platform": "demo"}}) + await hass.async_block_till_done() + + await hass.services.async_call( + "light", "turn_off", {"entity_id": "light.ceiling_lights"}, blocking=True + ) + await hass.async_block_till_done() + + events = async_capture_events(hass, EVENT_COMMAND_RECEIVED) + service_events = async_capture_events(hass, EVENT_CALL_SERVICE) + + platforms = entity_platform.async_get_platforms(hass, "demo") + assert platforms[0].domain == "light" + assert platforms[0].entities["light.ceiling_lights"] + + turn_on_wait = asyncio.Event() + + async def slow_turn_on(*args, **kwargs): + # Make DemoLigt.async_turn_on hang waiting for the turn_on_wait event + await turn_on_wait.wait(), + + with patch.object( + hass.services, "async_call", wraps=hass.services.async_call + ) as call_service_mock, patch.object( + DemoLight, "async_turn_on", wraps=slow_turn_on + ): + result = await sh.async_handle_message( + hass, + MockConfig(should_report_state=report_state), + None, + { + "requestId": REQ_ID, + "inputs": [ + { + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [ + { + "devices": [ + {"id": "light.non_existing"}, + {"id": "light.ceiling_lights"}, + {"id": "light.kitchen_lights"}, + ], + "execution": [ + { + "command": "action.devices.commands.OnOff", + "params": {"on": True}, + }, + { + "command": "action.devices.commands.BrightnessAbsolute", + "params": {"brightness": 20}, + }, + ], + } + ] + }, + } + ], + }, + const.SOURCE_CLOUD, + ) + # Only the two first calls are executed + assert call_service_mock.call_count == 2 + expected_calls = [ + call( + "light", + "turn_on", + {"entity_id": "light.ceiling_lights"}, + blocking=not report_state, + context=ANY, + ), + call( + "light", + "turn_on", + {"entity_id": "light.kitchen_lights"}, + blocking=not report_state, + context=ANY, + ), + ] + call_service_mock.assert_has_awaits(expected_calls, any_order=True) + + turn_on_wait.set() + await hass.async_block_till_done() + # The remaining two calls should now have executed + assert call_service_mock.call_count == 4 + expected_calls.extend( + [ + call( + "light", + "turn_on", + {"entity_id": "light.ceiling_lights", "brightness_pct": 20}, + blocking=not report_state, + context=ANY, + ), + call( + "light", + "turn_on", + {"entity_id": "light.kitchen_lights", "brightness_pct": 20}, + blocking=not report_state, + context=ANY, + ), + ] + ) + call_service_mock.assert_has_awaits(expected_calls, any_order=True) + await hass.async_block_till_done() + + assert result == { + "requestId": REQ_ID, + "payload": { + "commands": [ + { + "ids": ["light.non_existing"], + "status": "ERROR", + "errorCode": "deviceOffline", + }, + { + "ids": ["light.ceiling_lights"], + "status": "SUCCESS", + "states": { + "on": on, + "online": True, + "brightness": brightness, + }, + }, + { + "ids": ["light.kitchen_lights"], + "status": "SUCCESS", + "states": { + "on": True, + "online": True, + "brightness": brightness, + "color": { + "spectrumHsv": { + "hue": 345, + "saturation": 0.75, + "value": value, + }, + }, + }, + }, + ] + }, + } + + assert len(events) == 1 + assert events[0].event_type == EVENT_COMMAND_RECEIVED + assert events[0].data == { + "request_id": REQ_ID, + "entity_id": [ + "light.non_existing", + "light.ceiling_lights", + "light.kitchen_lights", + ], + "execution": [ + { + "command": "action.devices.commands.OnOff", + "params": {"on": True}, + }, + { + "command": "action.devices.commands.BrightnessAbsolute", + "params": {"brightness": 20}, + }, + ], + "source": "cloud", + } + + service_events = sorted( + service_events, key=lambda ev: ev.data["service_data"]["entity_id"] + ) + assert len(service_events) == 4 + assert service_events[0].data == { + "domain": "light", + "service": "turn_on", + "service_data": {"entity_id": "light.ceiling_lights"}, + } + assert service_events[1].data == { + "domain": "light", + "service": "turn_on", + "service_data": {"brightness_pct": 20, "entity_id": "light.ceiling_lights"}, + } + assert service_events[0].context == events[0].context + assert service_events[1].context == events[0].context + assert service_events[2].data == { + "domain": "light", + "service": "turn_on", + "service_data": {"entity_id": "light.kitchen_lights"}, + } + assert service_events[3].data == { + "domain": "light", + "service": "turn_on", + "service_data": {"brightness_pct": 20, "entity_id": "light.kitchen_lights"}, + } + assert service_events[2].context == events[0].context + assert service_events[3].context == events[0].context + + sh.EXECUTE_LIMIT = orig_execute_limit + + async def test_raising_error_trait(hass): """Test raising an error while executing a trait command.""" hass.states.async_set( @@ -940,7 +1184,7 @@ async def test_trait_execute_adding_query_data(hass): "states": { "online": True, "cameraStreamAccessUrl": "https://example.com/api/streams/bla", - "cameraStreamReceiverAppId": "B12CE3CA", + "cameraStreamReceiverAppId": "B45F4572", }, } ] diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 290aa00bb47..a396c1bc91d 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components import ( alarm_control_panel, binary_sensor, + button, camera, cover, fan, @@ -146,7 +147,7 @@ async def test_camera_stream(hass): assert trt.query_attributes() == { "cameraStreamAccessUrl": "https://example.com/api/streams/bla", - "cameraStreamReceiverAppId": "B12CE3CA", + "cameraStreamReceiverAppId": "B45F4572", } @@ -767,6 +768,26 @@ async def test_light_modes(hass): } +async def test_scene_button(hass): + """Test Scene trait support for the button domain.""" + assert helpers.get_google_type(button.DOMAIN, None) is not None + assert trait.SceneTrait.supported(button.DOMAIN, 0, None, None) + + trt = trait.SceneTrait(hass, State("button.bla", STATE_UNKNOWN), BASIC_CONFIG) + assert trt.sync_attributes() == {} + assert trt.query_attributes() == {} + assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) + + calls = async_mock_service(hass, button.DOMAIN, button.SERVICE_PRESS) + await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}, {}) + + # We don't wait till button press is done. + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data == {ATTR_ENTITY_ID: "button.bla"} + + async def test_scene_scene(hass): """Test Scene trait support for scene domain.""" assert helpers.get_google_type(scene.DOMAIN, None) is not None diff --git a/tests/components/gree/common.py b/tests/components/gree/common.py index 40403377957..c7db03b118f 100644 --- a/tests/components/gree/common.py +++ b/tests/components/gree/common.py @@ -5,7 +5,10 @@ from unittest.mock import AsyncMock, Mock from greeclimate.discovery import Listener -from homeassistant.components.gree.const import DISCOVERY_TIMEOUT +from homeassistant.components.gree.const import DISCOVERY_TIMEOUT, DOMAIN as GREE_DOMAIN +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry _LOGGER = logging.getLogger(__name__) @@ -16,6 +19,7 @@ class FakeDiscovery: def __init__(self, timeout: int = DISCOVERY_TIMEOUT) -> None: """Initialize the class.""" self.mock_devices = [build_device_mock()] + self.last_mock_infos = [] self.timeout = timeout self._listeners = [] self.scan_count = 0 @@ -29,14 +33,27 @@ class FakeDiscovery: self.scan_count += 1 _LOGGER.info("CALLED SCAN %d TIMES", self.scan_count) - infos = [x.device_info for x in self.mock_devices] + mock_infos = [x.device_info for x in self.mock_devices] + + new_infos = [] + updated_infos = [] + for info in mock_infos: + if not [i for i in self.last_mock_infos if info.mac == i.mac]: + new_infos.append(info) + else: + last_info = next(i for i in self.last_mock_infos if info.mac == i.mac) + if info.ip != last_info.ip: + updated_infos.append(info) + + self.last_mock_infos = mock_infos for listener in self._listeners: - [await listener.device_found(x) for x in infos] + [await listener.device_found(x) for x in new_infos] + [await listener.device_update(x) for x in updated_infos] if wait_for: await asyncio.sleep(wait_for) - return infos + return new_infos def build_device_info_mock( @@ -71,3 +88,10 @@ def build_device_mock(name="fake-device-1", ipAddress="1.1.1.1", mac="aabbcc1122 steady_heat=False, ) return mock + + +async def async_setup_gree(hass): + """Set up the gree platform.""" + MockConfigEntry(domain=GREE_DOMAIN).add_to_hass(hass) + await async_setup_component(hass, GREE_DOMAIN, {GREE_DOMAIN: {"climate": {}}}) + await hass.async_block_till_done() diff --git a/tests/components/gree/test_bridge.py b/tests/components/gree/test_bridge.py new file mode 100644 index 00000000000..13522b1216b --- /dev/null +++ b/tests/components/gree/test_bridge.py @@ -0,0 +1,67 @@ +"""Tests for gree component.""" +from datetime import timedelta +from unittest.mock import patch + +import pytest + +from homeassistant.components.climate.const import DOMAIN +from homeassistant.components.gree.const import COORDINATORS, DOMAIN as GREE +import homeassistant.util.dt as dt_util + +from .common import async_setup_gree, build_device_mock + +from tests.common import async_fire_time_changed + +ENTITY_ID_1 = f"{DOMAIN}.fake_device_1" +ENTITY_ID_2 = f"{DOMAIN}.fake_device_2" + + +@pytest.fixture +def mock_now(): + """Fixture for dtutil.now.""" + return dt_util.utcnow() + + +async def test_discovery_after_setup(hass, discovery, device, mock_now): + """Test gree devices don't change after multiple discoveries.""" + mock_device_1 = build_device_mock( + name="fake-device-1", ipAddress="1.1.1.1", mac="aabbcc112233" + ) + mock_device_2 = build_device_mock( + name="fake-device-2", ipAddress="2.2.2.2", mac="bbccdd223344" + ) + + discovery.return_value.mock_devices = [mock_device_1, mock_device_2] + device.side_effect = [mock_device_1, mock_device_2] + + await async_setup_gree(hass) + await hass.async_block_till_done() + + assert discovery.return_value.scan_count == 1 + assert len(hass.states.async_all(DOMAIN)) == 2 + + device_infos = [x.device.device_info for x in hass.data[GREE][COORDINATORS]] + assert device_infos[0].ip == "1.1.1.1" + assert device_infos[1].ip == "2.2.2.2" + + # rediscover the same devices with new ip addresses should update + mock_device_1 = build_device_mock( + name="fake-device-1", ipAddress="1.1.1.2", mac="aabbcc112233" + ) + mock_device_2 = build_device_mock( + name="fake-device-2", ipAddress="2.2.2.1", mac="bbccdd223344" + ) + discovery.return_value.mock_devices = [mock_device_1, mock_device_2] + device.side_effect = [mock_device_1, mock_device_2] + + next_update = mock_now + timedelta(minutes=6) + with patch("homeassistant.util.dt.utcnow", return_value=next_update): + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + assert discovery.return_value.scan_count == 2 + assert len(hass.states.async_all(DOMAIN)) == 2 + + device_infos = [x.device.device_info for x in hass.data[GREE][COORDINATORS]] + assert device_infos[0].ip == "1.1.1.2" + assert device_infos[1].ip == "2.2.2.1" diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index d88f6a6fbf0..ce1d8f3c705 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -43,11 +43,7 @@ from homeassistant.components.gree.climate import ( HVAC_MODES_REVERSE, SUPPORTED_FEATURES, ) -from homeassistant.components.gree.const import ( - DOMAIN as GREE_DOMAIN, - FAN_MEDIUM_HIGH, - FAN_MEDIUM_LOW, -) +from homeassistant.components.gree.const import FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, @@ -59,12 +55,11 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, ) -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .common import build_device_mock +from .common import async_setup_gree, build_device_mock -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import async_fire_time_changed ENTITY_ID = f"{DOMAIN}.fake_device_1" @@ -75,13 +70,6 @@ def mock_now(): return dt_util.utcnow() -async def async_setup_gree(hass): - """Set up the gree platform.""" - MockConfigEntry(domain=GREE_DOMAIN).add_to_hass(hass) - await async_setup_component(hass, GREE_DOMAIN, {GREE_DOMAIN: {"climate": {}}}) - await hass.async_block_till_done() - - async def test_discovery_called_once(hass, discovery, device): """Test discovery is only ever called once.""" await async_setup_gree(hass) diff --git a/tests/components/gree/test_config_flow.py b/tests/components/gree/test_config_flow.py index a3e881d6daf..27d290e3b90 100644 --- a/tests/components/gree/test_config_flow.py +++ b/tests/components/gree/test_config_flow.py @@ -7,6 +7,7 @@ from homeassistant.components.gree.const import DOMAIN as GREE_DOMAIN from .common import FakeDiscovery +@patch("homeassistant.components.gree.config_flow.DISCOVERY_TIMEOUT", 0) async def test_creating_entry_sets_up_climate(hass): """Test setting up Gree creates the climate components.""" with patch( @@ -32,6 +33,7 @@ async def test_creating_entry_sets_up_climate(hass): assert len(setup.mock_calls) == 1 +@patch("homeassistant.components.gree.config_flow.DISCOVERY_TIMEOUT", 0) async def test_creating_entry_has_no_devices(hass): """Test setting up Gree creates the climate components.""" with patch( diff --git a/tests/components/greeneye_monitor/__init__.py b/tests/components/greeneye_monitor/__init__.py new file mode 100644 index 00000000000..db9bcaee1f4 --- /dev/null +++ b/tests/components/greeneye_monitor/__init__.py @@ -0,0 +1 @@ +"""Tests for the GreenEye Monitor integration.""" diff --git a/tests/components/greeneye_monitor/common.py b/tests/components/greeneye_monitor/common.py new file mode 100644 index 00000000000..ac00ccbfc0b --- /dev/null +++ b/tests/components/greeneye_monitor/common.py @@ -0,0 +1,205 @@ +"""Common helpers for greeneye_monitor tests.""" +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.components.greeneye_monitor import ( + CONF_CHANNELS, + CONF_COUNTED_QUANTITY, + CONF_COUNTED_QUANTITY_PER_PULSE, + CONF_MONITORS, + CONF_NET_METERING, + CONF_NUMBER, + CONF_PULSE_COUNTERS, + CONF_SERIAL_NUMBER, + CONF_TEMPERATURE_SENSORS, + CONF_TIME_UNIT, + CONF_VOLTAGE_SENSORS, + DOMAIN, +) +from homeassistant.const import ( + CONF_NAME, + CONF_PORT, + CONF_SENSORS, + CONF_TEMPERATURE_UNIT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_setup_component + +SINGLE_MONITOR_SERIAL_NUMBER = 110011 + + +def make_single_monitor_config_with_sensors(sensors: dict[str, Any]) -> dict[str, Any]: + """Wrap the given sensor config in the boilerplate for a single monitor with serial number SINGLE_MONITOR_SERIAL_NUMBER.""" + return { + DOMAIN: { + CONF_PORT: 7513, + CONF_MONITORS: [ + { + CONF_SERIAL_NUMBER: f"00{SINGLE_MONITOR_SERIAL_NUMBER}", + **sensors, + } + ], + } + } + + +SINGLE_MONITOR_CONFIG_NO_SENSORS = make_single_monitor_config_with_sensors({}) +SINGLE_MONITOR_CONFIG_PULSE_COUNTERS = make_single_monitor_config_with_sensors( + { + CONF_PULSE_COUNTERS: [ + { + CONF_NUMBER: 1, + CONF_NAME: "pulse_a", + CONF_COUNTED_QUANTITY: "pulses", + CONF_COUNTED_QUANTITY_PER_PULSE: 1.0, + CONF_TIME_UNIT: "s", + }, + { + CONF_NUMBER: 2, + CONF_NAME: "pulse_2", + CONF_COUNTED_QUANTITY: "gal", + CONF_COUNTED_QUANTITY_PER_PULSE: 0.5, + CONF_TIME_UNIT: "min", + }, + { + CONF_NUMBER: 3, + CONF_NAME: "pulse_3", + CONF_COUNTED_QUANTITY: "gal", + CONF_COUNTED_QUANTITY_PER_PULSE: 0.5, + CONF_TIME_UNIT: "h", + }, + { + CONF_NUMBER: 4, + CONF_NAME: "pulse_d", + CONF_COUNTED_QUANTITY: "pulses", + CONF_COUNTED_QUANTITY_PER_PULSE: 1.0, + CONF_TIME_UNIT: "s", + }, + ] + } +) + +SINGLE_MONITOR_CONFIG_POWER_SENSORS = make_single_monitor_config_with_sensors( + { + CONF_CHANNELS: [ + { + CONF_NUMBER: 1, + CONF_NAME: "channel 1", + }, + { + CONF_NUMBER: 2, + CONF_NAME: "channel two", + CONF_NET_METERING: True, + }, + ] + } +) + + +SINGLE_MONITOR_CONFIG_TEMPERATURE_SENSORS = make_single_monitor_config_with_sensors( + { + CONF_TEMPERATURE_SENSORS: { + CONF_TEMPERATURE_UNIT: "F", + CONF_SENSORS: [ + {CONF_NUMBER: 1, CONF_NAME: "temp_a"}, + {CONF_NUMBER: 2, CONF_NAME: "temp_2"}, + {CONF_NUMBER: 3, CONF_NAME: "temp_c"}, + {CONF_NUMBER: 4, CONF_NAME: "temp_d"}, + {CONF_NUMBER: 5, CONF_NAME: "temp_5"}, + {CONF_NUMBER: 6, CONF_NAME: "temp_f"}, + {CONF_NUMBER: 7, CONF_NAME: "temp_g"}, + {CONF_NUMBER: 8, CONF_NAME: "temp_h"}, + ], + } + } +) + +SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS = make_single_monitor_config_with_sensors( + { + CONF_VOLTAGE_SENSORS: [ + { + CONF_NUMBER: 1, + CONF_NAME: "voltage 1", + }, + ] + } +) + + +async def setup_greeneye_monitor_component_with_config( + hass: HomeAssistant, config: ConfigType +) -> bool: + """Set up the greeneye_monitor component with the given config. Return True if successful, False otherwise.""" + result = await async_setup_component( + hass, + DOMAIN, + config, + ) + await hass.async_block_till_done() + + return result + + +def mock_with_listeners() -> MagicMock: + """Create a MagicMock with methods that follow the same pattern for working with listeners in the greeneye_monitor API.""" + mock = MagicMock() + add_listeners(mock) + return mock + + +def async_mock_with_listeners() -> AsyncMock: + """Create an AsyncMock with methods that follow the same pattern for working with listeners in the greeneye_monitor API.""" + mock = AsyncMock() + add_listeners(mock) + return mock + + +def add_listeners(mock: MagicMock | AsyncMock) -> None: + """Add add_listener and remove_listener methods to the given mock that behave like their counterparts on objects from the greeneye_monitor API, plus a notify_all_listeners method that calls all registered listeners.""" + mock.listeners = [] + mock.add_listener = mock.listeners.append + mock.remove_listener = mock.listeners.remove + + def notify_all_listeners(*args): + for listener in list(mock.listeners): + listener(*args) + + mock.notify_all_listeners = notify_all_listeners + + +def mock_pulse_counter() -> MagicMock: + """Create a mock GreenEye Monitor pulse counter.""" + pulse_counter = mock_with_listeners() + pulse_counter.pulses = 1000 + pulse_counter.pulses_per_second = 10 + return pulse_counter + + +def mock_temperature_sensor() -> MagicMock: + """Create a mock GreenEye Monitor temperature sensor.""" + temperature_sensor = mock_with_listeners() + temperature_sensor.temperature = 32.0 + return temperature_sensor + + +def mock_channel() -> MagicMock: + """Create a mock GreenEye Monitor CT channel.""" + channel = mock_with_listeners() + channel.absolute_watt_seconds = 1000 + channel.polarized_watt_seconds = -400 + channel.watts = None + return channel + + +def mock_monitor(serial_number: int) -> MagicMock: + """Create a mock GreenEye Monitor.""" + monitor = mock_with_listeners() + monitor.serial_number = serial_number + monitor.voltage = 120.0 + monitor.pulse_counters = [mock_pulse_counter() for i in range(0, 4)] + monitor.temperature_sensors = [mock_temperature_sensor() for i in range(0, 8)] + monitor.channels = [mock_channel() for i in range(0, 32)] + return monitor diff --git a/tests/components/greeneye_monitor/conftest.py b/tests/components/greeneye_monitor/conftest.py new file mode 100644 index 00000000000..a68cc9e8b96 --- /dev/null +++ b/tests/components/greeneye_monitor/conftest.py @@ -0,0 +1,118 @@ +"""Common fixtures for testing greeneye_monitor.""" +from typing import Any, Dict +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.greeneye_monitor import DOMAIN +from homeassistant.const import ( + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_POTENTIAL_VOLT, + POWER_WATT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import ( + RegistryEntry, + async_get as get_entity_registry, +) + +from .common import add_listeners + + +def assert_sensor_state( + hass: HomeAssistant, + entity_id: str, + expected_state: str, + attributes: Dict[str, Any] = {}, +) -> None: + """Assert that the given entity has the expected state and at least the provided attributes.""" + state = hass.states.get(entity_id) + assert state + actual_state = state.state + assert actual_state == expected_state + for (key, value) in attributes.items(): + assert key in state.attributes + assert state.attributes[key] == value + + +def assert_temperature_sensor_registered( + hass: HomeAssistant, + serial_number: int, + number: int, + name: str, +): + """Assert that a temperature sensor entity was registered properly.""" + sensor = assert_sensor_registered(hass, serial_number, "temp", number, name) + assert sensor.original_device_class == DEVICE_CLASS_TEMPERATURE + + +def assert_pulse_counter_registered( + hass: HomeAssistant, + serial_number: int, + number: int, + name: str, + quantity: str, + per_time: str, +): + """Assert that a pulse counter entity was registered properly.""" + sensor = assert_sensor_registered(hass, serial_number, "pulse", number, name) + assert sensor.unit_of_measurement == f"{quantity}/{per_time}" + + +def assert_power_sensor_registered( + hass: HomeAssistant, serial_number: int, number: int, name: str +) -> None: + """Assert that a power sensor entity was registered properly.""" + sensor = assert_sensor_registered(hass, serial_number, "current", number, name) + assert sensor.unit_of_measurement == POWER_WATT + assert sensor.original_device_class == DEVICE_CLASS_POWER + + +def assert_voltage_sensor_registered( + hass: HomeAssistant, serial_number: int, number: int, name: str +) -> None: + """Assert that a voltage sensor entity was registered properly.""" + sensor = assert_sensor_registered(hass, serial_number, "volts", number, name) + assert sensor.unit_of_measurement == ELECTRIC_POTENTIAL_VOLT + assert sensor.original_device_class == DEVICE_CLASS_VOLTAGE + + +def assert_sensor_registered( + hass: HomeAssistant, + serial_number: int, + sensor_type: str, + number: int, + name: str, +) -> RegistryEntry: + """Assert that a sensor entity of a given type was registered properly.""" + registry = get_entity_registry(hass) + unique_id = f"{serial_number}-{sensor_type}-{number}" + + entity_id = registry.async_get_entity_id("sensor", DOMAIN, unique_id) + assert entity_id is not None + + sensor = registry.async_get(entity_id) + assert sensor + assert sensor.unique_id == unique_id + assert sensor.original_name == name + + return sensor + + +@pytest.fixture +def monitors() -> AsyncMock: + """Provide a mock greeneye.Monitors object that has listeners and can add new monitors.""" + with patch("greeneye.Monitors", new=AsyncMock) as mock_monitors: + add_listeners(mock_monitors) + mock_monitors.monitors = {} + + def add_monitor(monitor: MagicMock) -> None: + """Add the given mock monitor as a monitor with the given serial number, notifying any listeners on the Monitors object.""" + serial_number = monitor.serial_number + mock_monitors.monitors[serial_number] = monitor + mock_monitors.notify_all_listeners(monitor) + + mock_monitors.add_monitor = add_monitor + yield mock_monitors diff --git a/tests/components/greeneye_monitor/test_init.py b/tests/components/greeneye_monitor/test_init.py new file mode 100644 index 00000000000..143fb14f28c --- /dev/null +++ b/tests/components/greeneye_monitor/test_init.py @@ -0,0 +1,199 @@ +"""Tests for greeneye_monitor component initialization.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.greeneye_monitor import ( + CONF_MONITORS, + CONF_NUMBER, + CONF_SERIAL_NUMBER, + CONF_TEMPERATURE_SENSORS, + DOMAIN, +) +from homeassistant.const import ( + CONF_NAME, + CONF_PORT, + CONF_SENSORS, + CONF_TEMPERATURE_UNIT, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import ( + SINGLE_MONITOR_CONFIG_NO_SENSORS, + SINGLE_MONITOR_CONFIG_POWER_SENSORS, + SINGLE_MONITOR_CONFIG_PULSE_COUNTERS, + SINGLE_MONITOR_CONFIG_TEMPERATURE_SENSORS, + SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS, + SINGLE_MONITOR_SERIAL_NUMBER, + setup_greeneye_monitor_component_with_config, +) +from .conftest import ( + assert_power_sensor_registered, + assert_pulse_counter_registered, + assert_temperature_sensor_registered, + assert_voltage_sensor_registered, +) + + +async def test_setup_fails_if_no_sensors_defined( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that component setup fails if there are no sensors defined in the YAML.""" + success = await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_NO_SENSORS + ) + assert not success + + +@pytest.mark.xfail(reason="Currently failing. Will fix in subsequent PR.") +async def test_setup_succeeds_no_config( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that component setup succeeds if there is no config present in the YAML.""" + assert await async_setup_component(hass, DOMAIN, {}) + + +async def test_setup_creates_temperature_entities( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that component setup registers temperature sensors properly.""" + assert await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_TEMPERATURE_SENSORS + ) + + assert_temperature_sensor_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 1, "temp_a" + ) + assert_temperature_sensor_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 2, "temp_2" + ) + assert_temperature_sensor_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 3, "temp_c" + ) + assert_temperature_sensor_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 4, "temp_d" + ) + assert_temperature_sensor_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 5, "temp_5" + ) + assert_temperature_sensor_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 6, "temp_f" + ) + assert_temperature_sensor_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 7, "temp_g" + ) + assert_temperature_sensor_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 8, "temp_h" + ) + + +async def test_setup_creates_pulse_counter_entities( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that component setup registers pulse counters properly.""" + assert await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_PULSE_COUNTERS + ) + + assert_pulse_counter_registered( + hass, + SINGLE_MONITOR_SERIAL_NUMBER, + 1, + "pulse_a", + "pulses", + "s", + ) + assert_pulse_counter_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 2, "pulse_2", "gal", "min" + ) + assert_pulse_counter_registered( + hass, + SINGLE_MONITOR_SERIAL_NUMBER, + 3, + "pulse_3", + "gal", + "h", + ) + assert_pulse_counter_registered( + hass, + SINGLE_MONITOR_SERIAL_NUMBER, + 4, + "pulse_d", + "pulses", + "s", + ) + + +async def test_setup_creates_power_sensor_entities( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that component setup registers power sensors correctly.""" + assert await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_POWER_SENSORS + ) + + assert_power_sensor_registered(hass, SINGLE_MONITOR_SERIAL_NUMBER, 1, "channel 1") + assert_power_sensor_registered(hass, SINGLE_MONITOR_SERIAL_NUMBER, 2, "channel two") + + +async def test_setup_creates_voltage_sensor_entities( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that component setup registers voltage sensors properly.""" + assert await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS + ) + + assert_voltage_sensor_registered(hass, SINGLE_MONITOR_SERIAL_NUMBER, 1, "voltage 1") + + +async def test_multi_monitor_config(hass: HomeAssistant, monitors: AsyncMock) -> None: + """Test that component setup registers entities from multiple monitors correctly.""" + assert await setup_greeneye_monitor_component_with_config( + hass, + { + DOMAIN: { + CONF_PORT: 7513, + CONF_MONITORS: [ + { + CONF_SERIAL_NUMBER: "00000001", + CONF_TEMPERATURE_SENSORS: { + CONF_TEMPERATURE_UNIT: "C", + CONF_SENSORS: [ + {CONF_NUMBER: 1, CONF_NAME: "unit_1_temp_1"} + ], + }, + }, + { + CONF_SERIAL_NUMBER: "00000002", + CONF_TEMPERATURE_SENSORS: { + CONF_TEMPERATURE_UNIT: "F", + CONF_SENSORS: [ + {CONF_NUMBER: 1, CONF_NAME: "unit_2_temp_1"} + ], + }, + }, + ], + } + }, + ) + + assert_temperature_sensor_registered(hass, 1, 1, "unit_1_temp_1") + assert_temperature_sensor_registered(hass, 2, 1, "unit_2_temp_1") + + +async def test_setup_and_shutdown(hass: HomeAssistant, monitors: AsyncMock) -> None: + """Test that the component can set up and shut down cleanly, closing the underlying server on shutdown.""" + server = AsyncMock() + monitors.start_server = AsyncMock(return_value=server) + assert await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_POWER_SENSORS + ) + + await hass.async_stop() + + assert server.close.called diff --git a/tests/components/greeneye_monitor/test_sensor.py b/tests/components/greeneye_monitor/test_sensor.py new file mode 100644 index 00000000000..63ab8b64423 --- /dev/null +++ b/tests/components/greeneye_monitor/test_sensor.py @@ -0,0 +1,165 @@ +"""Tests for greeneye_monitor sensors.""" +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.components.greeneye_monitor.sensor import ( + DATA_PULSES, + DATA_WATT_SECONDS, +) +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import async_get as get_entity_registry + +from .common import ( + SINGLE_MONITOR_CONFIG_POWER_SENSORS, + SINGLE_MONITOR_CONFIG_PULSE_COUNTERS, + SINGLE_MONITOR_CONFIG_TEMPERATURE_SENSORS, + SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS, + SINGLE_MONITOR_SERIAL_NUMBER, + mock_monitor, + setup_greeneye_monitor_component_with_config, +) +from .conftest import assert_sensor_state + + +async def test_disable_sensor_before_monitor_connected( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that a sensor disabled before its monitor connected stops listening for new monitors.""" + # The sensor base class handles connecting the monitor, so we test this with a single voltage sensor for ease + await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS + ) + + assert len(monitors.listeners) == 1 + await disable_entity(hass, "sensor.voltage_1") + assert len(monitors.listeners) == 0 # Make sure we cleaned up the listener + + +async def test_updates_state_when_monitor_connected( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that a sensor updates its state when its monitor first connects.""" + # The sensor base class handles updating the state on connection, so we test this with a single voltage sensor for ease + await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS + ) + + assert_sensor_state(hass, "sensor.voltage_1", STATE_UNKNOWN) + assert len(monitors.listeners) == 1 + connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER) + assert len(monitors.listeners) == 0 # Make sure we cleaned up the listener + assert_sensor_state(hass, "sensor.voltage_1", "120.0") + + +async def test_disable_sensor_after_monitor_connected( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that a sensor disabled after its monitor connected stops listening for sensor changes.""" + # The sensor base class handles connecting the monitor, so we test this with a single voltage sensor for ease + await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS + ) + monitor = connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER) + + assert len(monitor.listeners) == 1 + await disable_entity(hass, "sensor.voltage_1") + assert len(monitor.listeners) == 0 + + +async def test_updates_state_when_sensor_pushes( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that a sensor entity updates its state when the underlying sensor pushes an update.""" + # The sensor base class handles triggering state updates, so we test this with a single voltage sensor for ease + await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS + ) + monitor = connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER) + assert_sensor_state(hass, "sensor.voltage_1", "120.0") + + monitor.voltage = 119.8 + monitor.notify_all_listeners() + assert_sensor_state(hass, "sensor.voltage_1", "119.8") + + +async def test_power_sensor_initially_unknown( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that the power sensor can handle its initial state being unknown (since the GEM API needs at least two packets to arrive before it can compute watts).""" + await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_POWER_SENSORS + ) + connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER) + assert_sensor_state( + hass, "sensor.channel_1", STATE_UNKNOWN, {DATA_WATT_SECONDS: 1000} + ) + # This sensor was configured with net metering on, so we should be taking the + # polarized value + assert_sensor_state( + hass, "sensor.channel_two", STATE_UNKNOWN, {DATA_WATT_SECONDS: -400} + ) + + +async def test_power_sensor(hass: HomeAssistant, monitors: AsyncMock) -> None: + """Test that a power sensor reports its values correctly, including handling net metering.""" + await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_POWER_SENSORS + ) + monitor = connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER) + monitor.channels[0].watts = 120.0 + monitor.channels[1].watts = 120.0 + monitor.channels[0].notify_all_listeners() + monitor.channels[1].notify_all_listeners() + assert_sensor_state(hass, "sensor.channel_1", "120.0", {DATA_WATT_SECONDS: 1000}) + # This sensor was configured with net metering on, so we should be taking the + # polarized value + assert_sensor_state(hass, "sensor.channel_two", "120.0", {DATA_WATT_SECONDS: -400}) + + +async def test_pulse_counter(hass: HomeAssistant, monitors: AsyncMock) -> None: + """Test that a pulse counter sensor reports its values properly, including calculating different units.""" + await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_PULSE_COUNTERS + ) + connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER) + assert_sensor_state(hass, "sensor.pulse_a", "10.0", {DATA_PULSES: 1000}) + # This counter was configured with each pulse meaning 0.5 gallons and + # wanting to show gallons per minute, so 10 pulses per second -> 300 gal/min + assert_sensor_state(hass, "sensor.pulse_2", "300.0", {DATA_PULSES: 1000}) + # This counter was configured with each pulse meaning 0.5 gallons and + # wanting to show gallons per hour, so 10 pulses per second -> 18000 gal/hr + assert_sensor_state(hass, "sensor.pulse_3", "18000.0", {DATA_PULSES: 1000}) + + +async def test_temperature_sensor(hass: HomeAssistant, monitors: AsyncMock) -> None: + """Test that a temperature sensor reports its values properly, including proper handling of when its native unit is different from that configured in hass.""" + await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_TEMPERATURE_SENSORS + ) + connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER) + # The config says that the sensor is reporting in Fahrenheit; if we set that up + # properly, HA will have converted that to Celsius by default. + assert_sensor_state(hass, "sensor.temp_a", "0.0") + + +async def test_voltage_sensor(hass: HomeAssistant, monitors: AsyncMock) -> None: + """Test that a voltage sensor reports its values properly.""" + await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS + ) + connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER) + assert_sensor_state(hass, "sensor.voltage_1", "120.0") + + +def connect_monitor(monitors: AsyncMock, serial_number: int) -> MagicMock: + """Simulate a monitor connecting to Home Assistant. Returns the mock monitor API object.""" + monitor = mock_monitor(serial_number) + monitors.add_monitor(monitor) + return monitor + + +async def disable_entity(hass: HomeAssistant, entity_id: str) -> None: + """Disable the given entity.""" + entity_registry = get_entity_registry(hass) + entity_registry.async_update_entity(entity_id, disabled_by="user") + await hass.async_block_till_done() diff --git a/tests/fixtures/group/configuration.yaml b/tests/components/group/fixtures/configuration.yaml similarity index 100% rename from tests/fixtures/group/configuration.yaml rename to tests/components/group/fixtures/configuration.yaml diff --git a/tests/fixtures/group/fan_configuration.yaml b/tests/components/group/fixtures/fan_configuration.yaml similarity index 100% rename from tests/fixtures/group/fan_configuration.yaml rename to tests/components/group/fixtures/fan_configuration.yaml diff --git a/tests/components/group/test_binary_sensor.py b/tests/components/group/test_binary_sensor.py index 7bf62a16a42..9da54be2ab4 100644 --- a/tests/components/group/test_binary_sensor.py +++ b/tests/components/group/test_binary_sensor.py @@ -1,6 +1,4 @@ """The tests for the Group Binary Sensor platform.""" -from os import path - from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.group import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE @@ -42,7 +40,7 @@ async def test_default_state(hass): assert entry assert entry.unique_id == "unique_identifier" assert entry.original_name == "Bedroom Group" - assert entry.device_class == "presence" + assert entry.original_device_class == "presence" async def test_state_reporting_all(hass): @@ -145,7 +143,3 @@ async def test_state_reporting_any(hass): entry = entity_registry.async_get("binary_sensor.binary_sensor_group") assert entry assert entry.unique_id == "unique_identifier" - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py index 10770e3de06..abb1dcf245a 100644 --- a/tests/components/group/test_fan.py +++ b/tests/components/group/test_fan.py @@ -1,5 +1,4 @@ """The tests for the group fan platform.""" -from os import path from unittest.mock import patch import pytest @@ -38,7 +37,7 @@ from homeassistant.core import CoreState from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from tests.common import assert_setup_component +from tests.common import assert_setup_component, get_fixture_path FAN_GROUP = "fan.fan_group" @@ -379,11 +378,7 @@ async def test_reload(hass, setup_comp): await hass.async_block_till_done() assert hass.states.get(FAN_GROUP).state == STATE_OFF - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "group/fan_configuration.yaml", - ) + yaml_path = get_fixture_path("fan_configuration.yaml", "group") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( "group", @@ -397,10 +392,6 @@ async def test_reload(hass, setup_comp): assert hass.states.get("fan.upstairs_fans") is not None -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) - - @pytest.mark.parametrize("config_count", [(CONFIG_FULL_SUPPORT, 2)]) async def test_service_calls(hass, setup_comp): """Test calling services.""" diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index e1a45d6fe53..843f15c7113 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -1,5 +1,4 @@ """The tests for the Group Light platform.""" -from os import path import unittest.mock from unittest.mock import MagicMock, patch @@ -53,6 +52,8 @@ from homeassistant.const import ( from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from tests.common import get_fixture_path + async def test_default_state(hass): """Test light group default state.""" @@ -1379,11 +1380,7 @@ async def test_reload(hass): await hass.async_block_till_done() assert hass.states.get("light.light_group").state == STATE_ON - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "group/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "group") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( DOMAIN, @@ -1421,11 +1418,7 @@ async def test_reload_with_platform_not_setup(hass): ) await hass.async_block_till_done() - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "group/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "group") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( DOMAIN, @@ -1458,11 +1451,7 @@ async def test_reload_with_base_integration_platform_not_setup(hass): hass.states.async_set("light.outside_patio_lights", STATE_OFF) hass.states.async_set("light.outside_patio_lights_2", STATE_OFF) - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "group/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "group") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( DOMAIN, @@ -1513,7 +1502,3 @@ async def test_nested_group(hass): assert state is not None assert state.state == STATE_ON assert state.attributes.get(ATTR_ENTITY_ID) == ["light.bedroom_group"] - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py index a0f210c68a2..ad0be58dd16 100644 --- a/tests/components/group/test_notify.py +++ b/tests/components/group/test_notify.py @@ -1,5 +1,4 @@ """The tests for the notify.group platform.""" -from os import path from unittest.mock import MagicMock, patch from homeassistant import config as hass_config @@ -9,6 +8,8 @@ import homeassistant.components.group.notify as group import homeassistant.components.notify as notify from homeassistant.setup import async_setup_component +from tests.common import get_fixture_path + async def test_send_message_with_data(hass): """Test sending a message with to a notify group.""" @@ -110,11 +111,8 @@ async def test_reload_notify(hass): assert hass.services.has_service(notify.DOMAIN, "demo2") assert hass.services.has_service(notify.DOMAIN, "group_notify") - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "group/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "group") + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( "group", @@ -128,7 +126,3 @@ async def test_reload_notify(hass): assert hass.services.has_service(notify.DOMAIN, "demo2") assert not hass.services.has_service(notify.DOMAIN, "group_notify") assert hass.services.has_service(notify.DOMAIN, "new_group_notify") - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/components/guardian/test_config_flow.py b/tests/components/guardian/test_config_flow.py index 4fbff7d7e48..f3e0adeec0f 100644 --- a/tests/components/guardian/test_config_flow.py +++ b/tests/components/guardian/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import patch from aioguardian.errors import GuardianError from homeassistant import data_entry_flow -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp, zeroconf from homeassistant.components.guardian import CONF_UID, DOMAIN from homeassistant.components.guardian.config_flow import ( async_get_pin_from_discovery_hostname, @@ -83,14 +83,14 @@ async def test_step_user(hass, ping_client): async def test_step_zeroconf(hass, ping_client): """Test the zeroconf step.""" - zeroconf_data = { - "host": "192.168.1.100", - "port": 7777, - "hostname": "GVC1-ABCD.local.", - "type": "_api._udp.local.", - "name": "Guardian Valve Controller API._api._udp.local.", - "properties": {"_raw": {}}, - } + zeroconf_data = zeroconf.ZeroconfServiceInfo( + host="192.168.1.100", + port=7777, + hostname="GVC1-ABCD.local.", + type="_api._udp.local.", + name="Guardian Valve Controller API._api._udp.local.", + properties={"_raw": {}}, + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_data @@ -112,14 +112,14 @@ async def test_step_zeroconf(hass, ping_client): async def test_step_zeroconf_already_in_progress(hass): """Test the zeroconf step aborting because it's already in progress.""" - zeroconf_data = { - "host": "192.168.1.100", - "port": 7777, - "hostname": "GVC1-ABCD.local.", - "type": "_api._udp.local.", - "name": "Guardian Valve Controller API._api._udp.local.", - "properties": {"_raw": {}}, - } + zeroconf_data = zeroconf.ZeroconfServiceInfo( + host="192.168.1.100", + port=7777, + hostname="GVC1-ABCD.local.", + type="_api._udp.local.", + name="Guardian Valve Controller API._api._udp.local.", + properties={"_raw": {}}, + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_data @@ -136,11 +136,11 @@ async def test_step_zeroconf_already_in_progress(hass): async def test_step_dhcp(hass, ping_client): """Test the dhcp step.""" - dhcp_data = { - IP_ADDRESS: "192.168.1.100", - HOSTNAME: "GVC1-ABCD.local.", - MAC_ADDRESS: "aa:bb:cc:dd:ee:ff", - } + dhcp_data = dhcp.DhcpServiceInfo( + ip="192.168.1.100", + hostname="GVC1-ABCD.local.", + macaddress="aa:bb:cc:dd:ee:ff", + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=dhcp_data @@ -162,11 +162,11 @@ async def test_step_dhcp(hass, ping_client): async def test_step_dhcp_already_in_progress(hass): """Test the zeroconf step aborting because it's already in progress.""" - dhcp_data = { - IP_ADDRESS: "192.168.1.100", - HOSTNAME: "GVC1-ABCD.local.", - MAC_ADDRESS: "aa:bb:cc:dd:ee:ff", - } + dhcp_data = dhcp.DhcpServiceInfo( + ip="192.168.1.100", + hostname="GVC1-ABCD.local.", + macaddress="aa:bb:cc:dd:ee:ff", + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=dhcp_data diff --git a/tests/components/harmony/test_config_flow.py b/tests/components/harmony/test_config_flow.py index 9195af40cf1..7725e9752f5 100644 --- a/tests/components/harmony/test_config_flow.py +++ b/tests/components/harmony/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import aiohttp from homeassistant import config_entries, data_entry_flow +from homeassistant.components import ssdp from homeassistant.components.harmony.config_flow import CannotConnect from homeassistant.components.harmony.const import DOMAIN, PREVIOUS_ACTIVE_ACTIVITY from homeassistant.const import CONF_HOST, CONF_NAME @@ -58,10 +59,14 @@ async def test_form_ssdp(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - "friendlyName": "Harmony Hub", - "ssdp_location": "http://192.168.1.12:8088/description", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.12:8088/description", + upnp={ + "friendlyName": "Harmony Hub", + }, + ), ) assert result["type"] == "form" assert result["step_id"] == "link" @@ -106,10 +111,14 @@ async def test_form_ssdp_fails_to_get_remote_id(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - "friendlyName": "Harmony Hub", - "ssdp_location": "http://192.168.1.12:8088/description", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.12:8088/description", + upnp={ + "friendlyName": "Harmony Hub", + }, + ), ) assert result["type"] == "abort" assert result["reason"] == "cannot_connect" @@ -139,10 +148,14 @@ async def test_form_ssdp_aborts_before_checking_remoteid_if_host_known(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - "friendlyName": "Harmony Hub", - "ssdp_location": "http://2.2.2.2:8088/description", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://2.2.2.2:8088/description", + upnp={ + "friendlyName": "Harmony Hub", + }, + ), ) assert result["type"] == "abort" diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py new file mode 100644 index 00000000000..e4263eb5529 --- /dev/null +++ b/tests/components/hassio/test_binary_sensor.py @@ -0,0 +1,156 @@ +"""The tests for the hassio binary sensors.""" + +import os +from unittest.mock import patch + +import pytest + +from homeassistant.components.hassio import DOMAIN +from homeassistant.helpers import entity_registry +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +MOCK_ENVIRON = {"HASSIO": "127.0.0.1", "HASSIO_TOKEN": "abcdefgh"} + + +@pytest.fixture(autouse=True) +def mock_all(aioclient_mock, request): + """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": None}, + }, + ) + 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_latest": "1.0.0", + "addons": [ + { + "name": "test", + "state": "started", + "slug": "test", + "installed": True, + "update_available": True, + "version": "2.0.0", + "version_latest": "2.0.1", + "repository": "core", + "url": "https://github.com/home-assistant/addons/test", + }, + { + "name": "test2", + "state": "stopped", + "slug": "test2", + "installed": True, + "update_available": False, + "version": "3.1.0", + "version_latest": "3.1.0", + "repository": "core", + "url": "https://github.com", + }, + ], + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/addons/test/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} + ) + + +@pytest.mark.parametrize( + "entity_id,expected", + [ + ("binary_sensor.home_assistant_operating_system_update_available", "off"), + ("binary_sensor.test_update_available", "on"), + ("binary_sensor.test2_update_available", "off"), + ("binary_sensor.test_running", "on"), + ("binary_sensor.test2_running", "off"), + ], +) +async def test_binary_sensor(hass, entity_id, expected, aioclient_mock): + """Test hassio OS and addons binary sensor.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + # Verify that the entity is disabled by default. + assert hass.states.get(entity_id) is None + + # Enable the entity. + ent_reg = entity_registry.async_get(hass) + ent_reg.async_update_entity(entity_id, disabled_by=None) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify that the entity have the expected state. + state = hass.states.get(entity_id) + assert state.state == expected diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index fc99b06619f..71f5f8acf96 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -2,6 +2,7 @@ from http import HTTPStatus from unittest.mock import Mock, patch +from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.setup import async_setup_component @@ -48,14 +49,16 @@ async def test_hassio_discovery_startup(hass, aioclient_mock, hassio_client): assert aioclient_mock.call_count == 2 assert mock_mqtt.called mock_mqtt.assert_called_with( - { - "broker": "mock-broker", - "port": 1883, - "username": "mock-user", - "password": "mock-pass", - "protocol": "3.1.1", - "addon": "Mosquitto Test", - } + HassioServiceInfo( + config={ + "broker": "mock-broker", + "port": 1883, + "username": "mock-user", + "password": "mock-pass", + "protocol": "3.1.1", + "addon": "Mosquitto Test", + } + ) ) @@ -109,14 +112,16 @@ async def test_hassio_discovery_startup_done(hass, aioclient_mock, hassio_client assert aioclient_mock.call_count == 2 assert mock_mqtt.called mock_mqtt.assert_called_with( - { - "broker": "mock-broker", - "port": 1883, - "username": "mock-user", - "password": "mock-pass", - "protocol": "3.1.1", - "addon": "Mosquitto Test", - } + HassioServiceInfo( + config={ + "broker": "mock-broker", + "port": 1883, + "username": "mock-user", + "password": "mock-pass", + "protocol": "3.1.1", + "addon": "Mosquitto Test", + } + ) ) @@ -159,12 +164,14 @@ async def test_hassio_discovery_webhook(hass, aioclient_mock, hassio_client): assert aioclient_mock.call_count == 2 assert mock_mqtt.called mock_mqtt.assert_called_with( - { - "broker": "mock-broker", - "port": 1883, - "username": "mock-user", - "password": "mock-pass", - "protocol": "3.1.1", - "addon": "Mosquitto Test", - } + HassioServiceInfo( + config={ + "broker": "mock-broker", + "port": 1883, + "username": "mock-user", + "password": "mock-pass", + "protocol": "3.1.1", + "addon": "Mosquitto Test", + } + ) ) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index f5214b563b3..e006cf9d829 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -59,7 +59,7 @@ def mock_all(aioclient_mock, request): ) aioclient_mock.get( "http://127.0.0.1/os/info", - json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}}, ) aioclient_mock.get( "http://127.0.0.1/supervisor/info", @@ -165,8 +165,8 @@ async def test_setup_api_panel(hass, aioclient_mock): assert panels.get("hassio").to_response() == { "component_name": "custom", - "icon": "hass:home-assistant", - "title": "Supervisor", + "icon": None, + "title": None, "url_path": "hassio", "require_admin": True, "config": { diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 00d2c32c520..481ba1b578f 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -111,8 +111,25 @@ def mock_all(aioclient_mock, request): ) -async def test_sensors(hass, aioclient_mock): - """Test hassio OS and addons sensors.""" +@pytest.mark.parametrize( + "entity_id,expected", + [ + ("sensor.home_assistant_operating_system_version", "1.0.0"), + ("sensor.home_assistant_operating_system_newest_version", "1.0.0"), + ("sensor.test_version", "2.0.0"), + ("sensor.test_newest_version", "2.0.1"), + ("sensor.test2_version", "3.1.0"), + ("sensor.test2_newest_version", "3.2.0"), + ("sensor.test_cpu_percent", "0.99"), + ("sensor.test2_cpu_percent", "unavailable"), + ("sensor.test_memory_percent", "4.59"), + ("sensor.test2_memory_percent", "unavailable"), + ], +) +async def test_sensor(hass, entity_id, expected, aioclient_mock): + """Test hassio OS and addons sensor.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component( @@ -121,38 +138,17 @@ async def test_sensors(hass, aioclient_mock): {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - - config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) - config_entry.add_to_hass(hass) - await hass.async_block_till_done() - sensors = { - "sensor.home_assistant_operating_system_version": "1.0.0", - "sensor.home_assistant_operating_system_newest_version": "1.0.0", - "sensor.test_version": "2.0.0", - "sensor.test_newest_version": "2.0.1", - "sensor.test2_version": "3.1.0", - "sensor.test2_newest_version": "3.2.0", - "sensor.test_cpu_percent": "0.99", - "sensor.test2_cpu_percent": "unavailable", - "sensor.test_memory_percent": "4.59", - "sensor.test2_memory_percent": "unavailable", - } + # Verify that the entity is disabled by default. + assert hass.states.get(entity_id) is None - """Check that entities are disabled by default.""" - for sensor in sensors: - assert hass.states.get(sensor) is None - - """Enable sensors.""" + # Enable the entity. ent_reg = entity_registry.async_get(hass) - for sensor in sensors: - ent_reg.async_update_entity(sensor, disabled_by=None) + ent_reg.async_update_entity(entity_id, disabled_by=None) await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - """Check sensor values.""" - for sensor, value in sensors.items(): - state = hass.states.get(sensor) - assert state.state == value + # Verify that the entity have the expected state. + state = hass.states.get(entity_id) + assert state.state == expected diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 931c1527b78..5d11d13166e 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -156,3 +156,69 @@ async def test_websocket_supervisor_api_error( msg = await websocket_client.receive_json() assert msg["error"]["message"] == "example error" + + +async def test_websocket_non_admin_user( + hassio_env, hass: HomeAssistant, hass_ws_client, aioclient_mock, hass_admin_user +): + """Test Supervisor websocket api error.""" + hass_admin_user.groups = [] + assert await async_setup_component(hass, "hassio", {}) + websocket_client = await hass_ws_client(hass) + aioclient_mock.get( + "http://127.0.0.1/addons/test_addon/info", + json={"result": "ok", "data": {}}, + ) + aioclient_mock.get( + "http://127.0.0.1/ingress/session", + json={"result": "ok", "data": {}}, + ) + aioclient_mock.get( + "http://127.0.0.1/ingress/validate_session", + json={"result": "ok", "data": {}}, + ) + + await websocket_client.send_json( + { + WS_ID: 1, + WS_TYPE: WS_TYPE_API, + ATTR_ENDPOINT: "/addons/test_addon/info", + ATTR_METHOD: "get", + } + ) + msg = await websocket_client.receive_json() + assert msg["result"] == {} + + await websocket_client.send_json( + { + WS_ID: 2, + WS_TYPE: WS_TYPE_API, + ATTR_ENDPOINT: "/ingress/session", + ATTR_METHOD: "get", + } + ) + msg = await websocket_client.receive_json() + assert msg["result"] == {} + + await websocket_client.send_json( + { + WS_ID: 3, + WS_TYPE: WS_TYPE_API, + ATTR_ENDPOINT: "/ingress/validate_session", + ATTR_METHOD: "get", + } + ) + msg = await websocket_client.receive_json() + assert msg["result"] == {} + + await websocket_client.send_json( + { + WS_ID: 4, + WS_TYPE: WS_TYPE_API, + ATTR_ENDPOINT: "/supervisor/info", + ATTR_METHOD: "get", + } + ) + + msg = await websocket_client.receive_json() + assert msg["error"]["message"] == "Unauthorized" diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 2c48b7fe8e1..a6b5c11dc9e 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -4,7 +4,15 @@ from __future__ import annotations from typing import Sequence from unittest.mock import Mock, patch as patch -from pyheos import Dispatcher, Heos, HeosPlayer, HeosSource, InputSource, const +from pyheos import ( + Dispatcher, + Heos, + HeosGroup, + HeosPlayer, + HeosSource, + InputSource, + const, +) import pytest from homeassistant.components import ssdp @@ -24,7 +32,7 @@ def config_entry_fixture(): @pytest.fixture(name="controller") def controller_fixture( - players, favorites, input_sources, playlists, change_data, dispatcher + players, favorites, input_sources, playlists, change_data, dispatcher, group ): """Create a mock Heos controller fixture.""" mock_heos = Mock(Heos) @@ -40,6 +48,8 @@ def controller_fixture( mock_heos.is_signed_in = True mock_heos.signed_in_username = "user@user.com" mock_heos.connection_state = const.STATE_CONNECTED + mock_heos.get_groups.return_value = group + mock_heos.create_group.return_value = None mock = Mock(return_value=mock_heos) with patch("homeassistant.components.heos.Heos", new=mock), patch( @@ -56,35 +66,51 @@ def config_fixture(): @pytest.fixture(name="players") def player_fixture(quick_selects): - """Create a mock HeosPlayer.""" - player = Mock(HeosPlayer) - player.player_id = 1 - player.name = "Test Player" - player.model = "Test Model" - player.version = "1.0.0" - player.is_muted = False - player.available = True - player.state = const.PLAY_STATE_STOP - player.ip_address = "127.0.0.1" - player.network = "wired" - player.shuffle = False - player.repeat = const.REPEAT_OFF - player.volume = 25 - player.now_playing_media.supported_controls = const.CONTROLS_ALL - player.now_playing_media.album_id = 1 - player.now_playing_media.queue_id = 1 - player.now_playing_media.source_id = 1 - player.now_playing_media.station = "Station Name" - player.now_playing_media.type = "Station" - player.now_playing_media.album = "Album" - player.now_playing_media.artist = "Artist" - player.now_playing_media.media_id = "1" - player.now_playing_media.duration = None - player.now_playing_media.current_position = None - player.now_playing_media.image_url = "http://" - player.now_playing_media.song = "Song" - player.get_quick_selects.return_value = quick_selects - return {player.player_id: player} + """Create two mock HeosPlayers.""" + players = {} + for i in (1, 2): + player = Mock(HeosPlayer) + player.player_id = i + if i > 1: + player.name = f"Test Player {i}" + else: + player.name = "Test Player" + player.model = "Test Model" + player.version = "1.0.0" + player.is_muted = False + player.available = True + player.state = const.PLAY_STATE_STOP + player.ip_address = f"127.0.0.{i}" + player.network = "wired" + player.shuffle = False + player.repeat = const.REPEAT_OFF + player.volume = 25 + player.now_playing_media.supported_controls = const.CONTROLS_ALL + player.now_playing_media.album_id = 1 + player.now_playing_media.queue_id = 1 + player.now_playing_media.source_id = 1 + player.now_playing_media.station = "Station Name" + player.now_playing_media.type = "Station" + player.now_playing_media.album = "Album" + player.now_playing_media.artist = "Artist" + player.now_playing_media.media_id = "1" + player.now_playing_media.duration = None + player.now_playing_media.current_position = None + player.now_playing_media.image_url = "http://" + player.now_playing_media.song = "Song" + player.get_quick_selects.return_value = quick_selects + players[player.player_id] = player + return players + + +@pytest.fixture(name="group") +def group_fixture(players): + """Create a HEOS group consisting of two players.""" + group = Mock(HeosGroup) + group.leader = players[1] + group.members = [players[2]] + group.group_id = 999 + return {group.group_id: group} @pytest.fixture(name="favorites") @@ -120,16 +146,20 @@ def dispatcher_fixture() -> Dispatcher: @pytest.fixture(name="discovery_data") def discovery_data_fixture() -> dict: """Return mock discovery data for testing.""" - return { - ssdp.ATTR_SSDP_LOCATION: "http://127.0.0.1:60006/upnp/desc/aios_device/aios_device.xml", - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Office", - ssdp.ATTR_UPNP_MANUFACTURER: "Denon", - ssdp.ATTR_UPNP_MODEL_NAME: "HEOS Drive", - ssdp.ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", - ssdp.ATTR_UPNP_SERIAL: None, - ssdp.ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", - } + return ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://127.0.0.1:60006/upnp/desc/aios_device/aios_device.xml", + upnp={ + ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "Office", + ssdp.ATTR_UPNP_MANUFACTURER: "Denon", + ssdp.ATTR_UPNP_MODEL_NAME: "HEOS Drive", + ssdp.ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", + ssdp.ATTR_UPNP_SERIAL: None, + ssdp.ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", + }, + ) @pytest.fixture(name="quick_selects") diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 76ff06e2a96..d1d940671b8 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -79,7 +79,9 @@ async def test_create_entry_when_friendly_name_valid(hass, controller): assert DATA_DISCOVERED_HOSTS not in hass.data -async def test_discovery_shows_create_form(hass, controller, discovery_data): +async def test_discovery_shows_create_form( + hass, controller, discovery_data: ssdp.SsdpServiceInfo +): """Test discovery shows form to confirm setup and subsequent abort.""" await hass.config_entries.flow.async_init( @@ -91,9 +93,9 @@ async def test_discovery_shows_create_form(hass, controller, discovery_data): assert len(flows_in_progress) == 1 assert hass.data[DATA_DISCOVERED_HOSTS] == {"Office (127.0.0.1)": "127.0.0.1"} - port = urlparse(discovery_data[ssdp.ATTR_SSDP_LOCATION]).port - discovery_data[ssdp.ATTR_SSDP_LOCATION] = f"http://127.0.0.2:{port}/" - discovery_data[ssdp.ATTR_UPNP_FRIENDLY_NAME] = "Bedroom" + port = urlparse(discovery_data.ssdp_location).port + discovery_data.ssdp_location = f"http://127.0.0.2:{port}/" + discovery_data.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] = "Bedroom" await hass.config_entries.flow.async_init( heos.DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data @@ -109,7 +111,7 @@ async def test_discovery_shows_create_form(hass, controller, discovery_data): async def test_discovery_flow_aborts_already_setup( - hass, controller, discovery_data, config_entry + hass, controller, discovery_data: ssdp.SsdpServiceInfo, config_entry ): """Test discovery flow aborts when entry already setup.""" config_entry.add_to_hass(hass) @@ -120,12 +122,14 @@ async def test_discovery_flow_aborts_already_setup( assert result["reason"] == "single_instance_allowed" -async def test_discovery_sets_the_unique_id(hass, controller, discovery_data): +async def test_discovery_sets_the_unique_id( + hass, controller, discovery_data: ssdp.SsdpServiceInfo +): """Test discovery sets the unique id.""" - port = urlparse(discovery_data[ssdp.ATTR_SSDP_LOCATION]).port - discovery_data[ssdp.ATTR_SSDP_LOCATION] = f"http://127.0.0.2:{port}/" - discovery_data[ssdp.ATTR_UPNP_FRIENDLY_NAME] = "Bedroom" + port = urlparse(discovery_data.ssdp_location).port + discovery_data.ssdp_location = f"http://127.0.0.2:{port}/" + discovery_data.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] = "Bedroom" await hass.config_entries.flow.async_init( heos.DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 8b87acfd9fd..d134840d652 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -2,6 +2,7 @@ import asyncio from pyheos import CommandFailedError, const +from pyheos.error import HeosError from homeassistant.components.heos import media_player from homeassistant.components.heos.const import ( @@ -10,6 +11,7 @@ from homeassistant.components.heos.const import ( SIGNAL_HEOS_UPDATED, ) from homeassistant.components.media_player.const import ( + ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, ATTR_MEDIA_ALBUM_NAME, @@ -29,8 +31,10 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_PLAYLIST, MEDIA_TYPE_URL, SERVICE_CLEAR_PLAYLIST, + SERVICE_JOIN, SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, + SERVICE_UNJOIN, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -264,10 +268,10 @@ async def test_updates_from_players_changed_new_ids( await event.wait() # Assert device registry identifiers were updated - assert len(device_registry.devices) == 1 + assert len(device_registry.devices) == 2 assert device_registry.async_get_device({(DOMAIN, 101)}) # Assert entity registry unique id was updated - assert len(entity_registry.entities) == 1 + assert len(entity_registry.entities) == 2 assert ( entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "101") == "media_player.test_player" @@ -805,3 +809,99 @@ async def test_play_media_invalid_type(hass, config_entry, config, controller, c blocking=True, ) assert "Unable to play media: Unsupported media type 'Other'" in caplog.text + + +async def test_media_player_join_group(hass, config_entry, config, controller, caplog): + """Test grouping of media players through the join service.""" + await setup_platform(hass, config_entry, config) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_GROUP_MEMBERS: ["media_player.test_player_2"], + }, + blocking=True, + ) + controller.create_group.assert_called_once_with( + 1, + [ + 2, + ], + ) + assert "Failed to group media_player.test_player with" not in caplog.text + + controller.create_group.side_effect = HeosError("error") + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_GROUP_MEMBERS: ["media_player.test_player_2"], + }, + blocking=True, + ) + assert "Failed to group media_player.test_player with" in caplog.text + + +async def test_media_player_group_members( + hass, config_entry, config, controller, caplog +): + """Test group_members attribute.""" + await setup_platform(hass, config_entry, config) + await hass.async_block_till_done() + player_entity = hass.states.get("media_player.test_player") + assert player_entity.attributes[ATTR_GROUP_MEMBERS] == [ + "media_player.test_player", + "media_player.test_player_2", + ] + controller.get_groups.assert_called_once() + assert "Unable to get HEOS group info" not in caplog.text + + +async def test_media_player_group_members_error( + hass, config_entry, config, controller, caplog +): + """Test error in HEOS API.""" + controller.get_groups.side_effect = HeosError("error") + await setup_platform(hass, config_entry, config) + await hass.async_block_till_done() + assert "Unable to get HEOS group info" in caplog.text + player_entity = hass.states.get("media_player.test_player") + assert player_entity.attributes[ATTR_GROUP_MEMBERS] == [] + + +async def test_media_player_unjoin_group( + hass, config_entry, config, controller, caplog +): + """Test ungrouping of media players through the join service.""" + await setup_platform(hass, config_entry, config) + player = controller.players[1] + + player.heos.dispatcher.send( + const.SIGNAL_PLAYER_EVENT, + player.player_id, + const.EVENT_PLAYER_STATE_CHANGED, + ) + await hass.async_block_till_done() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + }, + blocking=True, + ) + controller.create_group.assert_called_once_with(1, []) + assert "Failed to ungroup media_player.test_player" not in caplog.text + + controller.create_group.side_effect = HeosError("error") + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + }, + blocking=True, + ) + assert "Failed to ungroup media_player.test_player" in caplog.text diff --git a/tests/components/here_travel_time/__init__.py b/tests/components/here_travel_time/__init__.py index ac0ec709654..b46865c8157 100644 --- a/tests/components/here_travel_time/__init__.py +++ b/tests/components/here_travel_time/__init__.py @@ -1 +1 @@ -"""Tests for here_travel_time component.""" +"""Tests for HERE Travel Time.""" diff --git a/tests/components/here_travel_time/conftest.py b/tests/components/here_travel_time/conftest.py new file mode 100644 index 00000000000..2d5af2b0186 --- /dev/null +++ b/tests/components/here_travel_time/conftest.py @@ -0,0 +1,23 @@ +"""Fixtures for HERE Travel Time tests.""" +import json +from unittest.mock import patch + +from herepy.models import RoutingResponse +import pytest + +from tests.common import load_fixture + +RESPONSE = RoutingResponse.new_from_jsondict( + json.loads(load_fixture("here_travel_time/car_response.json")) +) +RESPONSE.route_short = "US-29 - K St NW; US-29 - Whitehurst Fwy; I-495 N - Capital Beltway; MD-187 S - Old Georgetown Rd" + + +@pytest.fixture(name="valid_response") +def valid_response_fixture(): + """Return valid api response.""" + with patch( + "herepy.RoutingApi.public_transport_timetable", + return_value=RESPONSE, + ): + yield diff --git a/tests/components/here_travel_time/const.py b/tests/components/here_travel_time/const.py new file mode 100644 index 00000000000..0cc3143bc0b --- /dev/null +++ b/tests/components/here_travel_time/const.py @@ -0,0 +1,8 @@ +"""Constants for HERE Travel Time tests.""" + +API_KEY = "test" + +CAR_ORIGIN_LATITUDE = "38.9" +CAR_ORIGIN_LONGITUDE = "-77.04833" +CAR_DESTINATION_LATITUDE = "39.0" +CAR_DESTINATION_LONGITUDE = "-77.1" diff --git a/tests/fixtures/here_travel_time/car_response.json b/tests/components/here_travel_time/fixtures/car_response.json similarity index 96% rename from tests/fixtures/here_travel_time/car_response.json rename to tests/components/here_travel_time/fixtures/car_response.json index bda8454f3f3..ef050b78362 100644 --- a/tests/fixtures/here_travel_time/car_response.json +++ b/tests/components/here_travel_time/fixtures/car_response.json @@ -294,6 +294,15 @@ } } ], - "language": "en-us" + "language": "en-us", + "sourceAttribution": { + "attribution": "With the support of HERE Technologies. All information is provided without warranty of any kind.", + "supplier": [ + { + "title": "HERE Technologies", + "href": "https://transit.api.here.com/r?appId=Mt1bOYh3m9uxE7r3wuUx&u=https://wego.here.com" + } + ] + } } } \ No newline at end of file diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index ce0c2d9ca6d..03d2313da2e 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -1,9 +1,7 @@ -"""The test for the here_travel_time sensor platform.""" -import logging +"""The test for the HERE Travel Time sensor platform.""" from unittest.mock import patch -import urllib -import herepy +from herepy.routing_api import InvalidCredentialsError, NoRouteFoundError import pytest from homeassistant.components.here_travel_time.sensor import ( @@ -25,143 +23,43 @@ from homeassistant.components.here_travel_time.sensor import ( ICON_PUBLIC, ICON_TRUCK, NO_ROUTE_ERROR_MESSAGE, - ROUTE_MODE_FASTEST, - ROUTE_MODE_SHORTEST, - SCAN_INTERVAL, TIME_MINUTES, - TRAFFIC_MODE_DISABLED, - TRAFFIC_MODE_ENABLED, TRAVEL_MODE_BICYCLE, TRAVEL_MODE_CAR, TRAVEL_MODE_PEDESTRIAN, - TRAVEL_MODE_PUBLIC, TRAVEL_MODE_PUBLIC_TIME_TABLE, TRAVEL_MODE_TRUCK, - convert_time_to_isodate, + TRAVEL_MODES_VEHICLE, ) from homeassistant.const import ATTR_ICON, EVENT_HOMEASSISTANT_START from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, load_fixture +from tests.components.here_travel_time.const import ( + API_KEY, + CAR_DESTINATION_LATITUDE, + CAR_DESTINATION_LONGITUDE, + CAR_ORIGIN_LATITUDE, + CAR_ORIGIN_LONGITUDE, +) DOMAIN = "sensor" PLATFORM = "here_travel_time" -API_KEY = "test" -TRUCK_ORIGIN_LATITUDE = "41.9798" -TRUCK_ORIGIN_LONGITUDE = "-87.8801" -TRUCK_DESTINATION_LATITUDE = "41.9043" -TRUCK_DESTINATION_LONGITUDE = "-87.9216" - -BIKE_ORIGIN_LATITUDE = "41.9798" -BIKE_ORIGIN_LONGITUDE = "-87.8801" -BIKE_DESTINATION_LATITUDE = "41.9043" -BIKE_DESTINATION_LONGITUDE = "-87.9216" - -CAR_ORIGIN_LATITUDE = "38.9" -CAR_ORIGIN_LONGITUDE = "-77.04833" -CAR_DESTINATION_LATITUDE = "39.0" -CAR_DESTINATION_LONGITUDE = "-77.1" - - -def _build_mock_url(origin, destination, modes, api_key, departure=None, arrival=None): - """Construct a url for HERE.""" - base_url = "https://route.ls.hereapi.com/routing/7.2/calculateroute.json?" - parameters = { - "waypoint0": f"geo!{origin}", - "waypoint1": f"geo!{destination}", - "mode": ";".join(str(herepy.RouteMode[mode]) for mode in modes), - "apikey": api_key, - } - if arrival is not None: - parameters["arrival"] = arrival - if departure is not None: - parameters["departure"] = departure - if departure is None and arrival is None: - parameters["departure"] = "now" - url = base_url + urllib.parse.urlencode(parameters) - return url - - -def _assert_truck_sensor(sensor): - """Assert that states and attributes are correct for truck_response.""" - assert sensor.state == "14" - assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES - - assert sensor.attributes.get(ATTR_ATTRIBUTION) is None - assert sensor.attributes.get(ATTR_DURATION) == 13.533333333333333 - assert sensor.attributes.get(ATTR_DISTANCE) == 13.049 - assert sensor.attributes.get(ATTR_ROUTE) == ( - "I-190; I-294 S - Tri-State Tollway; I-290 W - Eisenhower Expy W; " - "IL-64 W - E North Ave; I-290 E - Eisenhower Expy E; I-290" - ) - assert sensor.attributes.get(CONF_UNIT_SYSTEM) == "metric" - assert sensor.attributes.get(ATTR_DURATION_IN_TRAFFIC) == 13.533333333333333 - assert sensor.attributes.get(ATTR_ORIGIN) == ",".join( - [TRUCK_ORIGIN_LATITUDE, TRUCK_ORIGIN_LONGITUDE] - ) - assert sensor.attributes.get(ATTR_DESTINATION) == ",".join( - [TRUCK_DESTINATION_LATITUDE, TRUCK_DESTINATION_LONGITUDE] - ) - assert sensor.attributes.get(ATTR_ORIGIN_NAME) == "" - assert sensor.attributes.get(ATTR_DESTINATION_NAME) == "Eisenhower Expy E" - assert sensor.attributes.get(CONF_MODE) == TRAVEL_MODE_TRUCK - assert sensor.attributes.get(CONF_TRAFFIC_MODE) is False - - assert sensor.attributes.get(ATTR_ICON) == ICON_TRUCK - - -@pytest.fixture -def requests_mock_credentials_check(requests_mock): - """Add the url used in the api validation to all requests mock.""" - modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url( - ",".join([CAR_ORIGIN_LATITUDE, CAR_ORIGIN_LONGITUDE]), - ",".join([CAR_DESTINATION_LATITUDE, CAR_DESTINATION_LONGITUDE]), - modes, - API_KEY, - ) - requests_mock.get( - response_url, text=load_fixture("here_travel_time/car_response.json") - ) - return requests_mock - - -@pytest.fixture -def requests_mock_truck_response(requests_mock_credentials_check): - """Return a requests_mock for truck response.""" - modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_TRUCK, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url( - ",".join([TRUCK_ORIGIN_LATITUDE, TRUCK_ORIGIN_LONGITUDE]), - ",".join([TRUCK_DESTINATION_LATITUDE, TRUCK_DESTINATION_LONGITUDE]), - modes, - API_KEY, - ) - requests_mock_credentials_check.get( - response_url, text=load_fixture("here_travel_time/truck_response.json") - ) - - -@pytest.fixture -def requests_mock_car_disabled_response(requests_mock_credentials_check): - """Return a requests_mock for truck response.""" - modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url( - ",".join([CAR_ORIGIN_LATITUDE, CAR_ORIGIN_LONGITUDE]), - ",".join([CAR_DESTINATION_LATITUDE, CAR_DESTINATION_LONGITUDE]), - modes, - API_KEY, - ) - requests_mock_credentials_check.get( - response_url, text=load_fixture("here_travel_time/car_response.json") - ) - - -async def test_car(hass, requests_mock_car_disabled_response): - """Test that car works.""" +@pytest.mark.parametrize( + "mode,icon,traffic_mode,unit_system", + [ + (TRAVEL_MODE_CAR, ICON_CAR, True, "metric"), + (TRAVEL_MODE_BICYCLE, ICON_BICYCLE, False, "metric"), + (TRAVEL_MODE_PEDESTRIAN, ICON_PEDESTRIAN, False, "imperial"), + (TRAVEL_MODE_PUBLIC_TIME_TABLE, ICON_PUBLIC, False, "imperial"), + (TRAVEL_MODE_TRUCK, ICON_TRUCK, True, "metric"), + ], +) +async def test_sensor(hass, mode, icon, traffic_mode, unit_system, valid_response): + """Test that sensor works.""" config = { DOMAIN: { "platform": PLATFORM, @@ -171,26 +69,41 @@ async def test_car(hass, requests_mock_car_disabled_response): "destination_latitude": CAR_DESTINATION_LATITUDE, "destination_longitude": CAR_DESTINATION_LONGITUDE, "api_key": API_KEY, + "traffic_mode": traffic_mode, + "unit_system": unit_system, + "mode": mode, } } assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() sensor = hass.states.get("sensor.test") - assert sensor.state == "30" assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES - assert sensor.attributes.get(ATTR_ATTRIBUTION) is None + assert ( + sensor.attributes.get(ATTR_ATTRIBUTION) + == "With the support of HERE Technologies. All information is provided without warranty of any kind." + ) + if traffic_mode: + assert sensor.state == "31" + else: + assert sensor.state == "30" + assert sensor.attributes.get(ATTR_DURATION) == 30.05 - assert sensor.attributes.get(ATTR_DISTANCE) == 23.903 + if unit_system == "metric": + assert sensor.attributes.get(ATTR_DISTANCE) == 23.903 + else: + assert sensor.attributes.get(ATTR_DISTANCE) == 14.852635608048994 assert sensor.attributes.get(ATTR_ROUTE) == ( "US-29 - K St NW; US-29 - Whitehurst Fwy; " "I-495 N - Capital Beltway; MD-187 S - Old Georgetown Rd" ) - assert sensor.attributes.get(CONF_UNIT_SYSTEM) == "metric" - assert sensor.attributes.get(ATTR_DURATION_IN_TRAFFIC) == 31.016666666666666 + assert sensor.attributes.get(CONF_UNIT_SYSTEM) == unit_system + if mode in TRAVEL_MODES_VEHICLE: + assert sensor.attributes.get(ATTR_DURATION_IN_TRAFFIC) == 31.016666666666666 + else: + assert sensor.attributes.get(ATTR_DURATION_IN_TRAFFIC) == 30.05 assert sensor.attributes.get(ATTR_ORIGIN) == ",".join( [CAR_ORIGIN_LATITUDE, CAR_ORIGIN_LONGITUDE] ) @@ -199,374 +112,19 @@ async def test_car(hass, requests_mock_car_disabled_response): ) assert sensor.attributes.get(ATTR_ORIGIN_NAME) == "22nd St NW" assert sensor.attributes.get(ATTR_DESTINATION_NAME) == "Service Rd S" - assert sensor.attributes.get(CONF_MODE) == TRAVEL_MODE_CAR - assert sensor.attributes.get(CONF_TRAFFIC_MODE) is False + assert sensor.attributes.get(CONF_MODE) == mode + assert sensor.attributes.get(CONF_TRAFFIC_MODE) is traffic_mode - assert sensor.attributes.get(ATTR_ICON) == ICON_CAR + assert sensor.attributes.get(ATTR_ICON) == icon - # Test traffic mode disabled - assert sensor.attributes.get(ATTR_DURATION) != sensor.attributes.get( - ATTR_DURATION_IN_TRAFFIC - ) + # Test traffic mode disabled for vehicles + if mode in TRAVEL_MODES_VEHICLE: + assert sensor.attributes.get(ATTR_DURATION) != sensor.attributes.get( + ATTR_DURATION_IN_TRAFFIC + ) -async def test_traffic_mode_enabled(hass, requests_mock_credentials_check): - """Test that traffic mode enabled works.""" - modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_ENABLED] - response_url = _build_mock_url( - ",".join([CAR_ORIGIN_LATITUDE, CAR_ORIGIN_LONGITUDE]), - ",".join([CAR_DESTINATION_LATITUDE, CAR_DESTINATION_LONGITUDE]), - modes, - API_KEY, - ) - requests_mock_credentials_check.get( - response_url, text=load_fixture("here_travel_time/car_enabled_response.json") - ) - - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_latitude": CAR_ORIGIN_LATITUDE, - "origin_longitude": CAR_ORIGIN_LONGITUDE, - "destination_latitude": CAR_DESTINATION_LATITUDE, - "destination_longitude": CAR_DESTINATION_LONGITUDE, - "api_key": API_KEY, - "traffic_mode": True, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - # Test traffic mode enabled - assert sensor.attributes.get(ATTR_DURATION) != sensor.attributes.get( - ATTR_DURATION_IN_TRAFFIC - ) - - -async def test_imperial(hass, requests_mock_car_disabled_response): - """Test that imperial units work.""" - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_latitude": CAR_ORIGIN_LATITUDE, - "origin_longitude": CAR_ORIGIN_LONGITUDE, - "destination_latitude": CAR_DESTINATION_LATITUDE, - "destination_longitude": CAR_DESTINATION_LONGITUDE, - "api_key": API_KEY, - "unit_system": "imperial", - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - assert sensor.attributes.get(ATTR_DISTANCE) == 14.852635608048994 - - -async def test_route_mode_shortest(hass, requests_mock_credentials_check): - """Test that route mode shortest works.""" - origin = "38.902981,-77.048338" - destination = "39.042158,-77.119116" - modes = [ROUTE_MODE_SHORTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url(origin, destination, modes, API_KEY) - requests_mock_credentials_check.get( - response_url, text=load_fixture("here_travel_time/car_shortest_response.json") - ) - - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_latitude": origin.split(",")[0], - "origin_longitude": origin.split(",")[1], - "destination_latitude": destination.split(",")[0], - "destination_longitude": destination.split(",")[1], - "api_key": API_KEY, - "route_mode": ROUTE_MODE_SHORTEST, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - assert sensor.attributes.get(ATTR_DISTANCE) == 18.388 - - -async def test_route_mode_fastest(hass, requests_mock_credentials_check): - """Test that route mode fastest works.""" - origin = "38.902981,-77.048338" - destination = "39.042158,-77.119116" - modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_ENABLED] - response_url = _build_mock_url(origin, destination, modes, API_KEY) - requests_mock_credentials_check.get( - response_url, text=load_fixture("here_travel_time/car_enabled_response.json") - ) - - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_latitude": origin.split(",")[0], - "origin_longitude": origin.split(",")[1], - "destination_latitude": destination.split(",")[0], - "destination_longitude": destination.split(",")[1], - "api_key": API_KEY, - "traffic_mode": True, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - assert sensor.attributes.get(ATTR_DISTANCE) == 23.381 - - -async def test_truck(hass, requests_mock_truck_response): - """Test that truck works.""" - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_latitude": TRUCK_ORIGIN_LATITUDE, - "origin_longitude": TRUCK_ORIGIN_LONGITUDE, - "destination_latitude": TRUCK_DESTINATION_LATITUDE, - "destination_longitude": TRUCK_DESTINATION_LONGITUDE, - "api_key": API_KEY, - "mode": TRAVEL_MODE_TRUCK, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - _assert_truck_sensor(sensor) - - -async def test_public_transport(hass, requests_mock_credentials_check): - """Test that publicTransport works.""" - origin = "41.9798,-87.8801" - destination = "41.9043,-87.9216" - modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PUBLIC, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url(origin, destination, modes, API_KEY) - requests_mock_credentials_check.get( - response_url, text=load_fixture("here_travel_time/public_response.json") - ) - - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_latitude": origin.split(",")[0], - "origin_longitude": origin.split(",")[1], - "destination_latitude": destination.split(",")[0], - "destination_longitude": destination.split(",")[1], - "api_key": API_KEY, - "mode": TRAVEL_MODE_PUBLIC, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - assert sensor.state == "89" - assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES - - assert sensor.attributes.get(ATTR_ATTRIBUTION) is None - assert sensor.attributes.get(ATTR_DURATION) == 89.16666666666667 - assert sensor.attributes.get(ATTR_DISTANCE) == 22.325 - assert sensor.attributes.get(ATTR_ROUTE) == ( - "332 - Palmer/Schiller; 332 - Cargo Rd./Delta Cargo; 332 - Palmer/Schiller" - ) - assert sensor.attributes.get(CONF_UNIT_SYSTEM) == "metric" - assert sensor.attributes.get(ATTR_DURATION_IN_TRAFFIC) == 89.16666666666667 - assert sensor.attributes.get(ATTR_ORIGIN) == origin - assert sensor.attributes.get(ATTR_DESTINATION) == destination - assert sensor.attributes.get(ATTR_ORIGIN_NAME) == "Mannheim Rd" - assert sensor.attributes.get(ATTR_DESTINATION_NAME) == "" - assert sensor.attributes.get(CONF_MODE) == TRAVEL_MODE_PUBLIC - assert sensor.attributes.get(CONF_TRAFFIC_MODE) is False - - assert sensor.attributes.get(ATTR_ICON) == ICON_PUBLIC - - -async def test_public_transport_time_table(hass, requests_mock_credentials_check): - """Test that publicTransportTimeTable works.""" - origin = "41.9798,-87.8801" - destination = "41.9043,-87.9216" - modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PUBLIC_TIME_TABLE, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url(origin, destination, modes, API_KEY) - requests_mock_credentials_check.get( - response_url, - text=load_fixture("here_travel_time/public_time_table_response.json"), - ) - - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_latitude": origin.split(",")[0], - "origin_longitude": origin.split(",")[1], - "destination_latitude": destination.split(",")[0], - "destination_longitude": destination.split(",")[1], - "api_key": API_KEY, - "mode": TRAVEL_MODE_PUBLIC_TIME_TABLE, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - assert sensor.state == "80" - assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES - - assert sensor.attributes.get(ATTR_ATTRIBUTION) is None - assert sensor.attributes.get(ATTR_DURATION) == 79.73333333333333 - assert sensor.attributes.get(ATTR_DISTANCE) == 14.775 - assert sensor.attributes.get(ATTR_ROUTE) == ( - "330 - Archer/Harlem (Terminal); 309 - Elmhurst Metra Station" - ) - assert sensor.attributes.get(CONF_UNIT_SYSTEM) == "metric" - assert sensor.attributes.get(ATTR_DURATION_IN_TRAFFIC) == 79.73333333333333 - assert sensor.attributes.get(ATTR_ORIGIN) == origin - assert sensor.attributes.get(ATTR_DESTINATION) == destination - assert sensor.attributes.get(ATTR_ORIGIN_NAME) == "Mannheim Rd" - assert sensor.attributes.get(ATTR_DESTINATION_NAME) == "" - assert sensor.attributes.get(CONF_MODE) == TRAVEL_MODE_PUBLIC_TIME_TABLE - assert sensor.attributes.get(CONF_TRAFFIC_MODE) is False - - assert sensor.attributes.get(ATTR_ICON) == ICON_PUBLIC - - -async def test_pedestrian(hass, requests_mock_credentials_check): - """Test that pedestrian works.""" - origin = "41.9798,-87.8801" - destination = "41.9043,-87.9216" - modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PEDESTRIAN, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url(origin, destination, modes, API_KEY) - requests_mock_credentials_check.get( - response_url, text=load_fixture("here_travel_time/pedestrian_response.json") - ) - - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_latitude": origin.split(",")[0], - "origin_longitude": origin.split(",")[1], - "destination_latitude": destination.split(",")[0], - "destination_longitude": destination.split(",")[1], - "api_key": API_KEY, - "mode": TRAVEL_MODE_PEDESTRIAN, - } - } - - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - assert sensor.state == "211" - assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES - - assert sensor.attributes.get(ATTR_ATTRIBUTION) is None - assert sensor.attributes.get(ATTR_DURATION) == 210.51666666666668 - assert sensor.attributes.get(ATTR_DISTANCE) == 12.533 - assert sensor.attributes.get(ATTR_ROUTE) == ( - "Mannheim Rd; W Belmont Ave; Cullerton St; E Fullerton Ave; " - "La Porte Ave; E Palmer Ave; N Railroad Ave; W North Ave; " - "E North Ave; E Third St" - ) - assert sensor.attributes.get(CONF_UNIT_SYSTEM) == "metric" - assert sensor.attributes.get(ATTR_DURATION_IN_TRAFFIC) == 210.51666666666668 - assert sensor.attributes.get(ATTR_ORIGIN) == origin - assert sensor.attributes.get(ATTR_DESTINATION) == destination - assert sensor.attributes.get(ATTR_ORIGIN_NAME) == "Mannheim Rd" - assert sensor.attributes.get(ATTR_DESTINATION_NAME) == "" - assert sensor.attributes.get(CONF_MODE) == TRAVEL_MODE_PEDESTRIAN - assert sensor.attributes.get(CONF_TRAFFIC_MODE) is False - - assert sensor.attributes.get(ATTR_ICON) == ICON_PEDESTRIAN - - -async def test_bicycle(hass, requests_mock_credentials_check): - """Test that bicycle works.""" - origin = "41.9798,-87.8801" - destination = "41.9043,-87.9216" - modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_BICYCLE, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url(origin, destination, modes, API_KEY) - requests_mock_credentials_check.get( - response_url, text=load_fixture("here_travel_time/bike_response.json") - ) - - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_latitude": origin.split(",")[0], - "origin_longitude": origin.split(",")[1], - "destination_latitude": destination.split(",")[0], - "destination_longitude": destination.split(",")[1], - "api_key": API_KEY, - "mode": TRAVEL_MODE_BICYCLE, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - assert sensor.state == "55" - assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES - - assert sensor.attributes.get(ATTR_ATTRIBUTION) is None - assert sensor.attributes.get(ATTR_DURATION) == 54.86666666666667 - assert sensor.attributes.get(ATTR_DISTANCE) == 12.613 - assert sensor.attributes.get(ATTR_ROUTE) == ( - "Mannheim Rd; W Belmont Ave; Cullerton St; N Landen Dr; " - "E Fullerton Ave; N Wolf Rd; W North Ave; N Clinton Ave; " - "E Third St; N Caroline Ave" - ) - assert sensor.attributes.get(CONF_UNIT_SYSTEM) == "metric" - assert sensor.attributes.get(ATTR_DURATION_IN_TRAFFIC) == 54.86666666666667 - assert sensor.attributes.get(ATTR_ORIGIN) == origin - assert sensor.attributes.get(ATTR_DESTINATION) == destination - assert sensor.attributes.get(ATTR_ORIGIN_NAME) == "Mannheim Rd" - assert sensor.attributes.get(ATTR_DESTINATION_NAME) == "" - assert sensor.attributes.get(CONF_MODE) == TRAVEL_MODE_BICYCLE - assert sensor.attributes.get(CONF_TRAFFIC_MODE) is False - - assert sensor.attributes.get(ATTR_ICON) == ICON_BICYCLE - - -async def test_location_zone(hass, requests_mock_truck_response, legacy_patchable_time): +async def test_entity_ids(hass, valid_response): """Test that origin/destination supplied by a zone works.""" utcnow = dt_util.utcnow() # Patching 'utcnow' to gain more control over the timed update. @@ -575,15 +133,15 @@ async def test_location_zone(hass, requests_mock_truck_response, legacy_patchabl "zone": [ { "name": "Destination", - "latitude": TRUCK_DESTINATION_LATITUDE, - "longitude": TRUCK_DESTINATION_LONGITUDE, + "latitude": CAR_DESTINATION_LATITUDE, + "longitude": CAR_DESTINATION_LONGITUDE, "radius": 250, "passive": False, }, { "name": "Origin", - "latitude": TRUCK_ORIGIN_LATITUDE, - "longitude": TRUCK_ORIGIN_LONGITUDE, + "latitude": CAR_ORIGIN_LATITUDE, + "longitude": CAR_ORIGIN_LONGITUDE, "radius": 250, "passive": False, }, @@ -607,299 +165,17 @@ async def test_location_zone(hass, requests_mock_truck_response, legacy_patchabl await hass.async_block_till_done() sensor = hass.states.get("sensor.test") - _assert_truck_sensor(sensor) - - # Test that update works more than once - async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - _assert_truck_sensor(sensor) + assert sensor.attributes.get(ATTR_DISTANCE) == 23.903 -async def test_location_sensor( - hass, requests_mock_truck_response, legacy_patchable_time -): - """Test that origin/destination supplied by a sensor works.""" - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch("homeassistant.util.dt.utcnow", return_value=utcnow): - hass.states.async_set( - "sensor.origin", ",".join([TRUCK_ORIGIN_LATITUDE, TRUCK_ORIGIN_LONGITUDE]) - ) - hass.states.async_set( - "sensor.destination", - ",".join([TRUCK_DESTINATION_LATITUDE, TRUCK_DESTINATION_LONGITUDE]), - ) - - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_entity_id": "sensor.origin", - "destination_entity_id": "sensor.destination", - "api_key": API_KEY, - "mode": TRAVEL_MODE_TRUCK, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - _assert_truck_sensor(sensor) - - # Test that update works more than once - async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - _assert_truck_sensor(sensor) - - -async def test_location_person( - hass, requests_mock_truck_response, legacy_patchable_time -): - """Test that origin/destination supplied by a person works.""" - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch("homeassistant.util.dt.utcnow", return_value=utcnow): - hass.states.async_set( - "person.origin", - "unknown", - { - "latitude": float(TRUCK_ORIGIN_LATITUDE), - "longitude": float(TRUCK_ORIGIN_LONGITUDE), - }, - ) - hass.states.async_set( - "person.destination", - "unknown", - { - "latitude": float(TRUCK_DESTINATION_LATITUDE), - "longitude": float(TRUCK_DESTINATION_LONGITUDE), - }, - ) - - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_entity_id": "person.origin", - "destination_entity_id": "person.destination", - "api_key": API_KEY, - "mode": TRAVEL_MODE_TRUCK, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - _assert_truck_sensor(sensor) - - # Test that update works more than once - async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - _assert_truck_sensor(sensor) - - -async def test_location_device_tracker( - hass, requests_mock_truck_response, legacy_patchable_time -): - """Test that origin/destination supplied by a device_tracker works.""" - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch("homeassistant.util.dt.utcnow", return_value=utcnow): - hass.states.async_set( - "device_tracker.origin", - "unknown", - { - "latitude": float(TRUCK_ORIGIN_LATITUDE), - "longitude": float(TRUCK_ORIGIN_LONGITUDE), - }, - ) - hass.states.async_set( - "device_tracker.destination", - "unknown", - { - "latitude": float(TRUCK_DESTINATION_LATITUDE), - "longitude": float(TRUCK_DESTINATION_LONGITUDE), - }, - ) - - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_entity_id": "device_tracker.origin", - "destination_entity_id": "device_tracker.destination", - "api_key": API_KEY, - "mode": TRAVEL_MODE_TRUCK, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - _assert_truck_sensor(sensor) - - # Test that update works more than once - async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - _assert_truck_sensor(sensor) - - -async def test_location_device_tracker_added_after_update( - hass, requests_mock_truck_response, legacy_patchable_time, caplog -): - """Test that device_tracker added after first update works.""" - caplog.set_level(logging.ERROR) - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch("homeassistant.util.dt.utcnow", return_value=utcnow): - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_entity_id": "device_tracker.origin", - "destination_entity_id": "device_tracker.destination", - "api_key": API_KEY, - "mode": TRAVEL_MODE_TRUCK, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - assert "Unable to find entity" in caplog.text - caplog.clear() - - # Device tracker appear after first update - hass.states.async_set( - "device_tracker.origin", - "unknown", - { - "latitude": float(TRUCK_ORIGIN_LATITUDE), - "longitude": float(TRUCK_ORIGIN_LONGITUDE), - }, - ) - hass.states.async_set( - "device_tracker.destination", - "unknown", - { - "latitude": float(TRUCK_DESTINATION_LATITUDE), - "longitude": float(TRUCK_DESTINATION_LONGITUDE), - }, - ) - - # Test that update works more than once - async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - _assert_truck_sensor(sensor) - assert len(caplog.records) == 0 - - -async def test_location_device_tracker_in_zone( - hass, requests_mock_truck_response, caplog -): - """Test that device_tracker in zone uses device_tracker state works.""" - caplog.set_level(logging.DEBUG) - zone_config = { - "zone": [ - { - "name": "Origin", - "latitude": TRUCK_ORIGIN_LATITUDE, - "longitude": TRUCK_ORIGIN_LONGITUDE, - "radius": 250, - "passive": False, - } - ] - } - assert await async_setup_component(hass, "zone", zone_config) - hass.states.async_set( - "device_tracker.origin", "origin", {"latitude": None, "longitude": None} - ) - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_entity_id": "device_tracker.origin", - "destination_latitude": TRUCK_DESTINATION_LATITUDE, - "destination_longitude": TRUCK_DESTINATION_LONGITUDE, - "api_key": API_KEY, - "mode": TRAVEL_MODE_TRUCK, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - _assert_truck_sensor(sensor) - assert ", getting zone location" in caplog.text - - -async def test_route_not_found(hass, requests_mock_credentials_check, caplog): +async def test_route_not_found(hass, caplog, valid_response): """Test that route not found error is correctly handled.""" - caplog.set_level(logging.ERROR) - origin = "52.516,13.3779" - destination = "47.013399,-10.171986" - modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url(origin, destination, modes, API_KEY) - requests_mock_credentials_check.get( - response_url, - text=load_fixture("here_travel_time/routing_error_no_route_found.json"), - ) - config = { DOMAIN: { "platform": PLATFORM, "name": "test", - "origin_latitude": origin.split(",")[0], - "origin_longitude": origin.split(",")[1], - "destination_latitude": destination.split(",")[0], - "destination_longitude": destination.split(",")[1], - "api_key": API_KEY, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - assert len(caplog.records) == 1 - assert NO_ROUTE_ERROR_MESSAGE in caplog.text - - -async def test_pattern_origin(hass, caplog): - """Test that pattern matching the origin works.""" - caplog.set_level(logging.ERROR) - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_latitude": "138.90", - "origin_longitude": "-77.04833", + "origin_latitude": CAR_ORIGIN_LATITUDE, + "origin_longitude": CAR_ORIGIN_LONGITUDE, "destination_latitude": CAR_DESTINATION_LATITUDE, "destination_longitude": CAR_DESTINATION_LONGITUDE, "api_key": API_KEY, @@ -907,43 +183,40 @@ async def test_pattern_origin(hass, caplog): } assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() - assert "invalid latitude" in caplog.text + with patch( + "herepy.RoutingApi.public_transport_timetable", + side_effect=NoRouteFoundError, + ): + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + assert NO_ROUTE_ERROR_MESSAGE in caplog.text -async def test_pattern_destination(hass, caplog): - """Test that pattern matching the destination works.""" - caplog.set_level(logging.ERROR) - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_latitude": CAR_ORIGIN_LATITUDE, - "origin_longitude": CAR_ORIGIN_LONGITUDE, - "destination_latitude": "139.0", - "destination_longitude": "-77.1", - "api_key": API_KEY, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - assert "invalid latitude" in caplog.text - - -async def test_invalid_credentials(hass, requests_mock, caplog): +async def test_invalid_credentials(hass, caplog): """Test that invalid credentials error is correctly handled.""" - caplog.set_level(logging.ERROR) - modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url( - ",".join([CAR_ORIGIN_LATITUDE, CAR_ORIGIN_LONGITUDE]), - ",".join([CAR_DESTINATION_LATITUDE, CAR_DESTINATION_LONGITUDE]), - modes, - API_KEY, - ) - requests_mock.get( - response_url, - text=load_fixture("here_travel_time/routing_error_invalid_credentials.json"), - ) + with patch( + "herepy.RoutingApi.public_transport_timetable", + side_effect=InvalidCredentialsError, + ): + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": CAR_ORIGIN_LATITUDE, + "origin_longitude": CAR_ORIGIN_LONGITUDE, + "destination_latitude": CAR_DESTINATION_LATITUDE, + "destination_longitude": CAR_DESTINATION_LONGITUDE, + "api_key": API_KEY, + } + } + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + assert "Invalid credentials" in caplog.text + +async def test_arrival(hass, valid_response): + """Test that arrival works.""" config = { DOMAIN: { "platform": PLATFORM, @@ -953,36 +226,8 @@ async def test_invalid_credentials(hass, requests_mock, caplog): "destination_latitude": CAR_DESTINATION_LATITUDE, "destination_longitude": CAR_DESTINATION_LONGITUDE, "api_key": API_KEY, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - assert len(caplog.records) == 1 - assert "Invalid credentials" in caplog.text - - -async def test_attribution(hass, requests_mock_credentials_check): - """Test that attributions are correctly displayed.""" - origin = "50.037751372637686,14.39233448220898" - destination = "50.07993838201255,14.42582157361062" - modes = [ROUTE_MODE_SHORTEST, TRAVEL_MODE_PUBLIC_TIME_TABLE, TRAFFIC_MODE_ENABLED] - response_url = _build_mock_url(origin, destination, modes, API_KEY) - requests_mock_credentials_check.get( - response_url, text=load_fixture("here_travel_time/attribution_response.json") - ) - - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_latitude": origin.split(",")[0], - "origin_longitude": origin.split(",")[1], - "destination_latitude": destination.split(",")[0], - "destination_longitude": destination.split(",")[1], - "api_key": API_KEY, - "traffic_mode": True, - "route_mode": ROUTE_MODE_SHORTEST, "mode": TRAVEL_MODE_PUBLIC_TIME_TABLE, + "arrival": "01:00:00", } } assert await async_setup_component(hass, DOMAIN, config) @@ -992,121 +237,22 @@ async def test_attribution(hass, requests_mock_credentials_check): await hass.async_block_till_done() sensor = hass.states.get("sensor.test") - assert ( - sensor.attributes.get(ATTR_ATTRIBUTION) - == "With the support of HERE Technologies. All information is provided without warranty of any kind." - ) + assert sensor.state == "30" -async def test_pattern_entity_state(hass, requests_mock_truck_response, caplog): - """Test that pattern matching the state of an entity works.""" - caplog.set_level(logging.ERROR) - hass.states.async_set("sensor.origin", "invalid") - +async def test_departure(hass, valid_response): + """Test that departure works.""" config = { DOMAIN: { "platform": PLATFORM, "name": "test", - "origin_entity_id": "sensor.origin", - "destination_latitude": TRUCK_DESTINATION_LATITUDE, - "destination_longitude": TRUCK_DESTINATION_LONGITUDE, - "api_key": API_KEY, - "mode": TRAVEL_MODE_TRUCK, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - assert len(caplog.records) == 1 - assert "is not a valid set of coordinates" in caplog.text - - -async def test_pattern_entity_state_with_space(hass, requests_mock_truck_response): - """Test that pattern matching the state including a space of an entity works.""" - hass.states.async_set( - "sensor.origin", ", ".join([TRUCK_ORIGIN_LATITUDE, TRUCK_ORIGIN_LONGITUDE]) - ) - - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_entity_id": "sensor.origin", - "destination_latitude": TRUCK_DESTINATION_LATITUDE, - "destination_longitude": TRUCK_DESTINATION_LONGITUDE, - "api_key": API_KEY, - "mode": TRAVEL_MODE_TRUCK, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - -async def test_delayed_update(hass, requests_mock_truck_response, caplog): - """Test that delayed update does not complain about missing entities.""" - caplog.set_level(logging.WARNING) - - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_entity_id": "sensor.origin", - "destination_latitude": TRUCK_DESTINATION_LATITUDE, - "destination_longitude": TRUCK_DESTINATION_LONGITUDE, - "api_key": API_KEY, - "mode": TRAVEL_MODE_TRUCK, - } - } - sensor_config = { - "sensor": { - "platform": "template", - "sensors": [ - {"template_sensor": {"value_template": "{{states('sensor.origin')}}"}} - ], - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - assert await async_setup_component(hass, "sensor", sensor_config) - hass.states.async_set( - "sensor.origin", ",".join([TRUCK_ORIGIN_LATITUDE, TRUCK_ORIGIN_LONGITUDE]) - ) - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - assert "Unable to find entity" not in caplog.text - - -async def test_arrival(hass, requests_mock_credentials_check): - """Test that arrival works.""" - origin = "41.9798,-87.8801" - destination = "41.9043,-87.9216" - arrival = "01:00:00" - arrival_isodate = convert_time_to_isodate(arrival) - modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PUBLIC_TIME_TABLE, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url( - origin, destination, modes, API_KEY, arrival=arrival_isodate - ) - requests_mock_credentials_check.get( - response_url, - text=load_fixture("here_travel_time/public_time_table_response.json"), - ) - - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_latitude": origin.split(",")[0], - "origin_longitude": origin.split(",")[1], - "destination_latitude": destination.split(",")[0], - "destination_longitude": destination.split(",")[1], + "origin_latitude": CAR_ORIGIN_LATITUDE, + "origin_longitude": CAR_ORIGIN_LONGITUDE, + "destination_latitude": CAR_DESTINATION_LATITUDE, + "destination_longitude": CAR_DESTINATION_LONGITUDE, "api_key": API_KEY, "mode": TRAVEL_MODE_PUBLIC_TIME_TABLE, - "arrival": arrival, + "departure": "23:00:00", } } assert await async_setup_component(hass, DOMAIN, config) @@ -1116,60 +262,19 @@ async def test_arrival(hass, requests_mock_credentials_check): await hass.async_block_till_done() sensor = hass.states.get("sensor.test") - assert sensor.state == "80" - - -async def test_departure(hass, requests_mock_credentials_check): - """Test that arrival works.""" - origin = "41.9798,-87.8801" - destination = "41.9043,-87.9216" - departure = "23:00:00" - departure_isodate = convert_time_to_isodate(departure) - modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PUBLIC_TIME_TABLE, TRAFFIC_MODE_DISABLED] - response_url = _build_mock_url( - origin, destination, modes, API_KEY, departure=departure_isodate - ) - requests_mock_credentials_check.get( - response_url, - text=load_fixture("here_travel_time/public_time_table_response.json"), - ) - - config = { - DOMAIN: { - "platform": PLATFORM, - "name": "test", - "origin_latitude": origin.split(",")[0], - "origin_longitude": origin.split(",")[1], - "destination_latitude": destination.split(",")[0], - "destination_longitude": destination.split(",")[1], - "api_key": API_KEY, - "mode": TRAVEL_MODE_PUBLIC_TIME_TABLE, - "departure": departure, - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - sensor = hass.states.get("sensor.test") - assert sensor.state == "80" + assert sensor.state == "30" async def test_arrival_only_allowed_for_timetable(hass, caplog): """Test that arrival is only allowed when mode is publicTransportTimeTable.""" - caplog.set_level(logging.ERROR) - origin = "41.9798,-87.8801" - destination = "41.9043,-87.9216" config = { DOMAIN: { "platform": PLATFORM, "name": "test", - "origin_latitude": origin.split(",")[0], - "origin_longitude": origin.split(",")[1], - "destination_latitude": destination.split(",")[0], - "destination_longitude": destination.split(",")[1], + "origin_latitude": CAR_ORIGIN_LATITUDE, + "origin_longitude": CAR_ORIGIN_LONGITUDE, + "destination_latitude": CAR_DESTINATION_LATITUDE, + "destination_longitude": CAR_DESTINATION_LONGITUDE, "api_key": API_KEY, "arrival": "01:00:00", } @@ -1181,17 +286,14 @@ async def test_arrival_only_allowed_for_timetable(hass, caplog): async def test_exclusive_arrival_and_departure(hass, caplog): """Test that arrival and departure are exclusive.""" - caplog.set_level(logging.ERROR) - origin = "41.9798,-87.8801" - destination = "41.9043,-87.9216" config = { DOMAIN: { "platform": PLATFORM, "name": "test", - "origin_latitude": origin.split(",")[0], - "origin_longitude": origin.split(",")[1], - "destination_latitude": destination.split(",")[0], - "destination_longitude": destination.split(",")[1], + "origin_latitude": CAR_ORIGIN_LATITUDE, + "origin_longitude": CAR_ORIGIN_LONGITUDE, + "destination_latitude": CAR_DESTINATION_LATITUDE, + "destination_longitude": CAR_DESTINATION_LONGITUDE, "api_key": API_KEY, "arrival": "01:00:00", "mode": TRAVEL_MODE_PUBLIC_TIME_TABLE, diff --git a/tests/fixtures/history_stats/configuration.yaml b/tests/components/history_stats/fixtures/configuration.yaml similarity index 100% rename from tests/fixtures/history_stats/configuration.yaml rename to tests/components/history_stats/fixtures/configuration.yaml diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 01ce5bf06b3..105943d4444 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -1,7 +1,6 @@ """The test for the History Statistics sensor platform.""" # pylint: disable=protected-access from datetime import datetime, timedelta -from os import path import unittest from unittest.mock import patch @@ -18,6 +17,7 @@ import homeassistant.util.dt as dt_util from tests.common import ( async_init_recorder_component, + get_fixture_path, get_test_home_assistant, init_recorder_component, ) @@ -253,11 +253,7 @@ async def test_reload(hass): assert hass.states.get("sensor.test") - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "history_stats/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "history_stats") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( DOMAIN, @@ -427,7 +423,3 @@ async def async_test_measure(hass): assert hass.states.get("sensor.sensor2").state == STATE_UNKNOWN assert hass.states.get("sensor.sensor3").state == "2" assert hass.states.get("sensor.sensor4").state == "50.0" - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index 0e71594937f..c0e5fdbdd99 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -12,6 +12,7 @@ from homeassistant.components.homeassistant.triggers import ( ) from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF from homeassistant.core import Context +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -126,6 +127,59 @@ async def test_if_fires_on_entity_change_below(hass, calls, below): assert calls[0].data["id"] == 0 +@pytest.mark.parametrize( + "below", (10, "input_number.value_10", "number.value_10", "sensor.value_10") +) +async def test_if_fires_on_entity_change_below_uuid(hass, calls, below): + """Test the firing with changed entity specified by registry entry id.""" + registry = er.async_get(hass) + entry = registry.async_get_or_create( + "test", "hue", "1234", suggested_object_id="entity" + ) + assert entry.entity_id == "test.entity" + + hass.states.async_set("test.entity", 11) + await hass.async_block_till_done() + + context = Context() + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "numeric_state", + "entity_id": entry.id, + "below": below, + }, + "action": { + "service": "test.automation", + "data_template": {"id": "{{ trigger.id}}"}, + }, + } + }, + ) + # 9 is below 10 + hass.states.async_set("test.entity", 9, context=context) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].context.parent_id == context.id + + # Set above 12 so the automation will fire again + hass.states.async_set("test.entity", 12) + + await hass.services.async_call( + automation.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_MATCH_ALL}, + blocking=True, + ) + hass.states.async_set("test.entity", 9) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["id"] == 0 + + @pytest.mark.parametrize( "below", (10, "input_number.value_10", "number.value_10", "sensor.value_10") ) @@ -1644,31 +1698,33 @@ async def test_if_fires_on_entities_change_overlap_for_template( assert calls[1].data["some"] == "test.entity_2 - 0:00:10" -def test_below_above(): +async def test_below_above(hass): """Test above cannot be above below.""" with pytest.raises(vol.Invalid): - numeric_state_trigger.TRIGGER_SCHEMA( - {"platform": "numeric_state", "above": 1200, "below": 1000} + await numeric_state_trigger.async_validate_trigger_config( + hass, {"platform": "numeric_state", "above": 1200, "below": 1000} ) -def test_schema_unacceptable_entities(): +async def test_schema_unacceptable_entities(hass): """Test input_number, number & sensor only is accepted for above/below.""" with pytest.raises(vol.Invalid): - numeric_state_trigger.TRIGGER_SCHEMA( + await numeric_state_trigger.async_validate_trigger_config( + hass, { "platform": "numeric_state", "above": "input_datetime.some_input", "below": 1000, - } + }, ) with pytest.raises(vol.Invalid): - numeric_state_trigger.TRIGGER_SCHEMA( + await numeric_state_trigger.async_validate_trigger_config( + hass, { "platform": "numeric_state", "below": "input_datetime.some_input", "above": 1200, - } + }, ) diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index 8671e40d293..026f096022b 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -8,6 +8,7 @@ import homeassistant.components.automation as automation from homeassistant.components.homeassistant.triggers import state as state_trigger from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF from homeassistant.core import Context +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -82,6 +83,64 @@ async def test_if_fires_on_entity_change(hass, calls): assert len(calls) == 1 +async def test_if_fires_on_entity_change_uuid(hass, calls): + """Test for firing on entity change.""" + context = Context() + + registry = er.async_get(hass) + entry = registry.async_get_or_create( + "test", "hue", "1234", suggested_object_id="beer" + ) + + assert entry.entity_id == "test.beer" + + hass.states.async_set("test.beer", "hello") + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "state", "entity_id": entry.id}, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + "id", + ) + ) + }, + }, + } + }, + ) + await hass.async_block_till_done() + + hass.states.async_set("test.beer", "world", context=context) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].context.parent_id == context.id + assert calls[0].data["some"] == "state - test.beer - hello - world - None - 0" + + await hass.services.async_call( + automation.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_MATCH_ALL}, + blocking=True, + ) + hass.states.async_set("test.beer", "planet") + await hass.async_block_till_done() + assert len(calls) == 1 + + async def test_if_fires_on_entity_change_with_from_filter(hass, calls): """Test for firing on entity change with filter.""" assert await async_setup_component( @@ -106,7 +165,7 @@ async def test_if_fires_on_entity_change_with_from_filter(hass, calls): async def test_if_fires_on_entity_change_with_to_filter(hass, calls): - """Test for firing on entity change with no filter.""" + """Test for firing on entity change with to filter.""" assert await async_setup_component( hass, automation.DOMAIN, @@ -128,6 +187,54 @@ async def test_if_fires_on_entity_change_with_to_filter(hass, calls): assert len(calls) == 1 +async def test_if_fires_on_entity_change_with_from_filter_all(hass, calls): + """Test for firing on entity change with filter.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "state", + "entity_id": "test.entity", + "from": None, + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() + + hass.states.async_set("test.entity", "world") + hass.states.async_set("test.entity", "world", {"attribute": 5}) + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_if_fires_on_entity_change_with_to_filter_all(hass, calls): + """Test for firing on entity change with to filter.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "state", + "entity_id": "test.entity", + "to": None, + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() + + hass.states.async_set("test.entity", "world") + hass.states.async_set("test.entity", "world", {"attribute": 5}) + await hass.async_block_till_done() + assert len(calls) == 1 + + async def test_if_fires_on_attribute_change_with_to_filter(hass, calls): """Test for not firing on attribute change.""" assert await async_setup_component( @@ -1217,6 +1324,94 @@ async def test_attribute_if_fires_on_entity_where_attr_stays_constant(hass, call assert len(calls) == 1 +async def test_attribute_if_fires_on_entity_where_attr_stays_constant_filter( + hass, calls +): + """Test for firing if attribute stays the same.""" + hass.states.async_set("test.entity", "bla", {"name": "other_name"}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "state", + "entity_id": "test.entity", + "attribute": "name", + "to": "best_name", + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() + + # Leave all attributes the same + hass.states.async_set( + "test.entity", "bla", {"name": "best_name", "other": "old_value"} + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + # Change the untracked attribute + hass.states.async_set( + "test.entity", "bla", {"name": "best_name", "other": "new_value"} + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + # Change the tracked attribute + hass.states.async_set( + "test.entity", "bla", {"name": "other_name", "other": "old_value"} + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_attribute_if_fires_on_entity_where_attr_stays_constant_all(hass, calls): + """Test for firing if attribute stays the same.""" + hass.states.async_set("test.entity", "bla", {"name": "hello", "other": "old_value"}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "state", + "entity_id": "test.entity", + "attribute": "name", + "to": None, + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() + + # Leave all attributes the same + hass.states.async_set( + "test.entity", "bla", {"name": "name_1", "other": "old_value"} + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + # Change the untracked attribute + hass.states.async_set( + "test.entity", "bla", {"name": "name_1", "other": "new_value"} + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + # Change the tracked attribute + hass.states.async_set( + "test.entity", "bla", {"name": "name_2", "other": "old_value"} + ) + await hass.async_block_till_done() + assert len(calls) == 2 + + async def test_attribute_if_not_fires_on_entities_change_with_for_after_stop( hass, calls ): diff --git a/tests/fixtures/homekit/configuration.yaml b/tests/components/homekit/fixtures/configuration.yaml similarity index 100% rename from tests/fixtures/homekit/configuration.yaml rename to tests/components/homekit/fixtures/configuration.yaml diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 60c7e5ac8e2..b47ab223be8 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -694,7 +694,7 @@ def test_home_driver(): # pair with patch("pyhap.accessory_driver.AccessoryDriver.pair") as mock_pair, patch( - "homeassistant.components.homekit.accessories.dismiss_setup_message" + "homeassistant.components.homekit.accessories.async_dismiss_setup_message" ) as mock_dissmiss_msg: driver.pair("client_uuid", "client_public", b"1") @@ -703,7 +703,7 @@ def test_home_driver(): # unpair with patch("pyhap.accessory_driver.AccessoryDriver.unpair") as mock_unpair, patch( - "homeassistant.components.homekit.accessories.show_setup_message" + "homeassistant.components.homekit.accessories.async_show_setup_message" ) as mock_show_msg: driver.unpair("client_uuid") diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index f1564f9e3ae..f076d8e00ae 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -362,7 +362,13 @@ async def test_options_flow_exclude_mode_basic(hass, mock_get_source_ip): async def test_options_flow_devices( - mock_hap, hass, demo_cleanup, device_reg, entity_reg, mock_get_source_ip + mock_hap, + hass, + demo_cleanup, + device_reg, + entity_reg, + mock_get_source_ip, + mock_async_zeroconf, ): """Test devices can be bridged.""" config_entry = MockConfigEntry( @@ -441,7 +447,7 @@ async def test_options_flow_devices( async def test_options_flow_devices_preserved_when_advanced_off( - mock_hap, hass, mock_get_source_ip + mock_hap, 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( @@ -508,8 +514,10 @@ async def test_options_flow_devices_preserved_when_advanced_off( } -async def test_options_flow_with_non_existant_entity(hass, mock_get_source_ip): - """Test config flow options in include mode.""" +async def test_options_flow_include_mode_with_non_existant_entity( + hass, mock_get_source_ip +): + """Test config flow options in include mode with a non-existent entity.""" config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345}, @@ -562,6 +570,63 @@ async def test_options_flow_with_non_existant_entity(hass, mock_get_source_ip): } +async def test_options_flow_exclude_mode_with_non_existant_entity( + hass, mock_get_source_ip +): + """Test config flow options in exclude mode with a non-existent entity.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "mock_name", CONF_PORT: 12345}, + options={ + "filter": { + "include_domains": ["climate"], + "exclude_entities": ["climate.not_exist", "climate.front_gate"], + }, + }, + ) + config_entry.add_to_hass(hass) + hass.states.async_set("climate.front_gate", "off") + hass.states.async_set("climate.new", "off") + + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": False} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"domains": ["climate"]}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "include_exclude" + + entities = result["data_schema"]({})["entities"] + assert "climate.not_exist" not in entities + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "entities": ["climate.new", "climate.front_gate"], + "include_exclude_mode": "exclude", + }, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + "mode": "bridge", + "filter": { + "exclude_domains": [], + "exclude_entities": ["climate.new", "climate.front_gate"], + "include_domains": ["climate"], + "include_entities": [], + }, + } + + async def test_options_flow_include_mode_basic(hass, mock_get_source_ip): """Test config flow options in include mode.""" diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index be2429c79cf..8cc416adabd 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -30,6 +30,7 @@ from homeassistant.const import ( DEVICE_CLASS_CO2, LIGHT_LUX, PERCENTAGE, + STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) @@ -270,6 +271,7 @@ def test_type_sensors(type_name, entity_id, state, attrs): [ ("Outlet", "switch.test", "on", {}, {CONF_TYPE: TYPE_OUTLET}), ("Switch", "automation.test", "on", {}, {}), + ("Switch", "button.test", STATE_UNKNOWN, {}, {}), ("Switch", "input_boolean.test", "on", {}, {}), ("Switch", "remote.test", "on", {}, {}), ("Switch", "scene.test", "on", {}, {}), diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index e77980d4c2c..0b1d2cc8535 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -68,7 +68,7 @@ from homeassistant.util import json as json_util from .util import PATH_HOMEKIT, async_init_entry, async_init_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_fixture_path IP_ADDRESS = "127.0.0.1" @@ -142,7 +142,7 @@ def _mock_pyhap_bridge(): ) -async def test_setup_min(hass, mock_zeroconf): +async def test_setup_min(hass, mock_async_zeroconf): """Test async_setup with min config options.""" entry = MockConfigEntry( @@ -181,7 +181,7 @@ async def test_setup_min(hass, mock_zeroconf): assert mock_homekit().async_start.called is True -async def test_homekit_setup(hass, hk_driver, mock_zeroconf): +async def test_homekit_setup(hass, hk_driver, mock_async_zeroconf): """Test setup of bridge and driver.""" entry = MockConfigEntry( domain=DOMAIN, @@ -226,7 +226,7 @@ async def test_homekit_setup(hass, hk_driver, mock_zeroconf): assert homekit.driver.safe_mode is False -async def test_homekit_setup_ip_address(hass, hk_driver, mock_zeroconf): +async def test_homekit_setup_ip_address(hass, hk_driver, mock_async_zeroconf): """Test setup with given IP address.""" entry = MockConfigEntry( domain=DOMAIN, @@ -247,11 +247,10 @@ async def test_homekit_setup_ip_address(hass, hk_driver, mock_zeroconf): entry_title=entry.title, ) - mock_zeroconf = MagicMock() path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) uuid = await hass.helpers.instance_id.async_get() with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: - await hass.async_add_executor_job(homekit.setup, mock_zeroconf, uuid) + await hass.async_add_executor_job(homekit.setup, mock_async_zeroconf, uuid) mock_driver.assert_called_with( hass, entry.entry_id, @@ -262,12 +261,12 @@ async def test_homekit_setup_ip_address(hass, hk_driver, mock_zeroconf): port=DEFAULT_PORT, persist_file=path, advertised_address=None, - async_zeroconf_instance=mock_zeroconf, + async_zeroconf_instance=mock_async_zeroconf, zeroconf_server=f"{uuid}-hap.local.", ) -async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_zeroconf): +async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_async_zeroconf): """Test setup with given IP address to advertise.""" entry = MockConfigEntry( domain=DOMAIN, @@ -308,7 +307,7 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_zeroconf): ) -async def test_homekit_add_accessory(hass, mock_zeroconf): +async def test_homekit_add_accessory(hass, mock_async_zeroconf): """Add accessory if config exists and get_acc returns an accessory.""" entry = MockConfigEntry( @@ -345,7 +344,7 @@ async def test_homekit_add_accessory(hass, mock_zeroconf): @pytest.mark.parametrize("acc_category", [CATEGORY_TELEVISION, CATEGORY_CAMERA]) async def test_homekit_warn_add_accessory_bridge( - hass, acc_category, mock_zeroconf, caplog + hass, acc_category, mock_async_zeroconf, caplog ): """Test we warn when adding cameras or tvs to a bridge.""" @@ -373,7 +372,7 @@ async def test_homekit_warn_add_accessory_bridge( assert "accessory mode" in caplog.text -async def test_homekit_remove_accessory(hass, mock_zeroconf): +async def test_homekit_remove_accessory(hass, mock_async_zeroconf): """Remove accessory from bridge.""" entry = await async_init_integration(hass) @@ -391,7 +390,7 @@ async def test_homekit_remove_accessory(hass, mock_zeroconf): assert len(homekit.bridge.accessories) == 0 -async def test_homekit_entity_filter(hass, mock_zeroconf): +async def test_homekit_entity_filter(hass, mock_async_zeroconf): """Test the entity filter.""" entry = await async_init_integration(hass) @@ -410,7 +409,7 @@ async def test_homekit_entity_filter(hass, mock_zeroconf): assert hass.states.get("light.demo") not in filtered_states -async def test_homekit_entity_glob_filter(hass, mock_zeroconf): +async def test_homekit_entity_glob_filter(hass, mock_async_zeroconf): """Test the entity filter.""" entry = await async_init_integration(hass) @@ -434,7 +433,7 @@ async def test_homekit_entity_glob_filter(hass, mock_zeroconf): assert hass.states.get("light.included_test") in filtered_states -async def test_homekit_start(hass, hk_driver, mock_zeroconf, device_reg): +async def test_homekit_start(hass, hk_driver, mock_async_zeroconf, device_reg): """Test HomeKit start method.""" entry = await async_init_integration(hass) @@ -460,7 +459,7 @@ async def test_homekit_start(hass, hk_driver, mock_zeroconf, device_reg): state = hass.states.async_all()[0] with patch(f"{PATH_HOMEKIT}.HomeKit.add_bridge_accessory") as mock_add_acc, patch( - f"{PATH_HOMEKIT}.show_setup_message" + f"{PATH_HOMEKIT}.async_show_setup_message" ) as mock_setup_msg, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ) as hk_driver_start: @@ -492,7 +491,7 @@ async def test_homekit_start(hass, hk_driver, mock_zeroconf, device_reg): # Start again to make sure the registry entry is kept homekit.status = STATUS_READY with patch(f"{PATH_HOMEKIT}.HomeKit.add_bridge_accessory") as mock_add_acc, patch( - f"{PATH_HOMEKIT}.show_setup_message" + f"{PATH_HOMEKIT}.async_show_setup_message" ) as mock_setup_msg, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ) as hk_driver_start: @@ -509,7 +508,9 @@ async def test_homekit_start(hass, hk_driver, mock_zeroconf, device_reg): assert homekit.driver.state.config_version == 1 -async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, mock_zeroconf): +async def test_homekit_start_with_a_broken_accessory( + hass, hk_driver, mock_async_zeroconf +): """Test HomeKit start method.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} @@ -528,7 +529,7 @@ async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, mock_zeroc hass.states.async_set("light.broken", "on") with patch(f"{PATH_HOMEKIT}.get_accessory", side_effect=Exception), patch( - f"{PATH_HOMEKIT}.show_setup_message" + f"{PATH_HOMEKIT}.async_show_setup_message" ) as mock_setup_msg, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ) as hk_driver_start: @@ -549,7 +550,7 @@ async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, mock_zeroc async def test_homekit_start_with_a_device( - hass, hk_driver, mock_zeroconf, demo_cleanup, device_reg, entity_reg + hass, hk_driver, mock_async_zeroconf, demo_cleanup, device_reg, entity_reg ): """Test HomeKit start method with a device.""" @@ -567,7 +568,7 @@ async def test_homekit_start_with_a_device( homekit.driver = hk_driver with patch(f"{PATH_HOMEKIT}.get_accessory", side_effect=Exception), patch( - f"{PATH_HOMEKIT}.show_setup_message" + f"{PATH_HOMEKIT}.async_show_setup_message" ) as mock_setup_msg: await homekit.async_start() @@ -611,7 +612,7 @@ async def test_homekit_stop(hass): assert homekit.driver.async_stop.called is True -async def test_homekit_reset_accessories(hass, mock_zeroconf): +async def test_homekit_reset_accessories(hass, mock_async_zeroconf): """Test resetting HomeKit accessories.""" entry = MockConfigEntry( @@ -656,7 +657,7 @@ async def test_homekit_reset_accessories(hass, mock_zeroconf): homekit.status = STATUS_READY -async def test_homekit_unpair(hass, device_reg, mock_zeroconf): +async def test_homekit_unpair(hass, device_reg, mock_async_zeroconf): """Test unpairing HomeKit accessories.""" entry = MockConfigEntry( @@ -698,7 +699,7 @@ async def test_homekit_unpair(hass, device_reg, mock_zeroconf): homekit.status = STATUS_STOPPED -async def test_homekit_unpair_missing_device_id(hass, device_reg, mock_zeroconf): +async def test_homekit_unpair_missing_device_id(hass, device_reg, mock_async_zeroconf): """Test unpairing HomeKit accessories with invalid device id.""" entry = MockConfigEntry( @@ -736,7 +737,7 @@ async def test_homekit_unpair_missing_device_id(hass, device_reg, mock_zeroconf) homekit.status = STATUS_STOPPED -async def test_homekit_unpair_not_homekit_device(hass, device_reg, mock_zeroconf): +async def test_homekit_unpair_not_homekit_device(hass, device_reg, mock_async_zeroconf): """Test unpairing HomeKit accessories with a non-homekit device id.""" entry = MockConfigEntry( @@ -784,7 +785,7 @@ async def test_homekit_unpair_not_homekit_device(hass, device_reg, mock_zeroconf homekit.status = STATUS_STOPPED -async def test_homekit_reset_accessories_not_supported(hass, mock_zeroconf): +async def test_homekit_reset_accessories_not_supported(hass, mock_async_zeroconf): """Test resetting HomeKit accessories with an unsupported entity.""" entry = MockConfigEntry( @@ -828,7 +829,7 @@ async def test_homekit_reset_accessories_not_supported(hass, mock_zeroconf): homekit.status = STATUS_STOPPED -async def test_homekit_reset_accessories_state_missing(hass, mock_zeroconf): +async def test_homekit_reset_accessories_state_missing(hass, mock_async_zeroconf): """Test resetting HomeKit accessories when the state goes missing.""" entry = MockConfigEntry( @@ -870,7 +871,7 @@ async def test_homekit_reset_accessories_state_missing(hass, mock_zeroconf): homekit.status = STATUS_STOPPED -async def test_homekit_reset_accessories_not_bridged(hass, mock_zeroconf): +async def test_homekit_reset_accessories_not_bridged(hass, mock_async_zeroconf): """Test resetting HomeKit accessories when the state is not bridged.""" entry = MockConfigEntry( @@ -912,7 +913,7 @@ async def test_homekit_reset_accessories_not_bridged(hass, mock_zeroconf): homekit.status = STATUS_STOPPED -async def test_homekit_reset_single_accessory(hass, mock_zeroconf): +async def test_homekit_reset_single_accessory(hass, mock_async_zeroconf): """Test resetting HomeKit single accessory.""" entry = MockConfigEntry( @@ -951,7 +952,7 @@ async def test_homekit_reset_single_accessory(hass, mock_zeroconf): homekit.status = STATUS_READY -async def test_homekit_reset_single_accessory_unsupported(hass, mock_zeroconf): +async def test_homekit_reset_single_accessory_unsupported(hass, mock_async_zeroconf): """Test resetting HomeKit single accessory with an unsupported entity.""" entry = MockConfigEntry( @@ -988,7 +989,7 @@ async def test_homekit_reset_single_accessory_unsupported(hass, mock_zeroconf): homekit.status = STATUS_STOPPED -async def test_homekit_reset_single_accessory_state_missing(hass, mock_zeroconf): +async def test_homekit_reset_single_accessory_state_missing(hass, mock_async_zeroconf): """Test resetting HomeKit single accessory when the state goes missing.""" entry = MockConfigEntry( @@ -1024,7 +1025,7 @@ async def test_homekit_reset_single_accessory_state_missing(hass, mock_zeroconf) homekit.status = STATUS_STOPPED -async def test_homekit_reset_single_accessory_no_match(hass, mock_zeroconf): +async def test_homekit_reset_single_accessory_no_match(hass, mock_async_zeroconf): """Test resetting HomeKit single accessory when the entity id does not match.""" entry = MockConfigEntry( @@ -1060,7 +1061,9 @@ async def test_homekit_reset_single_accessory_no_match(hass, mock_zeroconf): homekit.status = STATUS_STOPPED -async def test_homekit_too_many_accessories(hass, hk_driver, caplog, mock_zeroconf): +async def test_homekit_too_many_accessories( + hass, hk_driver, caplog, mock_async_zeroconf +): """Test adding too many accessories to HomeKit.""" entry = await async_init_integration(hass) @@ -1082,7 +1085,7 @@ async def test_homekit_too_many_accessories(hass, hk_driver, caplog, mock_zeroco hass.states.async_set("light.demo3", "on") with patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch( - f"{PATH_HOMEKIT}.show_setup_message" + f"{PATH_HOMEKIT}.async_show_setup_message" ), patch(f"{PATH_HOMEKIT}.HomeBridge", _mock_bridge): await homekit.async_start() await hass.async_block_till_done() @@ -1090,7 +1093,7 @@ async def test_homekit_too_many_accessories(hass, hk_driver, caplog, mock_zeroco async def test_homekit_finds_linked_batteries( - hass, hk_driver, device_reg, entity_reg, mock_zeroconf + hass, hk_driver, device_reg, entity_reg, mock_async_zeroconf ): """Test HomeKit start method.""" entry = await async_init_integration(hass) @@ -1115,14 +1118,14 @@ async def test_homekit_finds_linked_batteries( "powerwall", "battery_charging", device_id=device_entry.id, - device_class=DEVICE_CLASS_BATTERY_CHARGING, + original_device_class=DEVICE_CLASS_BATTERY_CHARGING, ) battery_sensor = entity_reg.async_get_or_create( "sensor", "powerwall", "battery", device_id=device_entry.id, - device_class=DEVICE_CLASS_BATTERY, + original_device_class=DEVICE_CLASS_BATTERY, ) light = entity_reg.async_get_or_create( "light", "powerwall", "demo", device_id=device_entry.id @@ -1138,7 +1141,7 @@ async def test_homekit_finds_linked_batteries( ) hass.states.async_set(light.entity_id, STATE_ON) - with patch(f"{PATH_HOMEKIT}.show_setup_message"), patch( + with patch(f"{PATH_HOMEKIT}.async_show_setup_message"), patch( f"{PATH_HOMEKIT}.get_accessory" ) as mock_get_acc, patch("pyhap.accessory_driver.AccessoryDriver.async_start"): await homekit.async_start() @@ -1161,7 +1164,7 @@ async def test_homekit_finds_linked_batteries( async def test_homekit_async_get_integration_fails( - hass, hk_driver, device_reg, entity_reg, mock_zeroconf + hass, hk_driver, device_reg, entity_reg, mock_async_zeroconf ): """Test that we continue if async_get_integration fails.""" entry = await async_init_integration(hass) @@ -1184,14 +1187,14 @@ async def test_homekit_async_get_integration_fails( "invalid_integration_does_not_exist", "battery_charging", device_id=device_entry.id, - device_class=DEVICE_CLASS_BATTERY_CHARGING, + original_device_class=DEVICE_CLASS_BATTERY_CHARGING, ) battery_sensor = entity_reg.async_get_or_create( "sensor", "invalid_integration_does_not_exist", "battery", device_id=device_entry.id, - device_class=DEVICE_CLASS_BATTERY, + original_device_class=DEVICE_CLASS_BATTERY, ) light = entity_reg.async_get_or_create( "light", "invalid_integration_does_not_exist", "demo", device_id=device_entry.id @@ -1208,7 +1211,7 @@ async def test_homekit_async_get_integration_fails( hass.states.async_set(light.entity_id, STATE_ON) with patch.object(homekit.bridge, "add_accessory"), patch( - f"{PATH_HOMEKIT}.show_setup_message" + f"{PATH_HOMEKIT}.async_show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ): @@ -1230,7 +1233,7 @@ async def test_homekit_async_get_integration_fails( ) -async def test_yaml_updates_update_config_entry_for_name(hass, mock_zeroconf): +async def test_yaml_updates_update_config_entry_for_name(hass, mock_async_zeroconf): """Test async_setup with imported config.""" entry = MockConfigEntry( @@ -1274,7 +1277,7 @@ async def test_yaml_updates_update_config_entry_for_name(hass, mock_zeroconf): mock_homekit().async_start.assert_called() -async def test_homekit_uses_system_zeroconf(hass, hk_driver, mock_zeroconf): +async def test_homekit_uses_system_zeroconf(hass, hk_driver, mock_async_zeroconf): """Test HomeKit uses system zeroconf.""" entry = MockConfigEntry( domain=DOMAIN, @@ -1306,7 +1309,7 @@ def _write_data(path: str, data: dict) -> None: async def test_homekit_ignored_missing_devices( - hass, hk_driver, device_reg, entity_reg, mock_zeroconf + hass, hk_driver, device_reg, entity_reg, mock_async_zeroconf ): """Test HomeKit handles a device in the entity registry but missing from the device registry.""" @@ -1331,14 +1334,14 @@ async def test_homekit_ignored_missing_devices( "powerwall", "battery_charging", device_id=device_entry.id, - device_class=DEVICE_CLASS_BATTERY_CHARGING, + original_device_class=DEVICE_CLASS_BATTERY_CHARGING, ) entity_reg.async_get_or_create( "sensor", "powerwall", "battery", device_id=device_entry.id, - device_class=DEVICE_CLASS_BATTERY, + original_device_class=DEVICE_CLASS_BATTERY, ) light = entity_reg.async_get_or_create( "light", "powerwall", "demo", device_id=device_entry.id @@ -1376,7 +1379,7 @@ async def test_homekit_ignored_missing_devices( async def test_homekit_finds_linked_motion_sensors( - hass, hk_driver, device_reg, entity_reg, mock_zeroconf + hass, hk_driver, device_reg, entity_reg, mock_async_zeroconf ): """Test HomeKit start method.""" entry = await async_init_integration(hass) @@ -1401,7 +1404,7 @@ async def test_homekit_finds_linked_motion_sensors( "camera", "motion_sensor", device_id=device_entry.id, - device_class=DEVICE_CLASS_MOTION, + original_device_class=DEVICE_CLASS_MOTION, ) camera = entity_reg.async_get_or_create( "camera", "camera", "demo", device_id=device_entry.id @@ -1415,7 +1418,7 @@ async def test_homekit_finds_linked_motion_sensors( hass.states.async_set(camera.entity_id, STATE_ON) with patch.object(homekit.bridge, "add_accessory"), patch( - f"{PATH_HOMEKIT}.show_setup_message" + f"{PATH_HOMEKIT}.async_show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ): @@ -1438,7 +1441,7 @@ async def test_homekit_finds_linked_motion_sensors( async def test_homekit_finds_linked_humidity_sensors( - hass, hk_driver, device_reg, entity_reg, mock_zeroconf + hass, hk_driver, device_reg, entity_reg, mock_async_zeroconf ): """Test HomeKit start method.""" entry = await async_init_integration(hass) @@ -1463,7 +1466,7 @@ async def test_homekit_finds_linked_humidity_sensors( "humidifier", "humidity_sensor", device_id=device_entry.id, - device_class=DEVICE_CLASS_HUMIDITY, + original_device_class=DEVICE_CLASS_HUMIDITY, ) humidifier = entity_reg.async_get_or_create( "humidifier", "humidifier", "demo", device_id=device_entry.id @@ -1480,7 +1483,7 @@ async def test_homekit_finds_linked_humidity_sensors( hass.states.async_set(humidifier.entity_id, STATE_ON) with patch.object(homekit.bridge, "add_accessory"), patch( - f"{PATH_HOMEKIT}.show_setup_message" + f"{PATH_HOMEKIT}.async_show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ): @@ -1502,7 +1505,7 @@ async def test_homekit_finds_linked_humidity_sensors( ) -async def test_reload(hass, mock_zeroconf): +async def test_reload(hass, mock_async_zeroconf): """Test we can reload from yaml.""" entry = MockConfigEntry( @@ -1536,15 +1539,11 @@ async def test_reload(hass, mock_zeroconf): entry.title, devices=[], ) - yaml_path = os.path.join( - _get_fixtures_base_path(), - "fixtures", - "homekit/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "homekit") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path), patch( f"{PATH_HOMEKIT}.HomeKit" ) as mock_homekit2, patch.object(homekit.bridge, "add_accessory"), patch( - f"{PATH_HOMEKIT}.show_setup_message" + f"{PATH_HOMEKIT}.async_show_setup_message" ), patch( f"{PATH_HOMEKIT}.get_accessory" ), patch( @@ -1577,12 +1576,8 @@ async def test_reload(hass, mock_zeroconf): ) -def _get_fixtures_base_path(): - return os.path.dirname(os.path.dirname(os.path.dirname(__file__))) - - async def test_homekit_start_in_accessory_mode( - hass, hk_driver, mock_zeroconf, device_reg + hass, hk_driver, mock_async_zeroconf, device_reg ): """Test HomeKit start method in accessory mode.""" entry = await async_init_integration(hass) @@ -1597,7 +1592,7 @@ async def test_homekit_start_in_accessory_mode( hass.states.async_set("light.demo", "on") with patch(f"{PATH_HOMEKIT}.HomeKit.add_bridge_accessory") as mock_add_acc, patch( - f"{PATH_HOMEKIT}.show_setup_message" + f"{PATH_HOMEKIT}.async_show_setup_message" ) as mock_setup_msg, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ) as hk_driver_start: @@ -1613,7 +1608,7 @@ async def test_homekit_start_in_accessory_mode( async def test_homekit_start_in_accessory_mode_unsupported_entity( - hass, hk_driver, mock_zeroconf, device_reg, caplog + hass, hk_driver, mock_async_zeroconf, device_reg, caplog ): """Test HomeKit start method in accessory mode with an unsupported entity.""" entry = await async_init_integration(hass) @@ -1628,7 +1623,7 @@ async def test_homekit_start_in_accessory_mode_unsupported_entity( hass.states.async_set("notsupported.demo", "on") with patch(f"{PATH_HOMEKIT}.HomeKit.add_bridge_accessory") as mock_add_acc, patch( - f"{PATH_HOMEKIT}.show_setup_message" + f"{PATH_HOMEKIT}.async_show_setup_message" ) as mock_setup_msg, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ) as hk_driver_start: @@ -1643,7 +1638,7 @@ async def test_homekit_start_in_accessory_mode_unsupported_entity( async def test_homekit_start_in_accessory_mode_missing_entity( - hass, hk_driver, mock_zeroconf, device_reg, caplog + hass, hk_driver, mock_async_zeroconf, device_reg, caplog ): """Test HomeKit start method in accessory mode when entity is not available.""" entry = await async_init_integration(hass) @@ -1656,7 +1651,7 @@ async def test_homekit_start_in_accessory_mode_missing_entity( homekit.driver.accessory = Accessory(hk_driver, "any") with patch(f"{PATH_HOMEKIT}.HomeKit.add_bridge_accessory") as mock_add_acc, patch( - f"{PATH_HOMEKIT}.show_setup_message" + f"{PATH_HOMEKIT}.async_show_setup_message" ), patch("pyhap.accessory_driver.AccessoryDriver.async_start"): await homekit.async_start() @@ -1667,7 +1662,7 @@ async def test_homekit_start_in_accessory_mode_missing_entity( assert "entity not available" in caplog.text -async def test_wait_for_port_to_free(hass, hk_driver, mock_zeroconf, caplog): +async def test_wait_for_port_to_free(hass, hk_driver, mock_async_zeroconf, caplog): """Test we wait for the port to free before declaring unload success.""" entry = MockConfigEntry( diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 89407edfbef..c357598a3df 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -92,7 +92,7 @@ async def test_garage_door_open_close(hass, hk_driver, events): call_close_cover = async_mock_service(hass, DOMAIN, "close_cover") call_open_cover = async_mock_service(hass, DOMAIN, "open_cover") - await hass.async_add_executor_job(acc.char_target_state.client_update_value, 1) + acc.char_target_state.client_update_value(1) await hass.async_block_till_done() assert call_close_cover assert call_close_cover[0].data[ATTR_ENTITY_ID] == entity_id @@ -104,14 +104,14 @@ async def test_garage_door_open_close(hass, hk_driver, events): hass.states.async_set(entity_id, STATE_CLOSED) await hass.async_block_till_done() - await hass.async_add_executor_job(acc.char_target_state.client_update_value, 1) + acc.char_target_state.client_update_value(1) await hass.async_block_till_done() assert acc.char_current_state.value == HK_DOOR_CLOSED assert acc.char_target_state.value == HK_DOOR_CLOSED assert len(events) == 2 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_target_state.client_update_value, 0) + acc.char_target_state.client_update_value(0) await hass.async_block_till_done() assert call_open_cover assert call_open_cover[0].data[ATTR_ENTITY_ID] == entity_id @@ -123,7 +123,7 @@ async def test_garage_door_open_close(hass, hk_driver, events): hass.states.async_set(entity_id, STATE_OPEN) await hass.async_block_till_done() - await hass.async_add_executor_job(acc.char_target_state.client_update_value, 0) + acc.char_target_state.client_update_value(0) await hass.async_block_till_done() assert acc.char_current_state.value == HK_DOOR_OPEN assert acc.char_target_state.value == HK_DOOR_OPEN @@ -202,7 +202,7 @@ async def test_windowcovering_set_cover_position(hass, hk_driver, events): # Set from HomeKit call_set_cover_position = async_mock_service(hass, DOMAIN, "set_cover_position") - await hass.async_add_executor_job(acc.char_target_position.client_update_value, 25) + acc.char_target_position.client_update_value(25) await hass.async_block_till_done() assert call_set_cover_position[0] assert call_set_cover_position[0].data[ATTR_ENTITY_ID] == entity_id @@ -212,7 +212,7 @@ async def test_windowcovering_set_cover_position(hass, hk_driver, events): assert len(events) == 1 assert events[-1].data[ATTR_VALUE] == 25 - await hass.async_add_executor_job(acc.char_target_position.client_update_value, 75) + acc.char_target_position.client_update_value(75) await hass.async_block_till_done() assert call_set_cover_position[1] assert call_set_cover_position[1].data[ATTR_ENTITY_ID] == entity_id @@ -286,7 +286,7 @@ async def test_windowcovering_cover_set_tilt(hass, hk_driver, events): # HomeKit sets tilts between -90 and 90 (degrees), whereas # Homeassistant expects a % between 0 and 100. Keep that in mind # when comparing - await hass.async_add_executor_job(acc.char_target_tilt.client_update_value, 90) + acc.char_target_tilt.client_update_value(90) await hass.async_block_till_done() assert call_set_tilt_position[0] assert call_set_tilt_position[0].data[ATTR_ENTITY_ID] == entity_id @@ -296,7 +296,7 @@ async def test_windowcovering_cover_set_tilt(hass, hk_driver, events): assert len(events) == 1 assert events[-1].data[ATTR_VALUE] == 100 - await hass.async_add_executor_job(acc.char_target_tilt.client_update_value, 45) + acc.char_target_tilt.client_update_value(45) await hass.async_block_till_done() assert call_set_tilt_position[1] assert call_set_tilt_position[1].data[ATTR_ENTITY_ID] == entity_id @@ -378,7 +378,7 @@ async def test_windowcovering_open_close(hass, hk_driver, events): call_close_cover = async_mock_service(hass, DOMAIN, "close_cover") call_open_cover = async_mock_service(hass, DOMAIN, "open_cover") - await hass.async_add_executor_job(acc.char_target_position.client_update_value, 25) + acc.char_target_position.client_update_value(25) await hass.async_block_till_done() assert call_close_cover assert call_close_cover[0].data[ATTR_ENTITY_ID] == entity_id @@ -388,7 +388,7 @@ async def test_windowcovering_open_close(hass, hk_driver, events): assert len(events) == 1 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_target_position.client_update_value, 90) + acc.char_target_position.client_update_value(90) await hass.async_block_till_done() assert call_open_cover[0] assert call_open_cover[0].data[ATTR_ENTITY_ID] == entity_id @@ -398,7 +398,7 @@ async def test_windowcovering_open_close(hass, hk_driver, events): assert len(events) == 2 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_target_position.client_update_value, 55) + acc.char_target_position.client_update_value(55) await hass.async_block_till_done() assert call_open_cover[1] assert call_open_cover[1].data[ATTR_ENTITY_ID] == entity_id @@ -425,7 +425,7 @@ async def test_windowcovering_open_close_stop(hass, hk_driver, events): call_open_cover = async_mock_service(hass, DOMAIN, "open_cover") call_stop_cover = async_mock_service(hass, DOMAIN, "stop_cover") - await hass.async_add_executor_job(acc.char_target_position.client_update_value, 25) + acc.char_target_position.client_update_value(25) await hass.async_block_till_done() assert call_close_cover assert call_close_cover[0].data[ATTR_ENTITY_ID] == entity_id @@ -435,7 +435,7 @@ async def test_windowcovering_open_close_stop(hass, hk_driver, events): assert len(events) == 1 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_target_position.client_update_value, 90) + acc.char_target_position.client_update_value(90) await hass.async_block_till_done() assert call_open_cover assert call_open_cover[0].data[ATTR_ENTITY_ID] == entity_id @@ -445,7 +445,7 @@ async def test_windowcovering_open_close_stop(hass, hk_driver, events): assert len(events) == 2 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_target_position.client_update_value, 55) + acc.char_target_position.client_update_value(55) await hass.async_block_till_done() assert call_stop_cover assert call_stop_cover[0].data[ATTR_ENTITY_ID] == entity_id @@ -474,11 +474,11 @@ async def test_windowcovering_open_close_with_position_and_stop( # Set from HomeKit call_stop_cover = async_mock_service(hass, DOMAIN, "stop_cover") - await hass.async_add_executor_job(acc.char_hold_position.client_update_value, 0) + acc.char_hold_position.client_update_value(0) await hass.async_block_till_done() assert not call_stop_cover - await hass.async_add_executor_job(acc.char_hold_position.client_update_value, 1) + acc.char_hold_position.client_update_value(1) await hass.async_block_till_done() assert call_stop_cover assert call_stop_cover[0].data[ATTR_ENTITY_ID] == entity_id @@ -506,7 +506,7 @@ async def test_windowcovering_basic_restore(hass, hk_driver, events): suggested_object_id="all_info_set", capabilities={}, supported_features=SUPPORT_STOP, - device_class="mock-device-class", + original_device_class="mock-device-class", ) hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) @@ -544,7 +544,7 @@ async def test_windowcovering_restore(hass, hk_driver, events): suggested_object_id="all_info_set", capabilities={}, supported_features=SUPPORT_STOP, - device_class="mock-device-class", + original_device_class="mock-device-class", ) hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index 85d00dcb287..c1ce1ffaddb 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -170,7 +170,7 @@ async def test_fan_direction(hass, hk_driver, events): }, "mock_addr", ) - await hass.async_add_executor_job(acc.char_direction.client_update_value, 1) + acc.char_direction.client_update_value(1) await hass.async_block_till_done() assert call_set_direction[1] assert call_set_direction[1].data[ATTR_ENTITY_ID] == entity_id @@ -219,7 +219,7 @@ async def test_fan_oscillate(hass, hk_driver, events): }, "mock_addr", ) - await hass.async_add_executor_job(acc.char_swing.client_update_value, 0) + acc.char_swing.client_update_value(0) await hass.async_block_till_done() assert call_oscillate[0] assert call_oscillate[0].data[ATTR_ENTITY_ID] == entity_id @@ -239,7 +239,7 @@ async def test_fan_oscillate(hass, hk_driver, events): }, "mock_addr", ) - await hass.async_add_executor_job(acc.char_swing.client_update_value, 1) + acc.char_swing.client_update_value(1) await hass.async_block_till_done() assert call_oscillate[1] assert call_oscillate[1].data[ATTR_ENTITY_ID] == entity_id @@ -295,7 +295,7 @@ async def test_fan_speed(hass, hk_driver, events): }, "mock_addr", ) - await hass.async_add_executor_job(acc.char_speed.client_update_value, 42) + acc.char_speed.client_update_value(42) await hass.async_block_till_done() assert acc.char_speed.value == 50 assert acc.char_active.value == 1 @@ -312,6 +312,8 @@ async def test_fan_speed(hass, hk_driver, events): assert acc.char_speed.value == 50 assert acc.char_active.value == 0 + call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + hk_driver.set_characteristics( { HAP_REPR_CHARS: [ @@ -328,6 +330,9 @@ async def test_fan_speed(hass, hk_driver, events): assert acc.char_speed.value == 50 assert acc.char_active.value == 1 + assert call_turn_on[0] + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + async def test_fan_set_all_one_shot(hass, hk_driver, events): """Test fan with speed.""" @@ -541,7 +546,7 @@ async def test_fan_restore(hass, hk_driver, events): suggested_object_id="all_info_set", capabilities={"speed_list": ["off", "low", "medium", "high"]}, supported_features=SUPPORT_SET_SPEED | SUPPORT_OSCILLATE | SUPPORT_DIRECTION, - device_class="mock-device-class", + original_device_class="mock-device-class", ) hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index de0fd532ec9..9c0d45126fc 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -546,7 +546,7 @@ async def test_light_restore(hass, hk_driver, events): suggested_object_id="all_info_set", capabilities={"supported_color_modes": ["brightness"], "max": 100}, supported_features=5, - device_class="mock-device-class", + original_device_class="mock-device-class", ) hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py index e47f4dfac71..1106699909b 100644 --- a/tests/components/homekit/test_type_locks.py +++ b/tests/components/homekit/test_type_locks.py @@ -76,7 +76,7 @@ async def test_lock_unlock(hass, hk_driver, events): call_lock = async_mock_service(hass, DOMAIN, "lock") call_unlock = async_mock_service(hass, DOMAIN, "unlock") - await hass.async_add_executor_job(acc.char_target_state.client_update_value, 1) + acc.char_target_state.client_update_value(1) await hass.async_block_till_done() assert call_lock assert call_lock[0].data[ATTR_ENTITY_ID] == entity_id @@ -85,7 +85,7 @@ async def test_lock_unlock(hass, hk_driver, events): assert len(events) == 1 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_target_state.client_update_value, 0) + acc.char_target_state.client_update_value(0) await hass.async_block_till_done() assert call_unlock assert call_unlock[0].data[ATTR_ENTITY_ID] == entity_id @@ -107,7 +107,7 @@ async def test_no_code(hass, hk_driver, config, events): # Set from HomeKit call_lock = async_mock_service(hass, DOMAIN, "lock") - await hass.async_add_executor_job(acc.char_target_state.client_update_value, 1) + acc.char_target_state.client_update_value(1) await hass.async_block_till_done() assert call_lock assert call_lock[0].data[ATTR_ENTITY_ID] == entity_id diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index c0184667e2c..6b24c731fab 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -112,63 +112,49 @@ async def test_media_player_set_state(hass, hk_driver, events): call_media_stop = async_mock_service(hass, DOMAIN, "media_stop") call_toggle_mute = async_mock_service(hass, DOMAIN, "volume_mute") - await hass.async_add_executor_job( - acc.chars[FEATURE_ON_OFF].client_update_value, True - ) + acc.chars[FEATURE_ON_OFF].client_update_value(True) await hass.async_block_till_done() assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 1 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job( - acc.chars[FEATURE_ON_OFF].client_update_value, False - ) + acc.chars[FEATURE_ON_OFF].client_update_value(False) await hass.async_block_till_done() assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 2 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job( - acc.chars[FEATURE_PLAY_PAUSE].client_update_value, True - ) + acc.chars[FEATURE_PLAY_PAUSE].client_update_value(True) await hass.async_block_till_done() assert call_media_play assert call_media_play[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 3 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job( - acc.chars[FEATURE_PLAY_PAUSE].client_update_value, False - ) + acc.chars[FEATURE_PLAY_PAUSE].client_update_value(False) await hass.async_block_till_done() assert call_media_pause assert call_media_pause[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 4 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job( - acc.chars[FEATURE_PLAY_STOP].client_update_value, True - ) + acc.chars[FEATURE_PLAY_STOP].client_update_value(True) await hass.async_block_till_done() assert call_media_play assert call_media_play[1].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 5 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job( - acc.chars[FEATURE_PLAY_STOP].client_update_value, False - ) + acc.chars[FEATURE_PLAY_STOP].client_update_value(False) await hass.async_block_till_done() assert call_media_stop assert call_media_stop[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 6 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job( - acc.chars[FEATURE_TOGGLE_MUTE].client_update_value, True - ) + acc.chars[FEATURE_TOGGLE_MUTE].client_update_value(True) await hass.async_block_till_done() assert call_toggle_mute assert call_toggle_mute[0].data[ATTR_ENTITY_ID] == entity_id @@ -176,9 +162,7 @@ async def test_media_player_set_state(hass, hk_driver, events): assert len(events) == 7 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job( - acc.chars[FEATURE_TOGGLE_MUTE].client_update_value, False - ) + acc.chars[FEATURE_TOGGLE_MUTE].client_update_value(False) await hass.async_block_till_done() assert call_toggle_mute assert call_toggle_mute[1].data[ATTR_ENTITY_ID] == entity_id @@ -258,21 +242,21 @@ async def test_media_player_television(hass, hk_driver, events, caplog): call_volume_down = async_mock_service(hass, DOMAIN, "volume_down") call_volume_set = async_mock_service(hass, DOMAIN, "volume_set") - await hass.async_add_executor_job(acc.char_active.client_update_value, 1) + acc.char_active.client_update_value(1) await hass.async_block_till_done() assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 1 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_active.client_update_value, 0) + acc.char_active.client_update_value(0) await hass.async_block_till_done() assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 2 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_remote_key.client_update_value, 11) + acc.char_remote_key.client_update_value(11) await hass.async_block_till_done() assert call_media_play_pause assert call_media_play_pause[0].data[ATTR_ENTITY_ID] == entity_id @@ -281,28 +265,28 @@ async def test_media_player_television(hass, hk_driver, events, caplog): hass.states.async_set(entity_id, STATE_PLAYING) await hass.async_block_till_done() - await hass.async_add_executor_job(acc.char_remote_key.client_update_value, 11) + acc.char_remote_key.client_update_value(11) await hass.async_block_till_done() assert call_media_pause assert call_media_pause[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 4 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_remote_key.client_update_value, 10) + acc.char_remote_key.client_update_value(10) await hass.async_block_till_done() assert len(events) == 4 assert events[-1].data[ATTR_VALUE] is None hass.states.async_set(entity_id, STATE_PAUSED) await hass.async_block_till_done() - await hass.async_add_executor_job(acc.char_remote_key.client_update_value, 11) + acc.char_remote_key.client_update_value(11) await hass.async_block_till_done() assert call_media_play assert call_media_play[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 5 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_mute.client_update_value, True) + acc.char_mute.client_update_value(True) await hass.async_block_till_done() assert call_toggle_mute assert call_toggle_mute[0].data[ATTR_ENTITY_ID] == entity_id @@ -310,7 +294,7 @@ async def test_media_player_television(hass, hk_driver, events, caplog): assert len(events) == 6 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_mute.client_update_value, False) + acc.char_mute.client_update_value(False) await hass.async_block_till_done() assert call_toggle_mute assert call_toggle_mute[1].data[ATTR_ENTITY_ID] == entity_id @@ -318,7 +302,7 @@ async def test_media_player_television(hass, hk_driver, events, caplog): assert len(events) == 7 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_input_source.client_update_value, 1) + acc.char_input_source.client_update_value(1) await hass.async_block_till_done() assert call_select_source assert call_select_source[0].data[ATTR_ENTITY_ID] == entity_id @@ -326,21 +310,21 @@ async def test_media_player_television(hass, hk_driver, events, caplog): assert len(events) == 8 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_volume_selector.client_update_value, 0) + acc.char_volume_selector.client_update_value(0) await hass.async_block_till_done() assert call_volume_up assert call_volume_up[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 9 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_volume_selector.client_update_value, 1) + acc.char_volume_selector.client_update_value(1) await hass.async_block_till_done() assert call_volume_down assert call_volume_down[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 10 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_volume.client_update_value, 20) + acc.char_volume.client_update_value(20) await hass.async_block_till_done() assert call_volume_set[0] assert call_volume_set[0].data[ATTR_ENTITY_ID] == entity_id @@ -356,10 +340,10 @@ async def test_media_player_television(hass, hk_driver, events, caplog): hass.bus.async_listen(EVENT_HOMEKIT_TV_REMOTE_KEY_PRESSED, listener) with pytest.raises(ValueError): - await hass.async_add_executor_job(acc.char_remote_key.client_update_value, 20) + acc.char_remote_key.client_update_value(20) await hass.async_block_till_done() - await hass.async_add_executor_job(acc.char_remote_key.client_update_value, 7) + acc.char_remote_key.client_update_value(7) await hass.async_block_till_done() assert len(events) == 1 @@ -431,7 +415,7 @@ async def test_tv_restore(hass, hk_driver, events): "generic", "1234", suggested_object_id="simple", - device_class=DEVICE_CLASS_TV, + original_device_class=DEVICE_CLASS_TV, ) registry.async_get_or_create( "media_player", @@ -442,7 +426,7 @@ async def test_tv_restore(hass, hk_driver, events): ATTR_INPUT_SOURCE_LIST: ["HDMI 1", "HDMI 2", "HDMI 3", "HDMI 4"], }, supported_features=3469, - device_class=DEVICE_CLASS_TV, + original_device_class=DEVICE_CLASS_TV, ) hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) @@ -471,3 +455,60 @@ async def test_tv_restore(hass, hk_driver, events): ] assert acc.support_select_source is True assert acc.char_input_source is not None + + +async def test_media_player_television_max_sources(hass, hk_driver, events, caplog): + """Test if television accessory that reaches the maximum number of sources.""" + entity_id = "media_player.television" + sources = [f"HDMI {i}" for i in range(1, 101)] + hass.states.async_set( + entity_id, + None, + { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TV, + ATTR_SUPPORTED_FEATURES: 3469, + ATTR_MEDIA_VOLUME_MUTED: False, + ATTR_INPUT_SOURCE: "HDMI 3", + ATTR_INPUT_SOURCE_LIST: sources, + }, + ) + await hass.async_block_till_done() + acc = TelevisionMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) + await acc.run() + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 31 # Television + + assert acc.char_active.value == 0 + assert acc.char_remote_key.value == 0 + assert acc.char_input_source.value == 2 + assert acc.char_mute.value is False + + hass.states.async_set( + entity_id, + None, + { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TV, + ATTR_SUPPORTED_FEATURES: 3469, + ATTR_MEDIA_VOLUME_MUTED: False, + ATTR_INPUT_SOURCE: "HDMI 90", + ATTR_INPUT_SOURCE_LIST: sources, + }, + ) + await hass.async_block_till_done() + assert acc.char_input_source.value == 89 + + hass.states.async_set( + entity_id, + None, + { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TV, + ATTR_SUPPORTED_FEATURES: 3469, + ATTR_MEDIA_VOLUME_MUTED: False, + ATTR_INPUT_SOURCE: "HDMI 91", + ATTR_INPUT_SOURCE_LIST: sources, + }, + ) + await hass.async_block_till_done() + assert acc.char_input_source.value == 0 diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index c7c06dcc90a..7ad48bfbce6 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -333,14 +333,14 @@ async def test_sensor_restore(hass, hk_driver, events): "generic", "1234", suggested_object_id="temperature", - device_class="temperature", + original_device_class="temperature", ) registry.async_get_or_create( "sensor", "generic", "12345", suggested_object_id="humidity", - device_class="humidity", + original_device_class="humidity", unit_of_measurement=PERCENTAGE, ) hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index c13f7ea2538..54eae42ca1d 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -71,14 +71,14 @@ async def test_outlet_set_state(hass, hk_driver, events): call_turn_on = async_mock_service(hass, "switch", "turn_on") call_turn_off = async_mock_service(hass, "switch", "turn_off") - await hass.async_add_executor_job(acc.char_on.client_update_value, True) + acc.char_on.client_update_value(True) await hass.async_block_till_done() assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 1 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_on.client_update_value, False) + acc.char_on.client_update_value(False) await hass.async_block_till_done() assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id @@ -123,14 +123,14 @@ async def test_switch_set_state(hass, hk_driver, entity_id, attrs, events): call_turn_on = async_mock_service(hass, domain, "turn_on") call_turn_off = async_mock_service(hass, domain, "turn_off") - await hass.async_add_executor_job(acc.char_on.client_update_value, True) + acc.char_on.client_update_value(True) await hass.async_block_till_done() assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 1 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_on.client_update_value, False) + acc.char_on.client_update_value(False) await hass.async_block_till_done() assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id @@ -188,7 +188,7 @@ async def test_valve_set_state(hass, hk_driver, events): call_turn_on = async_mock_service(hass, "switch", "turn_on") call_turn_off = async_mock_service(hass, "switch", "turn_off") - await hass.async_add_executor_job(acc.char_active.client_update_value, 1) + acc.char_active.client_update_value(1) await hass.async_block_till_done() assert acc.char_in_use.value == 1 assert call_turn_on @@ -196,7 +196,7 @@ async def test_valve_set_state(hass, hk_driver, events): assert len(events) == 1 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_active.client_update_value, 0) + acc.char_active.client_update_value(0) await hass.async_block_till_done() assert acc.char_in_use.value == 0 assert call_turn_off @@ -246,7 +246,7 @@ async def test_vacuum_set_state_with_returnhome_and_start_support( hass, VACUUM_DOMAIN, SERVICE_RETURN_TO_BASE ) - await hass.async_add_executor_job(acc.char_on.client_update_value, 1) + acc.char_on.client_update_value(1) await hass.async_block_till_done() assert acc.char_on.value == 1 assert call_start @@ -254,7 +254,7 @@ async def test_vacuum_set_state_with_returnhome_and_start_support( assert len(events) == 1 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_on.client_update_value, 0) + acc.char_on.client_update_value(0) await hass.async_block_till_done() assert acc.char_on.value == 0 assert call_return_to_base @@ -292,7 +292,7 @@ async def test_vacuum_set_state_without_returnhome_and_start_support( call_turn_on = async_mock_service(hass, VACUUM_DOMAIN, SERVICE_TURN_ON) call_turn_off = async_mock_service(hass, VACUUM_DOMAIN, SERVICE_TURN_OFF) - await hass.async_add_executor_job(acc.char_on.client_update_value, 1) + acc.char_on.client_update_value(1) await hass.async_block_till_done() assert acc.char_on.value == 1 assert call_turn_on @@ -300,7 +300,7 @@ async def test_vacuum_set_state_without_returnhome_and_start_support( assert len(events) == 1 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_on.client_update_value, 0) + acc.char_on.client_update_value(0) await hass.async_block_till_done() assert acc.char_on.value == 0 assert call_turn_off @@ -326,7 +326,7 @@ async def test_reset_switch(hass, hk_driver, events): call_turn_on = async_mock_service(hass, domain, "turn_on") call_turn_off = async_mock_service(hass, domain, "turn_off") - await hass.async_add_executor_job(acc.char_on.client_update_value, True) + acc.char_on.client_update_value(True) await hass.async_block_till_done() assert acc.char_on.value is True assert call_turn_on @@ -347,7 +347,7 @@ async def test_reset_switch(hass, hk_driver, events): assert len(events) == 1 assert not call_turn_off - await hass.async_add_executor_job(acc.char_on.client_update_value, False) + acc.char_on.client_update_value(False) await hass.async_block_till_done() assert acc.char_on.value is False assert len(events) == 1 @@ -370,7 +370,7 @@ async def test_script_switch(hass, hk_driver, events): call_turn_on = async_mock_service(hass, domain, "test") call_turn_off = async_mock_service(hass, domain, "turn_off") - await hass.async_add_executor_job(acc.char_on.client_update_value, True) + acc.char_on.client_update_value(True) await hass.async_block_till_done() assert acc.char_on.value is True assert call_turn_on @@ -391,7 +391,7 @@ async def test_script_switch(hass, hk_driver, events): assert len(events) == 1 assert not call_turn_off - await hass.async_add_executor_job(acc.char_on.client_update_value, False) + acc.char_on.client_update_value(False) await hass.async_block_till_done() assert acc.char_on.value is False assert len(events) == 1 @@ -449,3 +449,46 @@ async def test_input_select_switch(hass, hk_driver, events, domain): assert acc.select_chars["option1"].value is False assert acc.select_chars["option2"].value is False assert acc.select_chars["option3"].value is False + + +async def test_button_switch(hass, hk_driver, events): + """Test switch accessory from a button entity.""" + domain = "button" + entity_id = "button.test" + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Switch(hass, hk_driver, "Switch", entity_id, 2, None) + await acc.run() + await hass.async_block_till_done() + + assert acc.activate_only is True + assert acc.char_on.value is False + + call_press = async_mock_service(hass, domain, "press") + + acc.char_on.client_update_value(True) + await hass.async_block_till_done() + assert acc.char_on.value is True + assert len(call_press) == 1 + assert call_press[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None + + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert acc.char_on.value is True + + future = dt_util.utcnow() + timedelta(seconds=10) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert acc.char_on.value is False + + assert len(events) == 1 + assert len(call_press) == 1 + + acc.char_on.client_update_value(False) + await hass.async_block_till_done() + assert acc.char_on.value is False + assert len(events) == 1 diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index ef517f4ab96..20ed225552c 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -1018,7 +1018,7 @@ async def test_thermostat_restore(hass, hk_driver, events): ATTR_HVAC_MODES: [HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF], }, supported_features=0, - device_class="mock-device-class", + original_device_class="mock-device-class", ) hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) @@ -1062,16 +1062,16 @@ async def test_thermostat_hvac_modes(hass, hk_driver): assert acc.char_target_heat_cool.value == 0 with pytest.raises(ValueError): - await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 3) + acc.char_target_heat_cool.set_value(3) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 0 - await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 1) + acc.char_target_heat_cool.set_value(1) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 1 with pytest.raises(ValueError): - await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 2) + acc.char_target_heat_cool.set_value(2) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 1 @@ -1104,16 +1104,16 @@ async def test_thermostat_hvac_modes_with_auto_heat_cool(hass, hk_driver): assert hap["valid-values"] == [0, 1, 3] assert acc.char_target_heat_cool.value == 0 - await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 3) + acc.char_target_heat_cool.set_value(3) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 3 - await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 1) + acc.char_target_heat_cool.set_value(1) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 1 with pytest.raises(ValueError): - await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 2) + acc.char_target_heat_cool.set_value(2) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 1 @@ -1160,16 +1160,16 @@ async def test_thermostat_hvac_modes_with_auto_no_heat_cool(hass, hk_driver): assert hap["valid-values"] == [0, 1, 3] assert acc.char_target_heat_cool.value == 1 - await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 3) + acc.char_target_heat_cool.set_value(3) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 3 - await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 1) + acc.char_target_heat_cool.set_value(1) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 1 with pytest.raises(ValueError): - await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 2) + acc.char_target_heat_cool.set_value(2) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 1 @@ -1214,17 +1214,17 @@ async def test_thermostat_hvac_modes_with_auto_only(hass, hk_driver): assert hap["valid-values"] == [0, 3] assert acc.char_target_heat_cool.value == 3 - await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 3) + acc.char_target_heat_cool.set_value(3) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 3 with pytest.raises(ValueError): - await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 1) + acc.char_target_heat_cool.set_value(1) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 3 with pytest.raises(ValueError): - await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 2) + acc.char_target_heat_cool.set_value(2) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 3 @@ -1268,23 +1268,17 @@ async def test_thermostat_hvac_modes_with_heat_only(hass, hk_driver): assert hap["valid-values"] == [HC_HEAT_COOL_OFF, HC_HEAT_COOL_HEAT] assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT - await hass.async_add_executor_job( - acc.char_target_heat_cool.set_value, HC_HEAT_COOL_HEAT - ) + acc.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT with pytest.raises(ValueError): - await hass.async_add_executor_job( - acc.char_target_heat_cool.set_value, HC_HEAT_COOL_COOL - ) + acc.char_target_heat_cool.set_value(HC_HEAT_COOL_COOL) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT with pytest.raises(ValueError): - await hass.async_add_executor_job( - acc.char_target_heat_cool.set_value, HC_HEAT_COOL_AUTO - ) + acc.char_target_heat_cool.set_value(HC_HEAT_COOL_AUTO) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT @@ -1328,23 +1322,17 @@ async def test_thermostat_hvac_modes_with_cool_only(hass, hk_driver): assert hap["valid-values"] == [HC_HEAT_COOL_OFF, HC_HEAT_COOL_COOL] assert acc.char_target_heat_cool.value == HC_HEAT_COOL_COOL - await hass.async_add_executor_job( - acc.char_target_heat_cool.set_value, HC_HEAT_COOL_COOL - ) + acc.char_target_heat_cool.set_value(HC_HEAT_COOL_COOL) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == HC_HEAT_COOL_COOL with pytest.raises(ValueError): - await hass.async_add_executor_job( - acc.char_target_heat_cool.set_value, HC_HEAT_COOL_AUTO - ) + acc.char_target_heat_cool.set_value(HC_HEAT_COOL_AUTO) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == HC_HEAT_COOL_COOL with pytest.raises(ValueError): - await hass.async_add_executor_job( - acc.char_target_heat_cool.set_value, HC_HEAT_COOL_HEAT - ) + acc.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == HC_HEAT_COOL_COOL @@ -1396,22 +1384,16 @@ async def test_thermostat_hvac_modes_with_heat_cool_only(hass, hk_driver): ] assert acc.char_target_heat_cool.value == HC_HEAT_COOL_COOL - await hass.async_add_executor_job( - acc.char_target_heat_cool.set_value, HC_HEAT_COOL_COOL - ) + acc.char_target_heat_cool.set_value(HC_HEAT_COOL_COOL) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == HC_HEAT_COOL_COOL with pytest.raises(ValueError): - await hass.async_add_executor_job( - acc.char_target_heat_cool.set_value, HC_HEAT_COOL_AUTO - ) + acc.char_target_heat_cool.set_value(HC_HEAT_COOL_AUTO) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == HC_HEAT_COOL_COOL - await hass.async_add_executor_job( - acc.char_target_heat_cool.set_value, HC_HEAT_COOL_HEAT - ) + acc.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT char_target_temp_iid = acc.char_target_temp.to_HAP()[HAP_REPR_IID] @@ -1481,21 +1463,21 @@ async def test_thermostat_hvac_modes_without_off(hass, hk_driver): assert hap["valid-values"] == [1, 3] assert acc.char_target_heat_cool.value == 3 - await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 3) + acc.char_target_heat_cool.set_value(3) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 3 - await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 1) + acc.char_target_heat_cool.set_value(1) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 1 with pytest.raises(ValueError): - await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 2) + acc.char_target_heat_cool.set_value(2) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 1 with pytest.raises(ValueError): - await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 0) + acc.char_target_heat_cool.set_value(0) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 1 @@ -1737,7 +1719,7 @@ async def test_water_heater(hass, hk_driver, events): hass, DOMAIN_WATER_HEATER, "set_temperature" ) - await hass.async_add_executor_job(acc.char_target_temp.client_update_value, 52.0) + acc.char_target_temp.client_update_value(52.0) await hass.async_block_till_done() assert call_set_temperature assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id @@ -1746,12 +1728,12 @@ async def test_water_heater(hass, hk_driver, events): assert len(events) == 1 assert events[-1].data[ATTR_VALUE] == f"52.0{TEMP_CELSIUS}" - await hass.async_add_executor_job(acc.char_target_heat_cool.client_update_value, 1) + acc.char_target_heat_cool.client_update_value(1) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 1 with pytest.raises(ValueError): - await hass.async_add_executor_job(acc.char_target_heat_cool.set_value, 3) + acc.char_target_heat_cool.set_value(3) await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 1 @@ -1778,7 +1760,7 @@ async def test_water_heater_fahrenheit(hass, hk_driver, events): hass, DOMAIN_WATER_HEATER, "set_temperature" ) - await hass.async_add_executor_job(acc.char_target_temp.client_update_value, 60) + acc.char_target_temp.client_update_value(60) await hass.async_block_till_done() assert call_set_temperature assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id @@ -1826,7 +1808,7 @@ async def test_water_heater_restore(hass, hk_driver, events): suggested_object_id="all_info_set", capabilities={ATTR_MIN_TEMP: 60, ATTR_MAX_TEMP: 70}, supported_features=0, - device_class="mock-device-class", + original_device_class="mock-device-class", ) hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 0bb3d2053d4..31efcc0b948 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -25,21 +25,21 @@ from homeassistant.components.homekit.const import ( ) from homeassistant.components.homekit.util import ( accessory_friendly_name, + async_dismiss_setup_message, async_find_next_available_port, async_port_is_available, + async_show_setup_message, cleanup_name_for_homekit, convert_to_float, density_to_air_quality, - dismiss_setup_message, format_sw_version, - show_setup_message, state_needs_accessory_mode, temperature_to_homekit, temperature_to_states, validate_entity_config as vec, validate_media_player_features, ) -from homeassistant.components.persistent_notification import create, dismiss +from homeassistant.components.persistent_notification import async_create, async_dismiss from homeassistant.const import ( ATTR_CODE, ATTR_SUPPORTED_FEATURES, @@ -231,7 +231,7 @@ def test_density_to_air_quality(): assert density_to_air_quality(300) == 5 -async def test_show_setup_msg(hass, hk_driver, mock_get_source_ip): +async def test_async_show_setup_msg(hass, hk_driver, mock_get_source_ip): """Test show setup message as persistence notification.""" pincode = b"123-45-678" @@ -239,10 +239,11 @@ async def test_show_setup_msg(hass, hk_driver, mock_get_source_ip): assert entry with patch( - "homeassistant.components.persistent_notification.create", side_effect=create + "homeassistant.components.persistent_notification.async_create", + side_effect=async_create, ) as mock_create: - await hass.async_add_executor_job( - show_setup_message, hass, entry.entry_id, "bridge_name", pincode, "X-HM://0" + async_show_setup_message( + hass, entry.entry_id, "bridge_name", pincode, "X-HM://0" ) await hass.async_block_till_done() assert hass.data[DOMAIN][entry.entry_id][HOMEKIT_PAIRING_QR_SECRET] @@ -253,12 +254,13 @@ async def test_show_setup_msg(hass, hk_driver, mock_get_source_ip): assert pincode.decode() in mock_create.mock_calls[0][1][1] -async def test_dismiss_setup_msg(hass): +async def test_async_dismiss_setup_msg(hass): """Test dismiss setup message.""" with patch( - "homeassistant.components.persistent_notification.dismiss", side_effect=dismiss + "homeassistant.components.persistent_notification.async_dismiss", + side_effect=async_dismiss, ) as mock_dismiss: - await hass.async_add_executor_job(dismiss_setup_message, hass, "entry_id") + async_dismiss_setup_message(hass, "entry_id") await hass.async_block_till_done() assert len(mock_dismiss.mock_calls) == 1 diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index c3c182c8b51..49c63a761c2 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -9,6 +9,7 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes from aiohomekit.testing import FakeController +from homeassistant.components import zeroconf from homeassistant.components.homekit_controller import config_flow from homeassistant.components.homekit_controller.const import ( CONTROLLER, @@ -118,17 +119,19 @@ async def device_config_changed(hass, accessories): accessories_obj.add_accessory(accessory) pairing.accessories = accessories_obj - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": { + discovery_info = zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="mock_hostname", + name="TestDevice", + port=8080, + properties={ "md": "TestDevice", "id": "00:00:00:00:00:00", "c#": "2", "sf": "0", }, - } + type="mock_type", + ) # Config Flow will abort and notify us if the discovery event is of # interest - in this case c# has incremented diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index dc27162bc57..174fc4f7b8d 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -30,5 +30,5 @@ def controller(hass): @pytest.fixture(autouse=True) -def homekit_mock_zeroconf(mock_zeroconf): - """Mock zeroconf in all homekit tests.""" +def hk_mock_async_zeroconf(mock_async_zeroconf): + """Auto mock zeroconf.""" diff --git a/tests/fixtures/homekit_controller/anker_eufycam.json b/tests/components/homekit_controller/fixtures/anker_eufycam.json similarity index 100% rename from tests/fixtures/homekit_controller/anker_eufycam.json rename to tests/components/homekit_controller/fixtures/anker_eufycam.json diff --git a/tests/fixtures/homekit_controller/aqara_gateway.json b/tests/components/homekit_controller/fixtures/aqara_gateway.json similarity index 100% rename from tests/fixtures/homekit_controller/aqara_gateway.json rename to tests/components/homekit_controller/fixtures/aqara_gateway.json diff --git a/tests/fixtures/homekit_controller/aqara_switch.json b/tests/components/homekit_controller/fixtures/aqara_switch.json similarity index 100% rename from tests/fixtures/homekit_controller/aqara_switch.json rename to tests/components/homekit_controller/fixtures/aqara_switch.json diff --git a/tests/fixtures/homekit_controller/arlo_baby.json b/tests/components/homekit_controller/fixtures/arlo_baby.json similarity index 100% rename from tests/fixtures/homekit_controller/arlo_baby.json rename to tests/components/homekit_controller/fixtures/arlo_baby.json diff --git a/tests/fixtures/homekit_controller/ecobee3.json b/tests/components/homekit_controller/fixtures/ecobee3.json similarity index 100% rename from tests/fixtures/homekit_controller/ecobee3.json rename to tests/components/homekit_controller/fixtures/ecobee3.json diff --git a/tests/fixtures/homekit_controller/ecobee3_no_sensors.json b/tests/components/homekit_controller/fixtures/ecobee3_no_sensors.json similarity index 100% rename from tests/fixtures/homekit_controller/ecobee3_no_sensors.json rename to tests/components/homekit_controller/fixtures/ecobee3_no_sensors.json diff --git a/tests/fixtures/homekit_controller/ecobee_occupancy.json b/tests/components/homekit_controller/fixtures/ecobee_occupancy.json similarity index 100% rename from tests/fixtures/homekit_controller/ecobee_occupancy.json rename to tests/components/homekit_controller/fixtures/ecobee_occupancy.json diff --git a/tests/fixtures/homekit_controller/eve_degree.json b/tests/components/homekit_controller/fixtures/eve_degree.json similarity index 100% rename from tests/fixtures/homekit_controller/eve_degree.json rename to tests/components/homekit_controller/fixtures/eve_degree.json diff --git a/tests/fixtures/homekit_controller/haa_fan.json b/tests/components/homekit_controller/fixtures/haa_fan.json similarity index 100% rename from tests/fixtures/homekit_controller/haa_fan.json rename to tests/components/homekit_controller/fixtures/haa_fan.json diff --git a/tests/fixtures/homekit_controller/home_assistant_bridge_fan.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_fan.json similarity index 100% rename from tests/fixtures/homekit_controller/home_assistant_bridge_fan.json rename to tests/components/homekit_controller/fixtures/home_assistant_bridge_fan.json diff --git a/tests/fixtures/homekit_controller/hue_bridge.json b/tests/components/homekit_controller/fixtures/hue_bridge.json similarity index 100% rename from tests/fixtures/homekit_controller/hue_bridge.json rename to tests/components/homekit_controller/fixtures/hue_bridge.json diff --git a/tests/fixtures/homekit_controller/koogeek_ls1.json b/tests/components/homekit_controller/fixtures/koogeek_ls1.json similarity index 100% rename from tests/fixtures/homekit_controller/koogeek_ls1.json rename to tests/components/homekit_controller/fixtures/koogeek_ls1.json diff --git a/tests/fixtures/homekit_controller/koogeek_p1eu.json b/tests/components/homekit_controller/fixtures/koogeek_p1eu.json similarity index 100% rename from tests/fixtures/homekit_controller/koogeek_p1eu.json rename to tests/components/homekit_controller/fixtures/koogeek_p1eu.json diff --git a/tests/fixtures/homekit_controller/koogeek_sw2.json b/tests/components/homekit_controller/fixtures/koogeek_sw2.json similarity index 100% rename from tests/fixtures/homekit_controller/koogeek_sw2.json rename to tests/components/homekit_controller/fixtures/koogeek_sw2.json diff --git a/tests/fixtures/homekit_controller/lennox_e30.json b/tests/components/homekit_controller/fixtures/lennox_e30.json similarity index 100% rename from tests/fixtures/homekit_controller/lennox_e30.json rename to tests/components/homekit_controller/fixtures/lennox_e30.json diff --git a/tests/fixtures/homekit_controller/lg_tv.json b/tests/components/homekit_controller/fixtures/lg_tv.json similarity index 100% rename from tests/fixtures/homekit_controller/lg_tv.json rename to tests/components/homekit_controller/fixtures/lg_tv.json diff --git a/tests/fixtures/homekit_controller/mysa_living.json b/tests/components/homekit_controller/fixtures/mysa_living.json similarity index 100% rename from tests/fixtures/homekit_controller/mysa_living.json rename to tests/components/homekit_controller/fixtures/mysa_living.json diff --git a/tests/fixtures/homekit_controller/netamo_doorbell.json b/tests/components/homekit_controller/fixtures/netamo_doorbell.json similarity index 100% rename from tests/fixtures/homekit_controller/netamo_doorbell.json rename to tests/components/homekit_controller/fixtures/netamo_doorbell.json diff --git a/tests/fixtures/homekit_controller/rainmachine-pro-8.json b/tests/components/homekit_controller/fixtures/rainmachine-pro-8.json similarity index 100% rename from tests/fixtures/homekit_controller/rainmachine-pro-8.json rename to tests/components/homekit_controller/fixtures/rainmachine-pro-8.json diff --git a/tests/fixtures/homekit_controller/ryse_smart_bridge.json b/tests/components/homekit_controller/fixtures/ryse_smart_bridge.json similarity index 100% rename from tests/fixtures/homekit_controller/ryse_smart_bridge.json rename to tests/components/homekit_controller/fixtures/ryse_smart_bridge.json diff --git a/tests/fixtures/homekit_controller/ryse_smart_bridge_four_shades.json b/tests/components/homekit_controller/fixtures/ryse_smart_bridge_four_shades.json similarity index 100% rename from tests/fixtures/homekit_controller/ryse_smart_bridge_four_shades.json rename to tests/components/homekit_controller/fixtures/ryse_smart_bridge_four_shades.json diff --git a/tests/fixtures/homekit_controller/simpleconnect_fan.json b/tests/components/homekit_controller/fixtures/simpleconnect_fan.json similarity index 100% rename from tests/fixtures/homekit_controller/simpleconnect_fan.json rename to tests/components/homekit_controller/fixtures/simpleconnect_fan.json diff --git a/tests/fixtures/homekit_controller/velux_gateway.json b/tests/components/homekit_controller/fixtures/velux_gateway.json similarity index 100% rename from tests/fixtures/homekit_controller/velux_gateway.json rename to tests/components/homekit_controller/fixtures/velux_gateway.json diff --git a/tests/fixtures/homekit_controller/vocolinc_flowerbud.json b/tests/components/homekit_controller/fixtures/vocolinc_flowerbud.json similarity index 100% rename from tests/fixtures/homekit_controller/vocolinc_flowerbud.json rename to tests/components/homekit_controller/fixtures/vocolinc_flowerbud.json 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 9e04434d830..0339c61168f 100644 --- a/tests/components/homekit_controller/specific_devices/test_haa_fan.py +++ b/tests/components/homekit_controller/specific_devices/test_haa_fan.py @@ -46,3 +46,31 @@ async def test_haa_fan_setup(hass): state = await helper.poll_and_get_state() assert state.attributes["friendly_name"] == "HAA-C718B3" assert round(state.attributes["percentage_step"], 2) == 33.33 + + # Check that custom HAA Setup button is created + entry = entity_registry.async_get("button.haa_c718b3_setup") + assert entry.unique_id == "homekit-C718B3-1-aid:1-sid:1010-cid:1012" + + helper = Helper( + hass, + "button.haa_c718b3_setup", + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == "HAA-C718B3 - Setup" + + # Check that custom HAA Update button is created + entry = entity_registry.async_get("button.haa_c718b3_update") + assert entry.unique_id == "homekit-C718B3-1-aid:1-sid:1010-cid:1011" + + helper = Helper( + hass, + "button.haa_c718b3_update", + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == "HAA-C718B3 - Update" diff --git a/tests/components/homekit_controller/test_air_quality.py b/tests/components/homekit_controller/test_air_quality.py deleted file mode 100644 index 2477c6bacfd..00000000000 --- a/tests/components/homekit_controller/test_air_quality.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Basic checks for HomeKit air quality sensor.""" -from aiohomekit.model.characteristics import CharacteristicsTypes -from aiohomekit.model.services import ServicesTypes - -from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER -from homeassistant.helpers import entity_registry as er - -from tests.components.homekit_controller.common import setup_test_component - - -def create_air_quality_sensor_service(accessory): - """Define temperature characteristics.""" - service = accessory.add_service(ServicesTypes.AIR_QUALITY_SENSOR) - - cur_state = service.add_char(CharacteristicsTypes.AIR_QUALITY) - cur_state.value = 5 - - cur_state = service.add_char(CharacteristicsTypes.DENSITY_OZONE) - cur_state.value = 1111 - - cur_state = service.add_char(CharacteristicsTypes.DENSITY_NO2) - cur_state.value = 2222 - - cur_state = service.add_char(CharacteristicsTypes.DENSITY_SO2) - cur_state.value = 3333 - - cur_state = service.add_char(CharacteristicsTypes.DENSITY_PM25) - cur_state.value = 4444 - - cur_state = service.add_char(CharacteristicsTypes.DENSITY_PM10) - cur_state.value = 5555 - - cur_state = service.add_char(CharacteristicsTypes.DENSITY_VOC) - cur_state.value = 6666 - - -async def test_air_quality_sensor_read_state(hass, utcnow): - """Test reading the state of a HomeKit temperature sensor accessory.""" - helper = await setup_test_component(hass, create_air_quality_sensor_service) - - entity_registry = er.async_get(hass) - entity_registry.async_update_entity( - entity_id="air_quality.testdevice", disabled_by=None - ) - await hass.async_block_till_done() - - state = await helper.poll_and_get_state() - assert state.state == "4444" - - assert state.attributes["air_quality_text"] == "poor" - assert state.attributes["ozone"] == 1111 - assert state.attributes["nitrogen_dioxide"] == 2222 - assert state.attributes["sulphur_dioxide"] == 3333 - assert state.attributes["particulate_matter_2_5"] == 4444 - assert state.attributes["particulate_matter_10"] == 5555 - assert state.attributes["volatile_organic_compounds"] == 6666 - - -async def test_air_quality_sensor_read_state_even_if_air_quality_off(hass, utcnow): - """The air quality entity is disabled by default, the replacement sensors should always be available.""" - await setup_test_component(hass, create_air_quality_sensor_service) - - entity_registry = er.async_get(hass) - - sensors = [ - {"entity_id": "sensor.testdevice_air_quality"}, - { - "entity_id": "sensor.testdevice_pm10_density", - "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - }, - { - "entity_id": "sensor.testdevice_pm2_5_density", - "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - }, - { - "entity_id": "sensor.testdevice_pm10_density", - "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - }, - { - "entity_id": "sensor.testdevice_ozone_density", - "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - }, - { - "entity_id": "sensor.testdevice_sulphur_dioxide_density", - "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - }, - { - "entity_id": "sensor.testdevice_nitrogen_dioxide_density", - "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - }, - { - "entity_id": "sensor.testdevice_volatile_organic_compound_density", - "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - }, - ] - - for sensor in sensors: - entry = entity_registry.async_get(sensor["entity_id"]) - assert entry is not None - assert entry.unit_of_measurement == sensor.get("units") diff --git a/tests/components/homekit_controller/test_button.py b/tests/components/homekit_controller/test_button.py new file mode 100644 index 00000000000..020f303ffaa --- /dev/null +++ b/tests/components/homekit_controller/test_button.py @@ -0,0 +1,45 @@ +"""Basic checks for HomeKit button.""" +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes + +from tests.components.homekit_controller.common import Helper, setup_test_component + + +def create_switch_with_setup_button(accessory): + """Define setup button characteristics.""" + service = accessory.add_service(ServicesTypes.OUTLET) + + setup = service.add_char(CharacteristicsTypes.Vendor.HAA_SETUP) + + setup.value = "" + setup.format = "string" + + cur_state = service.add_char(CharacteristicsTypes.ON) + cur_state.value = True + + return service + + +async def test_press_button(hass): + """Test a switch service that has a button characteristic is correctly handled.""" + helper = await setup_test_component(hass, create_switch_with_setup_button) + + # Helper will be for the primary entity, which is the outlet. Make a helper for the button. + energy_helper = Helper( + hass, + "button.testdevice_setup", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + outlet = energy_helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + setup = outlet[CharacteristicsTypes.Vendor.HAA_SETUP] + + await hass.services.async_call( + "button", + "press", + {"entity_id": "button.testdevice_setup"}, + blocking=True, + ) + assert setup.value == "#HAA@trcmd" diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index b08659bf77b..d077bb8eb4e 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -11,6 +11,7 @@ from aiohomekit.model.services import ServicesTypes import pytest from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.components.homekit_controller import config_flow from homeassistant.components.homekit_controller.const import KNOWN_DEVICES from homeassistant.helpers import device_registry @@ -133,19 +134,20 @@ def get_flow_context(hass, result): return flow["context"] -def get_device_discovery_info(device, upper_case_props=False, missing_csharp=False): +def get_device_discovery_info( + device, upper_case_props=False, missing_csharp=False +) -> zeroconf.ZeroconfServiceInfo: """Turn a aiohomekit format zeroconf entry into a homeassistant one.""" record = device.info - result = { - "host": record["address"], - "port": record["port"], - "hostname": record["name"], - "type": "_hap._tcp.local.", - "name": record["name"], - "properties": { + result = zeroconf.ZeroconfServiceInfo( + host=record["address"], + hostname=record["name"], + name=record["name"], + port=record["port"], + properties={ "md": record["md"], "pv": record["pv"], - "id": device.device_id, + zeroconf.ATTR_PROPERTIES_ID: device.device_id, "c#": record["c#"], "s#": record["s#"], "ff": record["ff"], @@ -153,14 +155,15 @@ def get_device_discovery_info(device, upper_case_props=False, missing_csharp=Fal "sf": 0x01, # record["sf"], "sh": "", }, - } + type="_hap._tcp.local.", + ) if missing_csharp: - del result["properties"]["c#"] + del result.properties["c#"] if upper_case_props: - result["properties"] = { - key.upper(): val for (key, val) in result["properties"].items() + result.properties = { + key.upper(): val for (key, val) in result.properties.items() } return result @@ -252,7 +255,7 @@ async def test_pair_already_paired_1(hass, controller): discovery_info = get_device_discovery_info(device) # Flag device as already paired - discovery_info["properties"]["sf"] = 0x0 + discovery_info.properties["sf"] = 0x0 # Device is discovered result = await hass.config_entries.flow.async_init( @@ -270,7 +273,7 @@ async def test_id_missing(hass, controller): discovery_info = get_device_discovery_info(device) # Remove id from device - del discovery_info["properties"]["id"] + del discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] # Device is discovered result = await hass.config_entries.flow.async_init( @@ -286,8 +289,8 @@ async def test_discovery_ignored_model(hass, controller): """Already paired.""" device = setup_mock_accessory(controller) discovery_info = get_device_discovery_info(device) - discovery_info["properties"]["id"] = "AA:BB:CC:DD:EE:FF" - discovery_info["properties"]["md"] = "HHKBridge1,1" + discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties["md"] = "HHKBridge1,1" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -314,7 +317,7 @@ async def test_discovery_ignored_hk_bridge(hass, controller): connections={(device_registry.CONNECTION_NETWORK_MAC, formatted_mac)}, ) - discovery_info["properties"]["id"] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -341,7 +344,7 @@ async def test_discovery_does_not_ignore_non_homekit(hass, controller): connections={(device_registry.CONNECTION_NETWORK_MAC, formatted_mac)}, ) - discovery_info["properties"]["id"] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -373,7 +376,7 @@ async def test_discovery_broken_pairing_flag(hass, controller): discovery_info = get_device_discovery_info(device) # Make sure that we are pairable - assert discovery_info["properties"]["sf"] != 0x0 + assert discovery_info.properties["sf"] != 0x0 # Device is discovered result = await hass.config_entries.flow.async_init( @@ -445,7 +448,7 @@ async def test_discovery_already_configured(hass, controller): discovery_info = get_device_discovery_info(device) # Set device as already paired - discovery_info["properties"]["sf"] = 0x00 + discovery_info.properties["sf"] = 0x00 # Device is discovered result = await hass.config_entries.flow.async_init( @@ -455,8 +458,8 @@ async def test_discovery_already_configured(hass, controller): ) assert result["type"] == "abort" assert result["reason"] == "already_configured" - assert entry.data["AccessoryIP"] == discovery_info["host"] - assert entry.data["AccessoryPort"] == discovery_info["port"] + assert entry.data["AccessoryIP"] == discovery_info.host + assert entry.data["AccessoryPort"] == discovery_info.port async def test_discovery_already_configured_update_csharp(hass, controller): @@ -481,9 +484,9 @@ async def test_discovery_already_configured_update_csharp(hass, controller): discovery_info = get_device_discovery_info(device) # Set device as already paired - discovery_info["properties"]["sf"] = 0x00 - discovery_info["properties"]["c#"] = 99999 - discovery_info["properties"]["id"] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties["sf"] = 0x00 + discovery_info.properties["c#"] = 99999 + discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -495,8 +498,8 @@ async def test_discovery_already_configured_update_csharp(hass, controller): assert result["reason"] == "already_configured" await hass.async_block_till_done() - assert entry.data["AccessoryIP"] == discovery_info["host"] - assert entry.data["AccessoryPort"] == discovery_info["port"] + assert entry.data["AccessoryIP"] == discovery_info.host + assert entry.data["AccessoryPort"] == discovery_info.port assert connection_mock.async_refresh_entity_map.await_count == 1 diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 8e2703cd51b..1f1a3d32d2c 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -2,14 +2,18 @@ from datetime import timedelta from http import HTTPStatus from ipaddress import ip_network -from unittest.mock import patch +from unittest.mock import Mock, patch from aiohttp import BasicAuth, web from aiohttp.web_exceptions import HTTPUnauthorized import pytest from homeassistant.auth.providers import trusted_networks -from homeassistant.components.http.auth import async_sign_path, setup_auth +from homeassistant.components.http.auth import ( + async_sign_path, + async_user_not_allowed_do_auth, + setup_auth, +) from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components.http.forwarded import async_setup_forwarded from homeassistant.setup import async_setup_component @@ -26,7 +30,8 @@ TRUSTED_NETWORKS = [ ip_network("FD01:DB8::1"), ] TRUSTED_ADDRESSES = ["100.64.0.1", "192.0.2.100", "FD01:DB8::1", "2001:DB8:ABCD::1"] -UNTRUSTED_ADDRESSES = ["198.51.100.1", "2001:DB8:FA1::1", "127.0.0.1", "::1"] +EXTERNAL_ADDRESSES = ["198.51.100.1", "2001:DB8:FA1::1"] +UNTRUSTED_ADDRESSES = [*EXTERNAL_ADDRESSES, "127.0.0.1", "::1"] async def mock_handler(request): @@ -270,3 +275,68 @@ async def test_auth_access_signed_path(hass, app, aiohttp_client, hass_access_to await hass.auth.async_remove_refresh_token(refresh_token) req = await client.get(signed_path) assert req.status == HTTPStatus.UNAUTHORIZED + + +async def test_local_only_user_rejected(hass, app, aiohttp_client, hass_access_token): + """Test access with access token in header.""" + token = hass_access_token + setup_auth(hass, app) + set_mock_ip = mock_real_ip(app) + client = await aiohttp_client(app) + refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + + req = await client.get("/", headers={"Authorization": f"Bearer {token}"}) + assert req.status == HTTPStatus.OK + assert await req.json() == {"user_id": refresh_token.user.id} + + refresh_token.user.local_only = True + + for remote_addr in EXTERNAL_ADDRESSES: + set_mock_ip(remote_addr) + req = await client.get("/", headers={"Authorization": f"Bearer {token}"}) + assert req.status == HTTPStatus.UNAUTHORIZED + + +async def test_async_user_not_allowed_do_auth(hass, app): + """Test for not allowing auth.""" + user = await hass.auth.async_create_user("Hello") + user.is_active = False + + # User not active + assert async_user_not_allowed_do_auth(hass, user) == "User is not active" + + user.is_active = True + user.local_only = True + + # No current request + assert ( + async_user_not_allowed_do_auth(hass, user) + == "No request available to validate local access" + ) + + trusted_request = Mock(remote="192.168.1.123") + untrusted_request = Mock(remote=UNTRUSTED_ADDRESSES[0]) + + # Is Remote IP and local only (cloud not loaded) + assert async_user_not_allowed_do_auth(hass, user, trusted_request) is None + assert ( + async_user_not_allowed_do_auth(hass, user, untrusted_request) + == "User cannot authenticate remotely" + ) + + # Mimic cloud loaded and validate local IP again + hass.config.components.add("cloud") + assert async_user_not_allowed_do_auth(hass, user, trusted_request) is None + assert ( + async_user_not_allowed_do_auth(hass, user, untrusted_request) + == "User cannot authenticate remotely" + ) + + # Is Cloud request and local only, even a local IP will fail + with patch( + "hass_nabucasa.remote.is_cloud_request", Mock(get=Mock(return_value=True)) + ): + assert ( + async_user_not_allowed_do_auth(hass, user, trusted_request) + == "User is local only" + ) diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index 141627c7763..599df194195 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -52,8 +52,8 @@ async def mock_handler(request): def client(loop, aiohttp_client): """Fixture to set up a web.Application.""" app = web.Application() - app.router.add_get("/", mock_handler) setup_cors(app, [TRUSTED_ORIGIN]) + app["allow_configured_cors"](app.router.add_get("/", mock_handler)) return loop.run_until_complete(aiohttp_client(app)) diff --git a/tests/components/http/test_data_validator.py b/tests/components/http/test_data_validator.py index 4ff6d3e8c2a..28c43230c43 100644 --- a/tests/components/http/test_data_validator.py +++ b/tests/components/http/test_data_validator.py @@ -13,6 +13,7 @@ async def get_client(aiohttp_client, validator): """Generate a client that hits a view decorated with validator.""" app = web.Application() app["hass"] = Mock(is_stopping=False) + app["allow_configured_cors"] = lambda _: None class TestView(HomeAssistantView): url = "/" diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index 4eb9557c6a7..52b46c57b98 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -177,19 +177,22 @@ async def test_ssdp(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context=context, - data={ - ssdp.ATTR_SSDP_LOCATION: "http://192.168.100.1:60957/rootDesc.xml", - ssdp.ATTR_SSDP_ST: "upnp:rootdevice", - 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", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="upnp:rootdevice", + 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", + }, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py index 648337d7539..9e9ed9af31b 100644 --- a/tests/components/hue/conftest.py +++ b/tests/components/hue/conftest.py @@ -1,56 +1,68 @@ """Test helpers for Hue.""" +import asyncio from collections import deque +import json import logging from unittest.mock import AsyncMock, Mock, patch -from aiohue.groups import Groups -from aiohue.lights import Lights -from aiohue.scenes import Scenes -from aiohue.sensors import Sensors +import aiohue.v1 as aiohue_v1 +import aiohue.v2 as aiohue_v2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.models.clip import parse_clip_resource import pytest from homeassistant.components import hue -from homeassistant.components.hue import sensor_base as hue_sensor_base +from homeassistant.components.hue.v1 import sensor_base as hue_sensor_base +from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry -from tests.components.light.conftest import mock_light_profiles # noqa: F401 +from tests.common import ( + MockConfigEntry, + async_mock_service, + load_fixture, + mock_device_registry, +) @pytest.fixture(autouse=True) def no_request_delay(): """Make the request refresh delay 0 for instant tests.""" - with patch("homeassistant.components.hue.light.REQUEST_REFRESH_DELAY", 0): + with patch("homeassistant.components.hue.const.REQUEST_REFRESH_DELAY", 0): yield -def create_mock_bridge(hass): - """Create a mock Hue bridge.""" +def create_mock_bridge(hass, api_version=1): + """Create a mocked HueBridge instance.""" bridge = Mock( hass=hass, - available=True, authorized=True, - allow_unreachable=False, - allow_groups=False, - api=create_mock_api(hass), config_entry=None, reset_jobs=[], + api_version=api_version, spec=hue.HueBridge, ) - bridge.sensor_manager = hue_sensor_base.SensorManager(bridge) - bridge.mock_requests = bridge.api.mock_requests - bridge.mock_light_responses = bridge.api.mock_light_responses - bridge.mock_group_responses = bridge.api.mock_group_responses - bridge.mock_sensor_responses = bridge.api.mock_sensor_responses - async def async_setup(): + bridge.logger = logging.getLogger(__name__) + + if bridge.api_version == 2: + bridge.api = create_mock_api_v2(hass) + bridge.mock_requests = bridge.api.mock_requests + else: + bridge.api = create_mock_api_v1(hass) + bridge.sensor_manager = hue_sensor_base.SensorManager(bridge) + bridge.mock_requests = bridge.api.mock_requests + bridge.mock_light_responses = bridge.api.mock_light_responses + bridge.mock_group_responses = bridge.api.mock_group_responses + bridge.mock_sensor_responses = bridge.api.mock_sensor_responses + + async def async_initialize_bridge(): if bridge.config_entry: hass.data.setdefault(hue.DOMAIN, {})[bridge.config_entry.entry_id] = bridge return True - bridge.async_setup = async_setup + bridge.async_initialize_bridge = async_initialize_bridge - async def async_request_call(task): - await task() + async def async_request_call(task, *args, allowed_errors=None, **kwargs): + await task(*args, **kwargs) bridge.async_request_call = async_request_call @@ -65,14 +77,21 @@ def create_mock_bridge(hass): @pytest.fixture -def mock_api(hass): - """Mock the Hue api.""" - return create_mock_api(hass) +def mock_api_v1(hass): + """Mock the Hue V1 api.""" + return create_mock_api_v1(hass) -def create_mock_api(hass): - """Create a mock API.""" - api = Mock(initialize=AsyncMock()) +@pytest.fixture +def mock_api_v2(hass): + """Mock the Hue V2 api.""" + return create_mock_api_v2(hass) + + +def create_mock_api_v1(hass): + """Create a mock V1 API.""" + api = Mock(spec=aiohue_v1.HueBridgeV1) + api.initialize = AsyncMock() api.mock_requests = [] api.mock_light_responses = deque() api.mock_group_responses = deque() @@ -97,43 +116,181 @@ def create_mock_api(hass): logger = logging.getLogger(__name__) api.config = Mock( - bridgeid="ff:ff:ff:ff:ff:ff", - mac="aa:bb:cc:dd:ee:ff", - modelid="BSB002", + bridge_id="ff:ff:ff:ff:ff:ff", + mac_address="aa:bb:cc:dd:ee:ff", + model_id="BSB002", apiversion="9.9.9", - swversion="1935144040", + software_version="1935144040", ) api.config.name = "Home" - api.lights = Lights(logger, {}, [], mock_request) - api.groups = Groups(logger, {}, [], mock_request) - api.sensors = Sensors(logger, {}, [], mock_request) - api.scenes = Scenes(logger, {}, [], mock_request) + api.lights = aiohue_v1.Lights(logger, {}, mock_request) + api.groups = aiohue_v1.Groups(logger, {}, mock_request) + api.sensors = aiohue_v1.Sensors(logger, {}, mock_request) + api.scenes = aiohue_v1.Scenes(logger, {}, mock_request) + return api + + +@pytest.fixture(scope="session") +def v2_resources_test_data(): + """Load V2 resources mock data.""" + return json.loads(load_fixture("hue/v2_resources.json")) + + +def create_mock_api_v2(hass): + """Create a mock V2 API.""" + api = Mock(spec=aiohue_v2.HueBridgeV2) + api.initialize = AsyncMock() + api.config = Mock( + bridge_id="aabbccddeeffggh", + mac_address="00:17:88:01:aa:bb:fd:c7", + model_id="BSB002", + api_version="9.9.9", + software_version="1935144040", + bridge_device=Mock( + id="4a507550-8742-4087-8bf5-c2334f29891c", + product_data=Mock(manufacturer_name="Mock"), + ), + spec=aiohue_v2.ConfigController, + ) + api.config.name = "Home" + api.mock_requests = [] + + api.logger = logging.getLogger(__name__) + api.events = aiohue_v2.EventStream(api) + api.devices = aiohue_v2.DevicesController(api) + api.lights = aiohue_v2.LightsController(api) + api.sensors = aiohue_v2.SensorsController(api) + api.groups = aiohue_v2.GroupsController(api) + api.scenes = aiohue_v2.ScenesController(api) + + async def mock_request(method, path, **kwargs): + kwargs["method"] = method + kwargs["path"] = path + api.mock_requests.append(kwargs) + return kwargs.get("json") + + api.request = mock_request + + async def load_test_data(data): + """Load test data into controllers.""" + api.config = aiohue_v2.ConfigController(api) + + await asyncio.gather( + api.config.initialize(data), + api.devices.initialize(data), + api.lights.initialize(data), + api.scenes.initialize(data), + api.sensors.initialize(data), + api.groups.initialize(data), + ) + + def emit_event(event_type, data): + """Emit an event from a (hue resource) dict.""" + api.events.emit(EventType(event_type), parse_clip_resource(data)) + + api.load_test_data = load_test_data + api.emit_event = emit_event + # mock context manager too + api.__aenter__ = AsyncMock(return_value=api) + api.__aexit__ = AsyncMock() return api @pytest.fixture -def mock_bridge(hass): - """Mock a Hue bridge.""" - return create_mock_bridge(hass) +def mock_bridge_v1(hass): + """Mock a Hue bridge with V1 api.""" + return create_mock_bridge(hass, api_version=1) -async def setup_bridge_for_sensors(hass, mock_bridge, hostname=None): - """Load the Hue platform with the provided bridge for sensor-related platforms.""" +@pytest.fixture +def mock_bridge_v2(hass): + """Mock a Hue bridge with V2 api.""" + return create_mock_bridge(hass, api_version=2) + + +@pytest.fixture +def mock_config_entry_v1(hass): + """Mock a config entry for a Hue V1 bridge.""" + return create_config_entry(api_version=1) + + +@pytest.fixture +def mock_config_entry_v2(hass): + """Mock a config entry.""" + return create_config_entry(api_version=2) + + +def create_config_entry(api_version=1, host="mock-host"): + """Mock a config entry for a Hue bridge.""" + return MockConfigEntry( + domain=hue.DOMAIN, + title=f"Mock bridge {api_version}", + data={"host": host, "api_version": api_version, "api_key": ""}, + ) + + +async def setup_component(hass): + """Mock setup Hue component.""" + with patch.object(hue, "async_setup_entry", return_value=True): + assert ( + await async_setup_component( + hass, + hue.DOMAIN, + {}, + ) + is True + ) + + +async def setup_bridge(hass, mock_bridge, config_entry): + """Load the Hue integration with the provided bridge.""" + mock_bridge.config_entry = config_entry + with patch.object( + hue.migration, "is_v2_bridge", return_value=mock_bridge.api_version == 2 + ): + config_entry.add_to_hass(hass) + with patch("homeassistant.components.hue.HueBridge", return_value=mock_bridge): + await hass.config_entries.async_setup(config_entry.entry_id) + + +async def setup_platform( + hass, + mock_bridge, + platforms, + hostname=None, +): + """Load the Hue integration with the provided bridge for given platform(s).""" + if not isinstance(platforms, (list, tuple)): + platforms = [platforms] if hostname is None: hostname = "mock-host" hass.config.components.add(hue.DOMAIN) - config_entry = MockConfigEntry( - domain=hue.DOMAIN, - title="Mock Title", - data={"host": hostname}, + config_entry = create_config_entry( + api_version=mock_bridge.api_version, host=hostname ) mock_bridge.config_entry = config_entry hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge} - await hass.config_entries.async_forward_entry_setup(config_entry, "binary_sensor") - await hass.config_entries.async_forward_entry_setup(config_entry, "sensor") + # simulate a full setup by manually adding the bridge config entry - config_entry.add_to_hass(hass) + await setup_bridge(hass, mock_bridge, config_entry) + assert await async_setup_component(hass, hue.DOMAIN, {}) is True + await hass.async_block_till_done() + + for platform in platforms: + await hass.config_entries.async_forward_entry_setup(config_entry, platform) # and make sure it completes before going further await hass.async_block_till_done() + + +@pytest.fixture(name="device_reg") +def get_device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture(name="calls") +def track_calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") diff --git a/tests/components/hue/const.py b/tests/components/hue/const.py new file mode 100644 index 00000000000..03b2f1947cf --- /dev/null +++ b/tests/components/hue/const.py @@ -0,0 +1,97 @@ +"""Constants for Hue tests.""" + + +FAKE_DEVICE = { + "id": "fake_device_id_1", + "id_v1": "/lights/1", + "metadata": {"archetype": "unknown_archetype", "name": "Hue mocked device"}, + "product_data": { + "certified": True, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "abcdefg", + "product_archetype": "unknown_archetype", + "product_name": "Hue Mocked on/off light with a sensor", + "software_version": "1.88.1", + }, + "services": [ + {"rid": "fake_light_id_1", "rtype": "light"}, + {"rid": "fake_zigbee_connectivity_id_1", "rtype": "zigbee_connectivity"}, + {"rid": "fake_temperature_sensor_id_1", "rtype": "temperature"}, + {"rid": "fake_motion_sensor_id_1", "rtype": "motion"}, + ], + "type": "device", +} + +FAKE_LIGHT = { + "alert": {"action_values": ["breathe"]}, + "dynamics": { + "speed": 0.0, + "speed_valid": False, + "status": "none", + "status_values": ["none"], + }, + "id": "fake_light_id_1", + "id_v1": "/lights/1", + "metadata": {"archetype": "unknown", "name": "Hue fake light"}, + "mode": "normal", + "on": {"on": False}, + "owner": {"rid": "fake_device_id_1", "rtype": "device"}, + "type": "light", +} + +FAKE_ZIGBEE_CONNECTIVITY = { + "id": "fake_zigbee_connectivity_id_1", + "id_v1": "/lights/29", + "mac_address": "00:01:02:03:04:05:06:07", + "owner": {"rid": "fake_device_id_1", "rtype": "device"}, + "status": "connected", + "type": "zigbee_connectivity", +} + +FAKE_SENSOR = { + "enabled": True, + "id": "fake_temperature_sensor_id_1", + "id_v1": "/sensors/1", + "owner": {"rid": "fake_device_id_1", "rtype": "device"}, + "temperature": {"temperature": 18.0, "temperature_valid": True}, + "type": "temperature", +} + +FAKE_BINARY_SENSOR = { + "enabled": True, + "id": "fake_motion_sensor_id_1", + "id_v1": "/sensors/2", + "motion": {"motion": False, "motion_valid": True}, + "owner": {"rid": "fake_device_id_1", "rtype": "device"}, + "type": "motion", +} + +FAKE_SCENE = { + "actions": [ + { + "action": { + "color_temperature": {"mirek": 156}, + "dimming": {"brightness": 65.0}, + "on": {"on": True}, + }, + "target": {"rid": "3a6710fa-4474-4eba-b533-5e6e72968feb", "rtype": "light"}, + }, + { + "action": {"on": {"on": True}}, + "target": {"rid": "7697ac8a-25aa-4576-bb40-0036c0db15b9", "rtype": "light"}, + }, + ], + "group": {"rid": "6ddc9066-7e7d-4a03-a773-c73937968296", "rtype": "room"}, + "id": "fake_scene_id_1", + "id_v1": "/scenes/test", + "metadata": { + "image": { + "rid": "7fd2ccc5-5749-4142-b7a5-66405a676f03", + "rtype": "public_image", + }, + "name": "Mocked Scene", + }, + "palette": {"color": [], "color_temperature": [], "dimming": []}, + "speed": 0.5, + "type": "scene", +} diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json new file mode 100644 index 00000000000..806dcecfacf --- /dev/null +++ b/tests/components/hue/fixtures/v2_resources.json @@ -0,0 +1,2107 @@ +[ + { + "id": "9c489c26-9e34-4fcd-8324-a57e3a664cc0", + "status": "unpaired", + "status_values": ["pairing", "paired", "unpaired"], + "type": "homekit" + }, + { + "actions": [ + { + "action": { + "color": { + "xy": { + "x": 0.5058, + "y": 0.4477 + } + }, + "dimming": { + "brightness": 46.85 + }, + "on": { + "on": true + } + }, + "target": { + "rid": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "rtype": "light" + } + }, + { + "action": { + "dimming": { + "brightness": 46.85 + }, + "gradient": { + "points": [ + { + "color": { + "xy": { + "x": 0.4808, + "y": 0.4485 + } + } + }, + { + "color": { + "xy": { + "x": 0.4958, + "y": 0.443 + } + } + }, + { + "color": { + "xy": { + "x": 0.5058, + "y": 0.4477 + } + } + }, + { + "color": { + "xy": { + "x": 0.5586, + "y": 0.4081 + } + } + }, + { + "color": { + "xy": { + "x": 0.569, + "y": 0.4003 + } + } + } + ] + }, + "on": { + "on": true + } + }, + "target": { + "rid": "8015b17f-8336-415b-966a-b364bd082397", + "rtype": "light" + } + }, + { + "action": { + "color": { + "xy": { + "x": 0.5586, + "y": 0.4081 + } + }, + "dimming": { + "brightness": 46.85 + }, + "on": { + "on": true + } + }, + "target": { + "rid": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "rtype": "light" + } + } + ], + "group": { + "rid": "7cee478d-6455-483a-9e32-9f9fdcbcc4f6", + "rtype": "zone" + }, + "id": "fce5eabb-2f51-461b-b112-5362da301236", + "id_v1": "/scenes/qYDehk7EfGoRvkj", + "metadata": { + "image": { + "rid": "93984a4f-2d1b-4554-b972-b60fa8e476c5", + "rtype": "public_image" + }, + "name": "Dynamic Test Scene" + }, + "palette": { + "color": [ + { + "color": { + "xy": { + "x": 0.4808, + "y": 0.4485 + } + }, + "dimming": { + "brightness": 74.02 + } + }, + { + "color": { + "xy": { + "x": 0.5023, + "y": 0.4467 + } + }, + "dimming": { + "brightness": 100.0 + } + }, + { + "color": { + "xy": { + "x": 0.5615, + "y": 0.4059 + } + }, + "dimming": { + "brightness": 100.0 + } + } + ], + "color_temperature": [ + { + "color_temperature": { + "mirek": 451 + }, + "dimming": { + "brightness": 31.1 + } + } + ], + "dimming": [] + }, + "speed": 0.6269841194152832, + "type": "scene" + }, + { + "actions": [ + { + "action": { + "color_temperature": { + "mirek": 156 + }, + "dimming": { + "brightness": 100.0 + }, + "on": { + "on": true + } + }, + "target": { + "rid": "3a6710fa-4474-4eba-b533-5e6e72968feb", + "rtype": "light" + } + }, + { + "action": { + "on": { + "on": true + } + }, + "target": { + "rid": "7697ac8a-25aa-4576-bb40-0036c0db15b9", + "rtype": "light" + } + } + ], + "group": { + "rid": "6ddc9066-7e7d-4a03-a773-c73937968296", + "rtype": "room" + }, + "id": "cdbf3740-7977-4a11-8275-8c78636ad4bd", + "id_v1": "/scenes/LwgmWgRnaRUxg6K", + "metadata": { + "image": { + "rid": "7fd2ccc5-5749-4142-b7a5-66405a676f03", + "rtype": "public_image" + }, + "name": "Regular Test Scene" + }, + "palette": { + "color": [], + "color_temperature": [], + "dimming": [] + }, + "speed": 0.5, + "type": "scene" + }, + { + "id": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "id_v1": "/sensors/50", + "metadata": { + "archetype": "unknown_archetype", + "name": "Wall switch with 2 controls" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "RDM001", + "product_archetype": "unknown_archetype", + "product_name": "Hue wall switch module", + "software_version": "1.0.3" + }, + "services": [ + { + "rid": "c658d3d8-a013-4b81-8ac6-78b248537e70", + "rtype": "button" + }, + { + "rid": "be1eb834-bdf5-4d26-8fba-7b1feaa83a9d", + "rtype": "button" + }, + { + "rid": "c1cd98a6-6c23-43bb-b6e1-08dda9e168a4", + "rtype": "device_power" + }, + { + "rid": "af520f40-e080-43b0-9bb5-41a4d5251b2b", + "rtype": "zigbee_connectivity" + } + ], + "type": "device" + }, + { + "id": "0b216218-d811-4c95-8c55-bbcda50f9d50", + "id_v1": "/lights/29", + "metadata": { + "archetype": "floor_shade", + "name": "Hue light with color and color temperature 1" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "4080248P9", + "product_archetype": "floor_shade", + "product_name": "Hue color floor", + "software_version": "1.88.1" + }, + "services": [ + { + "rid": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "rtype": "light" + }, + { + "rid": "1987ba66-c21d-48d0-98fb-121d939a71f3", + "rtype": "zigbee_connectivity" + }, + { + "rid": "5d7b3979-b936-47ff-8458-554f8a2921db", + "rtype": "entertainment" + } + ], + "type": "device" + }, + { + "id": "60b849cc-a8b5-4034-8881-ed1cd560fd13", + "id_v1": "/lights/4", + "metadata": { + "archetype": "ceiling_round", + "name": "Hue light with color temperature only" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "LTC001", + "product_archetype": "ceiling_round", + "product_name": "Hue ambiance ceiling", + "software_version": "1.88.1" + }, + "services": [ + { + "rid": "3a6710fa-4474-4eba-b533-5e6e72968feb", + "rtype": "light" + }, + { + "rid": "bd878f44-feb7-406e-8af9-6a1796d1ddc9", + "rtype": "zigbee_connectivity" + } + ], + "type": "device" + }, + { + "id": "342daec9-391b-480b-abdd-87f1aa04ce3b", + "id_v1": "/sensors/10", + "metadata": { + "archetype": "unknown_archetype", + "name": "Hue Dimmer switch with 4 controls" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "RWL021", + "product_archetype": "unknown_archetype", + "product_name": "Hue dimmer switch", + "software_version": "1.1.28573" + }, + "services": [ + { + "rid": "f92aa267-1387-4f02-9950-210fb7ca1f5a", + "rtype": "button" + }, + { + "rid": "7f1ab9f6-cc2b-4b40-9011-65e2af153f75", + "rtype": "button" + }, + { + "rid": "b4edb2d6-55d0-47f4-bd43-7ae215ef1062", + "rtype": "button" + }, + { + "rid": "40a810bf-3d22-4c56-9334-4a59a00768ab", + "rtype": "button" + }, + { + "rid": "0bb058bc-2139-43d9-8c9b-edfb4570953b", + "rtype": "device_power" + }, + { + "rid": "db50a5d9-8cc7-486f-be06-c0b8f0d26c69", + "rtype": "zigbee_connectivity" + } + ], + "type": "device" + }, + { + "id": "b9e76da7-ac22-476a-986d-e466e62e962f", + "id_v1": "/lights/16", + "metadata": { + "archetype": "hue_lightstrip", + "name": "Hue light with color and color temperature 2" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "LST002", + "product_archetype": "hue_lightstrip", + "product_name": "Hue lightstrip plus", + "software_version": "67.88.1" + }, + "services": [ + { + "rid": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "rtype": "light" + }, + { + "rid": "717afeb6-b1ce-426e-96de-48e8fe037fb0", + "rtype": "zigbee_connectivity" + }, + { + "rid": "d88acc42-259c-43b5-bf5d-90c16cdb8f2f", + "rtype": "entertainment" + } + ], + "type": "device" + }, + { + "id": "fcdfab5d-8e04-4e9c-a999-7f92cb38c4fc", + "id_v1": "/lights/23", + "metadata": { + "archetype": "classic_bulb", + "name": "Hue on/off light" + }, + "product_data": { + "certified": false, + "manufacturer_name": "eWeLink", + "model_id": "SA-003-Zigbee", + "product_archetype": "classic_bulb", + "product_name": "On/Off light", + "software_version": "1.0.2" + }, + "services": [ + { + "rid": "7697ac8a-25aa-4576-bb40-0036c0db15b9", + "rtype": "light" + }, + { + "rid": "6b00ce2b-a8a5-4bab-bc5e-757a0b0338ff", + "rtype": "zigbee_connectivity" + } + ], + "type": "device" + }, + { + "id": "7745ebea-dd33-429c-a900-bae4e7ae1107", + "id_v1": "/sensors/5", + "metadata": { + "archetype": "unknown_archetype", + "name": "Hue Smart button 1 control" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "ROM001", + "product_archetype": "unknown_archetype", + "product_name": "Hue Smart button", + "software_version": "2.47.8" + }, + "services": [ + { + "rid": "31cffcda-efc2-401f-a152-e10db3eed232", + "rtype": "button" + }, + { + "rid": "3f219f5a-ad6c-484f-b976-769a9c267a72", + "rtype": "device_power" + }, + { + "rid": "bba44861-8222-45c9-9e6b-d7f3a6543829", + "rtype": "zigbee_connectivity" + } + ], + "type": "device" + }, + { + "id": "4a507550-8742-4087-8bf5-c2334f29891c", + "id_v1": "", + "metadata": { + "archetype": "bridge_v2", + "name": "Philips hue" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "BSB002", + "product_archetype": "bridge_v2", + "product_name": "Philips hue", + "software_version": "1.48.1948086000" + }, + "services": [ + { + "rid": "07dd5849-abcd-efgh-b9b9-eb540408ce00", + "rtype": "bridge" + }, + { + "rid": "6c898412-ed25-4402-9807-a0c326616b0f", + "rtype": "zigbee_connectivity" + }, + { + "rid": "b8ab0c30-b227-4d35-9c96-7cd16131fcc5", + "rtype": "entertainment" + } + ], + "type": "device" + }, + { + "id": "8d07d39c-3c19-47ce-ac7a-8bf3d8e849b9", + "id_v1": "/lights/11", + "metadata": { + "archetype": "hue_bloom", + "name": "Hue light with color only" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "LLC011", + "product_archetype": "hue_bloom", + "product_name": "Hue bloom", + "software_version": "67.91.1" + }, + "services": [ + { + "rid": "74a45fee-1b3d-4553-b5ab-040da8a10cfd", + "rtype": "light" + }, + { + "rid": "98baae94-76d9-4bc4-a1d1-d53f1d7b1286", + "rtype": "zigbee_connectivity" + }, + { + "rid": "8e6a4ff3-14ca-42f9-8358-9d691b9a4524", + "rtype": "entertainment" + } + ], + "type": "device" + }, + { + "id": "1c8be0d5-a68b-45c2-8f56-530d13b0c128", + "id_v1": "/lights/24", + "metadata": { + "archetype": "hue_lightstrip_tv", + "name": "Hue light with color and color temperature gradient" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "LCX003", + "product_archetype": "hue_lightstrip_tv", + "product_name": "Hue play gradient lightstrip", + "software_version": "1.86.7" + }, + "services": [ + { + "rid": "8015b17f-8336-415b-966a-b364bd082397", + "rtype": "light" + }, + { + "rid": "ff4e6545-341f-4b0d-9869-b6feb6e6fe87", + "rtype": "zigbee_connectivity" + }, + { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + ], + "type": "device" + }, + { + "id": "2330b45d-6079-4c6e-bba6-1b68afb1a0d6", + "id_v1": "/sensors/66", + "metadata": { + "archetype": "unknown_archetype", + "name": "Hue motion sensor" + }, + "product_data": { + "certified": true, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "SML001", + "product_archetype": "unknown_archetype", + "product_name": "Hue motion sensor", + "software_version": "1.1.27575" + }, + "services": [ + { + "rid": "b6896534-016d-4052-8cb4-ef04454df62c", + "rtype": "motion" + }, + { + "rid": "669f609d-4860-4f1c-bc25-7a9cec1c3b6c", + "rtype": "device_power" + }, + { + "rid": "ec9b5ad7-2471-4356-b757-d00537828963", + "rtype": "zigbee_connectivity" + }, + { + "rid": "d504e7a4-9a18-4854-90fd-c5b6ac102c40", + "rtype": "light_level" + }, + { + "rid": "66466e14-d2fa-4b96-b2a0-e10de9cd8b8b", + "rtype": "temperature" + } + ], + "type": "device" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "color": { + "gamut": { + "blue": { + "x": 0.1532, + "y": 0.0475 + }, + "green": { + "x": 0.17, + "y": 0.7 + }, + "red": { + "x": 0.6915, + "y": 0.3083 + } + }, + "gamut_type": "C", + "xy": { + "x": 0.5614, + "y": 0.4058 + } + }, + "color_temperature": { + "mirek": null, + "mirek_schema": { + "mirek_maximum": 500, + "mirek_minimum": 153 + }, + "mirek_valid": false + }, + "dimming": { + "brightness": 46.85, + "min_dim_level": 0.10000000149011612 + }, + "dynamics": { + "speed": 0.627, + "speed_valid": true, + "status": "dynamic_palette", + "status_values": ["none", "dynamic_palette"] + }, + "id": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "id_v1": "/lights/29", + "metadata": { + "archetype": "floor_shade", + "name": "Hue light with color and color temperature 1" + }, + "mode": "normal", + "on": { + "on": true + }, + "owner": { + "rid": "0b216218-d811-4c95-8c55-bbcda50f9d50", + "rtype": "device" + }, + "type": "light" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "color_temperature": { + "mirek": 369, + "mirek_schema": { + "mirek_maximum": 454, + "mirek_minimum": 153 + }, + "mirek_valid": true + }, + "dimming": { + "brightness": 59.45, + "min_dim_level": 0.10000000149011612 + }, + "dynamics": { + "speed": 0.0, + "speed_valid": false, + "status": "none", + "status_values": ["none"] + }, + "id": "3a6710fa-4474-4eba-b533-5e6e72968feb", + "id_v1": "/lights/4", + "metadata": { + "archetype": "ceiling_round", + "name": "Hue light with color temperature only" + }, + "mode": "normal", + "on": { + "on": false + }, + "owner": { + "rid": "60b849cc-a8b5-4034-8881-ed1cd560fd13", + "rtype": "device" + }, + "type": "light" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "color": { + "gamut": { + "blue": { + "x": 0.1532, + "y": 0.0475 + }, + "green": { + "x": 0.17, + "y": 0.7 + }, + "red": { + "x": 0.6915, + "y": 0.3083 + } + }, + "gamut_type": "C", + "xy": { + "x": 0.5022, + "y": 0.4466 + } + }, + "color_temperature": { + "mirek": null, + "mirek_schema": { + "mirek_maximum": 500, + "mirek_minimum": 153 + }, + "mirek_valid": false + }, + "dimming": { + "brightness": 46.85, + "min_dim_level": 0.02500000037252903 + }, + "dynamics": { + "speed": 0.627, + "speed_valid": true, + "status": "dynamic_palette", + "status_values": ["none", "dynamic_palette"] + }, + "id": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "id_v1": "/lights/16", + "metadata": { + "archetype": "hue_lightstrip", + "name": "Hue light with color and color temperature 2" + }, + "mode": "normal", + "on": { + "on": true + }, + "owner": { + "rid": "b9e76da7-ac22-476a-986d-e466e62e962f", + "rtype": "device" + }, + "type": "light" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "dynamics": { + "speed": 0.0, + "speed_valid": false, + "status": "none", + "status_values": ["none"] + }, + "id": "7697ac8a-25aa-4576-bb40-0036c0db15b9", + "id_v1": "/lights/23", + "metadata": { + "archetype": "classic_bulb", + "name": "Hue on/off light" + }, + "mode": "normal", + "on": { + "on": false + }, + "owner": { + "rid": "fcdfab5d-8e04-4e9c-a999-7f92cb38c4fc", + "rtype": "device" + }, + "type": "light" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "color": { + "gamut": { + "blue": { + "x": 0.138, + "y": 0.08 + }, + "green": { + "x": 0.2151, + "y": 0.7106 + }, + "red": { + "x": 0.704, + "y": 0.296 + } + }, + "gamut_type": "A", + "xy": { + "x": 0.4849, + "y": 0.3895 + } + }, + "dimming": { + "brightness": 50.0, + "min_dim_level": 10.0 + }, + "dynamics": { + "speed": 0.6389, + "speed_valid": true, + "status": "dynamic_palette", + "status_values": ["none", "dynamic_palette"] + }, + "id": "74a45fee-1b3d-4553-b5ab-040da8a10cfd", + "id_v1": "/lights/11", + "metadata": { + "archetype": "hue_bloom", + "name": "Hue light with color only" + }, + "mode": "normal", + "on": { + "on": true + }, + "owner": { + "rid": "8d07d39c-3c19-47ce-ac7a-8bf3d8e849b9", + "rtype": "device" + }, + "type": "light" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "color": { + "gamut": { + "blue": { + "x": 0.1532, + "y": 0.0475 + }, + "green": { + "x": 0.17, + "y": 0.7 + }, + "red": { + "x": 0.6915, + "y": 0.3083 + } + }, + "gamut_type": "C", + "xy": { + "x": 0.5022, + "y": 0.4466 + } + }, + "color_temperature": { + "mirek": null, + "mirek_schema": { + "mirek_maximum": 500, + "mirek_minimum": 153 + }, + "mirek_valid": false + }, + "dimming": { + "brightness": 46.85, + "min_dim_level": 0.10000000149011612 + }, + "dynamics": { + "speed": 0.627, + "speed_valid": true, + "status": "dynamic_palette", + "status_values": ["none", "dynamic_palette"] + }, + "gradient": { + "points": [ + { + "color": { + "xy": { + "x": 0.5022, + "y": 0.4466 + } + } + }, + { + "color": { + "xy": { + "x": 0.4806, + "y": 0.4484 + } + } + }, + { + "color": { + "xy": { + "x": 0.5022, + "y": 0.4466 + } + } + }, + { + "color": { + "xy": { + "x": 0.5614, + "y": 0.4058 + } + } + }, + { + "color": { + "xy": { + "x": 0.5022, + "y": 0.4466 + } + } + } + ], + "points_capable": 5 + }, + "id": "8015b17f-8336-415b-966a-b364bd082397", + "id_v1": "/lights/24", + "metadata": { + "archetype": "hue_lightstrip_tv", + "name": "Hue light with color and color temperature gradient" + }, + "mode": "normal", + "on": { + "on": true + }, + "owner": { + "rid": "1c8be0d5-a68b-45c2-8f56-530d13b0c128", + "rtype": "device" + }, + "type": "light" + }, + { + "id": "af520f40-e080-43b0-9bb5-41a4d5251b2b", + "id_v1": "/sensors/50", + "mac_address": "00:17:88:01:0b:aa:bb:99", + "owner": { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "1987ba66-c21d-48d0-98fb-121d939a71f3", + "id_v1": "/lights/29", + "mac_address": "00:17:88:01:09:aa:bb:65", + "owner": { + "rid": "0b216218-d811-4c95-8c55-bbcda50f9d50", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "bd878f44-feb7-406e-8af9-6a1796d1ddc9", + "id_v1": "/lights/4", + "mac_address": "00:17:88:01:06:aa:bb:58", + "owner": { + "rid": "60b849cc-a8b5-4034-8881-ed1cd560fd13", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "db50a5d9-8cc7-486f-be06-c0b8f0d26c69", + "id_v1": "/sensors/10", + "mac_address": "00:17:88:01:08:aa:cc:60", + "owner": { + "rid": "342daec9-391b-480b-abdd-87f1aa04ce3b", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "717afeb6-b1ce-426e-96de-48e8fe037fb0", + "id_v1": "/lights/16", + "mac_address": "00:17:88:aa:aa:bb:0d:ab", + "owner": { + "rid": "b9e76da7-ac22-476a-986d-e466e62e962f", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "6b00ce2b-a8a5-4bab-bc5e-757a0b0338ff", + "id_v1": "/lights/23", + "mac_address": "00:12:4b:00:1f:aa:bb:f3", + "owner": { + "rid": "fcdfab5d-8e04-4e9c-a999-7f92cb38c4fc", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "bba44861-8222-45c9-9e6b-d7f3a6543829", + "id_v1": "/sensors/5", + "mac_address": "00:17:88:01:aa:cc:87:b6", + "owner": { + "rid": "7745ebea-dd33-429c-a900-bae4e7ae1107", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "6c898412-ed25-4402-9807-a0c326616b0f", + "id_v1": "", + "mac_address": "00:17:88:01:aa:bb:fd:c7", + "owner": { + "rid": "4a507550-8742-4087-8bf5-c2334f29891c", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "d2ae969a-add5-41b1-afbd-f2837b2eb551", + "id_v1": "/lights/34", + "mac_address": "00:17:88:01:aa:bb:cc:ed", + "owner": { + "rid": "5ad8326c-e51a-4594-8738-fc700b53fcc4", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "98baae94-76d9-4bc4-a1d1-d53f1d7b1286", + "id_v1": "/lights/11", + "mac_address": "00:17:88:aa:bb:1e:cc:b2", + "owner": { + "rid": "8d07d39c-3c19-47ce-ac7a-8bf3d8e849b9", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "ff4e6545-341f-4b0d-9869-b6feb6e6fe87", + "id_v1": "/lights/24", + "mac_address": "00:17:88:01:aa:bb:cc:3d", + "owner": { + "rid": "1c8be0d5-a68b-45c2-8f56-530d13b0c128", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "ec9b5ad7-2471-4356-b757-d00537828963", + "id_v1": "/sensors/66", + "mac_address": "00:17:aa:bb:cc:09:ac:c3", + "owner": { + "rid": "2330b45d-6079-4c6e-bba6-1b68afb1a0d6", + "rtype": "device" + }, + "status": "connected", + "type": "zigbee_connectivity" + }, + { + "id": "5d7b3979-b936-47ff-8458-554f8a2921db", + "id_v1": "/lights/29", + "owner": { + "rid": "0b216218-d811-4c95-8c55-bbcda50f9d50", + "rtype": "device" + }, + "proxy": true, + "renderer": true, + "segments": { + "configurable": false, + "max_segments": 1, + "segments": [ + { + "length": 1, + "start": 0 + } + ] + }, + "type": "entertainment" + }, + { + "id": "d88acc42-259c-43b5-bf5d-90c16cdb8f2f", + "id_v1": "/lights/16", + "owner": { + "rid": "b9e76da7-ac22-476a-986d-e466e62e962f", + "rtype": "device" + }, + "proxy": true, + "renderer": true, + "segments": { + "configurable": false, + "max_segments": 1, + "segments": [ + { + "length": 1, + "start": 0 + } + ] + }, + "type": "entertainment" + }, + { + "id": "b8ab0c30-b227-4d35-9c96-7cd16131fcc5", + "id_v1": "", + "owner": { + "rid": "4a507550-8742-4087-8bf5-c2334f29891c", + "rtype": "device" + }, + "proxy": true, + "renderer": false, + "type": "entertainment" + }, + { + "id": "8e6a4ff3-14ca-42f9-8358-9d691b9a4524", + "id_v1": "/lights/11", + "owner": { + "rid": "8d07d39c-3c19-47ce-ac7a-8bf3d8e849b9", + "rtype": "device" + }, + "proxy": false, + "renderer": true, + "segments": { + "configurable": false, + "max_segments": 1, + "segments": [ + { + "length": 1, + "start": 0 + } + ] + }, + "type": "entertainment" + }, + { + "id": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "id_v1": "/lights/24", + "owner": { + "rid": "1c8be0d5-a68b-45c2-8f56-530d13b0c128", + "rtype": "device" + }, + "proxy": true, + "renderer": true, + "segments": { + "configurable": false, + "max_segments": 10, + "segments": [ + { + "length": 2, + "start": 0 + }, + { + "length": 3, + "start": 2 + }, + { + "length": 5, + "start": 5 + }, + { + "length": 4, + "start": 10 + }, + { + "length": 5, + "start": 14 + }, + { + "length": 3, + "start": 19 + }, + { + "length": 2, + "start": 22 + } + ] + }, + "type": "entertainment" + }, + { + "button": { + "last_event": "short_release" + }, + "id": "c658d3d8-a013-4b81-8ac6-78b248537e70", + "id_v1": "/sensors/50", + "metadata": { + "control_id": 1 + }, + "owner": { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + "type": "button" + }, + { + "id": "be1eb834-bdf5-4d26-8fba-7b1feaa83a9d", + "id_v1": "/sensors/50", + "metadata": { + "control_id": 2 + }, + "owner": { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + "type": "button" + }, + { + "id": "f92aa267-1387-4f02-9950-210fb7ca1f5a", + "id_v1": "/sensors/10", + "metadata": { + "control_id": 1 + }, + "owner": { + "rid": "342daec9-391b-480b-abdd-87f1aa04ce3b", + "rtype": "device" + }, + "type": "button" + }, + { + "button": { + "last_event": "short_release" + }, + "id": "7f1ab9f6-cc2b-4b40-9011-65e2af153f75", + "id_v1": "/sensors/10", + "metadata": { + "control_id": 2 + }, + "owner": { + "rid": "342daec9-391b-480b-abdd-87f1aa04ce3b", + "rtype": "device" + }, + "type": "button" + }, + { + "id": "b4edb2d6-55d0-47f4-bd43-7ae215ef1062", + "id_v1": "/sensors/10", + "metadata": { + "control_id": 3 + }, + "owner": { + "rid": "342daec9-391b-480b-abdd-87f1aa04ce3b", + "rtype": "device" + }, + "type": "button" + }, + { + "id": "40a810bf-3d22-4c56-9334-4a59a00768ab", + "id_v1": "/sensors/10", + "metadata": { + "control_id": 4 + }, + "owner": { + "rid": "342daec9-391b-480b-abdd-87f1aa04ce3b", + "rtype": "device" + }, + "type": "button" + }, + { + "button": { + "last_event": "short_release" + }, + "id": "31cffcda-efc2-401f-a152-e10db3eed232", + "id_v1": "/sensors/5", + "metadata": { + "control_id": 1 + }, + "owner": { + "rid": "7745ebea-dd33-429c-a900-bae4e7ae1107", + "rtype": "device" + }, + "type": "button" + }, + { + "id": "c1cd98a6-6c23-43bb-b6e1-08dda9e168a4", + "id_v1": "/sensors/50", + "owner": { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + "power_state": { + "battery_level": 100, + "battery_state": "normal" + }, + "type": "device_power" + }, + { + "id": "0bb058bc-2139-43d9-8c9b-edfb4570953b", + "id_v1": "/sensors/10", + "owner": { + "rid": "342daec9-391b-480b-abdd-87f1aa04ce3b", + "rtype": "device" + }, + "power_state": { + "battery_level": 83, + "battery_state": "normal" + }, + "type": "device_power" + }, + { + "id": "3f219f5a-ad6c-484f-b976-769a9c267a72", + "id_v1": "/sensors/5", + "owner": { + "rid": "7745ebea-dd33-429c-a900-bae4e7ae1107", + "rtype": "device" + }, + "power_state": { + "battery_level": 91, + "battery_state": "normal" + }, + "type": "device_power" + }, + { + "id": "669f609d-4860-4f1c-bc25-7a9cec1c3b6c", + "id_v1": "/sensors/66", + "owner": { + "rid": "2330b45d-6079-4c6e-bba6-1b68afb1a0d6", + "rtype": "device" + }, + "power_state": { + "battery_level": 100, + "battery_state": "normal" + }, + "type": "device_power" + }, + { + "children": [ + { + "rid": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "rtype": "light" + }, + { + "rid": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "rtype": "light" + }, + { + "rid": "8015b17f-8336-415b-966a-b364bd082397", + "rtype": "light" + } + ], + "grouped_services": [ + { + "rid": "f2416154-9607-43ab-a684-4453108a200e", + "rtype": "grouped_light" + } + ], + "id": "7cee478d-6455-483a-9e32-9f9fdcbcc4f6", + "id_v1": "/groups/5", + "metadata": { + "archetype": "downstairs", + "name": "Test Zone" + }, + "services": [ + { + "rid": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "rtype": "light" + }, + { + "rid": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "rtype": "light" + }, + { + "rid": "8015b17f-8336-415b-966a-b364bd082397", + "rtype": "light" + }, + { + "rid": "f2416154-9607-43ab-a684-4453108a200e", + "rtype": "grouped_light" + } + ], + "type": "zone" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "id": "f2416154-9607-43ab-a684-4453108a200e", + "id_v1": "/groups/5", + "on": { + "on": true + }, + "type": "grouped_light" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "id": "0a74457c-cb8d-44c2-a5a5-dcb7b3675550", + "id_v1": "/groups/0", + "on": { + "on": true + }, + "type": "grouped_light" + }, + { + "alert": { + "action_values": ["breathe"] + }, + "id": "e937f8db-2f0e-49a0-936e-027e60e15b34", + "id_v1": "/groups/3", + "on": { + "on": false + }, + "type": "grouped_light" + }, + { + "children": [ + { + "rid": "6ddc9066-7e7d-4a03-a773-c73937968296", + "rtype": "room" + }, + { + "rid": "0b216218-d811-4c95-8c55-bbcda50f9d50", + "rtype": "device" + }, + { + "rid": "b9e76da7-ac22-476a-986d-e466e62e962f", + "rtype": "device" + }, + { + "rid": "5ad8326c-e51a-4594-8738-fc700b53fcc4", + "rtype": "device" + }, + { + "rid": "8d07d39c-3c19-47ce-ac7a-8bf3d8e849b9", + "rtype": "device" + }, + { + "rid": "1c8be0d5-a68b-45c2-8f56-530d13b0c128", + "rtype": "device" + }, + { + "rid": "7745ebea-dd33-429c-a900-bae4e7ae1107", + "rtype": "device" + }, + { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + { + "rid": "2330b45d-6079-4c6e-bba6-1b68afb1a0d6", + "rtype": "device" + }, + { + "rid": "342daec9-391b-480b-abdd-87f1aa04ce3b", + "rtype": "device" + } + ], + "grouped_services": [ + { + "rid": "0a74457c-cb8d-44c2-a5a5-dcb7b3675550", + "rtype": "grouped_light" + } + ], + "id": "a3fbc86a-bf4c-4c69-899d-d6eafc37e288", + "id_v1": "/groups/0", + "services": [ + { + "rid": "3a6710fa-4474-4eba-b533-5e6e72968feb", + "rtype": "light" + }, + { + "rid": "d0df7249-02c1-4480-ba2c-d61b1e648a58", + "rtype": "light" + }, + { + "rid": "74a45fee-1b3d-4553-b5ab-040da8a10cfd", + "rtype": "light" + }, + { + "rid": "d2d48fac-df99-4f8d-8bdc-bac82d2cfb24", + "rtype": "light" + }, + { + "rid": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "rtype": "light" + }, + { + "rid": "1d1ac857-9b89-48aa-a4f3-68302e7d0998", + "rtype": "light" + }, + { + "rid": "7697ac8a-25aa-4576-bb40-0036c0db15b9", + "rtype": "light" + }, + { + "rid": "8015b17f-8336-415b-966a-b364bd082397", + "rtype": "light" + }, + { + "rid": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "rtype": "light" + }, + { + "rid": "6a5d8ce8-c0a0-43bb-870e-d7e641cdb063", + "rtype": "button" + }, + { + "rid": "85fa4928-b061-4d19-8458-c5e30d375e39", + "rtype": "button" + }, + { + "rid": "a0640313-0a01-42b9-b236-c5e0a1568ef5", + "rtype": "button" + }, + { + "rid": "50fe978e-117c-4fc5-bb17-f707c1614a11", + "rtype": "button" + }, + { + "rid": "31cffcda-efc2-401f-a152-e10db3eed232", + "rtype": "button" + }, + { + "rid": "f92aa267-1387-4f02-9950-210fb7ca1f5a", + "rtype": "button" + }, + { + "rid": "7f1ab9f6-cc2b-4b40-9011-65e2af153f75", + "rtype": "button" + }, + { + "rid": "b4edb2d6-55d0-47f4-bd43-7ae215ef1062", + "rtype": "button" + }, + { + "rid": "40a810bf-3d22-4c56-9334-4a59a00768ab", + "rtype": "button" + }, + { + "rid": "487aa265-8ea1-4280-a663-cbf93a79ccd7", + "rtype": "button" + }, + { + "rid": "f6e137cf-8e93-4f6a-be9c-2f820bf6d893", + "rtype": "button" + }, + { + "rid": "c658d3d8-a013-4b81-8ac6-78b248537e70", + "rtype": "button" + }, + { + "rid": "be1eb834-bdf5-4d26-8fba-7b1feaa83a9d", + "rtype": "button" + }, + { + "rid": "b6896534-016d-4052-8cb4-ef04454df62c", + "rtype": "motion" + }, + { + "rid": "d504e7a4-9a18-4854-90fd-c5b6ac102c40", + "rtype": "light_level" + }, + { + "rid": "66466e14-d2fa-4b96-b2a0-e10de9cd8b8b", + "rtype": "temperature" + }, + { + "rid": "0a74457c-cb8d-44c2-a5a5-dcb7b3675550", + "rtype": "grouped_light" + } + ], + "type": "bridge_home" + }, + { + "children": [ + { + "rid": "60b849cc-a8b5-4034-8881-ed1cd560fd13", + "rtype": "device" + }, + { + "rid": "fcdfab5d-8e04-4e9c-a999-7f92cb38c4fc", + "rtype": "device" + } + ], + "grouped_services": [ + { + "rid": "e937f8db-2f0e-49a0-936e-027e60e15b34", + "rtype": "grouped_light" + } + ], + "id": "6ddc9066-7e7d-4a03-a773-c73937968296", + "id_v1": "/groups/3", + "metadata": { + "archetype": "bathroom", + "name": "Test Room" + }, + "services": [ + { + "rid": "7697ac8a-25aa-4576-bb40-0036c0db15b9", + "rtype": "light" + }, + { + "rid": "3a6710fa-4474-4eba-b533-5e6e72968feb", + "rtype": "light" + }, + { + "rid": "e937f8db-2f0e-49a0-936e-027e60e15b34", + "rtype": "grouped_light" + } + ], + "type": "room" + }, + { + "channels": [ + { + "channel_id": 0, + "members": [ + { + "index": 0, + "service": { + "rid": "5d7b3979-b936-47ff-8458-554f8a2921db", + "rtype": "entertainment" + } + } + ], + "position": { + "x": -0.8399999737739563, + "y": 0.8999999761581421, + "z": -0.5 + } + }, + { + "channel_id": 1, + "members": [ + { + "index": 0, + "service": { + "rid": "d88acc42-259c-43b5-bf5d-90c16cdb8f2f", + "rtype": "entertainment" + } + } + ], + "position": { + "x": -0.9399999976158142, + "y": -0.20999999344348907, + "z": -1.0 + } + }, + { + "channel_id": 2, + "members": [ + { + "index": 0, + "service": { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + } + ], + "position": { + "x": -0.4000000059604645, + "y": 0.800000011920929, + "z": -0.4000000059604645 + } + }, + { + "channel_id": 3, + "members": [ + { + "index": 1, + "service": { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + } + ], + "position": { + "x": -0.4000000059604645, + "y": 0.800000011920929, + "z": 0.0 + } + }, + { + "channel_id": 4, + "members": [ + { + "index": 2, + "service": { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + } + ], + "position": { + "x": -0.4000000059604645, + "y": 0.800000011920929, + "z": 0.4000000059604645 + } + }, + { + "channel_id": 5, + "members": [ + { + "index": 3, + "service": { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + } + ], + "position": { + "x": 0.0, + "y": 0.800000011920929, + "z": 0.4000000059604645 + } + }, + { + "channel_id": 6, + "members": [ + { + "index": 4, + "service": { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + } + ], + "position": { + "x": 0.4000000059604645, + "y": 0.800000011920929, + "z": 0.4000000059604645 + } + }, + { + "channel_id": 7, + "members": [ + { + "index": 5, + "service": { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + } + ], + "position": { + "x": 0.4000000059604645, + "y": 0.800000011920929, + "z": 0.0 + } + }, + { + "channel_id": 8, + "members": [ + { + "index": 6, + "service": { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + } + ], + "position": { + "x": 0.4000000059604645, + "y": 0.800000011920929, + "z": -0.4000000059604645 + } + }, + { + "channel_id": 9, + "members": [ + { + "index": 0, + "service": { + "rid": "be321947-0a48-4742-913d-073b3b540c97", + "rtype": "entertainment" + } + } + ], + "position": { + "x": 0.9100000262260437, + "y": 0.8100000023841858, + "z": -0.3799999952316284 + } + } + ], + "configuration_type": "screen", + "id": "c14cf1cf-6c7a-4984-b8bb-c5b71aeb70fc", + "id_v1": "/groups/2", + "light_services": [ + { + "rid": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "rtype": "light" + }, + { + "rid": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "rtype": "light" + }, + { + "rid": "8015b17f-8336-415b-966a-b364bd082397", + "rtype": "light" + } + ], + "locations": { + "service_locations": [ + { + "position": { + "x": -0.8399999737739563, + "y": 0.8999999761581421, + "z": -0.5 + }, + "positions": [ + { + "x": -0.8399999737739563, + "y": 0.8999999761581421, + "z": -0.5 + } + ], + "service": { + "rid": "5d7b3979-b936-47ff-8458-554f8a2921db", + "rtype": "entertainment" + } + }, + { + "position": { + "x": -0.9399999976158142, + "y": -0.20999999344348907, + "z": -1.0 + }, + "positions": [ + { + "x": -0.9399999976158142, + "y": -0.20999999344348907, + "z": -1.0 + } + ], + "service": { + "rid": "d88acc42-259c-43b5-bf5d-90c16cdb8f2f", + "rtype": "entertainment" + } + }, + { + "position": { + "x": -0.4000000059604645, + "y": 0.800000011920929, + "z": -0.4000000059604645 + }, + "positions": [ + { + "x": -0.4000000059604645, + "y": 0.800000011920929, + "z": -0.4000000059604645 + }, + { + "x": 0.4000000059604645, + "y": 0.800000011920929, + "z": -0.4000000059604645 + } + ], + "service": { + "rid": "7b03eb98-4cfd-4acf-ac11-675f51613e5e", + "rtype": "entertainment" + } + }, + { + "position": { + "x": 0.9100000262260437, + "y": 0.8100000023841858, + "z": -0.3799999952316284 + }, + "positions": [ + { + "x": 0.9100000262260437, + "y": 0.8100000023841858, + "z": -0.3799999952316284 + } + ], + "service": { + "rid": "be321947-0a48-4742-913d-073b3b540c97", + "rtype": "entertainment" + } + } + ] + }, + "metadata": { + "name": "Entertainmentroom 1" + }, + "name": "Entertainmentroom 1", + "status": "inactive", + "stream_proxy": { + "mode": "auto", + "node": { + "rid": "b8ab0c30-b227-4d35-9c96-7cd16131fcc5", + "rtype": "entertainment" + } + }, + "type": "entertainment_configuration" + }, + { + "bridge_id": "aabbccddeeffggh", + "id": "07dd5849-abcd-efgh-b9b9-eb540408ce00", + "id_v1": "", + "owner": { + "rid": "4a507550-8742-4087-8bf5-c2334f29891c", + "rtype": "device" + }, + "time_zone": { + "time_zone": "Europe/Amsterdam" + }, + "type": "bridge" + }, + { + "enabled": true, + "id": "b6896534-016d-4052-8cb4-ef04454df62c", + "id_v1": "/sensors/66", + "motion": { + "motion": false, + "motion_valid": true + }, + "owner": { + "rid": "2330b45d-6079-4c6e-bba6-1b68afb1a0d6", + "rtype": "device" + }, + "type": "motion" + }, + { + "enabled": true, + "id": "d504e7a4-9a18-4854-90fd-c5b6ac102c40", + "id_v1": "/sensors/67", + "light": { + "light_level": 18027, + "light_level_valid": true + }, + "owner": { + "rid": "2330b45d-6079-4c6e-bba6-1b68afb1a0d6", + "rtype": "device" + }, + "type": "light_level" + }, + { + "enabled": true, + "id": "66466e14-d2fa-4b96-b2a0-e10de9cd8b8b", + "id_v1": "/sensors/68", + "owner": { + "rid": "2330b45d-6079-4c6e-bba6-1b68afb1a0d6", + "rtype": "device" + }, + "temperature": { + "temperature": 18.139999389648438, + "temperature_valid": true + }, + "type": "temperature" + }, + { + "configuration": { + "end_state": "last_state", + "where": [ + { + "group": { + "rid": "c14cf1cf-6c7a-4984-b8bb-c5b71aeb70fc", + "rtype": "entertainment_configuration" + } + } + ] + }, + "dependees": [ + { + "level": "critical", + "target": { + "rid": "c14cf1cf-6c7a-4984-b8bb-c5b71aeb70fc", + "rtype": "entertainment_configuration" + }, + "type": "ResourceDependee" + } + ], + "enabled": true, + "id": "0670cfb1-2bd7-4237-a0e3-1827a44d7231", + "last_error": "", + "metadata": { + "name": "state_after_streaming" + }, + "migrated_from": "/resourcelinks/47450", + "script_id": "7719b841-6b3d-448d-a0e7-601ae9edb6a2", + "status": "running", + "type": "behavior_instance" + }, + { + "configuration_schema": { + "$ref": "leaving_home_config.json#" + }, + "description": "Automatically turn off your lights when you leave", + "id": "0194752a-2d53-4f92-8209-dfdc52745af3", + "metadata": { + "name": "Leaving home" + }, + "state_schema": {}, + "trigger_schema": { + "$ref": "trigger.json#" + }, + "type": "behavior_script", + "version": "0.0.1" + }, + { + "configuration_schema": { + "$ref": "schedule_config.json#" + }, + "description": "Schedule turning on and off lights", + "id": "7238c707-8693-4f19-9095-ccdc1444d228", + "metadata": { + "name": "Schedule" + }, + "state_schema": {}, + "trigger_schema": { + "$ref": "trigger.json#" + }, + "type": "behavior_script", + "version": "0.0.1" + }, + { + "configuration_schema": { + "$ref": "lights_state_after_streaming_config.json#" + }, + "description": "State of lights in the entertainment group after streaming ends", + "id": "7719b841-6b3d-448d-a0e7-601ae9edb6a2", + "metadata": { + "name": "Light state after streaming" + }, + "state_schema": {}, + "trigger_schema": {}, + "type": "behavior_script", + "version": "0.0.1" + }, + { + "configuration_schema": { + "$ref": "basic_goto_sleep_config.json#" + }, + "description": "Get ready for nice sleep.", + "id": "7e571ac6-f363-42e1-809a-4cbf6523ed72", + "metadata": { + "name": "Basic go to sleep routine" + }, + "state_schema": {}, + "trigger_schema": { + "$ref": "trigger.json#" + }, + "type": "behavior_script", + "version": "0.0.1" + }, + { + "configuration_schema": { + "$ref": "coming_home_config.json#" + }, + "description": "Automatically turn your lights to choosen light states, when you arrive at home.", + "id": "fd60fcd1-4809-4813-b510-4a18856a595c", + "metadata": { + "name": "Coming home" + }, + "state_schema": {}, + "trigger_schema": { + "$ref": "trigger.json#" + }, + "type": "behavior_script", + "version": "0.0.1" + }, + { + "configuration_schema": { + "$ref": "basic_wake_up_config.json#" + }, + "description": "Get your body in the mood to wake up by fading on the lights in the morning.", + "id": "ff8957e3-2eb9-4699-a0c8-ad2cb3ede704", + "metadata": { + "name": "Basic wake up routine" + }, + "state_schema": {}, + "trigger_schema": { + "$ref": "trigger.json#" + }, + "type": "behavior_script", + "version": "0.0.1" + }, + { + "configuration_schema": { + "$ref": "natural_light_config.json#" + }, + "description": "Natural light during the day", + "id": "a4260b49-0c69-4926-a29c-417f4a38a352", + "metadata": { + "name": "Natural Light" + }, + "state_schema": { + "$ref": "natural_light_state.json#" + }, + "trigger_schema": { + "$ref": "smart_scene_trigger.json#" + }, + "type": "behavior_script", + "version": "0.0.1" + }, + { + "configuration_schema": { + "$ref": "timer_config.json#" + }, + "description": "Countdown Timer", + "id": "e73bc72d-96b1-46f8-aa57-729861f80c78", + "metadata": { + "name": "Timers" + }, + "state_schema": { + "$ref": "timer_state.json#" + }, + "trigger_schema": { + "$ref": "trigger.json#" + }, + "type": "behavior_script", + "version": "0.0.1" + }, + { + "id": "c6e03a31-4c30-4cef-834f-26ffbb06a593", + "name": "Test geofence client", + "type": "geofence_client" + }, + { + "id": "52612630-841e-4d39-9763-60346a0da759", + "is_configured": true, + "type": "geolocation" + } + ] + \ No newline at end of file diff --git a/tests/components/hue/test_binary_sensor.py b/tests/components/hue/test_binary_sensor.py new file mode 100644 index 00000000000..ba5a58b4be0 --- /dev/null +++ b/tests/components/hue/test_binary_sensor.py @@ -0,0 +1,61 @@ +"""Philips Hue binary_sensor platform tests for V2 bridge/api.""" + + +from .conftest import setup_platform +from .const import FAKE_BINARY_SENSOR, FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY + + +async def test_binary_sensors(hass, mock_bridge_v2, v2_resources_test_data): + """Test if all v2 binary_sensors get created with correct features.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "binary_sensor") + # there shouldn't have been any requests at this point + assert len(mock_bridge_v2.mock_requests) == 0 + # 2 binary_sensors should be created from test data + assert len(hass.states.async_all()) == 2 + + # test motion sensor + sensor = hass.states.get("binary_sensor.hue_motion_sensor_motion") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Hue motion sensor Motion" + assert sensor.attributes["device_class"] == "motion" + assert sensor.attributes["motion_valid"] is True + + # test entertainment room active sensor + sensor = hass.states.get( + "binary_sensor.entertainmentroom_1_entertainment_configuration" + ) + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Entertainmentroom 1: Entertainment Configuration" + assert sensor.attributes["device_class"] == "running" + + +async def test_binary_sensor_add_update(hass, mock_bridge_v2): + """Test if binary_sensor get added/updated from events.""" + await mock_bridge_v2.api.load_test_data([FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY]) + await setup_platform(hass, mock_bridge_v2, "binary_sensor") + + test_entity_id = "binary_sensor.hue_mocked_device_motion" + + # verify entity does not exist before we start + assert hass.states.get(test_entity_id) is None + + # Add new fake sensor by emitting event + mock_bridge_v2.api.emit_event("add", FAKE_BINARY_SENSOR) + await hass.async_block_till_done() + + # the entity should now be available + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "off" + + # test update of entity works on incoming event + updated_sensor = {**FAKE_BINARY_SENSOR, "motion": {"motion": True}} + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "on" diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index 034acf88efa..bede2f75789 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -2,61 +2,70 @@ import asyncio from unittest.mock import AsyncMock, Mock, patch +from aiohttp import client_exceptions +from aiohue.errors import Unauthorized +from aiohue.v1 import HueBridgeV1 +from aiohue.v2 import HueBridgeV2 import pytest -from homeassistant import config_entries -from homeassistant.components import hue -from homeassistant.components.hue import bridge, errors +from homeassistant.components.hue import bridge from homeassistant.components.hue.const import ( CONF_ALLOW_HUE_GROUPS, CONF_ALLOW_UNREACHABLE, ) from homeassistant.exceptions import ConfigEntryNotReady -ORIG_SUBSCRIBE_EVENTS = bridge.HueBridge._subscribe_events +async def test_bridge_setup_v1(hass, mock_api_v1): + """Test a successful setup for V1 bridge.""" + config_entry = Mock() + config_entry.data = {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1} + config_entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} -@pytest.fixture(autouse=True) -def mock_subscribe_events(): - """Mock subscribe events method.""" - with patch( - "homeassistant.components.hue.bridge.HueBridge._subscribe_events" - ) as mock: - yield mock - - -async def test_bridge_setup(hass, mock_subscribe_events): - """Test a successful setup.""" - entry = Mock() - api = Mock(initialize=AsyncMock()) - entry.data = {"host": "1.2.3.4", "username": "mock-username"} - entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} - hue_bridge = bridge.HueBridge(hass, entry) - - with patch("aiohue.Bridge", return_value=api), patch.object( + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1), patch.object( hass.config_entries, "async_forward_entry_setup" ) as mock_forward: - assert await hue_bridge.async_setup() is True + hue_bridge = bridge.HueBridge(hass, config_entry) + assert await hue_bridge.async_initialize_bridge() is True - assert hue_bridge.api is api + assert hue_bridge.api is mock_api_v1 + assert isinstance(hue_bridge.api, HueBridgeV1) + assert hue_bridge.api_version == 1 assert len(mock_forward.mock_calls) == 3 forward_entries = {c[1][1] for c in mock_forward.mock_calls} assert forward_entries == {"light", "binary_sensor", "sensor"} - assert len(mock_subscribe_events.mock_calls) == 1 + +async def test_bridge_setup_v2(hass, mock_api_v2): + """Test a successful setup for V2 bridge.""" + config_entry = Mock() + config_entry.data = {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 2} + + with patch.object(bridge, "HueBridgeV2", return_value=mock_api_v2), patch.object( + hass.config_entries, "async_forward_entry_setup" + ) as mock_forward: + hue_bridge = bridge.HueBridge(hass, config_entry) + assert await hue_bridge.async_initialize_bridge() is True + + assert hue_bridge.api is mock_api_v2 + assert isinstance(hue_bridge.api, HueBridgeV2) + assert hue_bridge.api_version == 2 + assert len(mock_forward.mock_calls) == 5 + forward_entries = {c[1][1] for c in mock_forward.mock_calls} + assert forward_entries == {"light", "binary_sensor", "sensor", "switch", "scene"} -async def test_bridge_setup_invalid_username(hass): +async def test_bridge_setup_invalid_api_key(hass): """Test we start config flow if username is no longer whitelisted.""" entry = Mock() - entry.data = {"host": "1.2.3.4", "username": "mock-username"} + entry.data = {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1} entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} hue_bridge = bridge.HueBridge(hass, entry) with patch.object( - bridge, "authenticate_bridge", side_effect=errors.AuthenticationRequired + hue_bridge.api, "initialize", side_effect=Unauthorized ), patch.object(hass.config_entries.flow, "async_init") as mock_init: - assert await hue_bridge.async_setup() is False + assert await hue_bridge.async_initialize_bridge() is False assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][2]["data"] == {"host": "1.2.3.4"} @@ -65,50 +74,34 @@ async def test_bridge_setup_invalid_username(hass): async def test_bridge_setup_timeout(hass): """Test we retry to connect if we cannot connect.""" entry = Mock() - entry.data = {"host": "1.2.3.4", "username": "mock-username"} + entry.data = {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1} entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} hue_bridge = bridge.HueBridge(hass, entry) with patch.object( - bridge, "authenticate_bridge", side_effect=errors.CannotConnect + hue_bridge.api, + "initialize", + side_effect=client_exceptions.ServerDisconnectedError, ), pytest.raises(ConfigEntryNotReady): - await hue_bridge.async_setup() + await hue_bridge.async_initialize_bridge() -async def test_reset_if_entry_had_wrong_auth(hass): - """Test calling reset when the entry contained wrong auth.""" - entry = Mock() - entry.data = {"host": "1.2.3.4", "username": "mock-username"} - entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} - hue_bridge = bridge.HueBridge(hass, entry) - - with patch.object( - bridge, "authenticate_bridge", side_effect=errors.AuthenticationRequired - ), patch.object(bridge, "create_config_flow") as mock_create: - assert await hue_bridge.async_setup() is False - - assert len(mock_create.mock_calls) == 1 - - assert await hue_bridge.async_reset() - - -async def test_reset_unloads_entry_if_setup(hass, mock_subscribe_events): +async def test_reset_unloads_entry_if_setup(hass, mock_api_v1): """Test calling reset while the entry has been setup.""" - entry = Mock() - entry.data = {"host": "1.2.3.4", "username": "mock-username"} - entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} - hue_bridge = bridge.HueBridge(hass, entry) + config_entry = Mock() + config_entry.data = {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1} + config_entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} - with patch.object(bridge, "authenticate_bridge"), patch( - "aiohue.Bridge" - ), patch.object(hass.config_entries, "async_forward_entry_setup") as mock_forward: - assert await hue_bridge.async_setup() is True + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1), patch.object( + hass.config_entries, "async_forward_entry_setup" + ) as mock_forward: + hue_bridge = bridge.HueBridge(hass, config_entry) + assert await hue_bridge.async_initialize_bridge() is True await asyncio.sleep(0) assert len(hass.services.async_services()) == 0 assert len(mock_forward.mock_calls) == 3 - assert len(mock_subscribe_events.mock_calls) == 1 with patch.object( hass.config_entries, "async_forward_entry_unload", return_value=True @@ -119,17 +112,15 @@ async def test_reset_unloads_entry_if_setup(hass, mock_subscribe_events): assert len(hass.services.async_services()) == 0 -async def test_handle_unauthorized(hass): +async def test_handle_unauthorized(hass, mock_api_v1): """Test handling an unauthorized error on update.""" - entry = Mock(async_setup=AsyncMock()) - entry.data = {"host": "1.2.3.4", "username": "mock-username"} - entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} - hue_bridge = bridge.HueBridge(hass, entry) + config_entry = Mock(async_setup=AsyncMock()) + config_entry.data = {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1} + config_entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} - with patch.object(bridge, "authenticate_bridge"), patch("aiohue.Bridge"): - assert await hue_bridge.async_setup() is True - - assert hue_bridge.authorized is True + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1): + hue_bridge = bridge.HueBridge(hass, config_entry) + assert await hue_bridge.async_initialize_bridge() is True with patch.object(bridge, "create_config_flow") as mock_create: await hue_bridge.handle_unauthorized_error() @@ -137,233 +128,3 @@ async def test_handle_unauthorized(hass): assert hue_bridge.authorized is False assert len(mock_create.mock_calls) == 1 assert mock_create.mock_calls[0][1][1] == "1.2.3.4" - - -GROUP_RESPONSE = { - "group_1": { - "name": "Group 1", - "lights": ["1", "2"], - "type": "LightGroup", - "action": { - "on": True, - "bri": 254, - "hue": 10000, - "sat": 254, - "effect": "none", - "xy": [0.5, 0.5], - "ct": 250, - "alert": "select", - "colormode": "ct", - }, - "state": {"any_on": True, "all_on": False}, - } -} -SCENE_RESPONSE = { - "scene_1": { - "name": "Cozy dinner", - "lights": ["1", "2"], - "owner": "ffffffffe0341b1b376a2389376a2389", - "recycle": True, - "locked": False, - "appdata": {"version": 1, "data": "myAppData"}, - "picture": "", - "lastupdated": "2015-12-03T10:09:22", - "version": 2, - } -} - - -async def test_hue_activate_scene(hass, mock_api): - """Test successful hue_activate_scene.""" - config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "mock-host", "username": "mock-username"}, - "test", - options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, - ) - hue_bridge = bridge.HueBridge(hass, config_entry) - - mock_api.mock_group_responses.append(GROUP_RESPONSE) - mock_api.mock_scene_responses.append(SCENE_RESPONSE) - - with patch("aiohue.Bridge", return_value=mock_api), patch.object( - hass.config_entries, "async_forward_entry_setup" - ): - assert await hue_bridge.async_setup() is True - - assert hue_bridge.api is mock_api - - call = Mock() - call.data = {"group_name": "Group 1", "scene_name": "Cozy dinner"} - with patch("aiohue.Bridge", return_value=mock_api): - assert await hue_bridge.hue_activate_scene(call.data) is None - - assert len(mock_api.mock_requests) == 3 - assert mock_api.mock_requests[2]["json"]["scene"] == "scene_1" - assert "transitiontime" not in mock_api.mock_requests[2]["json"] - assert mock_api.mock_requests[2]["path"] == "groups/group_1/action" - - -async def test_hue_activate_scene_transition(hass, mock_api): - """Test successful hue_activate_scene with transition.""" - config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "mock-host", "username": "mock-username"}, - "test", - options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, - ) - hue_bridge = bridge.HueBridge(hass, config_entry) - - mock_api.mock_group_responses.append(GROUP_RESPONSE) - mock_api.mock_scene_responses.append(SCENE_RESPONSE) - - with patch("aiohue.Bridge", return_value=mock_api), patch.object( - hass.config_entries, "async_forward_entry_setup" - ): - assert await hue_bridge.async_setup() is True - - assert hue_bridge.api is mock_api - - call = Mock() - call.data = {"group_name": "Group 1", "scene_name": "Cozy dinner", "transition": 30} - with patch("aiohue.Bridge", return_value=mock_api): - assert await hue_bridge.hue_activate_scene(call.data) is None - - assert len(mock_api.mock_requests) == 3 - assert mock_api.mock_requests[2]["json"]["scene"] == "scene_1" - assert mock_api.mock_requests[2]["json"]["transitiontime"] == 30 - assert mock_api.mock_requests[2]["path"] == "groups/group_1/action" - - -async def test_hue_activate_scene_group_not_found(hass, mock_api): - """Test failed hue_activate_scene due to missing group.""" - config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "mock-host", "username": "mock-username"}, - "test", - options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, - ) - hue_bridge = bridge.HueBridge(hass, config_entry) - - mock_api.mock_group_responses.append({}) - mock_api.mock_scene_responses.append(SCENE_RESPONSE) - - with patch("aiohue.Bridge", return_value=mock_api), patch.object( - hass.config_entries, "async_forward_entry_setup" - ): - assert await hue_bridge.async_setup() is True - - assert hue_bridge.api is mock_api - - call = Mock() - call.data = {"group_name": "Group 1", "scene_name": "Cozy dinner"} - with patch("aiohue.Bridge", return_value=mock_api): - assert await hue_bridge.hue_activate_scene(call.data) is False - - -async def test_hue_activate_scene_scene_not_found(hass, mock_api): - """Test failed hue_activate_scene due to missing scene.""" - config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "mock-host", "username": "mock-username"}, - "test", - options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, - ) - hue_bridge = bridge.HueBridge(hass, config_entry) - - mock_api.mock_group_responses.append(GROUP_RESPONSE) - mock_api.mock_scene_responses.append({}) - - with patch("aiohue.Bridge", return_value=mock_api), patch.object( - hass.config_entries, "async_forward_entry_setup" - ): - assert await hue_bridge.async_setup() is True - - assert hue_bridge.api is mock_api - - call = Mock() - call.data = {"group_name": "Group 1", "scene_name": "Cozy dinner"} - with patch("aiohue.Bridge", return_value=mock_api): - assert await hue_bridge.hue_activate_scene(call.data) is False - - -async def test_event_updates(hass, caplog): - """Test calling reset while the entry has been setup.""" - events = asyncio.Queue() - - async def iterate_queue(): - while True: - event = await events.get() - if event is None: - return - yield event - - async def wait_empty_queue(): - count = 0 - while not events.empty() and count < 50: - await asyncio.sleep(0) - count += 1 - - hue_bridge = bridge.HueBridge(None, None) - hue_bridge.api = Mock(listen_events=iterate_queue) - subscription_task = asyncio.create_task(ORIG_SUBSCRIBE_EVENTS(hue_bridge)) - - calls = [] - - def obj_updated(): - calls.append(True) - - unsub = hue_bridge.listen_updates("lights", "2", obj_updated) - - events.put_nowait(Mock(ITEM_TYPE="lights", id="1")) - - await wait_empty_queue() - assert len(calls) == 0 - - events.put_nowait(Mock(ITEM_TYPE="lights", id="2")) - - await wait_empty_queue() - assert len(calls) == 1 - - unsub() - - events.put_nowait(Mock(ITEM_TYPE="lights", id="2")) - - await wait_empty_queue() - assert len(calls) == 1 - - # Test we can override update listener. - def obj_updated_false(): - calls.append(False) - - unsub = hue_bridge.listen_updates("lights", "2", obj_updated) - unsub_false = hue_bridge.listen_updates("lights", "2", obj_updated_false) - - events.put_nowait(Mock(ITEM_TYPE="lights", id="2")) - - await wait_empty_queue() - assert len(calls) == 3 - assert calls[-2] is True - assert calls[-1] is False - - # Also call multiple times to make sure that works. - unsub() - unsub() - unsub_false() - unsub_false() - - events.put_nowait(Mock(ITEM_TYPE="lights", id="2")) - - await wait_empty_queue() - assert len(calls) == 3 - - events.put_nowait(None) - await subscription_task diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 2c79795d48b..6ce8ff3e1c4 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -1,16 +1,16 @@ """Tests for Philips Hue config flow.""" import asyncio -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import Mock, patch -from aiohttp import client_exceptions -import aiohue from aiohue.discovery import URL_NUPNP +from aiohue.errors import LinkButtonNotPressed import pytest import voluptuous as vol from homeassistant import config_entries -from homeassistant.components import ssdp +from homeassistant.components import ssdp, zeroconf from homeassistant.components.hue import config_flow, const +from homeassistant.components.hue.errors import CannotConnect from tests.common import MockConfigEntry @@ -22,36 +22,36 @@ def hue_setup_fixture(): yield -def get_mock_bridge( - bridge_id="aabbccddeeff", host="1.2.3.4", mock_create_user=None, username=None -): - """Return a mock bridge.""" - mock_bridge = Mock() - mock_bridge.host = host - mock_bridge.username = username - mock_bridge.config.name = "Mock Bridge" - mock_bridge.id = bridge_id +def get_discovered_bridge(bridge_id="aabbccddeeff", host="1.2.3.4", supports_v2=False): + """Return a mocked Discovered Bridge.""" + return Mock(host=host, id=bridge_id, supports_v2=supports_v2) - if not mock_create_user: - async def create_user(username): - mock_bridge.username = username - - mock_create_user = create_user - - mock_bridge.create_user = mock_create_user - mock_bridge.initialize = AsyncMock() - - return mock_bridge +def create_mock_api_discovery(aioclient_mock, bridges): + """Patch aiohttp responses with fake data for bridge discovery.""" + aioclient_mock.get( + URL_NUPNP, + json=[{"internalipaddress": host, "id": id} for (host, id) in bridges], + ) + for (host, bridge_id) in bridges: + aioclient_mock.get( + f"http://{host}/api/config", + json={"bridgeid": bridge_id}, + ) + # mock v2 support if v2 found in id + aioclient_mock.get( + f"https://{host}/clip/v2/resources", + status=403 if "v2" in bridge_id else 404, + ) async def test_flow_works(hass): """Test config flow .""" - mock_bridge = get_mock_bridge() + disc_bridge = get_discovered_bridge(supports_v2=True) with patch( "homeassistant.components.hue.config_flow.discover_nupnp", - return_value=[mock_bridge], + return_value=[disc_bridge], ): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -61,7 +61,7 @@ async def test_flow_works(hass): assert result["step_id"] == "init" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"id": mock_bridge.id} + result["flow_id"], user_input={"id": disc_bridge.id} ) assert result["type"] == "form" @@ -74,23 +74,23 @@ async def test_flow_works(hass): ) assert flow["context"]["unique_id"] == "aabbccddeeff" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + with patch.object(config_flow, "create_app_key", return_value="123456789"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) assert result["type"] == "create_entry" - assert result["title"] == "Mock Bridge" + assert result["title"] == "Hue Bridge aabbccddeeff" assert result["data"] == { "host": "1.2.3.4", - "username": "home-assistant#test-home", + "api_key": "123456789", + "api_version": 2, } - assert len(mock_bridge.initialize.mock_calls) == 1 - -async def test_manual_flow_works(hass, aioclient_mock): +async def test_manual_flow_works(hass): """Test config flow discovers only already configured bridges.""" - mock_bridge = get_mock_bridge() + disc_bridge = get_discovered_bridge(bridge_id="id-1234", host="2.2.2.2") MockConfigEntry( domain="hue", source=config_entries.SOURCE_IGNORE, unique_id="bla" @@ -98,7 +98,7 @@ async def test_manual_flow_works(hass, aioclient_mock): with patch( "homeassistant.components.hue.config_flow.discover_nupnp", - return_value=[mock_bridge], + return_value=[disc_bridge], ): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -114,14 +114,7 @@ async def test_manual_flow_works(hass, aioclient_mock): assert result["type"] == "form" assert result["step_id"] == "manual" - bridge = get_mock_bridge( - bridge_id="id-1234", host="2.2.2.2", username="username-abc" - ) - - with patch( - "aiohue.Bridge", - return_value=bridge, - ): + with patch.object(config_flow, "discover_bridge", return_value=disc_bridge): result = await hass.config_entries.flow.async_configure( result["flow_id"], {"host": "2.2.2.2"} ) @@ -129,16 +122,17 @@ async def test_manual_flow_works(hass, aioclient_mock): assert result["type"] == "form" assert result["step_id"] == "link" - with patch("homeassistant.components.hue.config_flow.authenticate_bridge"), patch( + with patch.object(config_flow, "create_app_key", return_value="123456789"), patch( "homeassistant.components.hue.async_unload_entry", return_value=True ): result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == "create_entry" - assert result["title"] == "Mock Bridge" + assert result["title"] == f"Hue Bridge {disc_bridge.id}" assert result["data"] == { "host": "2.2.2.2", - "username": "username-abc", + "api_key": "123456789", + "api_version": 1, } entries = hass.config_entries.async_entries("hue") assert len(entries) == 2 @@ -146,8 +140,8 @@ async def test_manual_flow_works(hass, aioclient_mock): assert entry.unique_id == "id-1234" -async def test_manual_flow_bridge_exist(hass, aioclient_mock): - """Test config flow discovers only already configured bridges.""" +async def test_manual_flow_bridge_exist(hass): + """Test config flow aborts on already configured bridges.""" MockConfigEntry( domain="hue", unique_id="id-1234", data={"host": "2.2.2.2"} ).add_to_hass(hass) @@ -163,25 +157,17 @@ async def test_manual_flow_bridge_exist(hass, aioclient_mock): assert result["type"] == "form" assert result["step_id"] == "manual" - bridge = get_mock_bridge( - bridge_id="id-1234", host="2.2.2.2", username="username-abc" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "2.2.2.2"} ) - with patch( - "aiohue.Bridge", - return_value=bridge, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"host": "2.2.2.2"} - ) - assert result["type"] == "abort" assert result["reason"] == "already_configured" async def test_manual_flow_no_discovered_bridges(hass, aioclient_mock): """Test config flow discovers no bridges.""" - aioclient_mock.get(URL_NUPNP, json=[]) + create_mock_api_discovery(aioclient_mock, []) result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -192,9 +178,12 @@ async def test_manual_flow_no_discovered_bridges(hass, aioclient_mock): async def test_flow_all_discovered_bridges_exist(hass, aioclient_mock): """Test config flow discovers only already configured bridges.""" - aioclient_mock.get(URL_NUPNP, json=[{"internalipaddress": "1.2.3.4", "id": "bla"}]) + mock_host = "1.2.3.4" + mock_id = "bla" + create_mock_api_discovery(aioclient_mock, [(mock_host, mock_id)]) + MockConfigEntry( - domain="hue", unique_id="bla", data={"host": "1.2.3.4"} + domain="hue", unique_id=mock_id, data={"host": mock_host} ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -212,12 +201,8 @@ async def test_flow_bridges_discovered(hass, aioclient_mock): domain="hue", source=config_entries.SOURCE_IGNORE, unique_id="bla" ).add_to_hass(hass) - aioclient_mock.get( - URL_NUPNP, - json=[ - {"internalipaddress": "1.2.3.4", "id": "bla"}, - {"internalipaddress": "5.6.7.8", "id": "beer"}, - ], + create_mock_api_discovery( + aioclient_mock, [("1.2.3.4", "bla"), ("5.6.7.8", "beer_v2")] ) result = await hass.config_entries.flow.async_init( @@ -230,19 +215,13 @@ async def test_flow_bridges_discovered(hass, aioclient_mock): assert result["data_schema"]({"id": "not-discovered"}) result["data_schema"]({"id": "bla"}) - result["data_schema"]({"id": "beer"}) + result["data_schema"]({"id": "beer_v2"}) result["data_schema"]({"id": "manual"}) async def test_flow_two_bridges_discovered_one_new(hass, aioclient_mock): """Test config flow discovers two bridges.""" - aioclient_mock.get( - URL_NUPNP, - json=[ - {"internalipaddress": "1.2.3.4", "id": "bla"}, - {"internalipaddress": "5.6.7.8", "id": "beer"}, - ], - ) + create_mock_api_discovery(aioclient_mock, [("1.2.3.4", "bla"), ("5.6.7.8", "beer")]) MockConfigEntry( domain="hue", unique_id="bla", data={"host": "1.2.3.4"} ).add_to_hass(hass) @@ -273,51 +252,25 @@ async def test_flow_timeout_discovery(hass): assert result["reason"] == "discover_timeout" -async def test_flow_link_timeout(hass): - """Test config flow.""" - mock_bridge = get_mock_bridge( - mock_create_user=AsyncMock(side_effect=asyncio.TimeoutError), - ) - with patch( - "homeassistant.components.hue.config_flow.discover_nupnp", - return_value=[mock_bridge], - ): - result = await hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"id": mock_bridge.id} - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - - assert result["type"] == "abort" - assert result["reason"] == "cannot_connect" - - async def test_flow_link_unknown_error(hass): """Test if a unknown error happened during the linking processes.""" - mock_bridge = get_mock_bridge( - mock_create_user=AsyncMock(side_effect=OSError), - ) + disc_bridge = get_discovered_bridge() with patch( "homeassistant.components.hue.config_flow.discover_nupnp", - return_value=[mock_bridge], + return_value=[disc_bridge], ): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"id": mock_bridge.id} - ) + with patch.object(config_flow, "create_app_key", side_effect=Exception): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"id": disc_bridge.id} + ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) assert result["type"] == "form" assert result["step_id"] == "link" @@ -326,66 +279,69 @@ async def test_flow_link_unknown_error(hass): async def test_flow_link_button_not_pressed(hass): """Test config flow .""" - mock_bridge = get_mock_bridge( - mock_create_user=AsyncMock(side_effect=aiohue.LinkButtonNotPressed), - ) + disc_bridge = get_discovered_bridge() with patch( "homeassistant.components.hue.config_flow.discover_nupnp", - return_value=[mock_bridge], + return_value=[disc_bridge], ): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"id": mock_bridge.id} - ) + with patch.object(config_flow, "create_app_key", side_effect=LinkButtonNotPressed): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"id": disc_bridge.id} + ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) assert result["type"] == "form" assert result["step_id"] == "link" assert result["errors"] == {"base": "register_failed"} -async def test_flow_link_unknown_host(hass): +async def test_flow_link_cannot_connect(hass): """Test config flow .""" - mock_bridge = get_mock_bridge( - mock_create_user=AsyncMock(side_effect=client_exceptions.ClientOSError), - ) + disc_bridge = get_discovered_bridge() with patch( "homeassistant.components.hue.config_flow.discover_nupnp", - return_value=[mock_bridge], + return_value=[disc_bridge], ): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"id": mock_bridge.id} - ) + with patch.object(config_flow, "create_app_key", side_effect=CannotConnect): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"id": disc_bridge.id} + ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) assert result["type"] == "abort" assert result["reason"] == "cannot_connect" @pytest.mark.parametrize("mf_url", config_flow.HUE_MANUFACTURERURL) -async def test_bridge_ssdp(hass, mf_url): +async def test_bridge_ssdp(hass, mf_url, aioclient_mock): """Test a bridge being discovered.""" + create_mock_api_discovery(aioclient_mock, [("0.0.0.0", "1234")]) result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: "http://0.0.0.0/", - ssdp.ATTR_UPNP_MANUFACTURER_URL: mf_url, - ssdp.ATTR_UPNP_SERIAL: "1234", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://0.0.0.0/", + upnp={ + ssdp.ATTR_UPNP_MANUFACTURER_URL: mf_url, + ssdp.ATTR_UPNP_SERIAL: "1234", + }, + ), ) assert result["type"] == "form" @@ -397,7 +353,11 @@ async def test_bridge_ssdp_discover_other_bridge(hass): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ssdp.ATTR_UPNP_MANUFACTURER_URL: "http://www.notphilips.com"}, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + upnp={ssdp.ATTR_UPNP_MANUFACTURER_URL: "http://www.notphilips.com"}, + ), ) assert result["type"] == "abort" @@ -409,12 +369,16 @@ async def test_bridge_ssdp_emulated_hue(hass): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: "http://0.0.0.0/", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Home Assistant Bridge", - ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.HUE_MANUFACTURERURL[0], - ssdp.ATTR_UPNP_SERIAL: "1234", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://0.0.0.0/", + upnp={ + ssdp.ATTR_UPNP_FRIENDLY_NAME: "Home Assistant Bridge", + ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.HUE_MANUFACTURERURL[0], + ssdp.ATTR_UPNP_SERIAL: "1234", + }, + ), ) assert result["type"] == "abort" @@ -426,10 +390,14 @@ async def test_bridge_ssdp_missing_location(hass): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.HUE_MANUFACTURERURL[0], - ssdp.ATTR_UPNP_SERIAL: "1234", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + upnp={ + ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.HUE_MANUFACTURERURL[0], + ssdp.ATTR_UPNP_SERIAL: "1234", + }, + ), ) assert result["type"] == "abort" @@ -441,10 +409,34 @@ async def test_bridge_ssdp_missing_serial(hass): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: "http://0.0.0.0/", - ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.HUE_MANUFACTURERURL[0], - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://0.0.0.0/", + upnp={ + ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.HUE_MANUFACTURERURL[0], + }, + ), + ) + + assert result["type"] == "abort" + assert result["reason"] == "not_hue_bridge" + + +async def test_bridge_ssdp_invalid_location(hass): + """Test if discovery info is a serial attribute.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http:///", + upnp={ + ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.HUE_MANUFACTURERURL[0], + ssdp.ATTR_UPNP_SERIAL: "1234", + }, + ), ) assert result["type"] == "abort" @@ -456,20 +448,25 @@ async def test_bridge_ssdp_espalexa(hass): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: "http://0.0.0.0/", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Espalexa (0.0.0.0)", - ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.HUE_MANUFACTURERURL[0], - ssdp.ATTR_UPNP_SERIAL: "1234", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://0.0.0.0/", + upnp={ + ssdp.ATTR_UPNP_FRIENDLY_NAME: "Espalexa (0.0.0.0)", + ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.HUE_MANUFACTURERURL[0], + ssdp.ATTR_UPNP_SERIAL: "1234", + }, + ), ) assert result["type"] == "abort" assert result["reason"] == "not_hue_bridge" -async def test_bridge_ssdp_already_configured(hass): +async def test_bridge_ssdp_already_configured(hass, aioclient_mock): """Test if a discovered bridge has already been configured.""" + create_mock_api_discovery(aioclient_mock, [("0.0.0.0", "1234")]) MockConfigEntry( domain="hue", unique_id="1234", data={"host": "0.0.0.0"} ).add_to_hass(hass) @@ -477,19 +474,24 @@ async def test_bridge_ssdp_already_configured(hass): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: "http://0.0.0.0/", - ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.HUE_MANUFACTURERURL[0], - ssdp.ATTR_UPNP_SERIAL: "1234", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://0.0.0.0/", + upnp={ + ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.HUE_MANUFACTURERURL[0], + ssdp.ATTR_UPNP_SERIAL: "1234", + }, + ), ) assert result["type"] == "abort" assert result["reason"] == "already_configured" -async def test_import_with_no_config(hass): +async def test_import_with_no_config(hass, aioclient_mock): """Test importing a host without an existing config file.""" + create_mock_api_discovery(aioclient_mock, [("0.0.0.0", "1234")]) result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, @@ -500,55 +502,52 @@ async def test_import_with_no_config(hass): assert result["step_id"] == "link" -async def test_creating_entry_removes_entries_for_same_host_or_bridge(hass): +async def test_creating_entry_removes_entries_for_same_host_or_bridge( + hass, aioclient_mock +): """Test that we clean up entries for same host and bridge. An IP can only hold a single bridge and a single bridge can only be accessible via a single IP. So when we create a new entry, we'll remove all existing entries that either have same IP or same bridge_id. """ + create_mock_api_discovery(aioclient_mock, [("2.2.2.2", "id-1234")]) orig_entry = MockConfigEntry( domain="hue", - data={"host": "0.0.0.0", "username": "aaaa"}, + data={"host": "0.0.0.0", "api_key": "123456789"}, unique_id="id-1234", ) orig_entry.add_to_hass(hass) MockConfigEntry( domain="hue", - data={"host": "1.2.3.4", "username": "bbbb"}, + data={"host": "1.2.3.4", "api_key": "123456789"}, unique_id="id-5678", ).add_to_hass(hass) assert len(hass.config_entries.async_entries("hue")) == 2 - bridge = get_mock_bridge( - bridge_id="id-1234", host="2.2.2.2", username="username-abc" + result = await hass.config_entries.flow.async_init( + "hue", + data={"host": "2.2.2.2"}, + context={"source": config_entries.SOURCE_IMPORT}, ) - with patch( - "aiohue.Bridge", - return_value=bridge, - ): - result = await hass.config_entries.flow.async_init( - "hue", - data={"host": "2.2.2.2"}, - context={"source": config_entries.SOURCE_IMPORT}, - ) - assert result["type"] == "form" assert result["step_id"] == "link" - with patch("homeassistant.components.hue.config_flow.authenticate_bridge"), patch( - "homeassistant.components.hue.async_unload_entry", return_value=True - ): + with patch( + "homeassistant.components.hue.config_flow.create_app_key", + return_value="123456789", + ), patch("homeassistant.components.hue.async_unload_entry", return_value=True): result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == "create_entry" - assert result["title"] == "Mock Bridge" + assert result["title"] == "Hue Bridge id-1234" assert result["data"] == { "host": "2.2.2.2", - "username": "username-abc", + "api_key": "123456789", + "api_version": 1, } entries = hass.config_entries.async_entries("hue") assert len(entries) == 2 @@ -559,17 +558,19 @@ async def test_creating_entry_removes_entries_for_same_host_or_bridge(hass): async def test_bridge_homekit(hass, aioclient_mock): """Test a bridge being discovered via HomeKit.""" - aioclient_mock.get(URL_NUPNP, json=[{"internalipaddress": "1.2.3.4", "id": "bla"}]) + create_mock_api_discovery(aioclient_mock, [("0.0.0.0", "bla")]) result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data={ - "host": "0.0.0.0", - "serial": "1234", - "manufacturerURL": config_flow.HUE_MANUFACTURERURL, - "properties": {"id": "aa:bb:cc:dd:ee:ff"}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="0.0.0.0", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + type="mock_type", + ), ) assert result["type"] == "form" @@ -599,8 +600,9 @@ async def test_bridge_import_already_configured(hass): assert result["reason"] == "already_configured" -async def test_bridge_homekit_already_configured(hass): +async def test_bridge_homekit_already_configured(hass, aioclient_mock): """Test if a HomeKit discovered bridge has already been configured.""" + create_mock_api_discovery(aioclient_mock, [("0.0.0.0", "aabbccddeeff")]) MockConfigEntry( domain="hue", unique_id="aabbccddeeff", data={"host": "0.0.0.0"} ).add_to_hass(hass) @@ -608,15 +610,23 @@ async def test_bridge_homekit_already_configured(hass): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data={"host": "0.0.0.0", "properties": {"id": "aa:bb:cc:dd:ee:ff"}}, + data=zeroconf.ZeroconfServiceInfo( + host="0.0.0.0", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + type="mock_type", + ), ) assert result["type"] == "abort" assert result["reason"] == "already_configured" -async def test_ssdp_discovery_update_configuration(hass): +async def test_ssdp_discovery_update_configuration(hass, aioclient_mock): """Test if a discovered bridge is configured and updated with new host.""" + create_mock_api_discovery(aioclient_mock, [("1.1.1.1", "aabbccddeeff")]) entry = MockConfigEntry( domain="hue", unique_id="aabbccddeeff", data={"host": "0.0.0.0"} ) @@ -625,11 +635,15 @@ async def test_ssdp_discovery_update_configuration(hass): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1/", - ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.HUE_MANUFACTURERURL[0], - ssdp.ATTR_UPNP_SERIAL: "aabbccddeeff", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://1.1.1.1/", + upnp={ + ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.HUE_MANUFACTURERURL[0], + ssdp.ATTR_UPNP_SERIAL: "aabbccddeeff", + }, + ), ) assert result["type"] == "abort" @@ -637,8 +651,8 @@ async def test_ssdp_discovery_update_configuration(hass): assert entry.data["host"] == "1.1.1.1" -async def test_options_flow(hass): - """Test options config flow.""" +async def test_options_flow_v1(hass): + """Test options config flow for a V1 bridge.""" entry = MockConfigEntry( domain="hue", unique_id="aabbccddeeff", @@ -683,31 +697,47 @@ def _get_schema_default(schema, key_name): raise KeyError(f"{key_name} not found in schema") -async def test_bridge_zeroconf(hass): +async def test_options_flow_v2(hass): + """Test options config flow for a V2 bridge.""" + entry = MockConfigEntry( + domain="hue", + unique_id="v2bridge", + data={"host": "0.0.0.0", "api_version": 2}, + ) + entry.add_to_hass(hass) + + assert config_flow.HueFlowHandler.async_supports_options_flow(entry) is False + + +async def test_bridge_zeroconf(hass, aioclient_mock): """Test a bridge being discovered.""" + create_mock_api_discovery(aioclient_mock, [("192.168.1.217", "ecb5fafffeabcabc")]) result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "host": "192.168.1.217", - "port": 443, - "hostname": "Philips-hue.local.", - "type": "_hue._tcp.local.", - "name": "Philips Hue - ABCABC._hue._tcp.local.", - "properties": { + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.217", + port=443, + hostname="Philips-hue.local", + type="_hue._tcp.local.", + name="Philips Hue - ABCABC._hue._tcp.local.", + properties={ "_raw": {"bridgeid": b"ecb5fafffeabcabc", "modelid": b"BSB002"}, "bridgeid": "ecb5fafffeabcabc", "modelid": "BSB002", }, - }, + ), ) assert result["type"] == "form" assert result["step_id"] == "link" -async def test_bridge_zeroconf_already_exists(hass): +async def test_bridge_zeroconf_already_exists(hass, aioclient_mock): """Test a bridge being discovered by zeroconf already exists.""" + create_mock_api_discovery( + aioclient_mock, [("0.0.0.0", "ecb5faabcabc"), ("192.168.1.217", "ecb5faabcabc")] + ) entry = MockConfigEntry( domain="hue", source=config_entries.SOURCE_SSDP, @@ -718,18 +748,18 @@ async def test_bridge_zeroconf_already_exists(hass): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "host": "192.168.1.217", - "port": 443, - "hostname": "Philips-hue.local.", - "type": "_hue._tcp.local.", - "name": "Philips Hue - ABCABC._hue._tcp.local.", - "properties": { + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.217", + port=443, + hostname="Philips-hue.local", + type="_hue._tcp.local.", + name="Philips Hue - ABCABC._hue._tcp.local.", + properties={ "_raw": {"bridgeid": b"ecb5faabcabc", "modelid": b"BSB002"}, "bridgeid": "ecb5faabcabc", "modelid": "BSB002", }, - }, + ), ) assert result["type"] == "abort" diff --git a/tests/components/hue/test_device_trigger.py b/tests/components/hue/test_device_trigger_v1.py similarity index 71% rename from tests/components/hue/test_device_trigger.py rename to tests/components/hue/test_device_trigger_v1.py index d0c20018c30..fcb6ca5668e 100644 --- a/tests/components/hue/test_device_trigger.py +++ b/tests/components/hue/test_device_trigger_v1.py @@ -1,43 +1,23 @@ -"""The tests for Philips Hue device triggers.""" -import pytest +"""The tests for Philips Hue device triggers for V1 bridge.""" -from homeassistant.components import hue -import homeassistant.components.automation as automation -from homeassistant.components.hue import device_trigger +from homeassistant.components import automation, hue +from homeassistant.components.hue.v1 import device_trigger from homeassistant.setup import async_setup_component -from .conftest import setup_bridge_for_sensors as setup_bridge -from .test_sensor_base import HUE_DIMMER_REMOTE_1, HUE_TAP_REMOTE_1 +from .conftest import setup_platform +from .test_sensor_v1 import HUE_DIMMER_REMOTE_1, HUE_TAP_REMOTE_1 -from tests.common import ( - assert_lists_same, - async_get_device_automations, - async_mock_service, - mock_device_registry, -) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 +from tests.common import assert_lists_same, async_get_device_automations REMOTES_RESPONSE = {"7": HUE_TAP_REMOTE_1, "8": HUE_DIMMER_REMOTE_1} -@pytest.fixture -def device_reg(hass): - """Return an empty, loaded, registry.""" - return mock_device_registry(hass) - - -@pytest.fixture -def calls(hass): - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") - - -async def test_get_triggers(hass, mock_bridge, device_reg): +async def test_get_triggers(hass, mock_bridge_v1, device_reg): """Test we get the expected triggers from a hue remote.""" - mock_bridge.mock_sensor_responses.append(REMOTES_RESPONSE) - await setup_bridge(hass, mock_bridge) + mock_bridge_v1.mock_sensor_responses.append(REMOTES_RESPONSE) + await setup_platform(hass, mock_bridge_v1, ["sensor", "binary_sensor"]) - assert len(mock_bridge.mock_requests) == 1 + assert len(mock_bridge_v1.mock_requests) == 1 # 2 remotes, just 1 battery sensor assert len(hass.states.async_all()) == 1 @@ -88,11 +68,11 @@ async def test_get_triggers(hass, mock_bridge, device_reg): assert_lists_same(triggers, expected_triggers) -async def test_if_fires_on_state_change(hass, mock_bridge, device_reg, calls): +async def test_if_fires_on_state_change(hass, mock_bridge_v1, device_reg, calls): """Test for button press trigger firing.""" - mock_bridge.mock_sensor_responses.append(REMOTES_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 1 + mock_bridge_v1.mock_sensor_responses.append(REMOTES_RESPONSE) + await setup_platform(hass, mock_bridge_v1, ["sensor", "binary_sensor"]) + assert len(mock_bridge_v1.mock_requests) == 1 assert len(hass.states.async_all()) == 1 # Set an automation with a specific tap switch trigger @@ -145,13 +125,13 @@ async def test_if_fires_on_state_change(hass, mock_bridge, device_reg, calls): "buttonevent": 18, "lastupdated": "2019-12-28T22:58:02", } - mock_bridge.mock_sensor_responses.append(new_sensor_response) + mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again - await mock_bridge.sensor_manager.coordinator.async_refresh() + await mock_bridge_v1.sensor_manager.coordinator.async_refresh() await hass.async_block_till_done() - assert len(mock_bridge.mock_requests) == 2 + assert len(mock_bridge_v1.mock_requests) == 2 assert len(calls) == 1 assert calls[0].data["some"] == "B4 - 18" @@ -162,10 +142,10 @@ async def test_if_fires_on_state_change(hass, mock_bridge, device_reg, calls): "buttonevent": 34, "lastupdated": "2019-12-28T22:58:05", } - mock_bridge.mock_sensor_responses.append(new_sensor_response) + mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again - await mock_bridge.sensor_manager.coordinator.async_refresh() + await mock_bridge_v1.sensor_manager.coordinator.async_refresh() await hass.async_block_till_done() - assert len(mock_bridge.mock_requests) == 3 + assert len(mock_bridge_v1.mock_requests) == 3 assert len(calls) == 1 diff --git a/tests/components/hue/test_device_trigger_v2.py b/tests/components/hue/test_device_trigger_v2.py new file mode 100644 index 00000000000..0641281b9fa --- /dev/null +++ b/tests/components/hue/test_device_trigger_v2.py @@ -0,0 +1,90 @@ +"""The tests for Philips Hue device triggers for V2 bridge.""" +from aiohue.v2.models.button import ButtonEvent + +from homeassistant.components import hue +from homeassistant.components.hue.v2.device import async_setup_devices +from homeassistant.components.hue.v2.hue_event import async_setup_hue_events + +from .conftest import setup_platform + +from tests.common import ( + assert_lists_same, + async_capture_events, + async_get_device_automations, +) + + +async def test_hue_event(hass, mock_bridge_v2, v2_resources_test_data): + """Test hue button events.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, ["binary_sensor", "sensor"]) + await async_setup_devices(mock_bridge_v2) + await async_setup_hue_events(mock_bridge_v2) + + events = async_capture_events(hass, "hue_event") + + # Emit button update event + btn_event = { + "button": {"last_event": "short_release"}, + "id": "c658d3d8-a013-4b81-8ac6-78b248537e70", + "metadata": {"control_id": 1}, + "type": "button", + } + mock_bridge_v2.api.emit_event("update", btn_event) + + # wait for the event + await hass.async_block_till_done() + await hass.async_block_till_done() + assert len(events) == 1 + assert events[0].data["id"] == "wall_switch_with_2_controls_button" + assert events[0].data["unique_id"] == btn_event["id"] + assert events[0].data["type"] == btn_event["button"]["last_event"] + assert events[0].data["subtype"] == btn_event["metadata"]["control_id"] + + +async def test_get_triggers(hass, mock_bridge_v2, v2_resources_test_data, device_reg): + """Test we get the expected triggers from a hue remote.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, ["binary_sensor", "sensor"]) + + # Get triggers for `Wall switch with 2 controls` + hue_wall_switch_device = device_reg.async_get_device( + {(hue.DOMAIN, "3ff06175-29e8-44a8-8fe7-af591b0025da")} + ) + triggers = await async_get_device_automations( + hass, "trigger", hue_wall_switch_device.id + ) + + trigger_batt = { + "platform": "device", + "domain": "sensor", + "device_id": hue_wall_switch_device.id, + "type": "battery_level", + "entity_id": "sensor.wall_switch_with_2_controls_battery", + } + + expected_triggers = [ + trigger_batt, + *( + { + "platform": "device", + "domain": hue.DOMAIN, + "device_id": hue_wall_switch_device.id, + "unique_id": resource_id, + "type": event_type.value, + "subtype": control_id, + } + for event_type in ( + ButtonEvent.INITIAL_PRESS, + ButtonEvent.LONG_RELEASE, + ButtonEvent.REPEAT, + ButtonEvent.SHORT_RELEASE, + ) + for control_id, resource_id in ( + (1, "c658d3d8-a013-4b81-8ac6-78b248537e70"), + (2, "be1eb834-bdf5-4d26-8fba-7b1feaa83a9d"), + ) + ), + ] + + assert_lists_same(triggers, expected_triggers) diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py index 05a7ade6948..3bce9f9ec83 100644 --- a/tests/components/hue/test_init.py +++ b/tests/components/hue/test_init.py @@ -1,6 +1,7 @@ """Test Hue setup process.""" from unittest.mock import AsyncMock, Mock, patch +import aiohue.v2 as aiohue_v2 import pytest from homeassistant import config_entries @@ -14,8 +15,20 @@ from tests.common import MockConfigEntry def mock_bridge_setup(): """Mock bridge setup.""" with patch.object(hue, "HueBridge") as mock_bridge: - mock_bridge.return_value.async_setup = AsyncMock(return_value=True) - mock_bridge.return_value.api.config = Mock(bridgeid="mock-id") + mock_bridge.return_value.api_version = 2 + mock_bridge.return_value.async_initialize_bridge = AsyncMock(return_value=True) + mock_bridge.return_value.api.config = Mock( + bridge_id="mock-id", + mac_address="00:00:00:00:00:00", + model_id="BSB002", + software_version="1.0.0", + bridge_device=Mock( + id="4a507550-8742-4087-8bf5-c2334f29891c", + product_data=Mock(manufacturer_name="Mock"), + ), + spec=aiohue_v2.ConfigController, + ) + mock_bridge.return_value.api.config.name = "Mock Hue bridge" yield mock_bridge.return_value @@ -32,7 +45,9 @@ async def test_setup_with_no_config(hass): async def test_unload_entry(hass, mock_bridge_setup): """Test being able to unload an entry.""" - entry = MockConfigEntry(domain=hue.DOMAIN, data={"host": "0.0.0.0"}) + entry = MockConfigEntry( + domain=hue.DOMAIN, data={"host": "0.0.0.0", "api_version": 2} + ) entry.add_to_hass(hass) assert await async_setup_component(hass, hue.DOMAIN, {}) is True @@ -51,7 +66,9 @@ async def test_unload_entry(hass, mock_bridge_setup): async def test_setting_unique_id(hass, mock_bridge_setup): """Test we set unique ID if not set yet.""" - entry = MockConfigEntry(domain=hue.DOMAIN, data={"host": "0.0.0.0"}) + entry = MockConfigEntry( + domain=hue.DOMAIN, data={"host": "0.0.0.0", "api_version": 2} + ) entry.add_to_hass(hass) assert await async_setup_component(hass, hue.DOMAIN, {}) is True assert entry.unique_id == "mock-id" @@ -60,7 +77,9 @@ async def test_setting_unique_id(hass, mock_bridge_setup): async def test_fixing_unique_id_no_other(hass, mock_bridge_setup): """Test we set unique ID if not set yet.""" entry = MockConfigEntry( - domain=hue.DOMAIN, data={"host": "0.0.0.0"}, unique_id="invalid-id" + domain=hue.DOMAIN, + data={"host": "0.0.0.0", "api_version": 2}, + unique_id="invalid-id", ) entry.add_to_hass(hass) assert await async_setup_component(hass, hue.DOMAIN, {}) is True @@ -71,13 +90,13 @@ async def test_fixing_unique_id_other_ignored(hass, mock_bridge_setup): """Test we set unique ID if not set yet.""" MockConfigEntry( domain=hue.DOMAIN, - data={"host": "0.0.0.0"}, + data={"host": "0.0.0.0", "api_version": 2}, unique_id="mock-id", source=config_entries.SOURCE_IGNORE, ).add_to_hass(hass) entry = MockConfigEntry( domain=hue.DOMAIN, - data={"host": "0.0.0.0"}, + data={"host": "0.0.0.0", "api_version": 2}, unique_id="invalid-id", ) entry.add_to_hass(hass) @@ -91,13 +110,13 @@ async def test_fixing_unique_id_other_correct(hass, mock_bridge_setup): """Test we remove config entry if another one has correct ID.""" correct_entry = MockConfigEntry( domain=hue.DOMAIN, - data={"host": "0.0.0.0"}, + data={"host": "0.0.0.0", "api_version": 2}, unique_id="mock-id", ) correct_entry.add_to_hass(hass) entry = MockConfigEntry( domain=hue.DOMAIN, - data={"host": "0.0.0.0"}, + data={"host": "0.0.0.0", "api_version": 2}, unique_id="invalid-id", ) entry.add_to_hass(hass) @@ -108,19 +127,27 @@ async def test_fixing_unique_id_other_correct(hass, mock_bridge_setup): async def test_security_vuln_check(hass): """Test that we report security vulnerabilities.""" - - entry = MockConfigEntry(domain=hue.DOMAIN, data={"host": "0.0.0.0"}) + entry = MockConfigEntry( + domain=hue.DOMAIN, data={"host": "0.0.0.0", "api_version": 1} + ) entry.add_to_hass(hass) - config = Mock(bridgeid="", mac="", modelid="BSB002", swversion="1935144020") + config = Mock( + bridge_id="", + mac_address="", + model_id="BSB002", + software_version="1935144020", + ) config.name = "Hue" - with patch.object( + with patch.object(hue.migration, "is_v2_bridge", return_value=False), patch.object( hue, "HueBridge", Mock( return_value=Mock( - async_setup=AsyncMock(return_value=True), api=Mock(config=config) + async_initialize_bridge=AsyncMock(return_value=True), + api=Mock(config=config), + api_version=1, ) ), ): diff --git a/tests/components/hue/test_init_multiple_bridges.py b/tests/components/hue/test_init_multiple_bridges.py deleted file mode 100644 index 1e3df824a38..00000000000 --- a/tests/components/hue/test_init_multiple_bridges.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Test Hue init with multiple bridges.""" -from unittest.mock import patch - -import pytest - -from homeassistant.components import hue -from homeassistant.setup import async_setup_component - -from .conftest import create_mock_bridge - -from tests.common import MockConfigEntry - - -async def setup_component(hass): - """Hue component.""" - with patch.object(hue, "async_setup_entry", return_value=True): - assert ( - await async_setup_component( - hass, - hue.DOMAIN, - {}, - ) - is True - ) - - -async def test_hue_activate_scene_both_responds( - hass, mock_bridge1, mock_bridge2, mock_config_entry1, mock_config_entry2 -): - """Test that makes both bridges successfully activate a scene.""" - - await setup_component(hass) - - await setup_bridge(hass, mock_bridge1, mock_config_entry1) - await setup_bridge(hass, mock_bridge2, mock_config_entry2) - - with patch.object( - mock_bridge1, "hue_activate_scene", return_value=None - ) as mock_hue_activate_scene1, patch.object( - mock_bridge2, "hue_activate_scene", return_value=None - ) as mock_hue_activate_scene2: - await hass.services.async_call( - "hue", - "hue_activate_scene", - {"group_name": "group_2", "scene_name": "my_scene"}, - blocking=True, - ) - - mock_hue_activate_scene1.assert_called_once() - mock_hue_activate_scene2.assert_called_once() - - -async def test_hue_activate_scene_one_responds( - hass, mock_bridge1, mock_bridge2, mock_config_entry1, mock_config_entry2 -): - """Test that makes only one bridge successfully activate a scene.""" - - await setup_component(hass) - - await setup_bridge(hass, mock_bridge1, mock_config_entry1) - await setup_bridge(hass, mock_bridge2, mock_config_entry2) - - with patch.object( - mock_bridge1, "hue_activate_scene", return_value=None - ) as mock_hue_activate_scene1, patch.object( - mock_bridge2, "hue_activate_scene", return_value=False - ) as mock_hue_activate_scene2: - await hass.services.async_call( - "hue", - "hue_activate_scene", - {"group_name": "group_2", "scene_name": "my_scene"}, - blocking=True, - ) - - mock_hue_activate_scene1.assert_called_once() - mock_hue_activate_scene2.assert_called_once() - - -async def test_hue_activate_scene_zero_responds( - hass, mock_bridge1, mock_bridge2, mock_config_entry1, mock_config_entry2 -): - """Test that makes no bridge successfully activate a scene.""" - - await setup_component(hass) - - await setup_bridge(hass, mock_bridge1, mock_config_entry1) - await setup_bridge(hass, mock_bridge2, mock_config_entry2) - - with patch.object( - mock_bridge1, "hue_activate_scene", return_value=False - ) as mock_hue_activate_scene1, patch.object( - mock_bridge2, "hue_activate_scene", return_value=False - ) as mock_hue_activate_scene2: - await hass.services.async_call( - "hue", - "hue_activate_scene", - {"group_name": "group_2", "scene_name": "my_scene"}, - blocking=True, - ) - - # both were retried - assert mock_hue_activate_scene1.call_count == 2 - assert mock_hue_activate_scene2.call_count == 2 - - -async def setup_bridge(hass, mock_bridge, config_entry): - """Load the Hue light platform with the provided bridge.""" - mock_bridge.config_entry = config_entry - config_entry.add_to_hass(hass) - with patch("homeassistant.components.hue.HueBridge", return_value=mock_bridge): - await hass.config_entries.async_setup(config_entry.entry_id) - - -@pytest.fixture -def mock_config_entry1(hass): - """Mock a config entry.""" - return create_config_entry() - - -@pytest.fixture -def mock_config_entry2(hass): - """Mock a config entry.""" - return create_config_entry() - - -def create_config_entry(): - """Mock a config entry.""" - return MockConfigEntry( - domain=hue.DOMAIN, - data={"host": "mock-host"}, - ) - - -@pytest.fixture -def mock_bridge1(hass): - """Mock a Hue bridge.""" - return create_mock_bridge(hass) - - -@pytest.fixture -def mock_bridge2(hass): - """Mock a Hue bridge.""" - return create_mock_bridge(hass) diff --git a/tests/components/hue/test_light.py b/tests/components/hue/test_light_v1.py similarity index 77% rename from tests/components/hue/test_light.py rename to tests/components/hue/test_light_v1.py index 6025b725c60..8c82a544ede 100644 --- a/tests/components/hue/test_light.py +++ b/tests/components/hue/test_light_v1.py @@ -4,12 +4,14 @@ from unittest.mock import Mock import aiohue -from homeassistant import config_entries from homeassistant.components import hue -from homeassistant.components.hue import light as hue_light +from homeassistant.components.hue.const import CONF_ALLOW_HUE_GROUPS +from homeassistant.components.hue.v1 import light as hue_light from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import color +from .conftest import create_config_entry + HUE_LIGHT_NS = "homeassistant.components.light.hue." GROUP_RESPONSE = { "1": { @@ -170,50 +172,43 @@ LIGHT_GAMUT = color.GamutType( LIGHT_GAMUT_TYPE = "A" -async def setup_bridge(hass, mock_bridge): +async def setup_bridge(hass, mock_bridge_v1): """Load the Hue light platform with the provided bridge.""" hass.config.components.add(hue.DOMAIN) - config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "mock-host"}, - "test", - ) - mock_bridge.config_entry = config_entry - hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge} + config_entry = create_config_entry() + config_entry.options = {CONF_ALLOW_HUE_GROUPS: True} + mock_bridge_v1.config_entry = config_entry + hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge_v1} await hass.config_entries.async_forward_entry_setup(config_entry, "light") # To flush out the service call to update the group await hass.async_block_till_done() -async def test_not_load_groups_if_old_bridge(hass, mock_bridge): - """Test that we don't try to load gorups if bridge runs old software.""" - mock_bridge.api.config.apiversion = "1.12.0" - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 1 +async def test_not_load_groups_if_old_bridge(hass, mock_bridge_v1): + """Test that we don't try to load groups if bridge runs old software.""" + mock_bridge_v1.api.config.apiversion = "1.12.0" + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append(GROUP_RESPONSE) + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 1 assert len(hass.states.async_all()) == 0 -async def test_no_lights_or_groups(hass, mock_bridge): +async def test_no_lights_or_groups(hass, mock_bridge_v1): """Test the update_lights function when no lights are found.""" - mock_bridge.allow_groups = True - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append({}) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append({}) + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 0 -async def test_lights(hass, mock_bridge): +async def test_lights(hass, mock_bridge_v1): """Test the update_lights function with some lights.""" - mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.mock_light_responses.append(LIGHT_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 # 2 lights assert len(hass.states.async_all()) == 2 @@ -228,12 +223,12 @@ async def test_lights(hass, mock_bridge): assert lamp_2.state == "off" -async def test_lights_color_mode(hass, mock_bridge): +async def test_lights_color_mode(hass, mock_bridge_v1): """Test that lights only report appropriate color mode.""" - mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.mock_light_responses.append(LIGHT_RESPONSE) + mock_bridge_v1.mock_group_responses.append(GROUP_RESPONSE) - await setup_bridge(hass, mock_bridge) + await setup_bridge(hass, mock_bridge_v1) lamp_1 = hass.states.get("light.hue_lamp_1") assert lamp_1 is not None @@ -245,15 +240,15 @@ async def test_lights_color_mode(hass, mock_bridge): new_light1_on = LIGHT_1_ON.copy() new_light1_on["state"] = new_light1_on["state"].copy() new_light1_on["state"]["colormode"] = "ct" - mock_bridge.mock_light_responses.append({"1": new_light1_on}) - mock_bridge.mock_group_responses.append({}) + mock_bridge_v1.mock_light_responses.append({"1": new_light1_on}) + mock_bridge_v1.mock_group_responses.append({}) # Calling a service will trigger the updates to run await hass.services.async_call( "light", "turn_on", {"entity_id": "light.hue_lamp_2"}, blocking=True ) # 2x light update, 1 group update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 lamp_1 = hass.states.get("light.hue_lamp_1") assert lamp_1 is not None @@ -263,18 +258,13 @@ async def test_lights_color_mode(hass, mock_bridge): assert "hs_color" in lamp_1.attributes -async def test_groups(hass, mock_bridge): +async def test_groups(hass, mock_bridge_v1): """Test the update_lights function with some lights.""" - mock_bridge.allow_groups = True - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) - mock_bridge.api.groups._v2_resources = [ - {"id_v1": "/groups/1", "id": "group-1-mock-id", "type": "room"}, - {"id_v1": "/groups/2", "id": "group-2-mock-id", "type": "room"}, - ] + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append(GROUP_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 # 2 hue group lights assert len(hass.states.async_all()) == 2 @@ -289,18 +279,18 @@ async def test_groups(hass, mock_bridge): assert lamp_2.state == "on" ent_reg = er.async_get(hass) - assert ent_reg.async_get("light.group_1").unique_id == "group-1-mock-id" - assert ent_reg.async_get("light.group_2").unique_id == "group-2-mock-id" + assert ent_reg.async_get("light.group_1").unique_id == "1" + assert ent_reg.async_get("light.group_2").unique_id == "2" -async def test_new_group_discovered(hass, mock_bridge): +async def test_new_group_discovered(hass, mock_bridge_v1): """Test if 2nd update has a new group.""" - mock_bridge.allow_groups = True - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.allow_groups = True + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append(GROUP_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 2 new_group_response = dict(GROUP_RESPONSE) @@ -322,15 +312,15 @@ async def test_new_group_discovered(hass, mock_bridge): "state": {"any_on": True, "all_on": False}, } - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append(new_group_response) + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append(new_group_response) # Calling a service will trigger the updates to run await hass.services.async_call( "light", "turn_on", {"entity_id": "light.group_1"}, blocking=True ) # 2x group update, 1x light update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 assert len(hass.states.async_all()) == 3 new_group = hass.states.get("light.group_3") @@ -340,13 +330,12 @@ async def test_new_group_discovered(hass, mock_bridge): assert new_group.attributes["color_temp"] == 250 -async def test_new_light_discovered(hass, mock_bridge): +async def test_new_light_discovered(hass, mock_bridge_v1): """Test if 2nd update has a new light.""" - mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.mock_light_responses.append(LIGHT_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 2 new_light_response = dict(LIGHT_RESPONSE) @@ -372,14 +361,14 @@ async def test_new_light_discovered(hass, mock_bridge): "uniqueid": "789", } - mock_bridge.mock_light_responses.append(new_light_response) + mock_bridge_v1.mock_light_responses.append(new_light_response) # Calling a service will trigger the updates to run await hass.services.async_call( "light", "turn_on", {"entity_id": "light.hue_lamp_1"}, blocking=True ) # 2x light update, 1 group update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 assert len(hass.states.async_all()) == 3 light = hass.states.get("light.hue_lamp_3") @@ -387,18 +376,18 @@ async def test_new_light_discovered(hass, mock_bridge): assert light.state == "off" -async def test_group_removed(hass, mock_bridge): +async def test_group_removed(hass, mock_bridge_v1): """Test if 2nd update has removed group.""" - mock_bridge.allow_groups = True - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.allow_groups = True + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append(GROUP_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 2 - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append({"1": GROUP_RESPONSE["1"]}) + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append({"1": GROUP_RESPONSE["1"]}) # Calling a service will trigger the updates to run await hass.services.async_call( @@ -406,7 +395,7 @@ async def test_group_removed(hass, mock_bridge): ) # 2x group update, 1x light update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 assert len(hass.states.async_all()) == 1 group = hass.states.get("light.group_1") @@ -416,17 +405,16 @@ async def test_group_removed(hass, mock_bridge): assert removed_group is None -async def test_light_removed(hass, mock_bridge): +async def test_light_removed(hass, mock_bridge_v1): """Test if 2nd update has removed light.""" - mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.mock_light_responses.append(LIGHT_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 2 - mock_bridge.mock_light_responses.clear() - mock_bridge.mock_light_responses.append({"1": LIGHT_RESPONSE.get("1")}) + mock_bridge_v1.mock_light_responses.clear() + mock_bridge_v1.mock_light_responses.append({"1": LIGHT_RESPONSE.get("1")}) # Calling a service will trigger the updates to run await hass.services.async_call( @@ -434,7 +422,7 @@ async def test_light_removed(hass, mock_bridge): ) # 2x light update, 1 group update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 assert len(hass.states.async_all()) == 1 light = hass.states.get("light.hue_lamp_1") @@ -444,14 +432,14 @@ async def test_light_removed(hass, mock_bridge): assert removed_light is None -async def test_other_group_update(hass, mock_bridge): +async def test_other_group_update(hass, mock_bridge_v1): """Test changing one group that will impact the state of other light.""" - mock_bridge.allow_groups = True - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.allow_groups = True + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append(GROUP_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 2 group_2 = hass.states.get("light.group_2") @@ -480,15 +468,15 @@ async def test_other_group_update(hass, mock_bridge): "state": {"any_on": False, "all_on": False}, } - mock_bridge.mock_light_responses.append({}) - mock_bridge.mock_group_responses.append(updated_group_response) + mock_bridge_v1.mock_light_responses.append({}) + mock_bridge_v1.mock_group_responses.append(updated_group_response) # Calling a service will trigger the updates to run await hass.services.async_call( "light", "turn_on", {"entity_id": "light.group_1"}, blocking=True ) # 2x group update, 1x light update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 assert len(hass.states.async_all()) == 2 group_2 = hass.states.get("light.group_2") @@ -497,13 +485,12 @@ async def test_other_group_update(hass, mock_bridge): assert group_2.state == "off" -async def test_other_light_update(hass, mock_bridge): +async def test_other_light_update(hass, mock_bridge_v1): """Test changing one light that will impact state of other light.""" - mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.mock_light_responses.append(LIGHT_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 2 lamp_2 = hass.states.get("light.hue_lamp_2") @@ -534,14 +521,14 @@ async def test_other_light_update(hass, mock_bridge): "uniqueid": "123", } - mock_bridge.mock_light_responses.append(updated_light_response) + mock_bridge_v1.mock_light_responses.append(updated_light_response) # Calling a service will trigger the updates to run await hass.services.async_call( "light", "turn_on", {"entity_id": "light.hue_lamp_1"}, blocking=True ) # 2x light update, 1 group update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 assert len(hass.states.async_all()) == 2 lamp_2 = hass.states.get("light.hue_lamp_2") @@ -551,30 +538,29 @@ async def test_other_light_update(hass, mock_bridge): assert lamp_2.attributes["brightness"] == 100 -async def test_update_timeout(hass, mock_bridge): +async def test_update_timeout(hass, mock_bridge_v1): """Test bridge marked as not available if timeout error during update.""" - mock_bridge.api.lights.update = Mock(side_effect=asyncio.TimeoutError) - mock_bridge.api.groups.update = Mock(side_effect=asyncio.TimeoutError) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 0 + mock_bridge_v1.api.lights.update = Mock(side_effect=asyncio.TimeoutError) + mock_bridge_v1.api.groups.update = Mock(side_effect=asyncio.TimeoutError) + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 0 assert len(hass.states.async_all()) == 0 -async def test_update_unauthorized(hass, mock_bridge): +async def test_update_unauthorized(hass, mock_bridge_v1): """Test bridge marked as not authorized if unauthorized during update.""" - mock_bridge.api.lights.update = Mock(side_effect=aiohue.Unauthorized) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 0 + mock_bridge_v1.api.lights.update = Mock(side_effect=aiohue.Unauthorized) + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 0 assert len(hass.states.async_all()) == 0 - assert len(mock_bridge.handle_unauthorized_error.mock_calls) == 1 + assert len(mock_bridge_v1.handle_unauthorized_error.mock_calls) == 1 -async def test_light_turn_on_service(hass, mock_bridge): +async def test_light_turn_on_service(hass, mock_bridge_v1): """Test calling the turn on service on a light.""" - mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.mock_light_responses.append(LIGHT_RESPONSE) - await setup_bridge(hass, mock_bridge) + await setup_bridge(hass, mock_bridge_v1) light = hass.states.get("light.hue_lamp_2") assert light is not None assert light.state == "off" @@ -582,7 +568,7 @@ async def test_light_turn_on_service(hass, mock_bridge): updated_light_response = dict(LIGHT_RESPONSE) updated_light_response["2"] = LIGHT_2_ON - mock_bridge.mock_light_responses.append(updated_light_response) + mock_bridge_v1.mock_light_responses.append(updated_light_response) await hass.services.async_call( "light", @@ -590,10 +576,10 @@ async def test_light_turn_on_service(hass, mock_bridge): {"entity_id": "light.hue_lamp_2", "brightness": 100, "color_temp": 300}, blocking=True, ) - # 2x light update, 1 group update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + # 2x light update, 1x group update, 1 turn on request + assert len(mock_bridge_v1.mock_requests) == 4 - assert mock_bridge.mock_requests[2]["json"] == { + assert mock_bridge_v1.mock_requests[2]["json"] == { "bri": 100, "on": True, "ct": 300, @@ -614,21 +600,20 @@ async def test_light_turn_on_service(hass, mock_bridge): blocking=True, ) - assert len(mock_bridge.mock_requests) == 6 + assert len(mock_bridge_v1.mock_requests) == 5 - assert mock_bridge.mock_requests[4]["json"] == { + assert mock_bridge_v1.mock_requests[4]["json"] == { "on": True, "xy": (0.138, 0.08), "alert": "none", } -async def test_light_turn_off_service(hass, mock_bridge): +async def test_light_turn_off_service(hass, mock_bridge_v1): """Test calling the turn on service on a light.""" - mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) - mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + mock_bridge_v1.mock_light_responses.append(LIGHT_RESPONSE) - await setup_bridge(hass, mock_bridge) + await setup_bridge(hass, mock_bridge_v1) light = hass.states.get("light.hue_lamp_1") assert light is not None assert light.state == "on" @@ -636,16 +621,16 @@ async def test_light_turn_off_service(hass, mock_bridge): updated_light_response = dict(LIGHT_RESPONSE) updated_light_response["1"] = LIGHT_1_OFF - mock_bridge.mock_light_responses.append(updated_light_response) + mock_bridge_v1.mock_light_responses.append(updated_light_response) await hass.services.async_call( "light", "turn_off", {"entity_id": "light.hue_lamp_1"}, blocking=True ) # 2x light update, 1 for group update, 1 turn on request - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 - assert mock_bridge.mock_requests[2]["json"] == {"on": False, "alert": "none"} + assert mock_bridge_v1.mock_requests[2]["json"] == {"on": False, "alert": "none"} assert len(hass.states.async_all()) == 2 @@ -663,8 +648,8 @@ def test_available(): colorgamuttype=LIGHT_GAMUT_TYPE, colorgamut=LIGHT_GAMUT, ), + bridge=Mock(config_entry=Mock(options={"allow_unreachable": False})), coordinator=Mock(last_update_success=True), - bridge=Mock(allow_unreachable=False), is_group=False, supported_features=hue_light.SUPPORT_HUE_EXTENDED, rooms={}, @@ -680,10 +665,10 @@ def test_available(): colorgamut=LIGHT_GAMUT, ), coordinator=Mock(last_update_success=True), - bridge=Mock(allow_unreachable=True), is_group=False, supported_features=hue_light.SUPPORT_HUE_EXTENDED, rooms={}, + bridge=Mock(config_entry=Mock(options={"allow_unreachable": True})), ) assert light.available is True @@ -696,10 +681,10 @@ def test_available(): colorgamut=LIGHT_GAMUT, ), coordinator=Mock(last_update_success=True), - bridge=Mock(allow_unreachable=False), is_group=True, supported_features=hue_light.SUPPORT_HUE_EXTENDED, rooms={}, + bridge=Mock(config_entry=Mock(options={"allow_unreachable": False})), ) assert light.available is True @@ -756,9 +741,8 @@ def test_hs_color(): assert light.hs_color == color.color_xy_to_hs(0.4, 0.5, LIGHT_GAMUT) -async def test_group_features(hass, mock_bridge): +async def test_group_features(hass, mock_bridge_v1): """Test group features.""" - color_temp_type = "Color temperature light" extended_color_type = "Extended color light" @@ -920,11 +904,10 @@ async def test_group_features(hass, mock_bridge): "4": light_4, } - mock_bridge.allow_groups = True - mock_bridge.mock_light_responses.append(light_response) - mock_bridge.mock_group_responses.append(group_response) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 2 + mock_bridge_v1.mock_light_responses.append(light_response) + mock_bridge_v1.mock_group_responses.append(group_response) + await setup_bridge(hass, mock_bridge_v1) + assert len(mock_bridge_v1.mock_requests) == 2 color_temp_feature = hue_light.SUPPORT_HUE["Color temperature light"] extended_color_feature = hue_light.SUPPORT_HUE["Extended color light"] diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py new file mode 100644 index 00000000000..7843cab1574 --- /dev/null +++ b/tests/components/hue/test_light_v2.py @@ -0,0 +1,348 @@ +"""Philips Hue lights platform tests for V2 bridge/api.""" + +from homeassistant.components.light import COLOR_MODE_COLOR_TEMP, COLOR_MODE_XY +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_platform +from .const import FAKE_DEVICE, FAKE_LIGHT, FAKE_ZIGBEE_CONNECTIVITY + + +async def test_lights(hass, mock_bridge_v2, v2_resources_test_data): + """Test if all v2 lights get created with correct features.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "light") + # there shouldn't have been any requests at this point + assert len(mock_bridge_v2.mock_requests) == 0 + # 6 entities should be created from test data (grouped_lights are disabled by default) + assert len(hass.states.async_all()) == 6 + + # test light which supports color and color temperature + light_1 = hass.states.get("light.hue_light_with_color_and_color_temperature_1") + assert light_1 is not None + assert ( + light_1.attributes["friendly_name"] + == "Hue light with color and color temperature 1" + ) + assert light_1.state == "on" + assert light_1.attributes["brightness"] == int(46.85 / 100 * 255) + assert light_1.attributes["mode"] == "normal" + assert light_1.attributes["color_mode"] == COLOR_MODE_XY + assert set(light_1.attributes["supported_color_modes"]) == { + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_XY, + } + assert light_1.attributes["xy_color"] == (0.5614, 0.4058) + assert light_1.attributes["min_mireds"] == 153 + assert light_1.attributes["max_mireds"] == 500 + assert light_1.attributes["dynamics"] == "dynamic_palette" + + # test light which supports color temperature only + light_2 = hass.states.get("light.hue_light_with_color_temperature_only") + assert light_2 is not None + assert ( + light_2.attributes["friendly_name"] == "Hue light with color temperature only" + ) + assert light_2.state == "off" + assert light_2.attributes["mode"] == "normal" + assert light_2.attributes["supported_color_modes"] == [COLOR_MODE_COLOR_TEMP] + assert light_2.attributes["min_mireds"] == 153 + assert light_2.attributes["max_mireds"] == 454 + assert light_2.attributes["dynamics"] == "none" + + # test light which supports color only + light_3 = hass.states.get("light.hue_light_with_color_only") + assert light_3 is not None + assert light_3.attributes["friendly_name"] == "Hue light with color only" + assert light_3.state == "on" + assert light_3.attributes["brightness"] == 128 + assert light_3.attributes["mode"] == "normal" + assert light_3.attributes["supported_color_modes"] == [COLOR_MODE_XY] + assert light_3.attributes["color_mode"] == COLOR_MODE_XY + assert light_3.attributes["dynamics"] == "dynamic_palette" + + # test light which supports on/off only + light_4 = hass.states.get("light.hue_on_off_light") + assert light_4 is not None + assert light_4.attributes["friendly_name"] == "Hue on/off light" + assert light_4.state == "off" + assert light_4.attributes["mode"] == "normal" + assert light_4.attributes["supported_color_modes"] == [] + + +async def test_light_turn_on_service(hass, mock_bridge_v2, v2_resources_test_data): + """Test calling the turn on service on a light.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "light") + + test_light_id = "light.hue_light_with_color_temperature_only" + + # verify the light is off before we start + assert hass.states.get(test_light_id).state == "off" + + # now call the HA turn_on service + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id, "brightness_pct": 100, "color_temp": 300}, + blocking=True, + ) + + # PUT request should have been sent to device with correct params + assert len(mock_bridge_v2.mock_requests) == 1 + assert mock_bridge_v2.mock_requests[0]["method"] == "put" + assert mock_bridge_v2.mock_requests[0]["json"]["on"]["on"] is True + assert mock_bridge_v2.mock_requests[0]["json"]["dimming"]["brightness"] == 100 + assert mock_bridge_v2.mock_requests[0]["json"]["color_temperature"]["mirek"] == 300 + + # Now generate update event by emitting the json we've sent as incoming event + mock_bridge_v2.mock_requests[0]["json"]["color_temperature"].pop("mirek_valid") + mock_bridge_v2.api.emit_event("update", mock_bridge_v2.mock_requests[0]["json"]) + await hass.async_block_till_done() + + # the light should now be on + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["mode"] == "normal" + assert test_light.attributes["supported_color_modes"] == [COLOR_MODE_COLOR_TEMP] + assert test_light.attributes["color_mode"] == COLOR_MODE_COLOR_TEMP + assert test_light.attributes["brightness"] == 255 + + # test again with sending transition + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id, "brightness_pct": 50, "transition": 6}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 2 + assert mock_bridge_v2.mock_requests[1]["json"]["on"]["on"] is True + assert mock_bridge_v2.mock_requests[1]["json"]["dynamics"]["duration"] == 600 + + +async def test_light_turn_off_service(hass, mock_bridge_v2, v2_resources_test_data): + """Test calling the turn off service on a light.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "light") + + test_light_id = "light.hue_light_with_color_and_color_temperature_1" + + # verify the light is on before we start + assert hass.states.get(test_light_id).state == "on" + + # now call the HA turn_off service + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": test_light_id}, + blocking=True, + ) + + # PUT request should have been sent to device with correct params + assert len(mock_bridge_v2.mock_requests) == 1 + assert mock_bridge_v2.mock_requests[0]["method"] == "put" + assert mock_bridge_v2.mock_requests[0]["json"]["on"]["on"] is False + + # Now generate update event by emitting the json we've sent as incoming event + mock_bridge_v2.api.emit_event("update", mock_bridge_v2.mock_requests[0]["json"]) + await hass.async_block_till_done() + + # the light should now be off + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "off" + + # test again with sending transition + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": test_light_id, "transition": 6}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 2 + assert mock_bridge_v2.mock_requests[1]["json"]["on"]["on"] is False + assert mock_bridge_v2.mock_requests[1]["json"]["dynamics"]["duration"] == 600 + + +async def test_light_added(hass, mock_bridge_v2): + """Test new light added to bridge.""" + await mock_bridge_v2.api.load_test_data([FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY]) + + await setup_platform(hass, mock_bridge_v2, "light") + + test_entity_id = "light.hue_fake_light" + + # verify entity does not exist before we start + assert hass.states.get(test_entity_id) is None + + # Add new fake entity (and attached device and zigbee_connectivity) by emitting events + mock_bridge_v2.api.emit_event("add", FAKE_LIGHT) + await hass.async_block_till_done() + + # the entity should now be available + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "off" + assert test_entity.attributes["friendly_name"] == FAKE_LIGHT["metadata"]["name"] + + +async def test_light_availability(hass, mock_bridge_v2, v2_resources_test_data): + """Test light availability property.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "light") + + test_light_id = "light.hue_light_with_color_and_color_temperature_1" + + # verify entity does exist and is available before we start + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + + # Change availability by modififying the zigbee_connectivity status + for status in ("connectivity_issue", "disconnected", "connected"): + mock_bridge_v2.api.emit_event( + "update", + { + "id": "1987ba66-c21d-48d0-98fb-121d939a71f3", + "status": status, + "type": "zigbee_connectivity", + }, + ) + await hass.async_block_till_done() + + # the entity should now be available only when zigbee is connected + test_light = hass.states.get(test_light_id) + assert test_light.state == "on" if status == "connected" else "unavailable" + + +async def test_grouped_lights(hass, mock_bridge_v2, v2_resources_test_data): + """Test if all v2 grouped lights get created with correct features.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "light") + + # test if entities for hue groups are created and disabled by default + for entity_id in ("light.test_zone", "light.test_room"): + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(entity_id) + + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + # entity should not have a device assigned + assert entity_entry.device_id is None + + # enable the entity + updated_entry = ent_reg.async_update_entity( + entity_entry.entity_id, **{"disabled_by": None} + ) + assert updated_entry != entity_entry + assert updated_entry.disabled is False + + # reload platform and check if entities are correctly there + await hass.config_entries.async_forward_entry_unload( + mock_bridge_v2.config_entry, "light" + ) + await hass.config_entries.async_forward_entry_setup( + mock_bridge_v2.config_entry, "light" + ) + await hass.async_block_till_done() + + # test light created for hue zone + test_entity = hass.states.get("light.test_zone") + assert test_entity is not None + assert test_entity.attributes["friendly_name"] == "Test Zone" + assert test_entity.state == "on" + assert test_entity.attributes["brightness"] == 119 + assert test_entity.attributes["color_mode"] == COLOR_MODE_XY + assert set(test_entity.attributes["supported_color_modes"]) == { + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_XY, + } + assert test_entity.attributes["min_mireds"] == 153 + assert test_entity.attributes["max_mireds"] == 500 + assert test_entity.attributes["is_hue_group"] is True + assert test_entity.attributes["hue_scenes"] == {"Dynamic Test Scene"} + assert test_entity.attributes["hue_type"] == "zone" + assert test_entity.attributes["lights"] == { + "Hue light with color and color temperature 1", + "Hue light with color and color temperature gradient", + "Hue light with color and color temperature 2", + } + + # test light created for hue room + test_entity = hass.states.get("light.test_room") + assert test_entity is not None + assert test_entity.attributes["friendly_name"] == "Test Room" + assert test_entity.state == "off" + assert test_entity.attributes["supported_color_modes"] == [COLOR_MODE_COLOR_TEMP] + assert test_entity.attributes["min_mireds"] == 153 + assert test_entity.attributes["max_mireds"] == 454 + assert test_entity.attributes["is_hue_group"] is True + assert test_entity.attributes["hue_scenes"] == {"Regular Test Scene"} + assert test_entity.attributes["hue_type"] == "room" + assert test_entity.attributes["lights"] == { + "Hue on/off light", + "Hue light with color temperature only", + } + + # Test calling the turn on service on a grouped light + test_light_id = "light.test_zone" + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id, "brightness_pct": 100, "xy_color": (0.123, 0.123)}, + blocking=True, + ) + + # PUT request should have been sent to ALL group lights with correct params + assert len(mock_bridge_v2.mock_requests) == 3 + for index in range(0, 3): + assert mock_bridge_v2.mock_requests[index]["json"]["on"]["on"] is True + assert ( + mock_bridge_v2.mock_requests[index]["json"]["dimming"]["brightness"] == 100 + ) + assert mock_bridge_v2.mock_requests[index]["json"]["color"]["xy"]["x"] == 0.123 + assert mock_bridge_v2.mock_requests[index]["json"]["color"]["xy"]["y"] == 0.123 + + # Now generate update events by emitting the json we've sent as incoming events + for index in range(0, 3): + mock_bridge_v2.api.emit_event( + "update", mock_bridge_v2.mock_requests[index]["json"] + ) + await hass.async_block_till_done() + + # the light should now be on and have the properties we've set + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["color_mode"] == COLOR_MODE_XY + assert test_light.attributes["brightness"] == 255 + assert test_light.attributes["xy_color"] == (0.123, 0.123) + + # Test calling the turn off service on a grouped light. + mock_bridge_v2.mock_requests.clear() + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": test_light_id}, + blocking=True, + ) + + # PUT request should have been sent to ONLY the grouped_light resource with correct params + assert len(mock_bridge_v2.mock_requests) == 1 + assert mock_bridge_v2.mock_requests[0]["method"] == "put" + assert mock_bridge_v2.mock_requests[0]["json"]["on"]["on"] is False + + # Now generate update event by emitting the json we've sent as incoming event + mock_bridge_v2.api.emit_event("update", mock_bridge_v2.mock_requests[0]["json"]) + await hass.async_block_till_done() + + # the light should now be off + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "off" diff --git a/tests/components/hue/test_migration.py b/tests/components/hue/test_migration.py new file mode 100644 index 00000000000..2dc1636d485 --- /dev/null +++ b/tests/components/hue/test_migration.py @@ -0,0 +1,211 @@ +"""Test Hue migration logic.""" +from unittest.mock import patch + +from homeassistant.components import hue +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_migrate_api_key(hass): + """Test if username gets migrated to api_key.""" + config_entry = MockConfigEntry( + domain=hue.DOMAIN, + data={"host": "0.0.0.0", "api_version": 2, "username": "abcdefgh"}, + ) + await hue.migration.check_migration(hass, config_entry) + # the username property should have been migrated to api_key + assert config_entry.data == { + "host": "0.0.0.0", + "api_version": 2, + "api_key": "abcdefgh", + } + + +async def test_auto_switchover(hass): + """Test if config entry from v1 automatically switches to v2.""" + config_entry = MockConfigEntry( + domain=hue.DOMAIN, + data={"host": "0.0.0.0", "api_version": 1, "username": "abcdefgh"}, + ) + + with patch.object(hue.migration, "is_v2_bridge", retun_value=True), patch.object( + hue.migration, "handle_v2_migration" + ) as mock_mig: + await hue.migration.check_migration(hass, config_entry) + assert len(mock_mig.mock_calls) == 1 + # the api version should now be version 2 + assert config_entry.data == { + "host": "0.0.0.0", + "api_version": 2, + "api_key": "abcdefgh", + } + + +async def test_light_entity_migration( + hass, mock_bridge_v2, mock_config_entry_v2, v2_resources_test_data +): + """Test if entity schema for lights migrates from v1 to v2.""" + config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 + + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + + # create device/entity with V1 schema in registry + device = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(hue.DOMAIN, "00:17:88:01:09:aa:bb:65-0b")}, + ) + ent_reg.async_get_or_create( + "light", + hue.DOMAIN, + "00:17:88:01:09:aa:bb:65-0b", + suggested_object_id="migrated_light_1", + device_id=device.id, + ) + + # now run the migration and check results + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.hue.migration.HueBridgeV2", + return_value=mock_bridge_v2.api, + ): + await hue.migration.handle_v2_migration(hass, config_entry) + + # migrated device should now have the new identifier (guid) instead of old style (mac) + migrated_device = dev_reg.async_get(device.id) + assert migrated_device is not None + assert migrated_device.identifiers == { + (hue.DOMAIN, "0b216218-d811-4c95-8c55-bbcda50f9d50") + } + # the entity should have the new unique_id (guid) + migrated_entity = ent_reg.async_get("light.migrated_light_1") + assert migrated_entity is not None + assert migrated_entity.unique_id == "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1" + + +async def test_sensor_entity_migration( + hass, mock_bridge_v2, mock_config_entry_v2, v2_resources_test_data +): + """Test if entity schema for sensors migrates from v1 to v2.""" + config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 + + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + + # create device with V1 schema in registry for Hue motion sensor + device_mac = "00:17:aa:bb:cc:09:ac:c3" + device = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={(hue.DOMAIN, device_mac)} + ) + + # mapping of device_class to new id + sensor_mappings = { + ("temperature", "sensor", "66466e14-d2fa-4b96-b2a0-e10de9cd8b8b"), + ("illuminance", "sensor", "d504e7a4-9a18-4854-90fd-c5b6ac102c40"), + ("battery", "sensor", "669f609d-4860-4f1c-bc25-7a9cec1c3b6c"), + ("motion", "binary_sensor", "b6896534-016d-4052-8cb4-ef04454df62c"), + } + + # create entities with V1 schema in registry for Hue motion sensor + for dev_class, platform, new_id in sensor_mappings: + ent_reg.async_get_or_create( + platform, + hue.DOMAIN, + f"{device_mac}-{dev_class}", + suggested_object_id=f"hue_migrated_{dev_class}_sensor", + device_id=device.id, + original_device_class=dev_class, + ) + + # now run the migration and check results + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.hue.migration.HueBridgeV2", + return_value=mock_bridge_v2.api, + ): + await hue.migration.handle_v2_migration(hass, config_entry) + + # migrated device should now have the new identifier (guid) instead of old style (mac) + migrated_device = dev_reg.async_get(device.id) + assert migrated_device is not None + assert migrated_device.identifiers == { + (hue.DOMAIN, "2330b45d-6079-4c6e-bba6-1b68afb1a0d6") + } + # the entities should have the correct V2 unique_id (guid) + for dev_class, platform, new_id in sensor_mappings: + migrated_entity = ent_reg.async_get( + f"{platform}.hue_migrated_{dev_class}_sensor" + ) + assert migrated_entity is not None + assert migrated_entity.unique_id == new_id + + +async def test_group_entity_migration_with_v1_id( + hass, mock_bridge_v2, mock_config_entry_v2, v2_resources_test_data +): + """Test if entity schema for grouped_lights migrates from v1 to v2.""" + config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 + + ent_reg = er.async_get(hass) + + # create (deviceless) entity with V1 schema in registry + # using the legacy style group id as unique id + ent_reg.async_get_or_create( + "light", + hue.DOMAIN, + "3", + suggested_object_id="hue_migrated_grouped_light", + config_entry=config_entry, + ) + + # now run the migration and check results + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await hass.async_block_till_done() + with patch( + "homeassistant.components.hue.migration.HueBridgeV2", + return_value=mock_bridge_v2.api, + ): + await hue.migration.handle_v2_migration(hass, config_entry) + + # the entity should have the new identifier (guid) + migrated_entity = ent_reg.async_get("light.hue_migrated_grouped_light") + assert migrated_entity is not None + assert migrated_entity.unique_id == "e937f8db-2f0e-49a0-936e-027e60e15b34" + + +async def test_group_entity_migration_with_v2_group_id( + hass, mock_bridge_v2, mock_config_entry_v2, v2_resources_test_data +): + """Test if entity schema for grouped_lights migrates from v1 to v2.""" + config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 + + ent_reg = er.async_get(hass) + + # create (deviceless) entity with V1 schema in registry + # using the V2 group id as unique id + ent_reg.async_get_or_create( + "light", + hue.DOMAIN, + "6ddc9066-7e7d-4a03-a773-c73937968296", + suggested_object_id="hue_migrated_grouped_light", + config_entry=config_entry, + ) + + # now run the migration and check results + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await hass.async_block_till_done() + with patch( + "homeassistant.components.hue.migration.HueBridgeV2", + return_value=mock_bridge_v2.api, + ): + await hue.migration.handle_v2_migration(hass, config_entry) + + # the entity should have the new identifier (guid) + migrated_entity = ent_reg.async_get("light.hue_migrated_grouped_light") + assert migrated_entity is not None + assert migrated_entity.unique_id == "e937f8db-2f0e-49a0-936e-027e60e15b34" diff --git a/tests/components/hue/test_scene.py b/tests/components/hue/test_scene.py new file mode 100644 index 00000000000..0f3d6255e86 --- /dev/null +++ b/tests/components/hue/test_scene.py @@ -0,0 +1,139 @@ +"""Philips Hue scene platform tests for V2 bridge/api.""" + + +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_platform +from .const import FAKE_SCENE + + +async def test_scene(hass, mock_bridge_v2, v2_resources_test_data): + """Test if (config) scenes get created.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "scene") + # there shouldn't have been any requests at this point + assert len(mock_bridge_v2.mock_requests) == 0 + # 2 entities should be created from test data + assert len(hass.states.async_all()) == 2 + + # test (dynamic) scene for a hue zone + test_entity = hass.states.get("scene.test_zone_dynamic_test_scene") + assert test_entity is not None + assert test_entity.name == "Test Zone - Dynamic Test Scene" + assert test_entity.state == "scening" + assert test_entity.attributes["group_name"] == "Test Zone" + assert test_entity.attributes["group_type"] == "zone" + assert test_entity.attributes["name"] == "Dynamic Test Scene" + assert test_entity.attributes["speed"] == 0.6269841194152832 + assert test_entity.attributes["brightness"] == 46.85 + assert test_entity.attributes["is_dynamic"] is True + + # test (regular) scene for a hue room + test_entity = hass.states.get("scene.test_room_regular_test_scene") + assert test_entity is not None + assert test_entity.name == "Test Room - Regular Test Scene" + assert test_entity.state == "scening" + assert test_entity.attributes["group_name"] == "Test Room" + assert test_entity.attributes["group_type"] == "room" + assert test_entity.attributes["name"] == "Regular Test Scene" + assert test_entity.attributes["speed"] == 0.5 + assert test_entity.attributes["brightness"] == 100.0 + assert test_entity.attributes["is_dynamic"] is False + + # scene entities should not have a device assigned + ent_reg = er.async_get(hass) + for entity_id in ( + "scene.test_zone_dynamic_test_scene", + "scene.test_room_regular_test_scene", + ): + entity_entry = ent_reg.async_get(entity_id) + assert entity_entry + assert entity_entry.device_id is None + + +async def test_scene_turn_on_service(hass, mock_bridge_v2, v2_resources_test_data): + """Test calling the turn on service on a scene.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "scene") + + test_entity_id = "scene.test_room_regular_test_scene" + + # call the HA turn_on service + await hass.services.async_call( + "scene", + "turn_on", + {"entity_id": test_entity_id}, + blocking=True, + ) + + # PUT request should have been sent to device with correct params + assert len(mock_bridge_v2.mock_requests) == 1 + assert mock_bridge_v2.mock_requests[0]["method"] == "put" + assert mock_bridge_v2.mock_requests[0]["json"]["recall"] == {"action": "active"} + + # test again with sending transition + await hass.services.async_call( + "scene", + "turn_on", + {"entity_id": test_entity_id, "transition": 6}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 2 + assert mock_bridge_v2.mock_requests[1]["json"]["recall"] == { + "action": "active", + "duration": 600, + } + + +async def test_scene_updates(hass, mock_bridge_v2, v2_resources_test_data): + """Test scene events from bridge.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "scene") + + test_entity_id = "scene.test_room_mocked_scene" + + # verify entity does not exist before we start + assert hass.states.get(test_entity_id) is None + + # Add new fake scene + mock_bridge_v2.api.emit_event("add", FAKE_SCENE) + await hass.async_block_till_done() + + # the entity should now be available + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "scening" + assert test_entity.name == "Test Room - Mocked Scene" + assert test_entity.attributes["brightness"] == 65.0 + + # test update + updated_resource = {**FAKE_SCENE} + updated_resource["actions"][0]["action"]["dimming"]["brightness"] = 35.0 + mock_bridge_v2.api.emit_event("update", updated_resource) + await hass.async_block_till_done() + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.attributes["brightness"] == 35.0 + + # test entity name changes on group name change + mock_bridge_v2.api.emit_event( + "update", + { + "type": "room", + "id": "6ddc9066-7e7d-4a03-a773-c73937968296", + "metadata": {"name": "Test Room 2"}, + }, + ) + await hass.async_block_till_done() + test_entity = hass.states.get(test_entity_id) + assert test_entity.name == "Test Room 2 - Mocked Scene" + + # test delete + mock_bridge_v2.api.emit_event("delete", updated_resource) + await hass.async_block_till_done() + await hass.async_block_till_done() + test_entity = hass.states.get(test_entity_id) + assert test_entity is None diff --git a/tests/components/hue/test_sensor_base.py b/tests/components/hue/test_sensor_v1.py similarity index 81% rename from tests/components/hue/test_sensor_base.py rename to tests/components/hue/test_sensor_v1.py index 8b8eb45a222..c35403eaac1 100644 --- a/tests/components/hue/test_sensor_base.py +++ b/tests/components/hue/test_sensor_v1.py @@ -3,29 +3,17 @@ import asyncio from unittest.mock import Mock import aiohue -import pytest from homeassistant.components import hue -from homeassistant.components.hue import sensor_base -from homeassistant.components.hue.hue_event import CONF_HUE_EVENT +from homeassistant.components.hue.const import ATTR_HUE_EVENT +from homeassistant.components.hue.v1 import sensor_base from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util -from .conftest import create_mock_bridge, setup_bridge_for_sensors as setup_bridge - -from tests.common import ( - async_capture_events, - async_fire_time_changed, - mock_device_registry, -) - - -@pytest.fixture -def device_reg(hass): - """Return an empty, loaded, registry.""" - return mock_device_registry(hass) +from .conftest import create_mock_bridge, setup_platform +from tests.common import async_capture_events, async_fire_time_changed PRESENCE_SENSOR_1_PRESENT = { "state": {"presence": True, "lastupdated": "2019-01-01T01:00:00"}, @@ -293,18 +281,17 @@ SENSOR_RESPONSE = { } -async def test_no_sensors(hass, mock_bridge): +async def test_no_sensors(hass, mock_bridge_v1): """Test the update_items function when no sensors are found.""" - mock_bridge.allow_groups = True - mock_bridge.mock_sensor_responses.append({}) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 1 + mock_bridge_v1.mock_sensor_responses.append({}) + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + assert len(mock_bridge_v1.mock_requests) == 1 assert len(hass.states.async_all()) == 0 -async def test_sensors_with_multiple_bridges(hass, mock_bridge): +async def test_sensors_with_multiple_bridges(hass, mock_bridge_v1): """Test the update_items function with some sensors.""" - mock_bridge_2 = create_mock_bridge(hass) + mock_bridge_2 = create_mock_bridge(hass, api_version=1) mock_bridge_2.mock_sensor_responses.append( { "1": PRESENCE_SENSOR_3_PRESENT, @@ -312,21 +299,23 @@ async def test_sensors_with_multiple_bridges(hass, mock_bridge): "3": TEMPERATURE_SENSOR_3, } ) - mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE) - await setup_bridge(hass, mock_bridge) - await setup_bridge(hass, mock_bridge_2, hostname="mock-bridge-2") + mock_bridge_v1.mock_sensor_responses.append(SENSOR_RESPONSE) + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + await setup_platform( + hass, mock_bridge_2, ["binary_sensor", "sensor"], "mock-bridge-2" + ) - assert len(mock_bridge.mock_requests) == 1 + assert len(mock_bridge_v1.mock_requests) == 1 assert len(mock_bridge_2.mock_requests) == 1 # 3 "physical" sensors with 3 virtual sensors each + 1 battery sensor assert len(hass.states.async_all()) == 10 -async def test_sensors(hass, mock_bridge): +async def test_sensors(hass, mock_bridge_v1): """Test the update_items function with some sensors.""" - mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 1 + mock_bridge_v1.mock_sensor_responses.append(SENSOR_RESPONSE) + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + assert len(mock_bridge_v1.mock_requests) == 1 # 2 "physical" sensors with 3 virtual sensors each assert len(hass.states.async_all()) == 7 @@ -366,23 +355,23 @@ async def test_sensors(hass, mock_bridge): ) -async def test_unsupported_sensors(hass, mock_bridge): +async def test_unsupported_sensors(hass, mock_bridge_v1): """Test that unsupported sensors don't get added and don't fail.""" response_with_unsupported = dict(SENSOR_RESPONSE) response_with_unsupported["7"] = UNSUPPORTED_SENSOR - mock_bridge.mock_sensor_responses.append(response_with_unsupported) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 1 + mock_bridge_v1.mock_sensor_responses.append(response_with_unsupported) + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + assert len(mock_bridge_v1.mock_requests) == 1 # 2 "physical" sensors with 3 virtual sensors each + 1 battery sensor assert len(hass.states.async_all()) == 7 -async def test_new_sensor_discovered(hass, mock_bridge): +async def test_new_sensor_discovered(hass, mock_bridge_v1): """Test if 2nd update has a new sensor.""" - mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE) + mock_bridge_v1.mock_sensor_responses.append(SENSOR_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 1 + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + assert len(mock_bridge_v1.mock_requests) == 1 assert len(hass.states.async_all()) == 7 new_sensor_response = dict(SENSOR_RESPONSE) @@ -394,13 +383,13 @@ async def test_new_sensor_discovered(hass, mock_bridge): } ) - mock_bridge.mock_sensor_responses.append(new_sensor_response) + mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again - await mock_bridge.sensor_manager.coordinator.async_refresh() + await mock_bridge_v1.sensor_manager.coordinator.async_refresh() await hass.async_block_till_done() - assert len(mock_bridge.mock_requests) == 2 + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 10 presence = hass.states.get("binary_sensor.bedroom_sensor_motion") @@ -411,25 +400,25 @@ async def test_new_sensor_discovered(hass, mock_bridge): assert temperature.state == "17.75" -async def test_sensor_removed(hass, mock_bridge): +async def test_sensor_removed(hass, mock_bridge_v1): """Test if 2nd update has removed sensor.""" - mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE) + mock_bridge_v1.mock_sensor_responses.append(SENSOR_RESPONSE) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 1 + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + assert len(mock_bridge_v1.mock_requests) == 1 assert len(hass.states.async_all()) == 7 - mock_bridge.mock_sensor_responses.clear() + mock_bridge_v1.mock_sensor_responses.clear() keys = ("1", "2", "3") - mock_bridge.mock_sensor_responses.append({k: SENSOR_RESPONSE[k] for k in keys}) + mock_bridge_v1.mock_sensor_responses.append({k: SENSOR_RESPONSE[k] for k in keys}) # Force updates to run again - await mock_bridge.sensor_manager.coordinator.async_refresh() + await mock_bridge_v1.sensor_manager.coordinator.async_refresh() # To flush out the service call to update the group await hass.async_block_till_done() - assert len(mock_bridge.mock_requests) == 2 + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 3 sensor = hass.states.get("binary_sensor.living_room_sensor_motion") @@ -439,31 +428,31 @@ async def test_sensor_removed(hass, mock_bridge): assert removed_sensor is None -async def test_update_timeout(hass, mock_bridge): +async def test_update_timeout(hass, mock_bridge_v1): """Test bridge marked as not available if timeout error during update.""" - mock_bridge.api.sensors.update = Mock(side_effect=asyncio.TimeoutError) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 0 + mock_bridge_v1.api.sensors.update = Mock(side_effect=asyncio.TimeoutError) + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + assert len(mock_bridge_v1.mock_requests) == 0 assert len(hass.states.async_all()) == 0 -async def test_update_unauthorized(hass, mock_bridge): +async def test_update_unauthorized(hass, mock_bridge_v1): """Test bridge marked as not authorized if unauthorized during update.""" - mock_bridge.api.sensors.update = Mock(side_effect=aiohue.Unauthorized) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 0 + mock_bridge_v1.api.sensors.update = Mock(side_effect=aiohue.Unauthorized) + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + assert len(mock_bridge_v1.mock_requests) == 0 assert len(hass.states.async_all()) == 0 - assert len(mock_bridge.handle_unauthorized_error.mock_calls) == 1 + assert len(mock_bridge_v1.handle_unauthorized_error.mock_calls) == 1 -async def test_hue_events(hass, mock_bridge, device_reg): +async def test_hue_events(hass, mock_bridge_v1, device_reg): """Test that hue remotes fire events when pressed.""" - mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE) + mock_bridge_v1.mock_sensor_responses.append(SENSOR_RESPONSE) - events = async_capture_events(hass, CONF_HUE_EVENT) + events = async_capture_events(hass, ATTR_HUE_EVENT) - await setup_bridge(hass, mock_bridge) - assert len(mock_bridge.mock_requests) == 1 + await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) + assert len(mock_bridge_v1.mock_requests) == 1 assert len(hass.states.async_all()) == 7 assert len(events) == 0 @@ -471,8 +460,8 @@ async def test_hue_events(hass, mock_bridge, device_reg): {(hue.DOMAIN, "00:00:00:00:00:44:23:08")} ) - mock_bridge.api.sensors["7"].last_event = {"type": "button"} - mock_bridge.api.sensors["8"].last_event = {"type": "button"} + mock_bridge_v1.api.sensors["7"].last_event = {"type": "button"} + mock_bridge_v1.api.sensors["8"].last_event = {"type": "button"} new_sensor_response = dict(SENSOR_RESPONSE) new_sensor_response["7"] = dict(new_sensor_response["7"]) @@ -480,7 +469,7 @@ async def test_hue_events(hass, mock_bridge, device_reg): "buttonevent": 18, "lastupdated": "2019-12-28T22:58:03", } - mock_bridge.mock_sensor_responses.append(new_sensor_response) + mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again async_fire_time_changed( @@ -488,7 +477,7 @@ async def test_hue_events(hass, mock_bridge, device_reg): ) await hass.async_block_till_done() - assert len(mock_bridge.mock_requests) == 2 + assert len(mock_bridge_v1.mock_requests) == 2 assert len(hass.states.async_all()) == 7 assert len(events) == 1 assert events[-1].data == { @@ -509,7 +498,7 @@ async def test_hue_events(hass, mock_bridge, device_reg): "buttonevent": 3002, "lastupdated": "2019-12-28T22:58:03", } - mock_bridge.mock_sensor_responses.append(new_sensor_response) + mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again async_fire_time_changed( @@ -517,7 +506,7 @@ async def test_hue_events(hass, mock_bridge, device_reg): ) await hass.async_block_till_done() - assert len(mock_bridge.mock_requests) == 3 + assert len(mock_bridge_v1.mock_requests) == 3 assert len(hass.states.async_all()) == 7 assert len(events) == 2 assert events[-1].data == { @@ -535,7 +524,7 @@ async def test_hue_events(hass, mock_bridge, device_reg): "buttonevent": 18, "lastupdated": "2019-12-28T22:58:02", } - mock_bridge.mock_sensor_responses.append(new_sensor_response) + mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again async_fire_time_changed( @@ -543,7 +532,7 @@ async def test_hue_events(hass, mock_bridge, device_reg): ) await hass.async_block_till_done() - assert len(mock_bridge.mock_requests) == 4 + assert len(mock_bridge_v1.mock_requests) == 4 assert len(hass.states.async_all()) == 7 assert len(events) == 2 @@ -580,7 +569,7 @@ async def test_hue_events(hass, mock_bridge, device_reg): ], }, } - mock_bridge.mock_sensor_responses.append(new_sensor_response) + mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again async_fire_time_changed( @@ -588,13 +577,13 @@ async def test_hue_events(hass, mock_bridge, device_reg): ) await hass.async_block_till_done() - assert len(mock_bridge.mock_requests) == 5 + assert len(mock_bridge_v1.mock_requests) == 5 assert len(hass.states.async_all()) == 8 assert len(events) == 2 # A new press fires the event new_sensor_response["21"]["state"]["lastupdated"] = "2020-01-31T15:57:19" - mock_bridge.mock_sensor_responses.append(new_sensor_response) + mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again async_fire_time_changed( @@ -606,7 +595,7 @@ async def test_hue_events(hass, mock_bridge, device_reg): {(hue.DOMAIN, "ff:ff:00:0f:e7:fd:bc:b7")} ) - assert len(mock_bridge.mock_requests) == 6 + assert len(mock_bridge_v1.mock_requests) == 6 assert len(hass.states.async_all()) == 8 assert len(events) == 3 assert events[-1].data == { diff --git a/tests/components/hue/test_sensor_v2.py b/tests/components/hue/test_sensor_v2.py new file mode 100644 index 00000000000..256c323ccce --- /dev/null +++ b/tests/components/hue/test_sensor_v2.py @@ -0,0 +1,123 @@ +"""Philips Hue sensor platform tests for V2 bridge/api.""" + +from homeassistant.components import hue +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import setup_bridge, setup_platform +from .const import FAKE_DEVICE, FAKE_SENSOR, FAKE_ZIGBEE_CONNECTIVITY + + +async def test_sensors(hass, mock_bridge_v2, v2_resources_test_data): + """Test if all v2 sensors get created with correct features.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "sensor") + # there shouldn't have been any requests at this point + assert len(mock_bridge_v2.mock_requests) == 0 + # 6 entities should be created from test data + assert len(hass.states.async_all()) == 6 + + # test temperature sensor + sensor = hass.states.get("sensor.hue_motion_sensor_temperature") + assert sensor is not None + assert sensor.state == "18.1" + assert sensor.attributes["friendly_name"] == "Hue motion sensor Temperature" + assert sensor.attributes["device_class"] == "temperature" + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "°C" + assert sensor.attributes["temperature_valid"] is True + + # test illuminance sensor + sensor = hass.states.get("sensor.hue_motion_sensor_illuminance") + assert sensor is not None + assert sensor.state == "63" + assert sensor.attributes["friendly_name"] == "Hue motion sensor Illuminance" + assert sensor.attributes["device_class"] == "illuminance" + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "lx" + assert sensor.attributes["light_level"] == 18027 + assert sensor.attributes["light_level_valid"] is True + + # test battery sensor + sensor = hass.states.get("sensor.wall_switch_with_2_controls_battery") + assert sensor is not None + assert sensor.state == "100" + assert sensor.attributes["friendly_name"] == "Wall switch with 2 controls Battery" + assert sensor.attributes["device_class"] == "battery" + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "%" + assert sensor.attributes["battery_state"] == "normal" + + # test disabled zigbee_connectivity sensor + entity_id = "sensor.wall_switch_with_2_controls_zigbee_connectivity" + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(entity_id) + + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + + +async def test_enable_sensor( + hass, mock_bridge_v2, v2_resources_test_data, mock_config_entry_v2 +): + """Test enabling of the by default disabled zigbee_connectivity sensor.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_bridge(hass, mock_bridge_v2, mock_config_entry_v2) + + assert await async_setup_component(hass, hue.DOMAIN, {}) is True + await hass.async_block_till_done() + await hass.config_entries.async_forward_entry_setup(mock_config_entry_v2, "sensor") + + entity_id = "sensor.wall_switch_with_2_controls_zigbee_connectivity" + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(entity_id) + + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + + # enable the entity + updated_entry = ent_reg.async_update_entity( + entity_entry.entity_id, **{"disabled_by": None} + ) + assert updated_entry != entity_entry + assert updated_entry.disabled is False + + # reload platform and check if entity is correctly there + await hass.config_entries.async_forward_entry_unload(mock_config_entry_v2, "sensor") + await hass.config_entries.async_forward_entry_setup(mock_config_entry_v2, "sensor") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "connected" + assert state.attributes["mac_address"] == "00:17:88:01:0b:aa:bb:99" + + +async def test_sensor_add_update(hass, mock_bridge_v2): + """Test if sensors get added/updated from events.""" + await mock_bridge_v2.api.load_test_data([FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY]) + await setup_platform(hass, mock_bridge_v2, "sensor") + + test_entity_id = "sensor.hue_mocked_device_temperature" + + # verify entity does not exist before we start + assert hass.states.get(test_entity_id) is None + + # Add new fake sensor by emitting event + mock_bridge_v2.api.emit_event("add", FAKE_SENSOR) + await hass.async_block_till_done() + + # the entity should now be available + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "18.0" + + # test update of entity works on incoming event + updated_sensor = {**FAKE_SENSOR, "temperature": {"temperature": 22.5}} + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "22.5" diff --git a/tests/components/hue/test_services.py b/tests/components/hue/test_services.py new file mode 100644 index 00000000000..86557b85748 --- /dev/null +++ b/tests/components/hue/test_services.py @@ -0,0 +1,265 @@ +"""Test Hue services.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components import hue +from homeassistant.components.hue import bridge +from homeassistant.components.hue.const import ( + CONF_ALLOW_HUE_GROUPS, + CONF_ALLOW_UNREACHABLE, +) + +from .conftest import setup_bridge, setup_component + +GROUP_RESPONSE = { + "group_1": { + "name": "Group 1", + "lights": ["1", "2"], + "type": "LightGroup", + "action": { + "on": True, + "bri": 254, + "hue": 10000, + "sat": 254, + "effect": "none", + "xy": [0.5, 0.5], + "ct": 250, + "alert": "select", + "colormode": "ct", + }, + "state": {"any_on": True, "all_on": False}, + } +} +SCENE_RESPONSE = { + "scene_1": { + "name": "Cozy dinner", + "lights": ["1", "2"], + "owner": "ffffffffe0341b1b376a2389376a2389", + "recycle": True, + "locked": False, + "appdata": {"version": 1, "data": "myAppData"}, + "picture": "", + "lastupdated": "2015-12-03T10:09:22", + "version": 2, + } +} + + +async def test_hue_activate_scene(hass, mock_api_v1): + """Test successful hue_activate_scene.""" + config_entry = config_entries.ConfigEntry( + 1, + hue.DOMAIN, + "Mock Title", + {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + "test", + options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, + ) + + mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) + mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1), patch.object( + hass.config_entries, "async_forward_entry_setup" + ): + hue_bridge = bridge.HueBridge(hass, config_entry) + assert await hue_bridge.async_initialize_bridge() is True + + assert hue_bridge.api is mock_api_v1 + + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1): + assert ( + await hue.services.hue_activate_scene_v1( + hue_bridge, "Group 1", "Cozy dinner" + ) + is True + ) + + assert len(mock_api_v1.mock_requests) == 3 + assert mock_api_v1.mock_requests[2]["json"]["scene"] == "scene_1" + assert "transitiontime" not in mock_api_v1.mock_requests[2]["json"] + assert mock_api_v1.mock_requests[2]["path"] == "groups/group_1/action" + + +async def test_hue_activate_scene_transition(hass, mock_api_v1): + """Test successful hue_activate_scene with transition.""" + config_entry = config_entries.ConfigEntry( + 1, + hue.DOMAIN, + "Mock Title", + {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + "test", + options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, + ) + + mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) + mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1), patch.object( + hass.config_entries, "async_forward_entry_setup" + ): + hue_bridge = bridge.HueBridge(hass, config_entry) + assert await hue_bridge.async_initialize_bridge() is True + + assert hue_bridge.api is mock_api_v1 + + with patch("aiohue.HueBridgeV1", return_value=mock_api_v1): + assert ( + await hue.services.hue_activate_scene_v1( + hue_bridge, "Group 1", "Cozy dinner", 30 + ) + is True + ) + + assert len(mock_api_v1.mock_requests) == 3 + assert mock_api_v1.mock_requests[2]["json"]["scene"] == "scene_1" + assert mock_api_v1.mock_requests[2]["json"]["transitiontime"] == 30 + assert mock_api_v1.mock_requests[2]["path"] == "groups/group_1/action" + + +async def test_hue_activate_scene_group_not_found(hass, mock_api_v1): + """Test failed hue_activate_scene due to missing group.""" + config_entry = config_entries.ConfigEntry( + 1, + hue.DOMAIN, + "Mock Title", + {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + "test", + options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, + ) + + mock_api_v1.mock_group_responses.append({}) + mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1), patch.object( + hass.config_entries, "async_forward_entry_setup" + ): + hue_bridge = bridge.HueBridge(hass, config_entry) + assert await hue_bridge.async_initialize_bridge() is True + + assert hue_bridge.api is mock_api_v1 + + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1): + assert ( + await hue.services.hue_activate_scene_v1( + hue_bridge, "Group 1", "Cozy dinner" + ) + is False + ) + + +async def test_hue_activate_scene_scene_not_found(hass, mock_api_v1): + """Test failed hue_activate_scene due to missing scene.""" + config_entry = config_entries.ConfigEntry( + 1, + hue.DOMAIN, + "Mock Title", + {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + "test", + options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, + ) + + mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) + mock_api_v1.mock_scene_responses.append({}) + + with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1), patch.object( + hass.config_entries, "async_forward_entry_setup" + ): + hue_bridge = bridge.HueBridge(hass, config_entry) + assert await hue_bridge.async_initialize_bridge() is True + + assert hue_bridge.api is mock_api_v1 + + with patch("aiohue.HueBridgeV1", return_value=mock_api_v1): + assert ( + await hue.services.hue_activate_scene_v1( + hue_bridge, "Group 1", "Cozy dinner" + ) + is False + ) + + +async def test_hue_multi_bridge_activate_scene_all_respond( + hass, mock_bridge_v1, mock_bridge_v2, mock_config_entry_v1, mock_config_entry_v2 +): + """Test that makes multiple bridges successfully activate a scene.""" + await setup_component(hass) + + mock_api_v1 = mock_bridge_v1.api + mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) + mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + + await setup_bridge(hass, mock_bridge_v1, mock_config_entry_v1) + await setup_bridge(hass, mock_bridge_v2, mock_config_entry_v2) + + with patch.object( + hue.services, "hue_activate_scene_v2", return_value=True + ) as mock_hue_activate_scene2: + await hass.services.async_call( + "hue", + "hue_activate_scene", + {"group_name": "Group 1", "scene_name": "Cozy dinner"}, + blocking=True, + ) + + assert len(mock_api_v1.mock_requests) == 3 + assert mock_api_v1.mock_requests[2]["json"]["scene"] == "scene_1" + assert mock_api_v1.mock_requests[2]["path"] == "groups/group_1/action" + + mock_hue_activate_scene2.assert_called_once() + + +async def test_hue_multi_bridge_activate_scene_one_responds( + hass, mock_bridge_v1, mock_bridge_v2, mock_config_entry_v1, mock_config_entry_v2 +): + """Test that makes only one bridge successfully activate a scene.""" + await setup_component(hass) + + mock_api_v1 = mock_bridge_v1.api + mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) + mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + + await setup_bridge(hass, mock_bridge_v1, mock_config_entry_v1) + await setup_bridge(hass, mock_bridge_v2, mock_config_entry_v2) + + with patch.object( + hue.services, "hue_activate_scene_v2", return_value=False + ) as mock_hue_activate_scene2: + await hass.services.async_call( + "hue", + "hue_activate_scene", + {"group_name": "Group 1", "scene_name": "Cozy dinner"}, + blocking=True, + ) + + assert len(mock_api_v1.mock_requests) == 3 + assert mock_api_v1.mock_requests[2]["json"]["scene"] == "scene_1" + assert mock_api_v1.mock_requests[2]["path"] == "groups/group_1/action" + mock_hue_activate_scene2.assert_called_once() + + +async def test_hue_multi_bridge_activate_scene_zero_responds( + hass, mock_bridge_v1, mock_bridge_v2, mock_config_entry_v1, mock_config_entry_v2 +): + """Test that makes no bridge successfully activate a scene.""" + await setup_component(hass) + mock_api_v1 = mock_bridge_v1.api + mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) + mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + + await setup_bridge(hass, mock_bridge_v1, mock_config_entry_v1) + await setup_bridge(hass, mock_bridge_v2, mock_config_entry_v2) + + with patch.object( + hue.services, "hue_activate_scene_v2", return_value=False + ) as mock_hue_activate_scene2: + await hass.services.async_call( + "hue", + "hue_activate_scene", + {"group_name": "Non existing group", "scene_name": "Non existing Scene"}, + blocking=True, + ) + + # the V1 implementation should have retried (2 calls) + assert len(mock_api_v1.mock_requests) == 2 + assert mock_hue_activate_scene2.call_count == 1 diff --git a/tests/components/hue/test_switch.py b/tests/components/hue/test_switch.py new file mode 100644 index 00000000000..257f1a253c3 --- /dev/null +++ b/tests/components/hue/test_switch.py @@ -0,0 +1,107 @@ +"""Philips Hue switch platform tests for V2 bridge/api.""" + +from .conftest import setup_platform +from .const import FAKE_BINARY_SENSOR, FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY + + +async def test_switch(hass, mock_bridge_v2, v2_resources_test_data): + """Test if (config) switches get created.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "switch") + # there shouldn't have been any requests at this point + assert len(mock_bridge_v2.mock_requests) == 0 + # 2 entities should be created from test data + assert len(hass.states.async_all()) == 2 + + # test config switch to enable/disable motion sensor + test_entity = hass.states.get("switch.hue_motion_sensor_motion") + assert test_entity is not None + assert test_entity.name == "Hue motion sensor Motion" + assert test_entity.state == "on" + assert test_entity.attributes["device_class"] == "switch" + + +async def test_switch_turn_on_service(hass, mock_bridge_v2, v2_resources_test_data): + """Test calling the turn on service on a switch.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "switch") + + test_entity_id = "switch.hue_motion_sensor_motion" + + # call the HA turn_on service + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": test_entity_id}, + blocking=True, + ) + + # PUT request should have been sent to device with correct params + assert len(mock_bridge_v2.mock_requests) == 1 + assert mock_bridge_v2.mock_requests[0]["method"] == "put" + assert mock_bridge_v2.mock_requests[0]["json"]["enabled"] is True + + +async def test_switch_turn_off_service(hass, mock_bridge_v2, v2_resources_test_data): + """Test calling the turn off service on a switch.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "switch") + + test_entity_id = "switch.hue_motion_sensor_motion" + + # verify the switch is on before we start + assert hass.states.get(test_entity_id).state == "on" + + # now call the HA turn_off service + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": test_entity_id}, + blocking=True, + ) + + # PUT request should have been sent to device with correct params + assert len(mock_bridge_v2.mock_requests) == 1 + assert mock_bridge_v2.mock_requests[0]["method"] == "put" + assert mock_bridge_v2.mock_requests[0]["json"]["enabled"] is False + + # Now generate update event by emitting the json we've sent as incoming event + mock_bridge_v2.api.emit_event("update", mock_bridge_v2.mock_requests[0]["json"]) + await hass.async_block_till_done() + + # the switch should now be off + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "off" + + +async def test_switch_added(hass, mock_bridge_v2): + """Test new switch added to bridge.""" + await mock_bridge_v2.api.load_test_data([FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY]) + + await setup_platform(hass, mock_bridge_v2, "switch") + + test_entity_id = "switch.hue_mocked_device_motion" + + # verify entity does not exist before we start + assert hass.states.get(test_entity_id) is None + + # Add new fake entity (and attached device and zigbee_connectivity) by emitting events + mock_bridge_v2.api.emit_event("add", FAKE_BINARY_SENSOR) + await hass.async_block_till_done() + + # the entity should now be available + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "on" + + # test update + updated_resource = {**FAKE_BINARY_SENSOR, "enabled": False} + mock_bridge_v2.api.emit_event("update", updated_resource) + await hass.async_block_till_done() + test_entity = hass.states.get(test_entity_id) + assert test_entity is not None + assert test_entity.state == "off" diff --git a/tests/fixtures/hunterdouglas_powerview/fwversion.json b/tests/components/hunterdouglas_powerview/fixtures/fwversion.json similarity index 100% rename from tests/fixtures/hunterdouglas_powerview/fwversion.json rename to tests/components/hunterdouglas_powerview/fixtures/fwversion.json diff --git a/tests/fixtures/hunterdouglas_powerview/userdata.json b/tests/components/hunterdouglas_powerview/fixtures/userdata.json similarity index 100% rename from tests/fixtures/hunterdouglas_powerview/userdata.json rename to tests/components/hunterdouglas_powerview/fixtures/userdata.json diff --git a/tests/fixtures/hunterdouglas_powerview/userdata_v1.json b/tests/components/hunterdouglas_powerview/fixtures/userdata_v1.json similarity index 100% rename from tests/fixtures/hunterdouglas_powerview/userdata_v1.json rename to tests/components/hunterdouglas_powerview/fixtures/userdata_v1.json diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py index e3cb9aa4c8b..7f5d4a569ed 100644 --- a/tests/components/hunterdouglas_powerview/test_config_flow.py +++ b/tests/components/hunterdouglas_powerview/test_config_flow.py @@ -6,22 +6,34 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant import config_entries +from homeassistant.components import dhcp, zeroconf from homeassistant.components.hunterdouglas_powerview.const import DOMAIN from tests.common import MockConfigEntry, load_fixture -HOMEKIT_DISCOVERY_INFO = { - "name": "Hunter Douglas Powerview Hub._hap._tcp.local.", - "host": "1.2.3.4", - "properties": {"id": "AA::BB::CC::DD::EE::FF"}, -} +HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( + host="1.2.3.4", + hostname="mock_hostname", + name="Hunter Douglas Powerview Hub._hap._tcp.local.", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "AA::BB::CC::DD::EE::FF"}, + type="mock_type", +) -ZEROCONF_DISCOVERY_INFO = { - "name": "Hunter Douglas Powerview Hub._powerview._tcp.local.", - "host": "1.2.3.4", -} +ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( + host="1.2.3.4", + hostname="mock_hostname", + name="Hunter Douglas Powerview Hub._powerview._tcp.local.", + port=None, + properties={}, + type="mock_type", +) -DHCP_DISCOVERY_INFO = {"hostname": "Hunter Douglas Powerview Hub", "ip": "1.2.3.4"} +DHCP_DISCOVERY_INFO = dhcp.DhcpServiceInfo( + hostname="Hunter Douglas Powerview Hub", + ip="1.2.3.4", + macaddress="AA:BB:CC:DD:EE:FF", +) DISCOVERY_DATA = [ ( diff --git a/tests/fixtures/hvv_departures/check_name.json b/tests/components/hvv_departures/fixtures/check_name.json similarity index 100% rename from tests/fixtures/hvv_departures/check_name.json rename to tests/components/hvv_departures/fixtures/check_name.json diff --git a/tests/fixtures/hvv_departures/config_entry.json b/tests/components/hvv_departures/fixtures/config_entry.json similarity index 100% rename from tests/fixtures/hvv_departures/config_entry.json rename to tests/components/hvv_departures/fixtures/config_entry.json diff --git a/tests/fixtures/hvv_departures/departure_list.json b/tests/components/hvv_departures/fixtures/departure_list.json similarity index 100% rename from tests/fixtures/hvv_departures/departure_list.json rename to tests/components/hvv_departures/fixtures/departure_list.json diff --git a/tests/fixtures/hvv_departures/init.json b/tests/components/hvv_departures/fixtures/init.json similarity index 100% rename from tests/fixtures/hvv_departures/init.json rename to tests/components/hvv_departures/fixtures/init.json diff --git a/tests/fixtures/hvv_departures/options.json b/tests/components/hvv_departures/fixtures/options.json similarity index 100% rename from tests/fixtures/hvv_departures/options.json rename to tests/components/hvv_departures/fixtures/options.json diff --git a/tests/fixtures/hvv_departures/station_information.json b/tests/components/hvv_departures/fixtures/station_information.json similarity index 100% rename from tests/fixtures/hvv_departures/station_information.json rename to tests/components/hvv_departures/fixtures/station_information.json diff --git a/tests/components/hyperion/test_camera.py b/tests/components/hyperion/test_camera.py index a32663d7725..2ab16fb1301 100644 --- a/tests/components/hyperion/test_camera.py +++ b/tests/components/hyperion/test_camera.py @@ -121,7 +121,7 @@ async def test_camera_image_failed_start_stream_call(hass: HomeAssistant) -> Non await setup_test_config_entry(hass, hyperion_client=client) with pytest.raises(HomeAssistantError): - await async_get_image(hass, TEST_CAMERA_ENTITY_ID, timeout=0) + await async_get_image(hass, TEST_CAMERA_ENTITY_ID, timeout=0.01) assert client.async_send_image_stream_start.called assert not client.async_send_image_stream_stop.called diff --git a/tests/components/hyperion/test_config_flow.py b/tests/components/hyperion/test_config_flow.py index 7260d589e71..5d2e77e8adb 100644 --- a/tests/components/hyperion/test_config_flow.py +++ b/tests/components/hyperion/test_config_flow.py @@ -3,12 +3,14 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable +import dataclasses from typing import Any from unittest.mock import AsyncMock, Mock, patch from hyperion import const from homeassistant import data_entry_flow +from homeassistant.components import ssdp from homeassistant.components.hyperion.const import ( CONF_AUTH_ID, CONF_CREATE_TOKEN, @@ -65,39 +67,41 @@ TEST_REQUEST_TOKEN_FAIL = { "error": "Token request timeout or denied", } -TEST_SSDP_SERVICE_INFO = { - "ssdp_location": f"http://{TEST_HOST}:{TEST_PORT_UI}/description.xml", - "ssdp_st": "upnp:rootdevice", - "deviceType": "urn:schemas-upnp-org:device:Basic:1", - "friendlyName": f"Hyperion ({TEST_HOST})", - "manufacturer": "Hyperion Open Source Ambient Lighting", - "manufacturerURL": "https://www.hyperion-project.org", - "modelDescription": "Hyperion Open Source Ambient Light", - "modelName": "Hyperion", - "modelNumber": "2.0.0-alpha.8", - "modelURL": "https://www.hyperion-project.org", - "serialNumber": f"{TEST_SYSINFO_ID}", - "UDN": f"uuid:{TEST_SYSINFO_ID}", - "ports": { - "jsonServer": f"{TEST_PORT}", - "sslServer": "8092", - "protoBuffer": "19445", - "flatBuffer": "19400", +TEST_SSDP_SERVICE_INFO = ssdp.SsdpServiceInfo( + ssdp_st="upnp:rootdevice", + ssdp_location=f"http://{TEST_HOST}:{TEST_PORT_UI}/description.xml", + ssdp_usn=f"uuid:{TEST_SYSINFO_ID}", + ssdp_ext="", + ssdp_server="Raspbian GNU/Linux 10 (buster)/10 UPnP/1.0 Hyperion/2.0.0-alpha.8", + upnp={ + "deviceType": "urn:schemas-upnp-org:device:Basic:1", + "friendlyName": f"Hyperion ({TEST_HOST})", + "manufacturer": "Hyperion Open Source Ambient Lighting", + "manufacturerURL": "https://www.hyperion-project.org", + "modelDescription": "Hyperion Open Source Ambient Light", + "modelName": "Hyperion", + "modelNumber": "2.0.0-alpha.8", + "modelURL": "https://www.hyperion-project.org", + "serialNumber": f"{TEST_SYSINFO_ID}", + "UDN": f"uuid:{TEST_SYSINFO_ID}", + "ports": { + "jsonServer": f"{TEST_PORT}", + "sslServer": "8092", + "protoBuffer": "19445", + "flatBuffer": "19400", + }, + "presentationURL": "index.html", + "iconList": { + "icon": { + "mimetype": "image/png", + "height": "100", + "width": "100", + "depth": "32", + "url": "img/hyperion/ssdp_icon.png", + } + }, }, - "presentationURL": "index.html", - "iconList": { - "icon": { - "mimetype": "image/png", - "height": "100", - "width": "100", - "depth": "32", - "url": "img/hyperion/ssdp_icon.png", - } - }, - "ssdp_usn": f"uuid:{TEST_SYSINFO_ID}", - "ssdp_ext": "", - "ssdp_server": "Raspbian GNU/Linux 10 (buster)/10 UPnP/1.0 Hyperion/2.0.0-alpha.8", -} +) async def _create_mock_entry(hass: HomeAssistant) -> MockConfigEntry: @@ -639,8 +643,9 @@ async def test_ssdp_missing_serial(hass: HomeAssistant) -> None: """Check an SSDP flow where no id is provided.""" client = create_mock_client() - bad_data = {**TEST_SSDP_SERVICE_INFO} - del bad_data["serialNumber"] + bad_data = dataclasses.replace(TEST_SSDP_SERVICE_INFO) + bad_data.upnp = bad_data.upnp.copy() + del bad_data.upnp["serialNumber"] with patch( "homeassistant.components.hyperion.client.HyperionClient", return_value=client @@ -656,8 +661,9 @@ async def test_ssdp_failure_bad_port_json(hass: HomeAssistant) -> None: """Check an SSDP flow with bad json port.""" client = create_mock_client() - bad_data: dict[str, Any] = {**TEST_SSDP_SERVICE_INFO} - bad_data["ports"]["jsonServer"] = "not_a_port" + bad_data = dataclasses.replace(TEST_SSDP_SERVICE_INFO) + bad_data.upnp = bad_data.upnp.copy() + bad_data.upnp["ports"]["jsonServer"] = "not_a_port" with patch( "homeassistant.components.hyperion.client.HyperionClient", return_value=client @@ -676,8 +682,8 @@ async def test_ssdp_failure_bad_port_ui(hass: HomeAssistant) -> None: client = create_mock_client() client.async_is_auth_required = AsyncMock(return_value=TEST_AUTH_REQUIRED_RESP) - bad_data = {**TEST_SSDP_SERVICE_INFO} - bad_data["ssdp_location"] = f"http://{TEST_HOST}:not_a_port/description.xml" + bad_data = dataclasses.replace(TEST_SSDP_SERVICE_INFO) + bad_data.ssdp_location = f"http://{TEST_HOST}:not_a_port/description.xml" with patch( "homeassistant.components.hyperion.client.HyperionClient", return_value=client diff --git a/tests/fixtures/insteon/aldb_data.json b/tests/components/insteon/fixtures/aldb_data.json similarity index 100% rename from tests/fixtures/insteon/aldb_data.json rename to tests/components/insteon/fixtures/aldb_data.json diff --git a/tests/fixtures/insteon/kpl_properties.json b/tests/components/insteon/fixtures/kpl_properties.json similarity index 100% rename from tests/fixtures/insteon/kpl_properties.json rename to tests/components/insteon/fixtures/kpl_properties.json diff --git a/tests/components/ipp/__init__.py b/tests/components/ipp/__init__.py index 1e269438ad5..0e88fb21baf 100644 --- a/tests/components/ipp/__init__.py +++ b/tests/components/ipp/__init__.py @@ -1,21 +1,13 @@ """Tests for the IPP integration.""" -import os - import aiohttp from pyipp import IPPConnectionUpgradeRequired, IPPError +from homeassistant.components import zeroconf from homeassistant.components.ipp.const import CONF_BASE_PATH, CONF_UUID, DOMAIN -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PORT, - CONF_SSL, - CONF_TYPE, - CONF_VERIFY_SSL, -) +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_fixture_path from tests.test_util.aiohttp import AiohttpClientMocker ATTR_HOSTNAME = "hostname" @@ -42,30 +34,28 @@ MOCK_USER_INPUT = { CONF_BASE_PATH: BASE_PATH, } -MOCK_ZEROCONF_IPP_SERVICE_INFO = { - CONF_TYPE: IPP_ZEROCONF_SERVICE_TYPE, - CONF_NAME: f"{ZEROCONF_NAME}.{IPP_ZEROCONF_SERVICE_TYPE}", - CONF_HOST: ZEROCONF_HOST, - ATTR_HOSTNAME: ZEROCONF_HOSTNAME, - CONF_PORT: ZEROCONF_PORT, - ATTR_PROPERTIES: {"rp": ZEROCONF_RP}, -} +MOCK_ZEROCONF_IPP_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( + type=IPP_ZEROCONF_SERVICE_TYPE, + name=f"{ZEROCONF_NAME}.{IPP_ZEROCONF_SERVICE_TYPE}", + host=ZEROCONF_HOST, + hostname=ZEROCONF_HOSTNAME, + port=ZEROCONF_PORT, + properties={"rp": ZEROCONF_RP}, +) -MOCK_ZEROCONF_IPPS_SERVICE_INFO = { - CONF_TYPE: IPPS_ZEROCONF_SERVICE_TYPE, - CONF_NAME: f"{ZEROCONF_NAME}.{IPPS_ZEROCONF_SERVICE_TYPE}", - CONF_HOST: ZEROCONF_HOST, - ATTR_HOSTNAME: ZEROCONF_HOSTNAME, - CONF_PORT: ZEROCONF_PORT, - ATTR_PROPERTIES: {"rp": ZEROCONF_RP}, -} +MOCK_ZEROCONF_IPPS_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( + type=IPPS_ZEROCONF_SERVICE_TYPE, + name=f"{ZEROCONF_NAME}.{IPPS_ZEROCONF_SERVICE_TYPE}", + host=ZEROCONF_HOST, + hostname=ZEROCONF_HOSTNAME, + port=ZEROCONF_PORT, + properties={"rp": ZEROCONF_RP}, +) def load_fixture_binary(filename): """Load a binary fixture.""" - path = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures", filename) - with open(path, "rb") as fptr: - return fptr.read() + return get_fixture_path(filename, "ipp").read_bytes() def mock_connection( @@ -97,11 +87,11 @@ def mock_connection( aioclient_mock.post(f"{ipp_url}{base_path}", exc=IPPConnectionUpgradeRequired) return - fixture = "ipp/get-printer-attributes.bin" + fixture = "get-printer-attributes.bin" if no_unique_id: - fixture = "ipp/get-printer-attributes-success-nodata.bin" + fixture = "get-printer-attributes-success-nodata.bin" elif version_not_supported: - fixture = "ipp/get-printer-attributes-error-0x0503.bin" + fixture = "get-printer-attributes-error-0x0503.bin" if parse_error: content = "BAD" diff --git a/tests/fixtures/ipp/get-printer-attributes-error-0x0503.bin b/tests/components/ipp/fixtures/get-printer-attributes-error-0x0503.bin similarity index 100% rename from tests/fixtures/ipp/get-printer-attributes-error-0x0503.bin rename to tests/components/ipp/fixtures/get-printer-attributes-error-0x0503.bin diff --git a/tests/fixtures/ipp/get-printer-attributes-success-nodata.bin b/tests/components/ipp/fixtures/get-printer-attributes-success-nodata.bin similarity index 100% rename from tests/fixtures/ipp/get-printer-attributes-success-nodata.bin rename to tests/components/ipp/fixtures/get-printer-attributes-success-nodata.bin diff --git a/tests/fixtures/ipp/get-printer-attributes.bin b/tests/components/ipp/fixtures/get-printer-attributes.bin similarity index 100% rename from tests/fixtures/ipp/get-printer-attributes.bin rename to tests/components/ipp/fixtures/get-printer-attributes.bin diff --git a/tests/components/ipp/test_config_flow.py b/tests/components/ipp/test_config_flow.py index 23670ff0d1a..8eefa4251f7 100644 --- a/tests/components/ipp/test_config_flow.py +++ b/tests/components/ipp/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the IPP config flow.""" +import dataclasses from unittest.mock import patch from homeassistant.components.ipp.const import CONF_BASE_PATH, CONF_UUID, DOMAIN @@ -39,7 +40,7 @@ async def test_show_zeroconf_form( """Test that the zeroconf confirmation form is served.""" mock_connection(aioclient_mock) - discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, @@ -75,7 +76,7 @@ async def test_zeroconf_connection_error( """Test we abort zeroconf flow on IPP connection error.""" mock_connection(aioclient_mock, conn_error=True) - discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, @@ -92,7 +93,7 @@ async def test_zeroconf_confirm_connection_error( """Test we abort zeroconf flow on IPP connection error.""" mock_connection(aioclient_mock, conn_error=True) - discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) @@ -125,7 +126,7 @@ async def test_zeroconf_connection_upgrade_required( """Test we abort zeroconf flow on IPP connection error.""" mock_connection(aioclient_mock, conn_upgrade_error=True) - discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, @@ -159,7 +160,7 @@ async def test_zeroconf_parse_error( """Test we abort zeroconf flow on IPP parse error.""" mock_connection(aioclient_mock, parse_error=True) - discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, @@ -193,7 +194,7 @@ async def test_zeroconf_ipp_error( """Test we abort zeroconf flow on IPP error.""" mock_connection(aioclient_mock, ipp_error=True) - discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, @@ -227,7 +228,7 @@ async def test_zeroconf_ipp_version_error( """Test we abort zeroconf flow on IPP version not supported error.""" mock_connection(aioclient_mock, version_not_supported=True) - discovery_info = {**MOCK_ZEROCONF_IPP_SERVICE_INFO} + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, @@ -261,7 +262,7 @@ async def test_zeroconf_device_exists_abort( """Test we abort zeroconf flow if printer already configured.""" await init_integration(hass, aioclient_mock, skip_setup=True) - discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, @@ -278,13 +279,12 @@ async def test_zeroconf_with_uuid_device_exists_abort( """Test we abort zeroconf flow if printer already configured.""" await init_integration(hass, aioclient_mock, skip_setup=True) - discovery_info = { - **MOCK_ZEROCONF_IPP_SERVICE_INFO, - "properties": { - **MOCK_ZEROCONF_IPP_SERVICE_INFO["properties"], - "UUID": "cfe92100-67c4-11d4-a45f-f8d027761251", - }, + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) + discovery_info.properties = { + **MOCK_ZEROCONF_IPP_SERVICE_INFO.properties, + "UUID": "cfe92100-67c4-11d4-a45f-f8d027761251", } + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, @@ -301,9 +301,10 @@ async def test_zeroconf_empty_unique_id( """Test zeroconf flow if printer lacks (empty) unique identification.""" mock_connection(aioclient_mock, no_unique_id=True) - discovery_info = { - **MOCK_ZEROCONF_IPP_SERVICE_INFO, - "properties": {**MOCK_ZEROCONF_IPP_SERVICE_INFO["properties"], "UUID": ""}, + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) + discovery_info.properties = { + **MOCK_ZEROCONF_IPP_SERVICE_INFO.properties, + "UUID": "", } result = await hass.config_entries.flow.async_init( DOMAIN, @@ -320,7 +321,7 @@ async def test_zeroconf_no_unique_id( """Test zeroconf flow if printer lacks unique identification.""" mock_connection(aioclient_mock, no_unique_id=True) - discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, @@ -367,7 +368,7 @@ async def test_full_zeroconf_flow_implementation( """Test the full manual user flow from start to finish.""" mock_connection(aioclient_mock) - discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, @@ -401,7 +402,7 @@ async def test_full_zeroconf_tls_flow_implementation( """Test the full manual user flow from start to finish.""" mock_connection(aioclient_mock, ssl=True) - discovery_info = MOCK_ZEROCONF_IPPS_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPPS_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, diff --git a/tests/components/islamic_prayer_times/test_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py index 842a877e292..18d64842c65 100644 --- a/tests/components/islamic_prayer_times/test_config_flow.py +++ b/tests/components/islamic_prayer_times/test_config_flow.py @@ -5,6 +5,7 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import islamic_prayer_times +from homeassistant.components.islamic_prayer_times import config_flow # noqa: F401 from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD, DOMAIN from tests.common import MockConfigEntry diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index 62a779293be..e9a4c5dc4fb 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -326,11 +326,15 @@ async def test_form_ssdp_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}", + upnp={ + ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", + ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + }, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -341,11 +345,15 @@ async def test_form_ssdp(hass: HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}", + upnp={ + ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", + ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + }, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" @@ -385,11 +393,15 @@ async def test_form_ssdp_existing_entry(hass: HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: f"http://3.3.3.3{ISY_URL_POSTFIX}", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=f"http://3.3.3.3{ISY_URL_POSTFIX}", + upnp={ + ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", + ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + }, + ), ) await hass.async_block_till_done() @@ -412,11 +424,15 @@ async def test_form_ssdp_existing_entry_with_no_port(hass: HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: f"http://3.3.3.3/{ISY_URL_POSTFIX}", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=f"http://3.3.3.3/{ISY_URL_POSTFIX}", + upnp={ + ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", + ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + }, + ), ) await hass.async_block_till_done() @@ -439,11 +455,15 @@ async def test_form_ssdp_existing_entry_with_alternate_port(hass: HomeAssistant) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: f"http://3.3.3.3:1443/{ISY_URL_POSTFIX}", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=f"http://3.3.3.3:1443/{ISY_URL_POSTFIX}", + upnp={ + ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", + ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + }, + ), ) await hass.async_block_till_done() @@ -466,11 +486,15 @@ async def test_form_ssdp_existing_entry_no_port_https(hass: HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: f"https://3.3.3.3/{ISY_URL_POSTFIX}", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=f"https://3.3.3.3/{ISY_URL_POSTFIX}", + upnp={ + ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", + ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + }, + ), ) await hass.async_block_till_done() @@ -485,11 +509,11 @@ async def test_form_dhcp(hass: HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data={ - dhcp.IP_ADDRESS: "1.2.3.4", - dhcp.HOSTNAME: "isy994-ems", - dhcp.MAC_ADDRESS: MOCK_MAC, - }, + data=dhcp.DhcpServiceInfo( + ip="1.2.3.4", + hostname="isy994-ems", + macaddress=MOCK_MAC, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" @@ -529,11 +553,11 @@ async def test_form_dhcp_existing_entry(hass: HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data={ - dhcp.IP_ADDRESS: "1.2.3.4", - dhcp.HOSTNAME: "isy994-ems", - dhcp.MAC_ADDRESS: MOCK_MAC, - }, + data=dhcp.DhcpServiceInfo( + ip="1.2.3.4", + hostname="isy994-ems", + macaddress=MOCK_MAC, + ), ) await hass.async_block_till_done() @@ -559,11 +583,11 @@ async def test_form_dhcp_existing_entry_preserves_port(hass: HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data={ - dhcp.IP_ADDRESS: "1.2.3.4", - dhcp.HOSTNAME: "isy994-ems", - dhcp.MAC_ADDRESS: MOCK_MAC, - }, + data=dhcp.DhcpServiceInfo( + ip="1.2.3.4", + hostname="isy994-ems", + macaddress=MOCK_MAC, + ), ) await hass.async_block_till_done() diff --git a/tests/components/jellyfin/__init__.py b/tests/components/jellyfin/__init__.py new file mode 100644 index 00000000000..e5ff9ab3207 --- /dev/null +++ b/tests/components/jellyfin/__init__.py @@ -0,0 +1 @@ +"""Tests for the jellyfin integration.""" diff --git a/tests/components/jellyfin/const.py b/tests/components/jellyfin/const.py new file mode 100644 index 00000000000..b33f00818b7 --- /dev/null +++ b/tests/components/jellyfin/const.py @@ -0,0 +1,17 @@ +"""Constants for the Jellyfin integration tests.""" + +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/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py new file mode 100644 index 00000000000..cc23265e011 --- /dev/null +++ b/tests/components/jellyfin/test_config_flow.py @@ -0,0 +1,164 @@ +"""Test the jellyfin config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.jellyfin.const import 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 tests.common import MockConfigEntry + + +async def test_abort_if_existing_entry(hass: HomeAssistant): + """Check flow abort when an entry already exist.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_form(hass: HomeAssistant): + """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() + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_URL + assert result2["data"] == { + 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_setup_entry.mock_calls) == 1 + assert len(mock_set_id.mock_calls) == 1 + + +async def test_form_cannot_connect(hass: HomeAssistant): + """Test we handle an unreachable server.""" + 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_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, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + assert len(mock_connect.mock_calls) == 1 + + +async def test_form_invalid_auth(hass: HomeAssistant): + """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"] == {} + + 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() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + assert len(mock_connect.mock_calls) == 1 + assert len(mock_login.mock_calls) == 1 + + +async def test_form_exception(hass: HomeAssistant): + """Test we handle an unexpected exception during server setup.""" + 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", + 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() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + assert len(mock_connect.mock_calls) == 1 diff --git a/tests/components/keenetic_ndms2/__init__.py b/tests/components/keenetic_ndms2/__init__.py index 9f96e56cdd0..f6bc72cb1e4 100644 --- a/tests/components/keenetic_ndms2/__init__.py +++ b/tests/components/keenetic_ndms2/__init__.py @@ -29,8 +29,12 @@ MOCK_OPTIONS = { const.CONF_INTERFACES: ["Home", "VPS0"], } -MOCK_SSDP_DISCOVERY_INFO = { - ssdp.ATTR_SSDP_LOCATION: SSDP_LOCATION, - ssdp.ATTR_UPNP_UDN: "uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_NAME, -} +MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=SSDP_LOCATION, + upnp={ + ssdp.ATTR_UPNP_UDN: "uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_NAME, + }, +) diff --git a/tests/components/keenetic_ndms2/test_config_flow.py b/tests/components/keenetic_ndms2/test_config_flow.py index 23c1bead25e..0b438487c49 100644 --- a/tests/components/keenetic_ndms2/test_config_flow.py +++ b/tests/components/keenetic_ndms2/test_config_flow.py @@ -1,5 +1,6 @@ """Test Keenetic NDMS2 setup process.""" +import dataclasses from unittest.mock import Mock, patch from ndms2_client import ConnectionException @@ -164,7 +165,7 @@ async def test_connection_error(hass: HomeAssistant, connect_error) -> None: async def test_ssdp_works(hass: HomeAssistant, connect) -> None: """Test host already configured and discovered.""" - discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( keenetic.DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_SSDP}, @@ -200,7 +201,7 @@ async def test_ssdp_already_configured(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) - discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( keenetic.DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_SSDP}, @@ -217,11 +218,11 @@ async def test_ssdp_ignored(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=keenetic.DOMAIN, source=config_entries.SOURCE_IGNORE, - unique_id=MOCK_SSDP_DISCOVERY_INFO[ssdp.ATTR_UPNP_UDN], + unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN], ) entry.add_to_hass(hass) - discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( keenetic.DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_SSDP}, @@ -239,16 +240,14 @@ async def test_ssdp_update_host(hass: HomeAssistant) -> None: domain=keenetic.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS, - unique_id=MOCK_SSDP_DISCOVERY_INFO[ssdp.ATTR_UPNP_UDN], + unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN], ) entry.add_to_hass(hass) new_ip = "10.10.10.10" - discovery_info = { - **MOCK_SSDP_DISCOVERY_INFO, - ssdp.ATTR_SSDP_LOCATION: f"http://{new_ip}/", - } + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) + discovery_info.ssdp_location = f"http://{new_ip}/" result = await hass.config_entries.flow.async_init( keenetic.DOMAIN, @@ -264,10 +263,9 @@ async def test_ssdp_update_host(hass: HomeAssistant) -> None: async def test_ssdp_reject_no_udn(hass: HomeAssistant) -> None: """Discovered device has no UDN.""" - discovery_info = { - **MOCK_SSDP_DISCOVERY_INFO, - } - discovery_info.pop(ssdp.ATTR_UPNP_UDN) + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) + discovery_info.upnp = {**discovery_info.upnp} + discovery_info.upnp.pop(ssdp.ATTR_UPNP_UDN) result = await hass.config_entries.flow.async_init( keenetic.DOMAIN, @@ -282,10 +280,9 @@ async def test_ssdp_reject_no_udn(hass: HomeAssistant) -> None: async def test_ssdp_reject_non_keenetic(hass: HomeAssistant) -> None: """Discovered device does not look like a keenetic router.""" - discovery_info = { - **MOCK_SSDP_DISCOVERY_INFO, - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Suspicious device", - } + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) + discovery_info.upnp = {**discovery_info.upnp} + discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] = "Suspicious device" result = await hass.config_entries.flow.async_init( keenetic.DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_SSDP}, diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 47e7b94c32d..a692fa97814 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -8,28 +8,45 @@ import pytest from xknx import XKNX from xknx.core import XknxConnectionState from xknx.dpt import DPTArray, DPTBinary +from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from xknx.telegram import Telegram, TelegramDirection from xknx.telegram.address import GroupAddress, IndividualAddress from xknx.telegram.apci import APCI, GroupValueRead, GroupValueResponse, GroupValueWrite -from homeassistant.components.knx.const import DOMAIN as KNX_DOMAIN +from homeassistant.components.knx import ConnectionSchema +from homeassistant.components.knx.const import ( + CONF_KNX_AUTOMATIC, + CONF_KNX_CONNECTION_TYPE, + CONF_KNX_INDIVIDUAL_ADDRESS, + DOMAIN as KNX_DOMAIN, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry + class KNXTestKit: """Test helper for the KNX integration.""" INDIVIDUAL_ADDRESS = "1.2.3" - def __init__(self, hass: HomeAssistant): + def __init__(self, hass: HomeAssistant, mock_config_entry: MockConfigEntry): """Init KNX test helper class.""" self.hass: HomeAssistant = hass + self.mock_config_entry: MockConfigEntry = mock_config_entry self.xknx: XKNX # outgoing telegrams will be put in the Queue instead of sent to the interface # telegrams to an InternalGroupAddress won't be queued here self._outgoing_telegrams: asyncio.Queue = asyncio.Queue() + def assert_state(self, entity_id: str, state: str, **attributes) -> None: + """Assert the state of an entity.""" + test_state = self.hass.states.get(entity_id) + assert test_state.state == state + for attribute, value in attributes.items(): + assert test_state.attributes.get(attribute) == value + async def setup_integration(self, config): """Create the KNX integration.""" @@ -53,6 +70,7 @@ class KNXTestKit: return_value=knx_ip_interface_mock(), side_effect=fish_xknx, ): + self.mock_config_entry.add_to_hass(self.hass) await async_setup_component(self.hass, KNX_DOMAIN, {KNX_DOMAIN: config}) await self.xknx.connection_manager.connection_state_changed( XknxConnectionState.CONNECTED @@ -184,8 +202,23 @@ class KNXTestKit: @pytest.fixture -async def knx(request, hass): +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="KNX", + domain=KNX_DOMAIN, + data={ + CONF_KNX_INDIVIDUAL_ADDRESS: XKNX.DEFAULT_ADDRESS, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + }, + ) + + +@pytest.fixture +async def knx(request, hass, mock_config_entry: MockConfigEntry): """Create a KNX TestKit instance.""" - knx_test_kit = KNXTestKit(hass) + knx_test_kit = KNXTestKit(hass, mock_config_entry) yield knx_test_kit await knx_test_kit.assert_no_telegram() diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index 9adc7205543..5513cefcbb4 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -26,7 +26,7 @@ async def test_binary_sensor_entity_category(hass: HomeAssistant, knx: KNXTestKi """Test KNX binary sensor entity category.""" await knx.setup_integration( { - BinarySensorSchema.PLATFORM_NAME: [ + BinarySensorSchema.PLATFORM: [ { CONF_NAME: "test_normal", CONF_STATE_ADDRESS: "1/1/1", @@ -49,7 +49,7 @@ async def test_binary_sensor(hass: HomeAssistant, knx: KNXTestKit): """Test KNX binary sensor and inverted binary_sensor.""" await knx.setup_integration( { - BinarySensorSchema.PLATFORM_NAME: [ + BinarySensorSchema.PLATFORM: [ { CONF_NAME: "test_normal", CONF_STATE_ADDRESS: "1/1/1", @@ -104,7 +104,7 @@ async def test_binary_sensor_ignore_internal_state( await knx.setup_integration( { - BinarySensorSchema.PLATFORM_NAME: [ + BinarySensorSchema.PLATFORM: [ { CONF_NAME: "test_normal", CONF_STATE_ADDRESS: "1/1/1", @@ -156,7 +156,7 @@ async def test_binary_sensor_counter(hass: HomeAssistant, knx: KNXTestKit): await knx.setup_integration( { - BinarySensorSchema.PLATFORM_NAME: [ + BinarySensorSchema.PLATFORM: [ { CONF_NAME: "test", CONF_STATE_ADDRESS: "2/2/2", @@ -223,7 +223,7 @@ async def test_binary_sensor_reset(hass: HomeAssistant, knx: KNXTestKit): await knx.setup_integration( { - BinarySensorSchema.PLATFORM_NAME: [ + BinarySensorSchema.PLATFORM: [ { CONF_NAME: "test", CONF_STATE_ADDRESS: "2/2/2", @@ -259,7 +259,7 @@ async def test_binary_sensor_restore_and_respond(hass, knx): ): await knx.setup_integration( { - BinarySensorSchema.PLATFORM_NAME: [ + BinarySensorSchema.PLATFORM: [ { CONF_NAME: "test", CONF_STATE_ADDRESS: _ADDRESS, @@ -291,7 +291,7 @@ async def test_binary_sensor_restore_invert(hass, knx): ): await knx.setup_integration( { - BinarySensorSchema.PLATFORM_NAME: [ + BinarySensorSchema.PLATFORM: [ { CONF_NAME: "test", CONF_STATE_ADDRESS: _ADDRESS, diff --git a/tests/components/knx/test_button.py b/tests/components/knx/test_button.py new file mode 100644 index 00000000000..eff81a3c6b6 --- /dev/null +++ b/tests/components/knx/test_button.py @@ -0,0 +1,91 @@ +"""Test KNX button.""" +from datetime import timedelta + +from homeassistant.components.knx.const import ( + CONF_PAYLOAD, + CONF_PAYLOAD_LENGTH, + KNX_ADDRESS, +) +from homeassistant.components.knx.schema import ButtonSchema +from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from .conftest import KNXTestKit + +from tests.common import async_capture_events, async_fire_time_changed + + +async def test_button_simple(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX button with default payload.""" + events = async_capture_events(hass, "state_changed") + await knx.setup_integration( + { + ButtonSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: "1/2/3", + } + } + ) + assert len(hass.states.async_all()) == 1 + assert len(events) == 1 + events.pop() + + # press button + await hass.services.async_call( + "button", "press", {"entity_id": "button.test"}, blocking=True + ) + await knx.assert_write("1/2/3", True) + assert len(events) == 1 + events.pop() + + # received telegrams on button GA are ignored by the entity + old_state = hass.states.get("button.test") + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=3)) + await knx.receive_write("1/2/3", False) + await knx.receive_write("1/2/3", True) + new_state = hass.states.get("button.test") + assert old_state == new_state + assert len(events) == 0 + + # button does not respond to read + await knx.receive_read("1/2/3") + await knx.assert_telegram_count(0) + + +async def test_button_raw(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX button with raw payload.""" + await knx.setup_integration( + { + ButtonSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: "1/2/3", + CONF_PAYLOAD: False, + CONF_PAYLOAD_LENGTH: 0, + } + } + ) + # press button + await hass.services.async_call( + "button", "press", {"entity_id": "button.test"}, blocking=True + ) + await knx.assert_write("1/2/3", False) + + +async def test_button_type(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX button with encoded payload.""" + await knx.setup_integration( + { + ButtonSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: "1/2/3", + ButtonSchema.CONF_VALUE: 21.5, + CONF_TYPE: "2byte_float", + } + } + ) + # press button + await hass.services.async_call( + "button", "press", {"entity_id": "button.test"}, blocking=True + ) + await knx.assert_write("1/2/3", (0x0C, 0x33)) diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py new file mode 100644 index 00000000000..ff1fc362aa5 --- /dev/null +++ b/tests/components/knx/test_config_flow.py @@ -0,0 +1,629 @@ +"""Test the KNX config flow.""" +from unittest.mock import patch + +from xknx import XKNX +from xknx.io import DEFAULT_MCAST_GRP +from xknx.io.gateway_scanner import GatewayDescriptor + +from homeassistant import config_entries +from homeassistant.components.knx import ConnectionSchema +from homeassistant.components.knx.config_flow import ( + CONF_KNX_GATEWAY, + DEFAULT_ENTRY_DATA, +) +from homeassistant.components.knx.const import ( + CONF_KNX_AUTOMATIC, + CONF_KNX_CONNECTION_TYPE, + CONF_KNX_INDIVIDUAL_ADDRESS, + CONF_KNX_ROUTING, + CONF_KNX_TUNNELING, + DOMAIN, +) +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + + +def _gateway_descriptor(ip: str, port: int) -> GatewayDescriptor: + """Get mock gw descriptor.""" + return GatewayDescriptor("Test", ip, port, "eth0", "127.0.0.1", True) + + +async def test_user_single_instance(hass): + """Test we only allow a single config flow.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" + + +async def test_routing_setup(hass: HomeAssistant) -> None: + """Test routing setup.""" + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [] + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + }, + ) + await hass.async_block_till_done() + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "routing" + assert not result2["errors"] + + with patch( + "homeassistant.components.knx.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110", + }, + ) + await hass.async_block_till_done() + assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == CONF_KNX_ROUTING.capitalize() + assert result3["data"] == { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_tunneling_setup(hass: HomeAssistant) -> None: + """Test tunneling if only one gateway is found.""" + gateway = _gateway_descriptor("192.168.0.1", 3675) + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [gateway] + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + }, + ) + await hass.async_block_till_done() + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "manual_tunnel" + assert not result2["errors"] + + with patch( + "homeassistant.components.knx.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.0.1", + CONF_PORT: 3675, + }, + ) + await hass.async_block_till_done() + assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == "Tunneling @ 192.168.0.1" + assert result3["data"] == { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + CONF_HOST: "192.168.0.1", + CONF_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_ROUTE_BACK: False, + ConnectionSchema.CONF_KNX_LOCAL_IP: None, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_tunneling_setup_for_local_ip(hass: HomeAssistant) -> None: + """Test tunneling if only one gateway is found.""" + gateway = _gateway_descriptor("192.168.0.2", 3675) + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [gateway] + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + }, + ) + await hass.async_block_till_done() + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "manual_tunnel" + assert not result2["errors"] + + with patch( + "homeassistant.components.knx.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.0.2", + CONF_PORT: 3675, + ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", + }, + ) + await hass.async_block_till_done() + assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == "Tunneling @ 192.168.0.2" + assert result3["data"] == { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + CONF_HOST: "192.168.0.2", + CONF_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_ROUTE_BACK: False, + ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_tunneling_setup_for_multiple_found_gateways(hass: HomeAssistant) -> None: + """Test tunneling if only one gateway is found.""" + gateway = _gateway_descriptor("192.168.0.1", 3675) + gateway2 = _gateway_descriptor("192.168.1.100", 3675) + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [gateway, gateway2] + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + tunnel_flow = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + }, + ) + await hass.async_block_till_done() + assert tunnel_flow["type"] == RESULT_TYPE_FORM + assert tunnel_flow["step_id"] == "tunnel" + assert not tunnel_flow["errors"] + + manual_tunnel = await hass.config_entries.flow.async_configure( + tunnel_flow["flow_id"], + {CONF_KNX_GATEWAY: str(gateway)}, + ) + await hass.async_block_till_done() + assert manual_tunnel["type"] == RESULT_TYPE_FORM + assert manual_tunnel["step_id"] == "manual_tunnel" + + with patch( + "homeassistant.components.knx.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + manual_tunnel_flow = await hass.config_entries.flow.async_configure( + manual_tunnel["flow_id"], + { + CONF_HOST: "192.168.0.1", + CONF_PORT: 3675, + }, + ) + await hass.async_block_till_done() + assert manual_tunnel_flow["type"] == RESULT_TYPE_CREATE_ENTRY + assert manual_tunnel_flow["data"] == { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + CONF_HOST: "192.168.0.1", + CONF_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_ROUTE_BACK: False, + ConnectionSchema.CONF_KNX_LOCAL_IP: None, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_manual_tunnel_step_when_no_gateway(hass: HomeAssistant) -> None: + """Test manual tunnel if no gateway is found and tunneling is selected.""" + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [] + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + tunnel_flow = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + }, + ) + await hass.async_block_till_done() + assert tunnel_flow["type"] == RESULT_TYPE_FORM + assert tunnel_flow["step_id"] == "manual_tunnel" + assert not tunnel_flow["errors"] + + +async def test_form_with_automatic_connection_handling(hass: HomeAssistant) -> None: + """Test we get the form.""" + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [_gateway_descriptor("192.168.0.1", 3675)] + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + with patch( + "homeassistant.components.knx.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == CONF_KNX_AUTOMATIC.capitalize() + assert result2["data"] == { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +## +# Import Tests +## +async def test_import_config_tunneling(hass: HomeAssistant) -> None: + """Test tunneling import from config.yaml.""" + config = { + CONF_KNX_INDIVIDUAL_ADDRESS: XKNX.DEFAULT_ADDRESS, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, # has a default in the original config + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, # has a default in the original config + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, # has a default in the original config + CONF_KNX_TUNNELING: { + CONF_HOST: "192.168.1.1", + CONF_PORT: 3675, + ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", + ConnectionSchema.CONF_KNX_ROUTE_BACK: True, + }, + } + + with patch( + "homeassistant.components.knx.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=config + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Tunneling @ 192.168.1.1" + assert result["data"] == { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + CONF_HOST: "192.168.1.1", + CONF_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_ROUTE_BACK: True, + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_config_routing(hass: HomeAssistant) -> None: + """Test routing import from config.yaml.""" + config = { + CONF_KNX_INDIVIDUAL_ADDRESS: XKNX.DEFAULT_ADDRESS, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, # has a default in the original config + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, # has a default in the original config + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, # has a default in the original config + CONF_KNX_ROUTING: {}, # is required when using routing + } + + with patch( + "homeassistant.components.knx.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=config + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == CONF_KNX_ROUTING.capitalize() + assert result["data"] == { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_config_automatic(hass: HomeAssistant) -> None: + """Test automatic import from config.yaml.""" + config = { + CONF_KNX_INDIVIDUAL_ADDRESS: XKNX.DEFAULT_ADDRESS, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, # has a default in the original config + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, # has a default in the original config + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, # has a default in the original config + } + + with patch( + "homeassistant.components.knx.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=config + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == CONF_KNX_AUTOMATIC.capitalize() + assert result["data"] == { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_rate_limit_out_of_range(hass: HomeAssistant) -> None: + """Test automatic import from config.yaml.""" + config = { + CONF_KNX_INDIVIDUAL_ADDRESS: XKNX.DEFAULT_ADDRESS, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, # has a default in the original config + ConnectionSchema.CONF_KNX_RATE_LIMIT: 80, + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, # has a default in the original config + } + + with patch( + "homeassistant.components.knx.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=config + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == CONF_KNX_AUTOMATIC.capitalize() + assert result["data"] == { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 60, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_options(hass: HomeAssistant) -> None: + """Test import from config.yaml with options.""" + config = { + CONF_KNX_INDIVIDUAL_ADDRESS: XKNX.DEFAULT_ADDRESS, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, # has a default in the original config + ConnectionSchema.CONF_KNX_STATE_UPDATER: False, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 30, + } + + with patch( + "homeassistant.components.knx.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=config + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == CONF_KNX_AUTOMATIC.capitalize() + assert result["data"] == { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_STATE_UPDATER: False, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 30, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_abort_if_entry_exists_already(hass: HomeAssistant) -> None: + """Test routing import from config.yaml.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" + + +async def test_options_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test options config flow.""" + mock_config_entry.add_to_hass(hass) + + gateway = _gateway_descriptor("192.168.0.1", 3675) + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [gateway] + result = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "init" + assert "flow_id" in result + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.255", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + }, + ) + + await hass.async_block_till_done() + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert not result2.get("data") + + assert mock_config_entry.data == { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.255", + CONF_HOST: "", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, + } + + +async def test_tunneling_options_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test options flow for tunneling.""" + mock_config_entry.add_to_hass(hass) + + gateway = _gateway_descriptor("192.168.0.1", 3675) + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [gateway] + result = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "init" + assert "flow_id" in result + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.255", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + }, + ) + + assert result2.get("type") == RESULT_TYPE_FORM + assert not result2.get("data") + assert "flow_id" in result2 + + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={ + CONF_HOST: "192.168.1.1", + CONF_PORT: 3675, + ConnectionSchema.CONF_KNX_ROUTE_BACK: True, + ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", + }, + ) + + await hass.async_block_till_done() + assert result3.get("type") == RESULT_TYPE_CREATE_ENTRY + assert not result3.get("data") + + assert mock_config_entry.data == { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.255", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, + CONF_HOST: "192.168.1.1", + CONF_PORT: 3675, + ConnectionSchema.CONF_KNX_ROUTE_BACK: True, + ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", + } + + +async def test_advanced_options( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test options config flow.""" + mock_config_entry.add_to_hass(hass) + + gateway = _gateway_descriptor("192.168.0.1", 3675) + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [gateway] + result = await hass.config_entries.options.async_init( + mock_config_entry.entry_id, context={"show_advanced_options": True} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "init" + assert "flow_id" in result + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 25, + ConnectionSchema.CONF_KNX_STATE_UPDATER: False, + }, + ) + + await hass.async_block_till_done() + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert not result2.get("data") + + assert mock_config_entry.data == { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + CONF_HOST: "", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 25, + ConnectionSchema.CONF_KNX_STATE_UPDATER: False, + } diff --git a/tests/components/knx/test_events.py b/tests/components/knx/test_events.py index 6a9e021ff53..360a1963d2d 100644 --- a/tests/components/knx/test_events.py +++ b/tests/components/knx/test_events.py @@ -1,6 +1,11 @@ """Test KNX events.""" -from homeassistant.components.knx import CONF_KNX_EVENT_FILTER +from homeassistant.components.knx import ( + CONF_EVENT, + CONF_KNX_EVENT_FILTER, + CONF_TYPE, + KNX_ADDRESS, +) from homeassistant.core import HomeAssistant from .conftest import KNXTestKit @@ -9,7 +14,7 @@ from tests.common import async_capture_events async def test_knx_event(hass: HomeAssistant, knx: KNXTestKit): - """Test `knx_event` event.""" + """Test the `knx_event` event.""" test_group_a = "0/4/*" test_address_a_1 = "0/4/0" test_address_a_2 = "0/4/100" @@ -20,13 +25,15 @@ async def test_knx_event(hass: HomeAssistant, knx: KNXTestKit): test_address_c_1 = "2/6/4" test_address_c_2 = "2/6/5" test_address_d = "5/4/3" + test_address_e = "6/4/3" events = async_capture_events(hass, "knx_event") - async def test_event_data(address, payload): + async def test_event_data(address, payload, value=None): await hass.async_block_till_done() assert len(events) == 1 event = events.pop() assert event.data["data"] == payload + assert event.data["value"] == value assert event.data["direction"] == "Incoming" assert event.data["destination"] == address if payload is None: @@ -40,12 +47,24 @@ async def test_knx_event(hass: HomeAssistant, knx: KNXTestKit): await knx.setup_integration( { - CONF_KNX_EVENT_FILTER: [ - test_group_a, - test_group_b, - test_group_c, - test_address_d, - ] + CONF_EVENT: [ + { + KNX_ADDRESS: [ + test_group_a, + test_group_b, + ], + CONF_TYPE: "2byte_unsigned", + }, + { + KNX_ADDRESS: test_group_c, + CONF_TYPE: "2byte_float", + }, + { + KNX_ADDRESS: [test_address_d], + }, + ], + # test legacy `event_filter` config + CONF_KNX_EVENT_FILTER: [test_address_e], } ) @@ -54,28 +73,35 @@ async def test_knx_event(hass: HomeAssistant, knx: KNXTestKit): assert len(events) == 0 # receive telegrams for group addresses matching the filter - await knx.receive_write(test_address_a_1, True) - await test_event_data(test_address_a_1, True) + await knx.receive_write(test_address_a_1, (0x03, 0x2F)) + await test_event_data(test_address_a_1, (0x03, 0x2F), value=815) - await knx.receive_response(test_address_a_2, False) - await test_event_data(test_address_a_2, False) + await knx.receive_response(test_address_a_2, (0x12, 0x67)) + await test_event_data(test_address_a_2, (0x12, 0x67), value=4711) - await knx.receive_write(test_address_b_1, (1,)) - await test_event_data(test_address_b_1, (1,)) + await knx.receive_write(test_address_b_1, (0, 0)) + await test_event_data(test_address_b_1, (0, 0), value=0) - await knx.receive_response(test_address_b_2, (255,)) - await test_event_data(test_address_b_2, (255,)) + await knx.receive_response(test_address_b_2, (255, 255)) + await test_event_data(test_address_b_2, (255, 255), value=65535) - await knx.receive_write(test_address_c_1, (89, 43, 34, 11)) - await test_event_data(test_address_c_1, (89, 43, 34, 11)) + await knx.receive_write(test_address_c_1, (0x06, 0xA0)) + await test_event_data(test_address_c_1, (0x06, 0xA0), value=16.96) - await knx.receive_response(test_address_c_2, (255, 255, 255, 255)) - await test_event_data(test_address_c_2, (255, 255, 255, 255)) + await knx.receive_response(test_address_c_2, (0x8A, 0x24)) + await test_event_data(test_address_c_2, (0x8A, 0x24), value=-30.0) await knx.receive_read(test_address_d) await test_event_data(test_address_d, None) - # receive telegrams for group addresses not matching the filter + await knx.receive_write(test_address_d, True) + await test_event_data(test_address_d, True) + + # test legacy `event_filter` config + await knx.receive_write(test_address_e, (89, 43, 34, 11)) + await test_event_data(test_address_e, (89, 43, 34, 11)) + + # receive telegrams for group addresses not matching any filter await knx.receive_write("0/5/0", True) await knx.receive_write("1/7/0", True) await knx.receive_write("2/6/6", True) diff --git a/tests/components/knx/test_fan.py b/tests/components/knx/test_fan.py index cc2365888f0..37a69911e51 100644 --- a/tests/components/knx/test_fan.py +++ b/tests/components/knx/test_fan.py @@ -11,7 +11,7 @@ async def test_fan_percent(hass: HomeAssistant, knx: KNXTestKit): """Test KNX fan with percentage speed.""" await knx.setup_integration( { - FanSchema.PLATFORM_NAME: { + FanSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: "1/2/3", } @@ -56,7 +56,7 @@ async def test_fan_step(hass: HomeAssistant, knx: KNXTestKit): """Test KNX fan with speed steps.""" await knx.setup_integration( { - FanSchema.PLATFORM_NAME: { + FanSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: "1/2/3", FanSchema.CONF_MAX_STEP: 4, @@ -109,7 +109,7 @@ async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit): """Test KNX fan oscillation.""" await knx.setup_integration( { - FanSchema.PLATFORM_NAME: { + FanSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: "1/1/1", FanSchema.CONF_OSCILLATION_ADDRESS: "2/2/2", diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py new file mode 100644 index 00000000000..9c7ef0e91ec --- /dev/null +++ b/tests/components/knx/test_light.py @@ -0,0 +1,1137 @@ +"""Test KNX light.""" +from __future__ import annotations + +from datetime import timedelta + +from xknx.core import XknxConnectionState +from xknx.devices.light import Light as XknxLight + +from homeassistant.components.knx.const import CONF_STATE_ADDRESS, KNX_ADDRESS +from homeassistant.components.knx.schema import LightSchema +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_NAME, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + ATTR_RGBW_COLOR, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, + COLOR_MODE_ONOFF, + COLOR_MODE_RGB, + COLOR_MODE_RGBW, + COLOR_MODE_XY, +) +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from .conftest import KNXTestKit + +from tests.common import async_fire_time_changed + + +async def test_light_simple(hass: HomeAssistant, knx: KNXTestKit): + """Test simple KNX light.""" + test_address = "1/1/1" + await knx.setup_integration( + { + LightSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + } + } + ) + assert len(hass.states.async_all()) == 1 + + knx.assert_state("light.test", STATE_OFF) + # turn on light + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test"}, + blocking=True, + ) + await knx.assert_write(test_address, True) + knx.assert_state( + "light.test", + STATE_ON, + color_mode=COLOR_MODE_ONOFF, + ) + # turn off light + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": "light.test"}, + blocking=True, + ) + await knx.assert_write(test_address, False) + knx.assert_state("light.test", STATE_OFF) + # receive ON telegram + await knx.receive_write(test_address, True) + knx.assert_state("light.test", STATE_ON) + + # receive OFF telegram + await knx.receive_write(test_address, False) + knx.assert_state("light.test", STATE_OFF) + + # switch does not respond to read by default + await knx.receive_read(test_address) + await knx.assert_telegram_count(0) + + +async def test_light_brightness(hass: HomeAssistant, knx: KNXTestKit): + """Test dimmable KNX light.""" + test_address = "1/1/1" + test_brightness = "1/1/2" + test_brightness_state = "1/1/3" + await knx.setup_integration( + { + LightSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + LightSchema.CONF_BRIGHTNESS_ADDRESS: test_brightness, + LightSchema.CONF_BRIGHTNESS_STATE_ADDRESS: test_brightness_state, + } + } + ) + # StateUpdater initialize state + await knx.assert_read(test_brightness_state) + await knx.xknx.connection_manager.connection_state_changed( + XknxConnectionState.CONNECTED + ) + # turn on light via brightness + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 80}, + blocking=True, + ) + await knx.assert_write(test_brightness, (80,)) + # state is still OFF until controller reports otherwise + knx.assert_state("light.test", STATE_OFF) + await knx.receive_write(test_address, True) + knx.assert_state( + "light.test", + STATE_ON, + brightness=80, + color_mode=COLOR_MODE_BRIGHTNESS, + ) + # receive brightness changes from KNX + await knx.receive_write(test_brightness_state, (255,)) + knx.assert_state("light.test", STATE_ON, brightness=255) + await knx.receive_write(test_brightness, (128,)) + knx.assert_state("light.test", STATE_ON, brightness=128) + # turn off light via brightness + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 0}, + blocking=True, + ) + await knx.assert_write(test_address, False) + knx.assert_state("light.test", STATE_OFF) + + +async def test_light_color_temp_absolute(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX light color temperature adjustable in Kelvin.""" + test_address = "1/1/1" + test_address_state = "1/1/2" + test_brightness = "1/1/3" + test_brightness_state = "1/1/4" + test_ct = "1/1/5" + test_ct_state = "1/1/6" + await knx.setup_integration( + { + LightSchema.PLATFORM: [ + { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_address_state, + LightSchema.CONF_BRIGHTNESS_ADDRESS: test_brightness, + LightSchema.CONF_BRIGHTNESS_STATE_ADDRESS: test_brightness_state, + LightSchema.CONF_COLOR_TEMP_ADDRESS: test_ct, + LightSchema.CONF_COLOR_TEMP_STATE_ADDRESS: test_ct_state, + LightSchema.CONF_COLOR_TEMP_MODE: "absolute", + }, + ] + } + ) + # StateUpdater initialize state + await knx.assert_read(test_address_state) + await knx.assert_read(test_brightness_state) + await knx.receive_response(test_address_state, True) + await knx.receive_response(test_brightness_state, (255,)) + # # StateUpdater semaphore allows 2 concurrent requests + await knx.assert_read(test_ct_state) + await knx.receive_response(test_ct_state, (0x0A, 0x8C)) # 2700 Kelvin - 370 Mired + + knx.assert_state( + "light.test", + STATE_ON, + brightness=255, + color_mode=COLOR_MODE_COLOR_TEMP, + color_temp=370, + ) + # change color temperature from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_COLOR_TEMP: 250}, # 4000 Kelvin - 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) + + +async def test_light_color_temp_relative(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX light color temperature adjustable in percent.""" + test_address = "1/1/1" + test_address_state = "1/1/2" + test_brightness = "1/1/3" + test_brightness_state = "1/1/4" + test_ct = "1/1/5" + test_ct_state = "1/1/6" + await knx.setup_integration( + { + LightSchema.PLATFORM: [ + { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_address_state, + LightSchema.CONF_BRIGHTNESS_ADDRESS: test_brightness, + LightSchema.CONF_BRIGHTNESS_STATE_ADDRESS: test_brightness_state, + LightSchema.CONF_COLOR_TEMP_ADDRESS: test_ct, + LightSchema.CONF_COLOR_TEMP_STATE_ADDRESS: test_ct_state, + LightSchema.CONF_COLOR_TEMP_MODE: "relative", + LightSchema.CONF_MIN_KELVIN: 3000, + LightSchema.CONF_MAX_KELVIN: 4000, + }, + ] + } + ) + # StateUpdater initialize state + await knx.assert_read(test_address_state) + await knx.assert_read(test_brightness_state) + await knx.receive_response(test_address_state, True) + await knx.receive_response(test_brightness_state, (255,)) + # # StateUpdater semaphore allows 2 concurrent requests + await knx.assert_read(test_ct_state) + await knx.receive_response(test_ct_state, (0xFF,)) # 100 % - 4000 K - 250 Mired + + knx.assert_state( + "light.test", + STATE_ON, + brightness=255, + color_mode=COLOR_MODE_COLOR_TEMP, + color_temp=250, + ) + # 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 + blocking=True, + ) + await knx.assert_write(test_ct, (0x54,)) + knx.assert_state("light.test", STATE_ON, color_temp=300) + # 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) + + +async def test_light_hs_color(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX light with hs color.""" + test_address = "1/1/1" + test_address_state = "1/1/2" + test_brightness = "1/1/3" + test_brightness_state = "1/1/4" + test_hue = "1/1/5" + test_hue_state = "1/1/6" + test_sat = "1/1/7" + test_sat_state = "1/1/8" + await knx.setup_integration( + { + LightSchema.PLATFORM: [ + { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_address_state, + LightSchema.CONF_BRIGHTNESS_ADDRESS: test_brightness, + LightSchema.CONF_BRIGHTNESS_STATE_ADDRESS: test_brightness_state, + LightSchema.CONF_HUE_ADDRESS: test_hue, + LightSchema.CONF_HUE_STATE_ADDRESS: test_hue_state, + LightSchema.CONF_SATURATION_ADDRESS: test_sat, + LightSchema.CONF_SATURATION_STATE_ADDRESS: test_sat_state, + }, + ] + } + ) + # StateUpdater initialize state + await knx.assert_read(test_address_state) + await knx.assert_read(test_brightness_state) + await knx.receive_response(test_address_state, True) + await knx.receive_response(test_brightness_state, (255,)) + # # StateUpdater semaphore allows 2 concurrent requests + await knx.assert_read(test_hue_state) + await knx.assert_read(test_sat_state) + await knx.receive_response(test_hue_state, (0xFF,)) + await knx.receive_response(test_sat_state, (0xFF,)) + + knx.assert_state( + "light.test", + STATE_ON, + brightness=255, + color_mode=COLOR_MODE_HS, + hs_color=(360, 100), + ) + # change color from HA - only hue + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_COLOR_NAME: "blue"}, # hue: 240, sat: 100 + blocking=True, + ) + await knx.assert_write(test_hue, (0xAA,)) + knx.assert_state("light.test", STATE_ON, brightness=255, hs_color=(240, 100)) + + # change color from HA - only saturation + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.test", + ATTR_HS_COLOR: (240, 50), + }, # hue: 60, sat: 12.157 + blocking=True, + ) + await knx.assert_write(test_sat, (0x80,)) + knx.assert_state("light.test", STATE_ON, brightness=255, hs_color=(240, 50)) + + # change color from HA - hue and sat + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_COLOR_NAME: "hotpink"}, # hue: 330, sat: 59 + blocking=True, + ) + await knx.assert_write(test_hue, (0xEA,)) + await knx.assert_write(test_sat, (0x96,)) + knx.assert_state("light.test", STATE_ON, brightness=255, hs_color=(330, 59)) + + # change color and brightness from KNX + await knx.receive_write(test_brightness, (0xB2,)) + knx.assert_state("light.test", STATE_ON, brightness=178, hs_color=(330, 59)) + await knx.receive_write(test_hue, (0x7D,)) + knx.assert_state("light.test", STATE_ON, brightness=178, hs_color=(176, 59)) + await knx.receive_write(test_sat, (0xD1,)) + knx.assert_state("light.test", STATE_ON, brightness=178, hs_color=(176, 82)) + + +async def test_light_xyy_color(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX light with xyy color.""" + test_address = "1/1/1" + test_address_state = "1/1/2" + test_xyy = "1/1/5" + test_xyy_state = "1/1/6" + await knx.setup_integration( + { + LightSchema.PLATFORM: [ + { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_address_state, + LightSchema.CONF_XYY_ADDRESS: test_xyy, + LightSchema.CONF_XYY_STATE_ADDRESS: test_xyy_state, + }, + ] + } + ) + # StateUpdater initialize state + await knx.assert_read(test_address_state) + await knx.assert_read(test_xyy_state) + await knx.receive_response(test_address_state, True) + await knx.receive_response(test_xyy_state, (0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0x03)) + + knx.assert_state( + "light.test", + STATE_ON, + brightness=204, + color_mode=COLOR_MODE_XY, + xy_color=(0.8, 0.8), + ) + # change color and brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 139, ATTR_COLOR_NAME: "red"}, + blocking=True, + ) + await knx.assert_write(test_xyy, (179, 116, 76, 139, 139, 3)) + knx.assert_state("light.test", STATE_ON, brightness=139, xy_color=(0.701, 0.299)) + + # change brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + await knx.assert_write(test_xyy, (0, 0, 0, 0, 255, 1)) + knx.assert_state("light.test", STATE_ON, brightness=255, xy_color=(0.701, 0.299)) + + # change color from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_COLOR_NAME: "hotpink"}, + blocking=True, + ) + await knx.assert_write(test_xyy, (120, 16, 63, 59, 0, 2)) + knx.assert_state("light.test", STATE_ON, brightness=255, xy_color=(0.469, 0.247)) + + # change color and brightness from KNX + await knx.receive_write(test_xyy, (0x85, 0x1E, 0x4F, 0x5C, 0x19, 0x03)) + knx.assert_state("light.test", STATE_ON, brightness=25, xy_color=(0.52, 0.31)) + # change brightness from KNX + await knx.receive_write(test_xyy, (0x00, 0x00, 0x00, 0x00, 0x80, 0x01)) + knx.assert_state("light.test", STATE_ON, brightness=128, xy_color=(0.52, 0.31)) + # change color from KNX + await knx.receive_write(test_xyy, (0x2E, 0x14, 0x40, 0x00, 0x00, 0x02)) + knx.assert_state("light.test", STATE_ON, brightness=128, xy_color=(0.18, 0.25)) + + +async def test_light_xyy_color_with_brightness(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX light with xyy color and explicit brightness address.""" + test_address = "1/1/1" + test_address_state = "1/1/2" + test_brightness = "1/1/3" + test_brightness_state = "1/1/4" + test_xyy = "1/1/5" + test_xyy_state = "1/1/6" + await knx.setup_integration( + { + LightSchema.PLATFORM: [ + { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_address_state, + LightSchema.CONF_BRIGHTNESS_ADDRESS: test_brightness, + LightSchema.CONF_BRIGHTNESS_STATE_ADDRESS: test_brightness_state, + LightSchema.CONF_XYY_ADDRESS: test_xyy, + LightSchema.CONF_XYY_STATE_ADDRESS: test_xyy_state, + }, + ] + } + ) + # StateUpdater initialize state + await knx.assert_read(test_address_state) + await knx.assert_read(test_brightness_state) + await knx.receive_response(test_address_state, True) + await knx.receive_response(test_brightness_state, (255,)) + # # StateUpdater semaphore allows 2 concurrent requests + await knx.assert_read(test_xyy_state) + await knx.receive_response(test_xyy_state, (0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0x03)) + + knx.assert_state( + "light.test", + STATE_ON, + brightness=255, # brightness form xyy_color ignored when extra brightness GA is used + color_mode=COLOR_MODE_XY, + xy_color=(0.8, 0.8), + ) + # change color from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_COLOR_NAME: "red"}, + blocking=True, + ) + await knx.assert_write(test_xyy, (179, 116, 76, 139, 0, 2)) + knx.assert_state("light.test", STATE_ON, brightness=255, xy_color=(0.701, 0.299)) + + # change brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 139}, + blocking=True, + ) + await knx.assert_write(test_brightness, (0x8B,)) + knx.assert_state("light.test", STATE_ON, brightness=139, xy_color=(0.701, 0.299)) + + # change color and brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 255, ATTR_COLOR_NAME: "hotpink"}, + blocking=True, + ) + await knx.assert_write(test_xyy, (120, 16, 63, 59, 255, 3)) + # brightness relies on brightness_state GA + await knx.receive_write(test_brightness_state, (255,)) + knx.assert_state("light.test", STATE_ON, brightness=255, xy_color=(0.469, 0.247)) + + # change color and brightness from KNX + await knx.receive_write(test_xyy, (0x85, 0x1E, 0x4F, 0x5C, 0x00, 0x02)) + knx.assert_state("light.test", STATE_ON, brightness=255, xy_color=(0.52, 0.31)) + await knx.receive_write(test_brightness, (21,)) + knx.assert_state("light.test", STATE_ON, brightness=21, xy_color=(0.52, 0.31)) + + +async def test_light_rgb_individual(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX light with rgb color in individual GAs.""" + test_red = "1/1/3" + test_red_state = "1/1/4" + test_green = "1/1/5" + test_green_state = "1/1/6" + test_blue = "1/1/7" + test_blue_state = "1/1/8" + await knx.setup_integration( + { + LightSchema.PLATFORM: [ + { + CONF_NAME: "test", + LightSchema.CONF_INDIVIDUAL_COLORS: { + LightSchema.CONF_RED: { + LightSchema.CONF_BRIGHTNESS_ADDRESS: test_red, + LightSchema.CONF_BRIGHTNESS_STATE_ADDRESS: test_red_state, + }, + LightSchema.CONF_GREEN: { + LightSchema.CONF_BRIGHTNESS_ADDRESS: test_green, + LightSchema.CONF_BRIGHTNESS_STATE_ADDRESS: test_green_state, + }, + LightSchema.CONF_BLUE: { + LightSchema.CONF_BRIGHTNESS_ADDRESS: test_blue, + LightSchema.CONF_BRIGHTNESS_STATE_ADDRESS: test_blue_state, + }, + }, + }, + ] + } + ) + # StateUpdater initialize state + await knx.assert_read(test_red_state) + await knx.assert_read(test_green_state) + await knx.receive_response(test_red_state, (255,)) + await knx.receive_response(test_green_state, (255,)) + # # StateUpdater semaphore allows 2 concurrent requests + await knx.assert_read(test_blue_state) + await knx.receive_response(test_blue_state, (255,)) + + knx.assert_state( + "light.test", + STATE_ON, + brightness=255, + color_mode=COLOR_MODE_RGB, + rgb_color=(255, 255, 255), + ) + # change color from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_COLOR_NAME: "red"}, + blocking=True, + ) + await knx.assert_write(test_red, (255,)) + await knx.assert_write(test_green, (0,)) + await knx.assert_write(test_blue, (0,)) + knx.assert_state("light.test", STATE_ON, brightness=255, rgb_color=(255, 0, 0)) + + # change brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 200}, + blocking=True, + ) + await knx.assert_write(test_red, (200,)) + await knx.assert_write(test_green, (0,)) + await knx.assert_write(test_blue, (0,)) + knx.assert_state("light.test", STATE_ON, brightness=200, rgb_color=(255, 0, 0)) + + # change only color, keep brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_COLOR_NAME: "hotpink"}, + blocking=True, + ) + await knx.assert_write(test_red, (200,)) + await knx.assert_write(test_green, (82,)) + await knx.assert_write(test_blue, (141,)) + knx.assert_state("light.test", STATE_ON, brightness=200, rgb_color=(255, 105, 180)) + + # change color and brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 100, ATTR_COLOR_NAME: "yellow"}, + blocking=True, + ) + await knx.assert_write(test_red, (100,)) + await knx.assert_write(test_green, (100,)) + await knx.assert_write(test_blue, (0,)) + knx.assert_state("light.test", STATE_ON, brightness=100, rgb_color=(255, 255, 0)) + + # turn OFF from KNX + await knx.receive_write(test_red, (0,)) + await knx.receive_write(test_green, (0,)) + await knx.receive_write(test_blue, (0,)) + knx.assert_state("light.test", STATE_OFF) + # turn ON from KNX + await knx.receive_write(test_red, (0,)) + await knx.receive_write(test_green, (180,)) + await knx.receive_write(test_blue, (0,)) + knx.assert_state("light.test", STATE_ON, brightness=180, rgb_color=(0, 255, 0)) + + # turn OFF from HA + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": "light.test"}, + blocking=True, + ) + await knx.assert_write(test_red, (0,)) + await knx.assert_write(test_green, (0,)) + await knx.assert_write(test_blue, (0,)) + knx.assert_state("light.test", STATE_OFF) + + # turn ON from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test"}, + blocking=True, + ) + # color will not be restored - defaults to white + await knx.assert_write(test_red, (255,)) + await knx.assert_write(test_green, (255,)) + await knx.assert_write(test_blue, (255,)) + knx.assert_state("light.test", STATE_ON, brightness=255, rgb_color=(255, 255, 255)) + + # turn ON with brightness only from HA - defaults to white + await knx.receive_write(test_red, (0,)) + await knx.receive_write(test_green, (0,)) + await knx.receive_write(test_blue, (0,)) + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 45}, + blocking=True, + ) + await knx.assert_write(test_red, (45,)) + await knx.assert_write(test_green, (45,)) + await knx.assert_write(test_blue, (45,)) + + +async def test_light_rgbw_individual(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX light with rgbw color in individual GAs.""" + test_red = "1/1/3" + test_red_state = "1/1/4" + test_green = "1/1/5" + test_green_state = "1/1/6" + test_blue = "1/1/7" + test_blue_state = "1/1/8" + test_white = "1/1/9" + test_white_state = "1/1/10" + await knx.setup_integration( + { + LightSchema.PLATFORM: [ + { + CONF_NAME: "test", + LightSchema.CONF_INDIVIDUAL_COLORS: { + LightSchema.CONF_RED: { + LightSchema.CONF_BRIGHTNESS_ADDRESS: test_red, + LightSchema.CONF_BRIGHTNESS_STATE_ADDRESS: test_red_state, + }, + LightSchema.CONF_GREEN: { + LightSchema.CONF_BRIGHTNESS_ADDRESS: test_green, + LightSchema.CONF_BRIGHTNESS_STATE_ADDRESS: test_green_state, + }, + LightSchema.CONF_BLUE: { + LightSchema.CONF_BRIGHTNESS_ADDRESS: test_blue, + LightSchema.CONF_BRIGHTNESS_STATE_ADDRESS: test_blue_state, + }, + LightSchema.CONF_WHITE: { + LightSchema.CONF_BRIGHTNESS_ADDRESS: test_white, + LightSchema.CONF_BRIGHTNESS_STATE_ADDRESS: test_white_state, + }, + }, + }, + ] + } + ) + # StateUpdater initialize state + await knx.assert_read(test_red_state) + await knx.assert_read(test_green_state) + await knx.receive_response(test_red_state, (0,)) + await knx.receive_response(test_green_state, (0,)) + # # StateUpdater semaphore allows 2 concurrent requests + await knx.assert_read(test_blue_state) + await knx.assert_read(test_white_state) + await knx.receive_response(test_blue_state, (0,)) + await knx.receive_response(test_white_state, (255,)) + + knx.assert_state( + "light.test", + STATE_ON, + brightness=255, + color_mode=COLOR_MODE_RGBW, + rgbw_color=(0, 0, 0, 255), + ) + # change color from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_COLOR_NAME: "red"}, + blocking=True, + ) + await knx.assert_write(test_red, (255,)) + await knx.assert_write(test_green, (0,)) + await knx.assert_write(test_blue, (0,)) + await knx.assert_write(test_white, (0,)) + knx.assert_state("light.test", STATE_ON, brightness=255, rgbw_color=(255, 0, 0, 0)) + + # change brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 200}, + blocking=True, + ) + await knx.assert_write(test_red, (200,)) + await knx.assert_write(test_green, (0,)) + await knx.assert_write(test_blue, (0,)) + await knx.assert_write(test_white, (0,)) + knx.assert_state("light.test", STATE_ON, brightness=200, rgbw_color=(255, 0, 0, 0)) + + # change only color, keep brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_COLOR_NAME: "hotpink"}, + blocking=True, + ) + await knx.assert_write(test_red, (200,)) + await knx.assert_write(test_green, (0,)) + await knx.assert_write(test_blue, (100,)) + await knx.assert_write(test_white, (139,)) + knx.assert_state( + "light.test", + STATE_ON, + brightness=200, + rgb_color=(255, 104, 179), # minor rounding error - expected (255, 105, 180) + rgbw_color=(255, 0, 127, 177), # expected (255, 0, 128, 178) + ) + + # change color and brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 100, ATTR_COLOR_NAME: "yellow"}, + blocking=True, + ) + await knx.assert_write(test_red, (100,)) + await knx.assert_write(test_green, (100,)) + await knx.assert_write(test_blue, (0,)) + await knx.assert_write(test_white, (0,)) + knx.assert_state( + "light.test", STATE_ON, brightness=100, rgbw_color=(255, 255, 0, 0) + ) + + # turn OFF from KNX + await knx.receive_write(test_red, (0,)) + await knx.receive_write(test_green, (0,)) + # # individual color debounce takes 0.2 seconds if not all 4 addresses received + knx.assert_state("light.test", STATE_ON) + async_fire_time_changed( + hass, dt.utcnow() + timedelta(seconds=XknxLight.DEBOUNCE_TIMEOUT) + ) + await knx.xknx.task_registry.block_till_done() + knx.assert_state("light.test", STATE_OFF) + # turn ON from KNX + await knx.receive_write(test_red, (0,)) + await knx.receive_write(test_green, (180,)) + await knx.receive_write(test_blue, (0,)) + await knx.receive_write(test_white, (0,)) + knx.assert_state("light.test", STATE_ON, brightness=180, rgbw_color=(0, 255, 0, 0)) + + # turn OFF from HA + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": "light.test"}, + blocking=True, + ) + await knx.assert_write(test_red, (0,)) + await knx.assert_write(test_green, (0,)) + await knx.assert_write(test_blue, (0,)) + await knx.assert_write(test_white, (0,)) + knx.assert_state("light.test", STATE_OFF) + + # turn ON from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test"}, + blocking=True, + ) + # color will not be restored - defaults to 100% on all channels + await knx.assert_write(test_red, (255,)) + await knx.assert_write(test_green, (255,)) + await knx.assert_write(test_blue, (255,)) + await knx.assert_write(test_white, (255,)) + knx.assert_state( + "light.test", STATE_ON, brightness=255, rgbw_color=(255, 255, 255, 255) + ) + + # turn ON with brightness only from HA - defaults to white + await knx.receive_write(test_red, (0,)) + await knx.receive_write(test_green, (0,)) + await knx.receive_write(test_blue, (0,)) + await knx.receive_write(test_white, (0,)) + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 45}, + blocking=True, + ) + await knx.assert_write(test_red, (0,)) + await knx.assert_write(test_green, (0,)) + await knx.assert_write(test_blue, (0,)) + await knx.assert_write(test_white, (45,)) + + +async def test_light_rgb(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX light with rgb color.""" + test_address = "1/1/1" + test_address_state = "1/1/2" + test_rgb = "1/1/5" + test_rgb_state = "1/1/6" + await knx.setup_integration( + { + LightSchema.PLATFORM: [ + { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_address_state, + LightSchema.CONF_COLOR_ADDRESS: test_rgb, + LightSchema.CONF_COLOR_STATE_ADDRESS: test_rgb_state, + }, + ] + } + ) + # StateUpdater initialize state + await knx.assert_read(test_address_state) + await knx.assert_read(test_rgb_state) + await knx.receive_response(test_address_state, True) + await knx.receive_response(test_rgb_state, (0xFF, 0xFF, 0xFF)) + + knx.assert_state( + "light.test", + STATE_ON, + brightness=255, + color_mode=COLOR_MODE_RGB, + rgb_color=(255, 255, 255), + ) + # change color from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_COLOR_NAME: "red"}, + blocking=True, + ) + await knx.assert_write(test_rgb, (255, 0, 0)) + knx.assert_state("light.test", STATE_ON, brightness=255, rgb_color=(255, 0, 0)) + + # change brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 200}, + blocking=True, + ) + await knx.assert_write(test_rgb, (200, 0, 0)) + knx.assert_state("light.test", STATE_ON, brightness=200, rgb_color=(255, 0, 0)) + + # change color, keep brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_COLOR_NAME: "hotpink"}, + blocking=True, + ) + await knx.assert_write(test_rgb, (200, 82, 141)) + knx.assert_state( + "light.test", + STATE_ON, + brightness=200, + rgb_color=(255, 105, 180), + ) + # change color and brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 100, ATTR_COLOR_NAME: "yellow"}, + blocking=True, + ) + await knx.assert_write(test_rgb, (100, 100, 0)) + knx.assert_state("light.test", STATE_ON, brightness=100, rgb_color=(255, 255, 0)) + + # turn OFF from KNX + await knx.receive_write(test_address_state, False) + knx.assert_state("light.test", STATE_OFF) + # receive color update from KNX - still OFF + await knx.receive_write(test_rgb, (0, 180, 0)) + knx.assert_state("light.test", STATE_OFF) + # turn ON from KNX - include color update + await knx.receive_write(test_address_state, True) + knx.assert_state("light.test", STATE_ON, brightness=180, rgb_color=(0, 255, 0)) + + # turn OFF from HA + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": "light.test"}, + blocking=True, + ) + await knx.assert_write(test_address, False) + knx.assert_state("light.test", STATE_OFF) + + # turn ON from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test"}, + blocking=True, + ) + # color will be restored in no other state was received + await knx.assert_write(test_address, True) + knx.assert_state("light.test", STATE_ON, brightness=180, rgb_color=(0, 255, 0)) + + +async def test_light_rgbw(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX light with rgbw color.""" + test_address = "1/1/1" + test_address_state = "1/1/2" + test_rgbw = "1/1/5" + test_rgbw_state = "1/1/6" + await knx.setup_integration( + { + LightSchema.PLATFORM: [ + { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_address_state, + LightSchema.CONF_RGBW_ADDRESS: test_rgbw, + LightSchema.CONF_RGBW_STATE_ADDRESS: test_rgbw_state, + }, + ] + } + ) + # StateUpdater initialize state + await knx.assert_read(test_address_state) + await knx.assert_read(test_rgbw_state) + await knx.receive_response(test_address_state, True) + await knx.receive_response(test_rgbw_state, (0xFF, 0x65, 0x66, 0x67, 0x00, 0x0F)) + + knx.assert_state( + "light.test", + STATE_ON, + brightness=255, + color_mode=COLOR_MODE_RGBW, + rgbw_color=(255, 101, 102, 103), + ) + # change color from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_COLOR_NAME: "red"}, + blocking=True, + ) + await knx.assert_write(test_rgbw, (0xFF, 0x00, 0x00, 0x00, 0x00, 0x0F)) + knx.assert_state("light.test", STATE_ON, brightness=255, rgbw_color=(255, 0, 0, 0)) + + # change brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 200}, + blocking=True, + ) + await knx.assert_write(test_rgbw, (0xC8, 0x00, 0x00, 0x00, 0x00, 0x0F)) + knx.assert_state("light.test", STATE_ON, brightness=200, rgbw_color=(255, 0, 0, 0)) + + # change color, keep brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_COLOR_NAME: "hotpink"}, + blocking=True, + ) + await knx.assert_write(test_rgbw, (200, 0, 100, 139, 0x00, 0x0F)) + knx.assert_state( + "light.test", + STATE_ON, + brightness=200, + rgb_color=(255, 104, 179), # minor rounding error - expected (255, 105, 180) + rgbw_color=(255, 0, 127, 177), # expected (255, 0, 128, 178) + ) + # change color and brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 100, ATTR_COLOR_NAME: "yellow"}, + blocking=True, + ) + await knx.assert_write(test_rgbw, (100, 100, 0, 0, 0x00, 0x0F)) + knx.assert_state( + "light.test", STATE_ON, brightness=100, rgbw_color=(255, 255, 0, 0) + ) + + # turn OFF from KNX + await knx.receive_write(test_address_state, False) + knx.assert_state("light.test", STATE_OFF) + # receive color update from KNX - still OFF + await knx.receive_write(test_rgbw, (0, 180, 0, 0, 0x00, 0x0F)) + knx.assert_state("light.test", STATE_OFF) + # turn ON from KNX - include color update + await knx.receive_write(test_address_state, True) + knx.assert_state("light.test", STATE_ON, brightness=180, rgbw_color=(0, 255, 0, 0)) + + # turn OFF from HA + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": "light.test"}, + blocking=True, + ) + await knx.assert_write(test_address, False) + knx.assert_state("light.test", STATE_OFF) + + # turn ON from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test"}, + blocking=True, + ) + # color will be restored if no other state was received + await knx.assert_write(test_address, True) + knx.assert_state("light.test", STATE_ON, brightness=180, rgbw_color=(0, 255, 0, 0)) + + +async def test_light_rgbw_brightness(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX light with rgbw color with dedicated brightness.""" + test_address = "1/1/1" + test_address_state = "1/1/2" + test_brightness = "1/1/3" + test_brightness_state = "1/1/4" + test_rgbw = "1/1/5" + test_rgbw_state = "1/1/6" + await knx.setup_integration( + { + LightSchema.PLATFORM: [ + { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_address_state, + LightSchema.CONF_BRIGHTNESS_ADDRESS: test_brightness, + LightSchema.CONF_BRIGHTNESS_STATE_ADDRESS: test_brightness_state, + LightSchema.CONF_RGBW_ADDRESS: test_rgbw, + LightSchema.CONF_RGBW_STATE_ADDRESS: test_rgbw_state, + }, + ] + } + ) + # StateUpdater initialize state + await knx.assert_read(test_address_state) + await knx.assert_read(test_brightness_state) + await knx.receive_response(test_address_state, True) + await knx.receive_response(test_brightness_state, (0xFF,)) + await knx.assert_read(test_rgbw_state) + await knx.receive_response(test_rgbw_state, (0xFF, 0x65, 0x66, 0x67, 0x00, 0x0F)) + + knx.assert_state( + "light.test", + STATE_ON, + brightness=255, + color_mode=COLOR_MODE_RGBW, + rgbw_color=(255, 101, 102, 103), + ) + # change color from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_COLOR_NAME: "red"}, + blocking=True, + ) + await knx.assert_write(test_rgbw, (0xFF, 0x00, 0x00, 0x00, 0x00, 0x0F)) + knx.assert_state("light.test", STATE_ON, brightness=255, rgbw_color=(255, 0, 0, 0)) + # # update from dedicated brightness state + await knx.receive_write(test_brightness_state, (0xF0,)) + knx.assert_state("light.test", STATE_ON, brightness=240, rgbw_color=(255, 0, 0, 0)) + + # single encoded brightness - at least one primary color = 255 + # # change brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + await knx.assert_write(test_brightness, (128,)) + knx.assert_state("light.test", STATE_ON, brightness=128, rgbw_color=(255, 0, 0, 0)) + # # change color and brightness from HA + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_BRIGHTNESS: 128, ATTR_COLOR_NAME: "hotpink"}, + blocking=True, + ) + await knx.assert_write(test_rgbw, (255, 0, 128, 178, 0x00, 0x0F)) + await knx.assert_write(test_brightness, (128,)) + knx.assert_state( + "light.test", + STATE_ON, + brightness=128, + rgb_color=(255, 105, 180), + rgbw_color=(255, 0, 128, 178), + ) + + # doubly encoded brightness + # brightness is handled by dedicated brightness address only + # # from dedicated rgbw state + await knx.receive_write(test_rgbw_state, (0xC8, 0x00, 0x00, 0x00, 0x00, 0x0F)) + knx.assert_state("light.test", STATE_ON, brightness=128, rgbw_color=(200, 0, 0, 0)) + # # from HA - only color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.test", ATTR_RGBW_COLOR: (20, 30, 40, 50)}, + blocking=True, + ) + await knx.assert_write(test_rgbw, (20, 30, 40, 50, 0x00, 0x0F)) + knx.assert_state( + "light.test", STATE_ON, brightness=128, rgbw_color=(20, 30, 40, 50) + ) + # # from HA - brightness and color + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.test", + ATTR_BRIGHTNESS: 50, + ATTR_RGBW_COLOR: (100, 200, 55, 12), + }, + blocking=True, + ) + await knx.assert_write(test_rgbw, (100, 200, 55, 12, 0x00, 0x0F)) + await knx.assert_write(test_brightness, (50,)) + knx.assert_state( + "light.test", STATE_ON, brightness=50, rgbw_color=(100, 200, 55, 12) + ) diff --git a/tests/components/knx/test_number.py b/tests/components/knx/test_number.py index d14f01ee5fe..668b046df74 100644 --- a/tests/components/knx/test_number.py +++ b/tests/components/knx/test_number.py @@ -16,7 +16,7 @@ async def test_number_set_value(hass: HomeAssistant, knx: KNXTestKit): test_address = "1/1/1" await knx.setup_integration( { - NumberSchema.PLATFORM_NAME: { + NumberSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: test_address, CONF_TYPE: "percent", @@ -72,7 +72,7 @@ async def test_number_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit): ): await knx.setup_integration( { - NumberSchema.PLATFORM_NAME: { + NumberSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: [test_address, test_passive_address], CONF_RESPOND_TO_READ: True, diff --git a/tests/components/knx/test_scene.py b/tests/components/knx/test_scene.py new file mode 100644 index 00000000000..c2f15df6f6c --- /dev/null +++ b/tests/components/knx/test_scene.py @@ -0,0 +1,44 @@ +"""Test KNX scene.""" + +from homeassistant.components.knx.const import KNX_ADDRESS +from homeassistant.components.knx.schema import SceneSchema +from homeassistant.const import ( + CONF_ENTITY_CATEGORY, + CONF_NAME, + ENTITY_CATEGORY_DIAGNOSTIC, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import ( + async_get_registry as async_get_entity_registry, +) + +from .conftest import KNXTestKit + + +async def test_activate_knx_scene(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX scene.""" + await knx.setup_integration( + { + SceneSchema.PLATFORM: [ + { + CONF_NAME: "test", + SceneSchema.CONF_SCENE_NUMBER: 24, + KNX_ADDRESS: "1/1/1", + CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC, + }, + ] + } + ) + assert len(hass.states.async_all()) == 1 + + registry = await async_get_entity_registry(hass) + entity = registry.async_get("scene.test") + assert entity.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entity.unique_id == "1/1/1_24" + + await hass.services.async_call( + "scene", "turn_on", {"entity_id": "scene.test"}, blocking=True + ) + + # assert scene was called on bus + await knx.assert_write("1/1/1", (0x17,)) diff --git a/tests/components/knx/test_select.py b/tests/components/knx/test_select.py index d8089976aca..c8db3625c05 100644 --- a/tests/components/knx/test_select.py +++ b/tests/components/knx/test_select.py @@ -4,6 +4,8 @@ from unittest.mock import patch import pytest from homeassistant.components.knx.const import ( + CONF_PAYLOAD, + CONF_PAYLOAD_LENGTH, CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, @@ -19,18 +21,18 @@ from .conftest import KNXTestKit async def test_select_dpt_2_simple(hass: HomeAssistant, knx: KNXTestKit): """Test simple KNX select.""" _options = [ - {SelectSchema.CONF_PAYLOAD: 0b00, SelectSchema.CONF_OPTION: "No control"}, - {SelectSchema.CONF_PAYLOAD: 0b10, SelectSchema.CONF_OPTION: "Control - Off"}, - {SelectSchema.CONF_PAYLOAD: 0b11, SelectSchema.CONF_OPTION: "Control - On"}, + {CONF_PAYLOAD: 0b00, SelectSchema.CONF_OPTION: "No control"}, + {CONF_PAYLOAD: 0b10, SelectSchema.CONF_OPTION: "Control - Off"}, + {CONF_PAYLOAD: 0b11, SelectSchema.CONF_OPTION: "Control - On"}, ] test_address = "1/1/1" await knx.setup_integration( { - SelectSchema.PLATFORM_NAME: { + SelectSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: test_address, CONF_SYNC_STATE: False, - SelectSchema.CONF_PAYLOAD_LENGTH: 0, + CONF_PAYLOAD_LENGTH: 0, SelectSchema.CONF_OPTIONS: _options, } } @@ -89,9 +91,9 @@ async def test_select_dpt_2_simple(hass: HomeAssistant, knx: KNXTestKit): async def test_select_dpt_2_restore(hass: HomeAssistant, knx: KNXTestKit): """Test KNX select with passive_address and respond_to_read restoring state.""" _options = [ - {SelectSchema.CONF_PAYLOAD: 0b00, SelectSchema.CONF_OPTION: "No control"}, - {SelectSchema.CONF_PAYLOAD: 0b10, SelectSchema.CONF_OPTION: "Control - Off"}, - {SelectSchema.CONF_PAYLOAD: 0b11, SelectSchema.CONF_OPTION: "Control - On"}, + {CONF_PAYLOAD: 0b00, SelectSchema.CONF_OPTION: "No control"}, + {CONF_PAYLOAD: 0b10, SelectSchema.CONF_OPTION: "Control - Off"}, + {CONF_PAYLOAD: 0b11, SelectSchema.CONF_OPTION: "Control - On"}, ] test_address = "1/1/1" test_passive_address = "3/3/3" @@ -103,11 +105,11 @@ async def test_select_dpt_2_restore(hass: HomeAssistant, knx: KNXTestKit): ): await knx.setup_integration( { - SelectSchema.PLATFORM_NAME: { + SelectSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: [test_address, test_passive_address], CONF_RESPOND_TO_READ: True, - SelectSchema.CONF_PAYLOAD_LENGTH: 0, + CONF_PAYLOAD_LENGTH: 0, SelectSchema.CONF_OPTIONS: _options, } } @@ -129,11 +131,11 @@ async def test_select_dpt_2_restore(hass: HomeAssistant, knx: KNXTestKit): async def test_select_dpt_20_103_all_options(hass: HomeAssistant, knx: KNXTestKit): """Test KNX select with state_address, passive_address and respond_to_read.""" _options = [ - {SelectSchema.CONF_PAYLOAD: 0, SelectSchema.CONF_OPTION: "Auto"}, - {SelectSchema.CONF_PAYLOAD: 1, SelectSchema.CONF_OPTION: "Legio protect"}, - {SelectSchema.CONF_PAYLOAD: 2, SelectSchema.CONF_OPTION: "Normal"}, - {SelectSchema.CONF_PAYLOAD: 3, SelectSchema.CONF_OPTION: "Reduced"}, - {SelectSchema.CONF_PAYLOAD: 4, SelectSchema.CONF_OPTION: "Off"}, + {CONF_PAYLOAD: 0, SelectSchema.CONF_OPTION: "Auto"}, + {CONF_PAYLOAD: 1, SelectSchema.CONF_OPTION: "Legio protect"}, + {CONF_PAYLOAD: 2, SelectSchema.CONF_OPTION: "Normal"}, + {CONF_PAYLOAD: 3, SelectSchema.CONF_OPTION: "Reduced"}, + {CONF_PAYLOAD: 4, SelectSchema.CONF_OPTION: "Off"}, ] test_address = "1/1/1" test_state_address = "2/2/2" @@ -141,12 +143,12 @@ async def test_select_dpt_20_103_all_options(hass: HomeAssistant, knx: KNXTestKi await knx.setup_integration( { - SelectSchema.PLATFORM_NAME: { + SelectSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: [test_address, test_passive_address], CONF_STATE_ADDRESS: test_state_address, CONF_RESPOND_TO_READ: True, - SelectSchema.CONF_PAYLOAD_LENGTH: 1, + CONF_PAYLOAD_LENGTH: 1, SelectSchema.CONF_OPTIONS: _options, } } diff --git a/tests/components/knx/test_sensor.py b/tests/components/knx/test_sensor.py index 16ea5e8d385..8deb4629d8b 100644 --- a/tests/components/knx/test_sensor.py +++ b/tests/components/knx/test_sensor.py @@ -14,7 +14,7 @@ async def test_sensor(hass: HomeAssistant, knx: KNXTestKit): await knx.setup_integration( { - SensorSchema.PLATFORM_NAME: { + SensorSchema.PLATFORM: { CONF_NAME: "test", CONF_STATE_ADDRESS: "1/1/1", CONF_TYPE: "current", # 2 byte unsigned int @@ -47,7 +47,7 @@ async def test_always_callback(hass: HomeAssistant, knx: KNXTestKit): events = async_capture_events(hass, "state_changed") await knx.setup_integration( { - SensorSchema.PLATFORM_NAME: [ + SensorSchema.PLATFORM: [ { CONF_NAME: "test_normal", CONF_STATE_ADDRESS: "1/1/1", diff --git a/tests/components/knx/test_services.py b/tests/components/knx/test_services.py index 80ed51e6aec..c61dc542586 100644 --- a/tests/components/knx/test_services.py +++ b/tests/components/knx/test_services.py @@ -86,14 +86,19 @@ async def test_event_register(hass: HomeAssistant, knx: KNXTestKit): await hass.async_block_till_done() assert len(events) == 0 - # register event + # register event with `type` await hass.services.async_call( - "knx", "event_register", {"address": test_address}, blocking=True + "knx", + "event_register", + {"address": test_address, "type": "2byte_unsigned"}, + blocking=True, ) - await knx.receive_write(test_address, True) - await knx.receive_write(test_address, False) + await knx.receive_write(test_address, (0x04, 0xD2)) await hass.async_block_till_done() - assert len(events) == 2 + assert len(events) == 1 + typed_event = events.pop() + assert typed_event.data["data"] == (0x04, 0xD2) + assert typed_event.data["value"] == 1234 # remove event registration - no event added await hass.services.async_call( @@ -104,7 +109,22 @@ async def test_event_register(hass: HomeAssistant, knx: KNXTestKit): ) await knx.receive_write(test_address, True) await hass.async_block_till_done() + assert len(events) == 0 + + # register event without `type` + await hass.services.async_call( + "knx", "event_register", {"address": test_address}, blocking=True + ) + await knx.receive_write(test_address, True) + await knx.receive_write(test_address, False) + await hass.async_block_till_done() assert len(events) == 2 + untyped_event_2 = events.pop() + assert untyped_event_2.data["data"] is False + assert untyped_event_2.data["value"] is None + untyped_event_1 = events.pop() + assert untyped_event_1.data["data"] is True + assert untyped_event_1.data["value"] is None async def test_exposure_register(hass: HomeAssistant, knx: KNXTestKit): diff --git a/tests/components/knx/test_switch.py b/tests/components/knx/test_switch.py index eff34243ca8..07fe8793fd8 100644 --- a/tests/components/knx/test_switch.py +++ b/tests/components/knx/test_switch.py @@ -17,7 +17,7 @@ async def test_switch_simple(hass: HomeAssistant, knx: KNXTestKit): """Test simple KNX switch.""" await knx.setup_integration( { - SwitchSchema.PLATFORM_NAME: { + SwitchSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: "1/2/3", } @@ -59,7 +59,7 @@ async def test_switch_state(hass: HomeAssistant, knx: KNXTestKit): await knx.setup_integration( { - SwitchSchema.PLATFORM_NAME: { + SwitchSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: _ADDRESS, CONF_STATE_ADDRESS: _STATE_ADDRESS, @@ -122,7 +122,7 @@ async def test_switch_restore_and_respond(hass, knx): ): await knx.setup_integration( { - SwitchSchema.PLATFORM_NAME: { + SwitchSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: _ADDRESS, CONF_RESPOND_TO_READ: True, diff --git a/tests/components/knx/test_weather.py b/tests/components/knx/test_weather.py new file mode 100644 index 00000000000..21d80248b97 --- /dev/null +++ b/tests/components/knx/test_weather.py @@ -0,0 +1,101 @@ +"""Test KNX weather.""" +from homeassistant.components.knx.schema import WeatherSchema +from homeassistant.components.weather import ( + ATTR_CONDITION_EXCEPTIONAL, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant + +from .conftest import KNXTestKit + + +async def test_weather(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX weather.""" + + await knx.setup_integration( + { + WeatherSchema.PLATFORM: { + CONF_NAME: "test", + WeatherSchema.CONF_KNX_WIND_ALARM_ADDRESS: "1/1/1", + WeatherSchema.CONF_KNX_RAIN_ALARM_ADDRESS: "1/1/2", + WeatherSchema.CONF_KNX_FROST_ALARM_ADDRESS: "1/1/3", + WeatherSchema.CONF_KNX_HUMIDITY_ADDRESS: "1/1/4", + WeatherSchema.CONF_KNX_BRIGHTNESS_EAST_ADDRESS: "1/1/5", + WeatherSchema.CONF_KNX_BRIGHTNESS_SOUTH_ADDRESS: "1/1/6", + WeatherSchema.CONF_KNX_BRIGHTNESS_WEST_ADDRESS: "1/1/7", + WeatherSchema.CONF_KNX_BRIGHTNESS_NORTH_ADDRESS: "1/1/8", + WeatherSchema.CONF_KNX_WIND_SPEED_ADDRESS: "1/1/9", + WeatherSchema.CONF_KNX_WIND_BEARING_ADDRESS: "1/1/10", + WeatherSchema.CONF_KNX_TEMPERATURE_ADDRESS: "1/1/11", + WeatherSchema.CONF_KNX_DAY_NIGHT_ADDRESS: "1/1/12", + WeatherSchema.CONF_KNX_AIR_PRESSURE_ADDRESS: "1/1/13", + } + } + ) + assert len(hass.states.async_all()) == 1 + state = hass.states.get("weather.test") + assert state.state is ATTR_CONDITION_EXCEPTIONAL + + # StateUpdater initialize states + await knx.assert_read("1/1/11") + await knx.receive_response("1/1/11", (0, 40)) + + # brightness + await knx.assert_read("1/1/6") + await knx.receive_response("1/1/6", (0x7C, 0x5E)) + await knx.assert_read("1/1/8") + await knx.receive_response("1/1/8", (0x7C, 0x5E)) + await knx.assert_read("1/1/7") + await knx.receive_response("1/1/7", (0x7C, 0x5E)) + await knx.assert_read("1/1/5") + await knx.receive_response("1/1/5", (0x7C, 0x5E)) + + # wind speed + await knx.assert_read("1/1/9") + await knx.receive_response("1/1/9", (0, 40)) + + # wind bearing + await knx.assert_read("1/1/10") + await knx.receive_response("1/1/10", (0xBF,)) + + # alarms + await knx.assert_read("1/1/2") + await knx.receive_response("1/1/2", False) + await knx.assert_read("1/1/3") + await knx.receive_response("1/1/3", False) + await knx.assert_read("1/1/1") + await knx.receive_response("1/1/1", False) + + # day night + await knx.assert_read("1/1/12") + await knx.receive_response("1/1/12", False) + + # air pressure + await knx.assert_read("1/1/13") + await knx.receive_response("1/1/13", (0x6C, 0xAD)) + + # humidity + await knx.assert_read("1/1/4") + await knx.receive_response("1/1/4", (0, 40)) + + # verify state + state = hass.states.get("weather.test") + assert state.attributes["temperature"] == 0.4 + assert state.attributes["wind_bearing"] == 270 + assert state.attributes["wind_speed"] == 1.4400000000000002 + assert state.attributes["pressure"] == 980.5824 + assert state.state is ATTR_CONDITION_SUNNY + + # update from KNX - set rain alarm + await knx.receive_write("1/1/2", True) + state = hass.states.get("weather.test") + assert state.state is ATTR_CONDITION_RAINY + + # update from KNX - set wind alarm + await knx.receive_write("1/1/2", False) + await knx.receive_write("1/1/1", True) + state = hass.states.get("weather.test") + assert state.state is ATTR_CONDITION_WINDY diff --git a/tests/components/kodi/util.py b/tests/components/kodi/util.py index edd6950d76e..c3aaca16d5a 100644 --- a/tests/components/kodi/util.py +++ b/tests/components/kodi/util.py @@ -1,4 +1,5 @@ """Test the Kodi config flow.""" +from homeassistant.components import zeroconf from homeassistant.components.kodi.const import DEFAULT_SSL TEST_HOST = { @@ -14,24 +15,24 @@ TEST_CREDENTIALS = {"username": "username", "password": "password"} TEST_WS_PORT = {"ws_port": 9090} UUID = "11111111-1111-1111-1111-111111111111" -TEST_DISCOVERY = { - "host": "1.1.1.1", - "port": 8080, - "hostname": "hostname.local.", - "type": "_xbmc-jsonrpc-h._tcp.local.", - "name": "hostname._xbmc-jsonrpc-h._tcp.local.", - "properties": {"uuid": UUID}, -} +TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + port=8080, + hostname="hostname.local.", + type="_xbmc-jsonrpc-h._tcp.local.", + name="hostname._xbmc-jsonrpc-h._tcp.local.", + properties={"uuid": UUID}, +) -TEST_DISCOVERY_WO_UUID = { - "host": "1.1.1.1", - "port": 8080, - "hostname": "hostname.local.", - "type": "_xbmc-jsonrpc-h._tcp.local.", - "name": "hostname._xbmc-jsonrpc-h._tcp.local.", - "properties": {}, -} +TEST_DISCOVERY_WO_UUID = zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + port=8080, + hostname="hostname.local.", + type="_xbmc-jsonrpc-h._tcp.local.", + name="hostname._xbmc-jsonrpc-h._tcp.local.", + properties={}, +) TEST_IMPORT = { diff --git a/tests/components/konnected/test_config_flow.py b/tests/components/konnected/test_config_flow.py index 36f582fcb57..7397f03c1fc 100644 --- a/tests/components/konnected/test_config_flow.py +++ b/tests/components/konnected/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import konnected +from homeassistant.components import konnected, ssdp from homeassistant.components.konnected import config_flow from tests.common import MockConfigEntry @@ -109,14 +109,19 @@ async def test_ssdp(hass, mock_panel): "model": "Konnected", } + # Test success result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - "ssdp_location": "http://1.2.3.4:1234/Device.xml", - "manufacturer": config_flow.KONN_MANUFACTURER, - "modelName": config_flow.KONN_MODEL, - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://1.2.3.4:1234/Device.xml", + upnp={ + "manufacturer": config_flow.KONN_MANUFACTURER, + "modelName": config_flow.KONN_MODEL, + }, + ), ) assert result["type"] == "form" @@ -128,6 +133,101 @@ async def test_ssdp(hass, mock_panel): "port": 1234, } + # Test abort if connection failed + mock_panel.get_status.side_effect = config_flow.CannotConnect + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://1.2.3.4:1234/Device.xml", + upnp={ + "manufacturer": config_flow.KONN_MANUFACTURER, + "modelName": config_flow.KONN_MODEL, + }, + ), + ) + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + # Test abort if invalid data + mock_panel.get_status.side_effect = KeyError + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://1.2.3.4:1234/Device.xml", + upnp={}, + ), + ) + + assert result["type"] == "abort" + assert result["reason"] == "unknown" + + # Test abort if invalid manufacturer + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://1.2.3.4:1234/Device.xml", + upnp={ + "manufacturer": "SHOULD_FAIL", + "modelName": config_flow.KONN_MODEL, + }, + ), + ) + + assert result["type"] == "abort" + assert result["reason"] == "not_konn_panel" + + # Test abort if invalid model + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://1.2.3.4:1234/Device.xml", + upnp={ + "manufacturer": config_flow.KONN_MANUFACTURER, + "modelName": "SHOULD_FAIL", + }, + ), + ) + + assert result["type"] == "abort" + assert result["reason"] == "not_konn_panel" + + # Test abort if already configured + config_entry = MockConfigEntry( + domain=config_flow.DOMAIN, + data={config_flow.CONF_HOST: "1.2.3.4", config_flow.CONF_PORT: 1234}, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://1.2.3.4:1234/Device.xml", + upnp={ + "manufacturer": config_flow.KONN_MANUFACTURER, + "modelName": config_flow.KONN_MODEL, + }, + ), + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + async def test_import_no_host_user_finish(hass, mock_panel): """Test importing a panel with no host info.""" @@ -240,11 +340,15 @@ async def test_import_ssdp_host_user_finish(hass, mock_panel): ssdp_result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - "ssdp_location": "http://0.0.0.0:1234/Device.xml", - "manufacturer": config_flow.KONN_MANUFACTURER, - "modelName": config_flow.KONN_MODEL_PRO, - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://0.0.0.0:1234/Device.xml", + upnp={ + "manufacturer": config_flow.KONN_MANUFACTURER, + "modelName": config_flow.KONN_MODEL_PRO, + }, + ), ) assert ssdp_result["type"] == "abort" assert ssdp_result["reason"] == "already_in_progress" @@ -283,11 +387,15 @@ async def test_ssdp_already_configured(hass, mock_panel): result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - "ssdp_location": "http://0.0.0.0:1234/Device.xml", - "manufacturer": config_flow.KONN_MANUFACTURER, - "modelName": config_flow.KONN_MODEL_PRO, - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://0.0.0.0:1234/Device.xml", + upnp={ + "manufacturer": config_flow.KONN_MANUFACTURER, + "modelName": config_flow.KONN_MODEL_PRO, + }, + ), ) assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -359,11 +467,15 @@ async def test_ssdp_host_update(hass, mock_panel): result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - "ssdp_location": "http://1.1.1.1:1234/Device.xml", - "manufacturer": config_flow.KONN_MANUFACTURER, - "modelName": config_flow.KONN_MODEL_PRO, - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://1.1.1.1:1234/Device.xml", + upnp={ + "manufacturer": config_flow.KONN_MANUFACTURER, + "modelName": config_flow.KONN_MODEL_PRO, + }, + ), ) assert result["type"] == "abort" diff --git a/tests/components/konnected/test_panel.py b/tests/components/konnected/test_panel.py index 38507aa973c..bf2da15b7a4 100644 --- a/tests/components/konnected/test_panel.py +++ b/tests/components/konnected/test_panel.py @@ -144,7 +144,7 @@ async def test_create_and_setup(hass, mock_panel): "sensors": [{"pin": "1"}, {"pin": "2"}, {"pin": "5"}], "actuators": [{"trigger": 0, "pin": "8"}, {"trigger": 1, "pin": "9"}], "dht_sensors": [{"poll_interval": 3, "pin": "6"}], - "ds18b20_sensors": [{"pin": "7"}], + "ds18b20_sensors": [{"poll_interval": 3, "pin": "7"}], "auth_token": "11223344556677889900", "blink": True, "discovery": True, @@ -240,7 +240,7 @@ async def test_create_and_setup_pro(hass, mock_panel): ], "sensors": [ {"zone": "3", "type": "dht", "poll_interval": 5}, - {"zone": "7", "type": "ds18b20", "name": "temper"}, + {"zone": "7", "type": "ds18b20", "poll_interval": 1, "name": "temper"}, ], "switches": [ {"zone": "4"}, @@ -302,7 +302,7 @@ async def test_create_and_setup_pro(hass, mock_panel): {"trigger": 1, "zone": "alarm1"}, ], "dht_sensors": [{"poll_interval": 5, "zone": "3"}], - "ds18b20_sensors": [{"zone": "7"}], + "ds18b20_sensors": [{"poll_interval": 1, "zone": "7"}], "auth_token": "11223344556677889900", "blink": True, "discovery": True, @@ -344,7 +344,7 @@ async def test_create_and_setup_pro(hass, mock_panel): "type": "dht", "zone": "3", }, - {"name": "temper", "poll_interval": 3, "type": "ds18b20", "zone": "7"}, + {"name": "temper", "poll_interval": 1, "type": "ds18b20", "zone": "7"}, ], "switches": [ { @@ -492,7 +492,7 @@ async def test_default_options(hass, mock_panel): "sensors": [{"pin": "1"}, {"pin": "2"}, {"pin": "5"}], "actuators": [{"trigger": 0, "pin": "8"}, {"trigger": 1, "pin": "9"}], "dht_sensors": [{"poll_interval": 3, "pin": "6"}], - "ds18b20_sensors": [{"pin": "7"}], + "ds18b20_sensors": [{"poll_interval": 3, "pin": "7"}], "auth_token": "11223344556677889900", "blink": True, "discovery": True, diff --git a/tests/components/kraken/test_sensor.py b/tests/components/kraken/test_sensor.py index ca98251f157..bc37b67e676 100644 --- a/tests/components/kraken/test_sensor.py +++ b/tests/components/kraken/test_sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.kraken.const import ( DOMAIN, ) from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START +from homeassistant.helpers.device_registry import DeviceEntryType import homeassistant.util.dt as dt_util from .const import ( @@ -253,7 +254,7 @@ async def test_sensors_available_after_restart(hass): identifiers={(DOMAIN, "XBT_USD")}, name="XBT USD", manufacturer="Kraken.com", - entry_type="service", + entry_type=DeviceEntryType.SERVICE, ) entry.add_to_hass(hass) diff --git a/tests/components/lcn/conftest.py b/tests/components/lcn/conftest.py index aae4acfa914..81c2fdc68e4 100644 --- a/tests/components/lcn/conftest.py +++ b/tests/components/lcn/conftest.py @@ -21,8 +21,14 @@ class MockModuleConnection(ModuleConnection): status_request_handler = AsyncMock() activate_status_request_handler = AsyncMock() cancel_status_request_handler = AsyncMock() + request_name = AsyncMock(return_value="TestModule") send_command = AsyncMock(return_value=True) + def __init__(self, *args, **kwargs): + """Construct ModuleConnection instance.""" + super().__init__(*args, **kwargs) + self.serials_request_handler.serial_known.set() + class MockGroupConnection(GroupConnection): """Fake a LCN group connection.""" diff --git a/tests/fixtures/lcn/config.json b/tests/components/lcn/fixtures/config.json similarity index 84% rename from tests/fixtures/lcn/config.json rename to tests/components/lcn/fixtures/config.json index 50a1ca05e29..3cbb66b4e31 100644 --- a/tests/fixtures/lcn/config.json +++ b/tests/components/lcn/fixtures/config.json @@ -25,6 +25,11 @@ "name": "Switch_Output1", "address": "s0.m7", "output": "output1" + }, + { + "name": "Switch_Group5", + "address": "s0.g5", + "output": "relay1" } ] } diff --git a/tests/fixtures/lcn/config_entry_myhome.json b/tests/components/lcn/fixtures/config_entry_myhome.json similarity index 100% rename from tests/fixtures/lcn/config_entry_myhome.json rename to tests/components/lcn/fixtures/config_entry_myhome.json diff --git a/tests/fixtures/lcn/config_entry_pchk.json b/tests/components/lcn/fixtures/config_entry_pchk.json similarity index 61% rename from tests/fixtures/lcn/config_entry_pchk.json rename to tests/components/lcn/fixtures/config_entry_pchk.json index 3058389a95d..a4f78c16b41 100644 --- a/tests/fixtures/lcn/config_entry_pchk.json +++ b/tests/components/lcn/fixtures/config_entry_pchk.json @@ -13,6 +13,13 @@ "hardware_serial": -1, "software_serial": -1, "hardware_type": -1 + }, + { + "address": [0, 5, true], + "name": "", + "hardware_serial": -1, + "software_serial": -1, + "hardware_type": -1 } ], "entities": [ @@ -24,6 +31,15 @@ "domain_data": { "output": "OUTPUT1" } + }, + { + "address": [0, 5, true], + "name": "Switch_Group5", + "resource": "relay1", + "domain": "switch", + "domain_data": { + "output": "RELAY1" + } } ] } diff --git a/tests/components/lcn/test_config_flow.py b/tests/components/lcn/test_config_flow.py index 5f83ab27762..49351b023b6 100644 --- a/tests/components/lcn/test_config_flow.py +++ b/tests/components/lcn/test_config_flow.py @@ -76,8 +76,7 @@ async def test_step_import_existing_host(hass): ], ) async def test_step_import_error(hass, error, reason): - """Test for authentication error is handled correctly.""" - + """Test for error in import is handled correctly.""" with patch( "pypck.connection.PchkConnectionManager.async_connect", side_effect=error ): diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index 79f0eed4e46..e4fb5beef0d 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -10,7 +10,7 @@ from pypck.connection import ( from homeassistant import config_entries from homeassistant.components.lcn.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import MockPchkConnectionManager, init_integration, setup_component @@ -53,19 +53,31 @@ async def test_async_setup_entry_update(hass, entry): """Test a successful setup entry if entry with same id already exists.""" # setup first entry entry.source = config_entries.SOURCE_IMPORT + entry.add_to_hass(hass) # create dummy entity for LCN platform as an orphan - entity_registry = await er.async_get_registry(hass) + entity_registry = er.async_get(hass) dummy_entity = entity_registry.async_get_or_create( "switch", DOMAIN, "dummy", config_entry=entry ) + + # create dummy device for LCN platform as an orphan + device_registry = dr.async_get(hass) + dummy_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.entry_id, 0, 7, False)}, + via_device=(DOMAIN, entry.entry_id), + ) + assert dummy_entity in entity_registry.entities.values() + assert dummy_device in device_registry.devices.values() - # add entity to hass and setup (should cleanup dummy entity) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + # setup new entry with same data via import step (should cleanup dummy device) + await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=entry.data + ) + assert dummy_device not in device_registry.devices.values() assert dummy_entity not in entity_registry.entities.values() diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py index 5628861b72d..58743b1ae05 100644 --- a/tests/components/light/test_device_action.py +++ b/tests/components/light/test_device_action.py @@ -118,6 +118,8 @@ async def test_get_action_capabilities(hass, device_reg, entity_reg): ).entity_id actions = await async_get_device_automations(hass, "action", device_entry.id) assert len(actions) == 3 + action_types = {action["type"] for action in actions} + assert action_types == {"turn_on", "toggle", "turn_off"} for action in actions: capabilities = await async_get_device_automation_capabilities( hass, "action", action @@ -134,11 +136,17 @@ async def test_get_action_capabilities(hass, device_reg, entity_reg): @pytest.mark.parametrize( - "set_state,num_actions,supported_features_reg,supported_features_state,capabilities_reg,attributes_state,expected_capabilities", + "set_state,expected_actions,supported_features_reg,supported_features_state,capabilities_reg,attributes_state,expected_capabilities", [ ( False, - 5, + { + "turn_on", + "toggle", + "turn_off", + "brightness_increase", + "brightness_decrease", + }, 0, 0, {ATTR_SUPPORTED_COLOR_MODES: [COLOR_MODE_BRIGHTNESS]}, @@ -157,7 +165,13 @@ async def test_get_action_capabilities(hass, device_reg, entity_reg): ), ( True, - 5, + { + "turn_on", + "toggle", + "turn_off", + "brightness_increase", + "brightness_decrease", + }, 0, 0, None, @@ -176,7 +190,7 @@ async def test_get_action_capabilities(hass, device_reg, entity_reg): ), ( False, - 4, + {"turn_on", "toggle", "turn_off", "flash"}, SUPPORT_FLASH, 0, None, @@ -194,7 +208,7 @@ async def test_get_action_capabilities(hass, device_reg, entity_reg): ), ( True, - 4, + {"turn_on", "toggle", "turn_off", "flash"}, 0, SUPPORT_FLASH, None, @@ -217,7 +231,7 @@ async def test_get_action_capabilities_features( device_reg, entity_reg, set_state, - num_actions, + expected_actions, supported_features_reg, supported_features_state, capabilities_reg, @@ -247,7 +261,9 @@ async def test_get_action_capabilities_features( ) actions = await async_get_device_automations(hass, "action", device_entry.id) - assert len(actions) == num_actions + assert len(actions) == len(expected_actions) + action_types = {action["type"] for action in actions} + assert action_types == expected_actions for action in actions: capabilities = await async_get_device_automation_capabilities( hass, "action", action diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index c408ed28819..c5355a218af 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -59,6 +59,12 @@ def mock_account_with_no_robots() -> MagicMock: return create_mock_account(skip_robots=True) +@pytest.fixture +def mock_account_with_sleeping_robot() -> MagicMock: + """Mock a Litter-Robot account with a sleeping robot.""" + return create_mock_account({"sleepModeActive": "102:00:00"}) + + @pytest.fixture def mock_account_with_error() -> MagicMock: """Mock a Litter-Robot account with error.""" diff --git a/tests/components/litterrobot/test_button.py b/tests/components/litterrobot/test_button.py new file mode 100644 index 00000000000..0ca74da5d02 --- /dev/null +++ b/tests/components/litterrobot/test_button.py @@ -0,0 +1,48 @@ +"""Test the Litter-Robot button entity.""" +from unittest.mock import MagicMock + +from freezegun import freeze_time + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ICON, + ENTITY_CATEGORY_CONFIG, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_integration + +BUTTON_ENTITY = "button.test_reset_waste_drawer" + + +@freeze_time("2021-11-15 17:37:00", tz_offset=-7) +async def test_button(hass: HomeAssistant, mock_account: MagicMock) -> None: + """Test the creation and values of the Litter-Robot button.""" + await setup_integration(hass, mock_account, BUTTON_DOMAIN) + entity_registry = er.async_get(hass) + + state = hass.states.get(BUTTON_ENTITY) + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:delete-variant" + assert state.state == STATE_UNKNOWN + + entry = entity_registry.async_get(BUTTON_ENTITY) + assert entry + assert entry.entity_category == ENTITY_CATEGORY_CONFIG + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: BUTTON_ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + assert mock_account.robots[0].reset_waste_drawer.call_count == 1 + mock_account.robots[0].reset_waste_drawer.assert_called_with() + + state = hass.states.get(BUTTON_ENTITY) + assert state + assert state.state == "2021-11-15T10:37:00+00:00" diff --git a/tests/components/litterrobot/test_select.py b/tests/components/litterrobot/test_select.py new file mode 100644 index 00000000000..dfb1b0e639e --- /dev/null +++ b/tests/components/litterrobot/test_select.py @@ -0,0 +1,72 @@ +"""Test the Litter-Robot select entity.""" +from datetime import timedelta + +from pylitterbot.robot import VALID_WAIT_TIMES +import pytest + +from homeassistant.components.litterrobot.entity import REFRESH_WAIT_TIME_SECONDS +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as PLATFORM_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, ENTITY_CATEGORY_CONFIG +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry +from homeassistant.util.dt import utcnow + +from .conftest import setup_integration + +from tests.common import async_fire_time_changed + +SELECT_ENTITY_ID = "select.test_clean_cycle_wait_time_minutes" + + +async def test_wait_time_select(hass: HomeAssistant, mock_account): + """Tests the wait time select entity.""" + await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + + select = hass.states.get(SELECT_ENTITY_ID) + assert select + + ent_reg = entity_registry.async_get(hass) + entity_entry = ent_reg.async_get(SELECT_ENTITY_ID) + assert entity_entry + assert entity_entry.entity_category == ENTITY_CATEGORY_CONFIG + + data = {ATTR_ENTITY_ID: SELECT_ENTITY_ID} + + count = 0 + for wait_time in VALID_WAIT_TIMES: + count += 1 + data[ATTR_OPTION] = wait_time + + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SELECT_OPTION, + data, + blocking=True, + ) + + future = utcnow() + timedelta(seconds=REFRESH_WAIT_TIME_SECONDS) + async_fire_time_changed(hass, future) + assert mock_account.robots[0].set_wait_time.call_count == count + + +async def test_invalid_wait_time_select(hass: HomeAssistant, mock_account): + """Tests the wait time select entity with invalid value.""" + await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + + select = hass.states.get(SELECT_ENTITY_ID) + assert select + + data = {ATTR_ENTITY_ID: SELECT_ENTITY_ID, ATTR_OPTION: "10"} + + with pytest.raises(ValueError): + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SELECT_OPTION, + data, + blocking=True, + ) + assert not mock_account.robots[0].set_wait_time.called diff --git a/tests/components/litterrobot/test_switch.py b/tests/components/litterrobot/test_switch.py index 2659b1cc049..99c34e4273f 100644 --- a/tests/components/litterrobot/test_switch.py +++ b/tests/components/litterrobot/test_switch.py @@ -1,5 +1,6 @@ """Test the Litter-Robot switch entity.""" from datetime import timedelta +from unittest.mock import MagicMock import pytest @@ -9,7 +10,14 @@ from homeassistant.components.switch import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.const import ( + ATTR_ENTITY_ID, + ENTITY_CATEGORY_CONFIG, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry from homeassistant.util.dt import utcnow from .conftest import setup_integration @@ -20,13 +28,18 @@ NIGHT_LIGHT_MODE_ENTITY_ID = "switch.test_night_light_mode" PANEL_LOCKOUT_ENTITY_ID = "switch.test_panel_lockout" -async def test_switch(hass, mock_account): +async def test_switch(hass: HomeAssistant, mock_account: MagicMock): """Tests the switch entity was set up.""" await setup_integration(hass, mock_account, PLATFORM_DOMAIN) - switch = hass.states.get(NIGHT_LIGHT_MODE_ENTITY_ID) - assert switch - assert switch.state == STATE_ON + state = hass.states.get(NIGHT_LIGHT_MODE_ENTITY_ID) + assert state + assert state.state == STATE_ON + + ent_reg = entity_registry.async_get(hass) + entity_entry = ent_reg.async_get(NIGHT_LIGHT_MODE_ENTITY_ID) + assert entity_entry + assert entity_entry.entity_category == ENTITY_CATEGORY_CONFIG @pytest.mark.parametrize( @@ -36,12 +49,14 @@ async def test_switch(hass, mock_account): (PANEL_LOCKOUT_ENTITY_ID, "set_panel_lockout"), ], ) -async def test_on_off_commands(hass, mock_account, entity_id, robot_command): +async def test_on_off_commands( + hass: HomeAssistant, mock_account: MagicMock, entity_id: str, robot_command: str +): """Test sending commands to the switch.""" await setup_integration(hass, mock_account, PLATFORM_DOMAIN) - switch = hass.states.get(entity_id) - assert switch + state = hass.states.get(entity_id) + assert state data = {ATTR_ENTITY_ID: entity_id} @@ -59,3 +74,6 @@ async def test_on_off_commands(hass, mock_account, entity_id, robot_command): future = utcnow() + timedelta(seconds=REFRESH_WAIT_TIME_SECONDS) async_fire_time_changed(hass, future) assert getattr(mock_account.robots[0], robot_command).call_count == count + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON if service == SERVICE_TURN_ON else STATE_OFF diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 7db0ca5dde4..89f8f077b55 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -1,5 +1,9 @@ """Test the Litter-Robot vacuum entity.""" +from __future__ import annotations + from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock import pytest from voluptuous.error import MultipleInvalid @@ -12,6 +16,7 @@ from homeassistant.components.litterrobot.vacuum import ( SERVICE_SET_WAIT_TIME, ) from homeassistant.components.vacuum import ( + ATTR_STATUS, DOMAIN as PLATFORM_DOMAIN, SERVICE_START, SERVICE_TURN_OFF, @@ -35,7 +40,7 @@ COMPONENT_SERVICE_DOMAIN = { } -async def test_vacuum(hass: HomeAssistant, mock_account): +async def test_vacuum(hass: HomeAssistant, mock_account: MagicMock) -> None: """Tests the vacuum entity was set up.""" await setup_integration(hass, mock_account, PLATFORM_DOMAIN) assert hass.services.has_service(DOMAIN, SERVICE_RESET_WASTE_DRAWER) @@ -46,14 +51,29 @@ async def test_vacuum(hass: HomeAssistant, mock_account): assert vacuum.attributes["is_sleeping"] is False -async def test_no_robots(hass: HomeAssistant, mock_account_with_no_robots): +async def test_vacuum_status_when_sleeping( + hass: HomeAssistant, mock_account_with_sleeping_robot: MagicMock +) -> None: + """Tests the vacuum status when sleeping.""" + await setup_integration(hass, mock_account_with_sleeping_robot, PLATFORM_DOMAIN) + + vacuum = hass.states.get(VACUUM_ENTITY_ID) + assert vacuum + assert vacuum.attributes.get(ATTR_STATUS) == "Ready (Sleeping)" + + +async def test_no_robots( + hass: HomeAssistant, mock_account_with_no_robots: MagicMock +) -> None: """Tests the vacuum entity was set up.""" await setup_integration(hass, mock_account_with_no_robots, PLATFORM_DOMAIN) assert not hass.services.has_service(DOMAIN, SERVICE_RESET_WASTE_DRAWER) -async def test_vacuum_with_error(hass: HomeAssistant, mock_account_with_error): +async def test_vacuum_with_error( + hass: HomeAssistant, mock_account_with_error: MagicMock +) -> None: """Tests a vacuum entity with an error.""" await setup_integration(hass, mock_account_with_error, PLATFORM_DOMAIN) @@ -68,39 +88,34 @@ async def test_vacuum_with_error(hass: HomeAssistant, mock_account_with_error): (SERVICE_START, "start_cleaning", None), (SERVICE_TURN_OFF, "set_power_status", None), (SERVICE_TURN_ON, "set_power_status", None), - ( - SERVICE_RESET_WASTE_DRAWER, - "reset_waste_drawer", - None, - ), + (SERVICE_RESET_WASTE_DRAWER, "reset_waste_drawer", {"deprecated": True}), ( SERVICE_SET_SLEEP_MODE, "set_sleep_mode", - {"enabled": True, "start_time": "22:30"}, + {"data": {"enabled": True, "start_time": "22:30"}}, ), + (SERVICE_SET_SLEEP_MODE, "set_sleep_mode", {"data": {"enabled": True}}), + (SERVICE_SET_SLEEP_MODE, "set_sleep_mode", {"data": {"enabled": False}}), ( - SERVICE_SET_SLEEP_MODE, - "set_sleep_mode", - {"enabled": True}, - ), - ( - SERVICE_SET_SLEEP_MODE, - "set_sleep_mode", - {"enabled": False}, + SERVICE_SET_WAIT_TIME, + "set_wait_time", + {"data": {"minutes": 3}, "deprecated": True}, ), ( SERVICE_SET_WAIT_TIME, "set_wait_time", - {"minutes": 3}, - ), - ( - SERVICE_SET_WAIT_TIME, - "set_wait_time", - {"minutes": "15"}, + {"data": {"minutes": "15"}, "deprecated": True}, ), ], ) -async def test_commands(hass: HomeAssistant, mock_account, service, command, extra): +async def test_commands( + hass: HomeAssistant, + mock_account: MagicMock, + caplog: pytest.LogCaptureFixture, + service: str, + command: str, + extra: dict[str, Any], +) -> None: """Test sending commands to the vacuum.""" await setup_integration(hass, mock_account, PLATFORM_DOMAIN) @@ -108,9 +123,9 @@ async def test_commands(hass: HomeAssistant, mock_account, service, command, ext assert vacuum assert vacuum.state == STATE_DOCKED - data = {ATTR_ENTITY_ID: VACUUM_ENTITY_ID} - if extra: - data.update(extra) + extra = extra or {} + data = {ATTR_ENTITY_ID: VACUUM_ENTITY_ID, **extra.get("data", {})} + deprecated = extra.get("deprecated", False) await hass.services.async_call( COMPONENT_SERVICE_DOMAIN.get(service, PLATFORM_DOMAIN), @@ -121,9 +136,10 @@ async def test_commands(hass: HomeAssistant, mock_account, service, command, ext future = utcnow() + timedelta(seconds=REFRESH_WAIT_TIME_SECONDS) async_fire_time_changed(hass, future) getattr(mock_account.robots[0], command).assert_called_once() + assert (f"'{DOMAIN}.{service}' service is deprecated" in caplog.text) is deprecated -async def test_invalid_wait_time(hass: HomeAssistant, mock_account): +async def test_invalid_wait_time(hass: HomeAssistant, mock_account: MagicMock) -> None: """Test an attempt to send an invalid wait time to the vacuum.""" await setup_integration(hass, mock_account, PLATFORM_DOMAIN) diff --git a/tests/components/lookin/__init__.py b/tests/components/lookin/__init__.py index c2821fafab8..911e984a57e 100644 --- a/tests/components/lookin/__init__.py +++ b/tests/components/lookin/__init__.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch from aiolookin import Climate, Device, Remote -from homeassistant.components.zeroconf import HaServiceInfo +from homeassistant.components.zeroconf import ZeroconfServiceInfo DEVICE_ID = "98F33163" MODULE = "homeassistant.components.lookin" @@ -17,14 +17,14 @@ DEFAULT_ENTRY_TITLE = DEVICE_NAME ZC_NAME = f"LOOKin_{DEVICE_ID}" ZC_TYPE = "_lookin._tcp." -ZEROCONF_DATA: HaServiceInfo = { - "host": IP_ADDRESS, - "hostname": f"{ZC_NAME.lower()}.local.", - "port": 80, - "type": ZC_TYPE, - "name": ZC_NAME, - "properties": {}, -} +ZEROCONF_DATA = ZeroconfServiceInfo( + host=IP_ADDRESS, + hostname=f"{ZC_NAME.lower()}.local.", + port=80, + type=ZC_TYPE, + name=ZC_NAME, + properties={}, +) def _mocked_climate() -> Climate: diff --git a/tests/components/lookin/test_config_flow.py b/tests/components/lookin/test_config_flow.py index 92f6e500045..e24b9c92221 100644 --- a/tests/components/lookin/test_config_flow.py +++ b/tests/components/lookin/test_config_flow.py @@ -1,6 +1,7 @@ """Define tests for the lookin config flow.""" from __future__ import annotations +import dataclasses from unittest.mock import patch from aiolookin import NoUsableService @@ -137,8 +138,8 @@ async def test_discovered_zeroconf(hass): assert mock_async_setup_entry.called entry = hass.config_entries.async_entries(DOMAIN)[0] - zc_data_new_ip = ZEROCONF_DATA.copy() - zc_data_new_ip["host"] = "127.0.0.2" + zc_data_new_ip = dataclasses.replace(ZEROCONF_DATA) + zc_data_new_ip.host = "127.0.0.2" with _patch_get_info(), patch( f"{MODULE}.async_setup_entry", return_value=True diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index 2697bb363f6..2b947c36982 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -8,6 +8,7 @@ from pylutron_caseta.smartbridge import Smartbridge import pytest from homeassistant import config_entries, data_entry_flow +from homeassistant.components import zeroconf from homeassistant.components.lutron_caseta import DOMAIN import homeassistant.components.lutron_caseta.config_flow as CasetaConfigFlow from homeassistant.components.lutron_caseta.const import ( @@ -424,10 +425,14 @@ async def test_zeroconf_host_already_configured(hass, tmpdir): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - CONF_HOST: "1.1.1.1", - ATTR_HOSTNAME: "lutron-abc.local.", - }, + data=zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + hostname="LuTrOn-abc.local.", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), ) await hass.async_block_till_done() @@ -447,10 +452,14 @@ async def test_zeroconf_lutron_id_already_configured(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - CONF_HOST: "1.1.1.1", - ATTR_HOSTNAME: "lutron-abc.local.", - }, + data=zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + hostname="LuTrOn-abc.local.", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), ) await hass.async_block_till_done() @@ -465,10 +474,14 @@ async def test_zeroconf_not_lutron_device(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - CONF_HOST: "1.1.1.1", - ATTR_HOSTNAME: "notlutron-abc.local.", - }, + data=zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + hostname="notlutron-abc.local.", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), ) await hass.async_block_till_done() @@ -489,10 +502,14 @@ async def test_zeroconf(hass, source, tmpdir): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data={ - CONF_HOST: "1.1.1.1", - ATTR_HOSTNAME: "lutron-abc.local.", - }, + data=zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + hostname="LuTrOn-abc.local.", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), ) await hass.async_block_till_done() diff --git a/tests/components/lutron_caseta/test_device_trigger.py b/tests/components/lutron_caseta/test_device_trigger.py index 32d6eb3dc5f..23faa929574 100644 --- a/tests/components/lutron_caseta/test_device_trigger.py +++ b/tests/components/lutron_caseta/test_device_trigger.py @@ -335,11 +335,3 @@ async def test_validate_trigger_invalid_triggers(hass, device_reg): ] }, ) - - assert ( - len(entity_ids := hass.states.async_entity_ids("persistent_notification")) == 1 - ) - assert ( - "The following integrations and platforms could not be set up" - in hass.states.get(entity_ids[0]).attributes["message"] - ) diff --git a/tests/fixtures/mazda/get_vehicle_status.json b/tests/components/mazda/fixtures/get_vehicle_status.json similarity index 100% rename from tests/fixtures/mazda/get_vehicle_status.json rename to tests/components/mazda/fixtures/get_vehicle_status.json diff --git a/tests/fixtures/mazda/get_vehicles.json b/tests/components/mazda/fixtures/get_vehicles.json similarity index 100% rename from tests/fixtures/mazda/get_vehicles.json rename to tests/components/mazda/fixtures/get_vehicles.json diff --git a/tests/fixtures/melissa_cur_settings.json b/tests/components/melissa/fixtures/cur_settings.json similarity index 100% rename from tests/fixtures/melissa_cur_settings.json rename to tests/components/melissa/fixtures/cur_settings.json diff --git a/tests/fixtures/melissa_fetch_devices.json b/tests/components/melissa/fixtures/fetch_devices.json similarity index 100% rename from tests/fixtures/melissa_fetch_devices.json rename to tests/components/melissa/fixtures/fetch_devices.json diff --git a/tests/fixtures/melissa_status.json b/tests/components/melissa/fixtures/status.json similarity index 100% rename from tests/fixtures/melissa_status.json rename to tests/components/melissa/fixtures/status.json diff --git a/tests/components/melissa/test_climate.py b/tests/components/melissa/test_climate.py index 590c5149f9e..224a878b065 100644 --- a/tests/components/melissa/test_climate.py +++ b/tests/components/melissa/test_climate.py @@ -25,13 +25,13 @@ def melissa_mock(): """Use this to mock the melissa api.""" api = Mock() api.async_fetch_devices = AsyncMock( - return_value=json.loads(load_fixture("melissa_fetch_devices.json")) + return_value=json.loads(load_fixture("fetch_devices.json", "melissa")) ) api.async_status = AsyncMock( - return_value=json.loads(load_fixture("melissa_status.json")) + return_value=json.loads(load_fixture("status.json", "melissa")) ) api.async_cur_settings = AsyncMock( - return_value=json.loads(load_fixture("melissa_cur_settings.json")) + return_value=json.loads(load_fixture("cur_settings.json", "melissa")) ) api.async_send = AsyncMock(return_value=True) diff --git a/tests/components/metoffice/const.py b/tests/components/metoffice/const.py index c9a173e3f12..56383764d08 100644 --- a/tests/components/metoffice/const.py +++ b/tests/components/metoffice/const.py @@ -1,5 +1,6 @@ """Helpers for testing Met Office DataPoint.""" +from homeassistant.components.metoffice.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME TEST_DATETIME_STRING = "2020-04-25T12:00:00+00:00" @@ -55,3 +56,10 @@ WAVERTREE_SENSOR_RESULTS = { "wind_speed": ("wind_speed", "9"), "humidity": ("humidity", "50"), } + +DEVICE_KEY_KINGSLYNN = { + (DOMAIN, f"{TEST_LATITUDE_KINGSLYNN}_{TEST_LONGITUDE_KINGSLYNN}") +} +DEVICE_KEY_WAVERTREE = { + (DOMAIN, f"{TEST_LATITUDE_WAVERTREE}_{TEST_LONGITUDE_WAVERTREE}") +} diff --git a/tests/components/metoffice/test_sensor.py b/tests/components/metoffice/test_sensor.py index 201c5922d33..b8b2458dc5b 100644 --- a/tests/components/metoffice/test_sensor.py +++ b/tests/components/metoffice/test_sensor.py @@ -3,9 +3,12 @@ import json from unittest.mock import patch from homeassistant.components.metoffice.const import ATTRIBUTION, DOMAIN +from homeassistant.helpers.device_registry import async_get as get_dev_reg from . import NewDateTime from .const import ( + DEVICE_KEY_KINGSLYNN, + DEVICE_KEY_WAVERTREE, KINGSLYNN_SENSOR_RESULTS, METOFFICE_CONFIG_KINGSLYNN, METOFFICE_CONFIG_WAVERTREE, @@ -48,6 +51,11 @@ async def test_one_sensor_site_running(hass, requests_mock, legacy_patchable_tim await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + dev_reg = get_dev_reg(hass) + assert len(dev_reg.devices) == 1 + device_wavertree = dev_reg.async_get_device(identifiers=DEVICE_KEY_WAVERTREE) + assert device_wavertree.name == "Met Office Wavertree" + running_sensor_ids = hass.states.async_entity_ids("sensor") assert len(running_sensor_ids) > 0 for running_id in running_sensor_ids: @@ -105,6 +113,13 @@ async def test_two_sensor_sites_running(hass, requests_mock, legacy_patchable_ti await hass.config_entries.async_setup(entry2.entry_id) await hass.async_block_till_done() + dev_reg = get_dev_reg(hass) + assert len(dev_reg.devices) == 2 + device_kingslynn = dev_reg.async_get_device(identifiers=DEVICE_KEY_KINGSLYNN) + assert device_kingslynn.name == "Met Office King's Lynn" + device_wavertree = dev_reg.async_get_device(identifiers=DEVICE_KEY_WAVERTREE) + assert device_wavertree.name == "Met Office Wavertree" + running_sensor_ids = hass.states.async_entity_ids("sensor") assert len(running_sensor_ids) > 0 for running_id in running_sensor_ids: diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index 76e01b638c3..158e44ca15b 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -5,10 +5,13 @@ from unittest.mock import patch from homeassistant.components.metoffice.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.helpers.device_registry import async_get as get_dev_reg from homeassistant.util import utcnow from . import NewDateTime from .const import ( + DEVICE_KEY_KINGSLYNN, + DEVICE_KEY_WAVERTREE, METOFFICE_CONFIG_KINGSLYNN, METOFFICE_CONFIG_WAVERTREE, WAVERTREE_SENSOR_RESULTS, @@ -36,6 +39,9 @@ async def test_site_cannot_connect(hass, requests_mock, legacy_patchable_time): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + dev_reg = get_dev_reg(hass) + assert len(dev_reg.devices) == 0 + assert hass.states.get("weather.met_office_wavertree_3hourly") is None assert hass.states.get("weather.met_office_wavertree_daily") is None for sensor_id in WAVERTREE_SENSOR_RESULTS: @@ -124,6 +130,11 @@ async def test_one_weather_site_running(hass, requests_mock, legacy_patchable_ti await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + dev_reg = get_dev_reg(hass) + assert len(dev_reg.devices) == 1 + device_wavertree = dev_reg.async_get_device(identifiers=DEVICE_KEY_WAVERTREE) + assert device_wavertree.name == "Met Office Wavertree" + # Wavertree 3-hourly weather platform expected results weather = hass.states.get("weather.met_office_wavertree_3_hourly") assert weather @@ -143,6 +154,7 @@ async def test_one_weather_site_running(hass, requests_mock, legacy_patchable_ti == "2020-04-28T21:00:00+00:00" ) assert weather.attributes.get("forecast")[26]["condition"] == "cloudy" + assert weather.attributes.get("forecast")[26]["precipitation_probability"] == 9 assert weather.attributes.get("forecast")[26]["temperature"] == 10 assert weather.attributes.get("forecast")[26]["wind_speed"] == 4 assert weather.attributes.get("forecast")[26]["wind_bearing"] == "NNE" @@ -165,6 +177,7 @@ async def test_one_weather_site_running(hass, requests_mock, legacy_patchable_ti weather.attributes.get("forecast")[7]["datetime"] == "2020-04-29T12:00:00+00:00" ) assert weather.attributes.get("forecast")[7]["condition"] == "rainy" + assert weather.attributes.get("forecast")[7]["precipitation_probability"] == 59 assert weather.attributes.get("forecast")[7]["temperature"] == 13 assert weather.attributes.get("forecast")[7]["wind_speed"] == 13 assert weather.attributes.get("forecast")[7]["wind_bearing"] == "SE" @@ -213,6 +226,13 @@ async def test_two_weather_sites_running(hass, requests_mock, legacy_patchable_t await hass.config_entries.async_setup(entry2.entry_id) await hass.async_block_till_done() + dev_reg = get_dev_reg(hass) + assert len(dev_reg.devices) == 2 + device_kingslynn = dev_reg.async_get_device(identifiers=DEVICE_KEY_KINGSLYNN) + assert device_kingslynn.name == "Met Office King's Lynn" + device_wavertree = dev_reg.async_get_device(identifiers=DEVICE_KEY_WAVERTREE) + assert device_wavertree.name == "Met Office Wavertree" + # Wavertree 3-hourly weather platform expected results weather = hass.states.get("weather.met_office_wavertree_3_hourly") assert weather @@ -232,6 +252,7 @@ async def test_two_weather_sites_running(hass, requests_mock, legacy_patchable_t == "2020-04-27T21:00:00+00:00" ) assert weather.attributes.get("forecast")[18]["condition"] == "clear-night" + assert weather.attributes.get("forecast")[18]["precipitation_probability"] == 1 assert weather.attributes.get("forecast")[18]["temperature"] == 9 assert weather.attributes.get("forecast")[18]["wind_speed"] == 4 assert weather.attributes.get("forecast")[18]["wind_bearing"] == "NW" @@ -254,6 +275,7 @@ async def test_two_weather_sites_running(hass, requests_mock, legacy_patchable_t weather.attributes.get("forecast")[7]["datetime"] == "2020-04-29T12:00:00+00:00" ) assert weather.attributes.get("forecast")[7]["condition"] == "rainy" + assert weather.attributes.get("forecast")[7]["precipitation_probability"] == 59 assert weather.attributes.get("forecast")[7]["temperature"] == 13 assert weather.attributes.get("forecast")[7]["wind_speed"] == 13 assert weather.attributes.get("forecast")[7]["wind_bearing"] == "SE" @@ -277,6 +299,7 @@ async def test_two_weather_sites_running(hass, requests_mock, legacy_patchable_t == "2020-04-27T21:00:00+00:00" ) assert weather.attributes.get("forecast")[18]["condition"] == "cloudy" + assert weather.attributes.get("forecast")[18]["precipitation_probability"] == 9 assert weather.attributes.get("forecast")[18]["temperature"] == 10 assert weather.attributes.get("forecast")[18]["wind_speed"] == 7 assert weather.attributes.get("forecast")[18]["wind_bearing"] == "SE" @@ -299,6 +322,7 @@ async def test_two_weather_sites_running(hass, requests_mock, legacy_patchable_t weather.attributes.get("forecast")[5]["datetime"] == "2020-04-28T12:00:00+00:00" ) assert weather.attributes.get("forecast")[5]["condition"] == "cloudy" + assert weather.attributes.get("forecast")[5]["precipitation_probability"] == 14 assert weather.attributes.get("forecast")[5]["temperature"] == 11 assert weather.attributes.get("forecast")[5]["wind_speed"] == 7 assert weather.attributes.get("forecast")[5]["wind_bearing"] == "ESE" diff --git a/tests/components/mill/test_config_flow.py b/tests/components/mill/test_config_flow.py index ce35b3d9708..ff2f7393c82 100644 --- a/tests/components/mill/test_config_flow.py +++ b/tests/components/mill/test_config_flow.py @@ -1,47 +1,57 @@ """Tests for Mill config flow.""" from unittest.mock import patch -import pytest - from homeassistant import config_entries -from homeassistant.components.mill.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.mill.const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import RESULT_TYPE_FORM from tests.common import MockConfigEntry -@pytest.fixture(name="mill_setup", autouse=True) -def mill_setup_fixture(): - """Patch mill setup entry.""" - with patch("homeassistant.components.mill.async_setup_entry", return_value=True): - yield - - async def test_show_config_form(hass): """Test show configuration form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "user" async def test_create_entry(hass): """Test create entry from user input.""" - test_data = { - CONF_USERNAME: "user", - CONF_PASSWORD: "pswd", - } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: CLOUD, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM with patch("mill.Mill.connect", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_USERNAME: "user", + CONF_PASSWORD: "pswd", + }, ) + await hass.async_block_till_done() assert result["type"] == "create_entry" - assert result["title"] == test_data[CONF_USERNAME] - assert result["data"] == test_data + assert result["title"] == "user" + assert result["data"] == { + CONF_USERNAME: "user", + CONF_PASSWORD: "pswd", + CONNECTION_TYPE: CLOUD, + } async def test_flow_entry_already_exists(hass): @@ -59,10 +69,26 @@ async def test_flow_entry_already_exists(hass): ) first_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: CLOUD, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + with patch("mill.Mill.connect", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + test_data, ) + await hass.async_block_till_done() assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -70,23 +96,152 @@ async def test_flow_entry_already_exists(hass): async def test_connection_error(hass): """Test connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: CLOUD, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + + with patch("mill.Mill.connect", return_value=False): + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_USERNAME: "user", + CONF_PASSWORD: "pswd", + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_local_create_entry(hass): + """Test create entry from user input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: LOCAL, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM test_data = { - CONF_USERNAME: "user", - CONF_PASSWORD: "pswd", + CONF_IP_ADDRESS: "192.168.1.59", + } + + with patch( + "mill_local.Mill.connect", + return_value={ + "name": "panel heater gen. 3", + "version": "0x210927", + "operation_key": "", + "status": "ok", + }, + ): + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + test_data, + ) + + test_data[CONNECTION_TYPE] = LOCAL + assert result["type"] == "create_entry" + assert result["title"] == test_data[CONF_IP_ADDRESS] + assert result["data"] == test_data + + +async def test_local_flow_entry_already_exists(hass): + """Test user input for config_entry that already exists.""" + + test_data = { + CONF_IP_ADDRESS: "192.168.1.59", } first_entry = MockConfigEntry( domain="mill", data=test_data, - unique_id=test_data[CONF_USERNAME], + unique_id=test_data[CONF_IP_ADDRESS], ) first_entry.add_to_hass(hass) - with patch("mill.Mill.connect", return_value=False): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: LOCAL, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + + test_data = { + CONF_IP_ADDRESS: "192.168.1.59", + } + + with patch( + "mill_local.Mill.connect", + return_value={ + "name": "panel heater gen. 3", + "version": "0x210927", + "operation_key": "", + "status": "ok", + }, + ): + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + test_data, ) - assert result["type"] == "form" - assert result["errors"]["cannot_connect"] == "cannot_connect" + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_local_connection_error(hass): + """Test connection error.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: LOCAL, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + + test_data = { + CONF_IP_ADDRESS: "192.168.1.59", + } + + with patch( + "mill_local.Mill.connect", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + test_data, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/mill/test_init.py b/tests/components/mill/test_init.py new file mode 100644 index 00000000000..f92b4689ebf --- /dev/null +++ b/tests/components/mill/test_init.py @@ -0,0 +1,121 @@ +"""Tests for Mill init.""" + +from unittest.mock import patch + +from homeassistant.components import mill +from homeassistant.config_entries import ConfigEntryState +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, mock_coro + + +async def test_setup_with_cloud_config(hass): + """Test setup of cloud config.""" + entry = MockConfigEntry( + domain=mill.DOMAIN, + data={ + mill.CONF_USERNAME: "user", + mill.CONF_PASSWORD: "pswd", + mill.CONNECTION_TYPE: mill.CLOUD, + }, + ) + entry.add_to_hass(hass) + with patch( + "mill.Mill.fetch_heater_and_sensor_data", return_value={} + ) as mock_fetch, patch("mill.Mill.connect", return_value=True) as mock_connect: + assert await async_setup_component(hass, "mill", entry) + assert len(mock_fetch.mock_calls) == 1 + assert len(mock_connect.mock_calls) == 1 + + +async def test_setup_with_cloud_config_fails(hass): + """Test setup of cloud config.""" + entry = MockConfigEntry( + domain=mill.DOMAIN, + data={ + mill.CONF_USERNAME: "user", + mill.CONF_PASSWORD: "pswd", + mill.CONNECTION_TYPE: mill.CLOUD, + }, + ) + entry.add_to_hass(hass) + with patch("mill.Mill.connect", return_value=False): + assert await async_setup_component(hass, "mill", entry) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_with_old_cloud_config(hass): + """Test setup of old cloud config.""" + entry = MockConfigEntry( + domain=mill.DOMAIN, + data={ + mill.CONF_USERNAME: "user", + mill.CONF_PASSWORD: "pswd", + }, + ) + entry.add_to_hass(hass) + with patch("mill.Mill.fetch_heater_and_sensor_data", return_value={}), patch( + "mill.Mill.connect", return_value=True + ) as mock_connect: + assert await async_setup_component(hass, "mill", entry) + + assert len(mock_connect.mock_calls) == 1 + + +async def test_setup_with_local_config(hass): + """Test setup of local config.""" + entry = MockConfigEntry( + domain=mill.DOMAIN, + data={ + mill.CONF_IP_ADDRESS: "192.168.1.59", + mill.CONNECTION_TYPE: mill.LOCAL, + }, + ) + entry.add_to_hass(hass) + with patch( + "mill_local.Mill.fetch_heater_and_sensor_data", + return_value={ + "ambient_temperature": 20, + "set_temperature": 22, + "current_power": 0, + }, + ) as mock_fetch, patch( + "mill_local.Mill.connect", + return_value={ + "name": "panel heater gen. 3", + "version": "0x210927", + "operation_key": "", + "status": "ok", + }, + ) as mock_connect: + assert await async_setup_component(hass, "mill", entry) + + assert len(mock_fetch.mock_calls) == 1 + assert len(mock_connect.mock_calls) == 1 + + +async def test_unload_entry(hass): + """Test removing mill client.""" + entry = MockConfigEntry( + domain=mill.DOMAIN, + data={ + mill.CONF_USERNAME: "user", + mill.CONF_PASSWORD: "pswd", + mill.CONNECTION_TYPE: mill.CLOUD, + }, + ) + entry.add_to_hass(hass) + + with patch.object( + hass.config_entries, "async_forward_entry_unload", return_value=mock_coro(True) + ) as unload_entry, patch( + "mill.Mill.fetch_heater_and_sensor_data", return_value={} + ), patch( + "mill.Mill.connect", return_value=True + ): + assert await async_setup_component(hass, "mill", entry) + + assert await hass.config_entries.async_unload(entry.entry_id) + + assert unload_entry.call_count == 2 + assert entry.entry_id not in hass.data[mill.DOMAIN] diff --git a/tests/fixtures/min_max/configuration.yaml b/tests/components/min_max/fixtures/configuration.yaml similarity index 100% rename from tests/fixtures/min_max/configuration.yaml rename to tests/components/min_max/fixtures/configuration.yaml diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py index c6c352a8c3e..1712a9027ca 100644 --- a/tests/components/min_max/test_sensor.py +++ b/tests/components/min_max/test_sensor.py @@ -1,5 +1,4 @@ """The test for the min/max sensor platform.""" -from os import path import statistics from unittest.mock import patch @@ -16,6 +15,8 @@ from homeassistant.const import ( ) from homeassistant.setup import async_setup_component +from tests.common import get_fixture_path + VALUES = [17, 20, 15.3] COUNT = len(VALUES) MIN_VALUE = min(VALUES) @@ -365,11 +366,8 @@ async def test_reload(hass): assert hass.states.get("sensor.test") - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "min_max/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "min_max") + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( DOMAIN, @@ -383,7 +381,3 @@ async def test_reload(hass): assert hass.states.get("sensor.test") is None assert hass.states.get("sensor.second_test") - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/components/mobile_app/test_sensor.py b/tests/components/mobile_app/test_sensor.py index 032870ffb8c..295e37ee7d9 100644 --- a/tests/components/mobile_app/test_sensor.py +++ b/tests/components/mobile_app/test_sensor.py @@ -1,7 +1,10 @@ """Entity tests for mobile_app.""" from http import HTTPStatus -from homeassistant.const import PERCENTAGE, STATE_UNKNOWN +import pytest + +from homeassistant.components.sensor import DEVICE_CLASS_DATE, DEVICE_CLASS_TIMESTAMP +from homeassistant.const import PERCENTAGE, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -86,7 +89,7 @@ async def test_sensor(hass, create_registrations, webhook_client): await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() unloaded_entity = hass.states.get("sensor.test_1_battery_state") - assert unloaded_entity.state == "unavailable" + assert unloaded_entity.state == STATE_UNAVAILABLE await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -276,3 +279,64 @@ async def test_update_sensor_no_state(hass, create_registrations, webhook_client updated_entity = hass.states.get("sensor.test_1_battery_state") assert updated_entity.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "device_class,native_value,state_value", + [ + (DEVICE_CLASS_DATE, "2021-11-18", "2021-11-18"), + ( + DEVICE_CLASS_TIMESTAMP, + "2021-11-18T20:25:00+00:00", + "2021-11-18T20:25:00+00:00", + ), + ( + DEVICE_CLASS_TIMESTAMP, + "2021-11-18 20:25:00+01:00", + "2021-11-18T19:25:00+00:00", + ), + ( + DEVICE_CLASS_TIMESTAMP, + "unavailable", + STATE_UNAVAILABLE, + ), + ( + DEVICE_CLASS_TIMESTAMP, + "unknown", + STATE_UNKNOWN, + ), + ], +) +async def test_sensor_datetime( + hass, create_registrations, webhook_client, device_class, native_value, state_value +): + """Test that sensors can be registered and updated.""" + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "device_class": device_class, + "name": "Datetime sensor test", + "state": native_value, + "type": "sensor", + "unique_id": "super_unique", + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + + json = await reg_resp.json() + assert json == {"success": True} + await hass.async_block_till_done() + + entity = hass.states.get("sensor.test_1_datetime_sensor_test") + assert entity is not None + + assert entity.attributes["device_class"] == device_class + assert entity.domain == "sensor" + assert entity.state == state_value diff --git a/tests/components/modem_callerid/test_config_flow.py b/tests/components/modem_callerid/test_config_flow.py index 5a2e4e5fd6d..0956a8fe1b7 100644 --- a/tests/components/modem_callerid/test_config_flow.py +++ b/tests/components/modem_callerid/test_config_flow.py @@ -5,8 +5,8 @@ import phone_modem import serial.tools.list_ports from homeassistant.components import usb -from homeassistant.components.modem_callerid.const import DEFAULT_NAME, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USB, SOURCE_USER +from homeassistant.components.modem_callerid.const import DOMAIN +from homeassistant.config_entries import SOURCE_USB, SOURCE_USER from homeassistant.const import CONF_DEVICE, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import ( @@ -15,16 +15,16 @@ from homeassistant.data_entry_flow import ( RESULT_TYPE_FORM, ) -from . import CONF_DATA, IMPORT_DATA, _patch_config_flow_modem +from . import _patch_config_flow_modem -DISCOVERY_INFO = { - "device": phone_modem.DEFAULT_PORT, - "pid": "1340", - "vid": "0572", - "serial_number": "1234", - "description": "modem", - "manufacturer": "Connexant", -} +DISCOVERY_INFO = usb.UsbServiceInfo( + device=phone_modem.DEFAULT_PORT, + pid="1340", + vid="0572", + serial_number="1234", + description="modem", + manufacturer="Connexant", +) def _patch_setup(): @@ -171,34 +171,3 @@ async def test_abort_user_with_existing_flow(hass: HomeAssistant): assert result2["type"] == RESULT_TYPE_ABORT assert result2["reason"] == "already_in_progress" - - -@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) -async def test_flow_import(hass: HomeAssistant): - """Test import step.""" - with _patch_config_flow_modem(AsyncMock()): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=IMPORT_DATA - ) - - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == DEFAULT_NAME - assert result["data"] == CONF_DATA - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=IMPORT_DATA - ) - - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -async def test_flow_import_cannot_connect(hass: HomeAssistant): - """Test import connection error.""" - with _patch_config_flow_modem(AsyncMock()) as modemmock: - modemmock.side_effect = phone_modem.exceptions.SerialError - result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=IMPORT_DATA - ) - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "cannot_connect" diff --git a/tests/fixtures/modern_forms/device_info.json b/tests/components/modern_forms/fixtures/device_info.json similarity index 100% rename from tests/fixtures/modern_forms/device_info.json rename to tests/components/modern_forms/fixtures/device_info.json diff --git a/tests/fixtures/modern_forms/device_info_no_light.json b/tests/components/modern_forms/fixtures/device_info_no_light.json similarity index 100% rename from tests/fixtures/modern_forms/device_info_no_light.json rename to tests/components/modern_forms/fixtures/device_info_no_light.json diff --git a/tests/fixtures/modern_forms/device_status.json b/tests/components/modern_forms/fixtures/device_status.json similarity index 100% rename from tests/fixtures/modern_forms/device_status.json rename to tests/components/modern_forms/fixtures/device_status.json diff --git a/tests/fixtures/modern_forms/device_status_no_light.json b/tests/components/modern_forms/fixtures/device_status_no_light.json similarity index 100% rename from tests/fixtures/modern_forms/device_status_no_light.json rename to tests/components/modern_forms/fixtures/device_status_no_light.json diff --git a/tests/fixtures/modern_forms/device_status_timers_active.json b/tests/components/modern_forms/fixtures/device_status_timers_active.json similarity index 100% rename from tests/fixtures/modern_forms/device_status_timers_active.json rename to tests/components/modern_forms/fixtures/device_status_timers_active.json diff --git a/tests/components/modern_forms/test_config_flow.py b/tests/components/modern_forms/test_config_flow.py index 967e9d354d5..b1b2eb618af 100644 --- a/tests/components/modern_forms/test_config_flow.py +++ b/tests/components/modern_forms/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import aiohttp from aiomodernforms import ModernFormsConnectionError +from homeassistant.components import zeroconf from homeassistant.components.modern_forms.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONTENT_TYPE_JSON @@ -68,7 +69,14 @@ async def test_full_zeroconf_flow_implementation( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={"host": "192.168.1.123", "hostname": "example.local.", "properties": {}}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.123", + hostname="example.local.", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), ) flows = hass.config_entries.flow.async_progress() @@ -130,7 +138,14 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={"host": "192.168.1.123", "hostname": "example.local.", "properties": {}}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.123", + hostname="example.local.", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), ) assert result.get("type") == RESULT_TYPE_ABORT @@ -154,7 +169,14 @@ async def test_zeroconf_confirm_connection_error( CONF_HOST: "example.com", CONF_NAME: "test", }, - data={"host": "192.168.1.123", "hostname": "example.com.", "properties": {}}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.123", + hostname="example.com.", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), ) assert result.get("type") == RESULT_TYPE_ABORT @@ -216,11 +238,14 @@ async def test_zeroconf_with_mac_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={ - "host": "192.168.1.123", - "hostname": "example.local.", - "properties": {CONF_MAC: "AA:BB:CC:DD:EE:FF"}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.123", + hostname="example.local.", + name="mock_name", + port=None, + properties={CONF_MAC: "AA:BB:CC:DD:EE:FF"}, + type="mock_type", + ), ) assert result.get("type") == RESULT_TYPE_ABORT diff --git a/tests/components/motioneye/__init__.py b/tests/components/motioneye/__init__.py index dcc030e7e5b..7558e6fbcc4 100644 --- a/tests/components/motioneye/__init__.py +++ b/tests/components/motioneye/__init__.py @@ -6,11 +6,13 @@ from unittest.mock import AsyncMock, Mock, patch from motioneye_client.const import DEFAULT_PORT +from homeassistant.components.motioneye import get_motioneye_entity_unique_id from homeassistant.components.motioneye.const import DOMAIN from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry @@ -23,7 +25,7 @@ TEST_CAMERA_DEVICE_IDENTIFIER = (DOMAIN, f"{TEST_CONFIG_ENTRY_ID}_{TEST_CAMERA_I TEST_CAMERA = { "show_frame_changes": False, "framerate": 25, - "actions": [], + "actions": ["one", "two", "three"], "preserve_movies": 0, "auto_threshold_tuning": True, "recording_mode": "motion-triggered", @@ -133,6 +135,7 @@ TEST_CAMERA = { } TEST_CAMERAS = {"cameras": [TEST_CAMERA]} TEST_SURVEILLANCE_USERNAME = "surveillance_username" +TEST_SENSOR_ACTION_ENTITY_ID = "sensor.test_camera_actions" TEST_SWITCH_ENTITY_ID_BASE = "switch.test_camera" TEST_SWITCH_MOTION_DETECTION_ENTITY_ID = ( f"{TEST_SWITCH_ENTITY_ID_BASE}_motion_detection" @@ -176,7 +179,10 @@ async def setup_mock_motioneye_config_entry( await async_process_ha_core_config( hass, - {"external_url": "https://example.com"}, + { + "internal_url": "https://internal.url", + "external_url": "https://external.url", + }, ) config_entry = config_entry or create_mock_motioneye_config_entry(hass) @@ -189,3 +195,23 @@ async def setup_mock_motioneye_config_entry( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return config_entry + + +def register_test_entity( + hass: HomeAssistant, platform: str, camera_id: int, type_name: str, entity_id: str +) -> None: + """Register a test entity.""" + + unique_id = get_motioneye_entity_unique_id( + TEST_CONFIG_ENTRY_ID, camera_id, type_name + ) + entity_id = entity_id.split(".")[1] + + entity_registry = er.async_get(hass) + entity_registry.async_get_or_create( + platform, + DOMAIN, + unique_id, + suggested_object_id=entity_id, + disabled_by=None, + ) diff --git a/tests/components/motioneye/test_camera.py b/tests/components/motioneye/test_camera.py index b2264e78556..15462f6c592 100644 --- a/tests/components/motioneye/test_camera.py +++ b/tests/components/motioneye/test_camera.py @@ -1,31 +1,44 @@ """Test the motionEye camera.""" import copy from typing import Any, cast -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, call from aiohttp import web from aiohttp.web_exceptions import HTTPBadGateway from motioneye_client.client import ( MotionEyeClientError, MotionEyeClientInvalidAuthError, + MotionEyeClientURLParseError, ) from motioneye_client.const import ( KEY_CAMERAS, KEY_MOTION_DETECTION, KEY_NAME, + KEY_TEXT_OVERLAY_CUSTOM_TEXT, + KEY_TEXT_OVERLAY_CUSTOM_TEXT_LEFT, + KEY_TEXT_OVERLAY_CUSTOM_TEXT_RIGHT, + KEY_TEXT_OVERLAY_LEFT, + KEY_TEXT_OVERLAY_RIGHT, + KEY_TEXT_OVERLAY_TIMESTAMP, KEY_VIDEO_STREAMING, ) import pytest +import voluptuous as vol from homeassistant.components.camera import async_get_image, async_get_mjpeg_stream from homeassistant.components.motioneye import get_motioneye_device_identifier from homeassistant.components.motioneye.const import ( + CONF_ACTION, + CONF_STREAM_URL_TEMPLATE, CONF_SURVEILLANCE_USERNAME, DEFAULT_SCAN_INTERVAL, DOMAIN, MOTIONEYE_MANUFACTURER, + SERVICE_ACTION, + SERVICE_SET_TEXT_OVERLAY, + SERVICE_SNAPSHOT, ) -from homeassistant.const import CONF_URL +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -33,6 +46,7 @@ from homeassistant.helpers.device_registry import async_get_registry import homeassistant.util.dt as dt_util from . import ( + TEST_CAMERA, TEST_CAMERA_DEVICE_IDENTIFIER, TEST_CAMERA_ENTITY_ID, TEST_CAMERA_ID, @@ -61,7 +75,7 @@ async def test_setup_camera(hass: HomeAssistant) -> None: entity_state = hass.states.get(TEST_CAMERA_ENTITY_ID) assert entity_state - assert entity_state.state == "idle" + assert entity_state.state == "streaming" assert entity_state.attributes.get("friendly_name") == TEST_CAMERA_NAME @@ -178,7 +192,7 @@ async def test_setup_camera_new_data_without_streaming(hass: HomeAssistant) -> N await setup_mock_motioneye_config_entry(hass, client=client) entity_state = hass.states.get(TEST_CAMERA_ENTITY_ID) assert entity_state - assert entity_state.state == "idle" + assert entity_state.state == "streaming" cameras = copy.deepcopy(TEST_CAMERAS) cameras[KEY_CAMERAS][0][KEY_VIDEO_STREAMING] = False @@ -220,12 +234,12 @@ async def test_get_still_image_from_camera( server = await aiohttp_server(app) client = create_mock_motioneye_client() client.get_camera_snapshot_url = Mock( - return_value=f"http://localhost:{server.port}/foo" + return_value=f"http://127.0.0.1:{server.port}/foo" ) config_entry = create_mock_motioneye_config_entry( hass, data={ - CONF_URL: f"http://localhost:{server.port}", + CONF_URL: f"http://127.0.0.1:{server.port}", CONF_SURVEILLANCE_USERNAME: TEST_SURVEILLANCE_USERNAME, }, ) @@ -320,3 +334,212 @@ async def test_device_info(hass: HomeAssistant) -> None: for entry in er.async_entries_for_device(entity_registry, device.id) ] assert TEST_CAMERA_ENTITY_ID in entities_from_device + + +async def test_camera_option_stream_url_template( + aiohttp_server: Any, hass: HomeAssistant +) -> None: + """Verify camera with a stream URL template option.""" + client = create_mock_motioneye_client() + + stream_handler = Mock(return_value="") + app = web.Application() + app.add_routes([web.get(f"/{TEST_CAMERA_NAME}/{TEST_CAMERA_ID}", stream_handler)]) + stream_server = await aiohttp_server(app) + + client = create_mock_motioneye_client() + + config_entry = create_mock_motioneye_config_entry( + hass, + data={ + CONF_URL: f"http://localhost:{stream_server.port}", + # The port won't be used as the client is a mock. + CONF_SURVEILLANCE_USERNAME: TEST_SURVEILLANCE_USERNAME, + }, + options={ + CONF_STREAM_URL_TEMPLATE: ( + f"http://localhost:{stream_server.port}/" "{{ name }}/{{ id }}" + ) + }, + ) + + await setup_mock_motioneye_config_entry( + hass, config_entry=config_entry, client=client + ) + await hass.async_block_till_done() + + # It won't actually get a stream from the dummy handler, so just catch + # the expected exception, then verify the right handler was called. + with pytest.raises(HTTPBadGateway): + await async_get_mjpeg_stream(hass, Mock(), TEST_CAMERA_ENTITY_ID) + assert stream_handler.called + assert not client.get_camera_stream_url.called + + +async def test_get_stream_from_camera_with_broken_host( + aiohttp_server: Any, hass: HomeAssistant +) -> None: + """Test getting a stream with a broken URL (no host).""" + + client = create_mock_motioneye_client() + config_entry = create_mock_motioneye_config_entry(hass, data={CONF_URL: "http://"}) + client.get_camera_stream_url = Mock(side_effect=MotionEyeClientURLParseError) + + await setup_mock_motioneye_config_entry( + hass, config_entry=config_entry, client=client + ) + await hass.async_block_till_done() + with pytest.raises(HTTPBadGateway): + await async_get_mjpeg_stream(hass, Mock(), TEST_CAMERA_ENTITY_ID) + + +async def test_set_text_overlay_bad_extra_key(hass: HomeAssistant) -> None: + """Test text overlay with incorrect input data.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + data = {ATTR_ENTITY_ID: TEST_CAMERA_ENTITY_ID, "extra_key": "foo"} + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, data) + + +async def test_set_text_overlay_bad_entity_identifier(hass: HomeAssistant) -> None: + """Test text overlay with bad entity identifier.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + data = { + ATTR_ENTITY_ID: "some random string", + KEY_TEXT_OVERLAY_LEFT: KEY_TEXT_OVERLAY_TIMESTAMP, + } + + client.reset_mock() + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, data) + await hass.async_block_till_done() + + +async def test_set_text_overlay_bad_empty(hass: HomeAssistant) -> None: + """Test text overlay with incorrect input data.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, {}) + await hass.async_block_till_done() + + +async def test_set_text_overlay_bad_no_left_or_right(hass: HomeAssistant) -> None: + """Test text overlay with incorrect input data.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + data = {ATTR_ENTITY_ID: TEST_CAMERA_ENTITY_ID} + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, data) + await hass.async_block_till_done() + + +async def test_set_text_overlay_good(hass: HomeAssistant) -> None: + """Test a working text overlay.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + custom_left_text = "one\ntwo\nthree" + custom_right_text = "four\nfive\nsix" + data = { + ATTR_ENTITY_ID: TEST_CAMERA_ENTITY_ID, + KEY_TEXT_OVERLAY_LEFT: KEY_TEXT_OVERLAY_CUSTOM_TEXT, + KEY_TEXT_OVERLAY_RIGHT: KEY_TEXT_OVERLAY_CUSTOM_TEXT, + KEY_TEXT_OVERLAY_CUSTOM_TEXT_LEFT: custom_left_text, + KEY_TEXT_OVERLAY_CUSTOM_TEXT_RIGHT: custom_right_text, + } + client.async_get_camera = AsyncMock(return_value=copy.deepcopy(TEST_CAMERA)) + + await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, data) + await hass.async_block_till_done() + assert client.async_get_camera.called + + expected_camera = copy.deepcopy(TEST_CAMERA) + expected_camera[KEY_TEXT_OVERLAY_LEFT] = KEY_TEXT_OVERLAY_CUSTOM_TEXT + expected_camera[KEY_TEXT_OVERLAY_RIGHT] = KEY_TEXT_OVERLAY_CUSTOM_TEXT + expected_camera[KEY_TEXT_OVERLAY_CUSTOM_TEXT_LEFT] = "one\\ntwo\\nthree" + expected_camera[KEY_TEXT_OVERLAY_CUSTOM_TEXT_RIGHT] = "four\\nfive\\nsix" + assert client.async_set_camera.call_args == call(TEST_CAMERA_ID, expected_camera) + + +async def test_set_text_overlay_good_entity_id(hass: HomeAssistant) -> None: + """Test a working text overlay with entity_id.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + data = { + ATTR_ENTITY_ID: TEST_CAMERA_ENTITY_ID, + KEY_TEXT_OVERLAY_LEFT: KEY_TEXT_OVERLAY_TIMESTAMP, + } + client.async_get_camera = AsyncMock(return_value=copy.deepcopy(TEST_CAMERA)) + await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, data) + await hass.async_block_till_done() + assert client.async_get_camera.called + + expected_camera = copy.deepcopy(TEST_CAMERA) + expected_camera[KEY_TEXT_OVERLAY_LEFT] = KEY_TEXT_OVERLAY_TIMESTAMP + assert client.async_set_camera.call_args == call(TEST_CAMERA_ID, expected_camera) + + +async def test_set_text_overlay_bad_device(hass: HomeAssistant) -> None: + """Test a working text overlay.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + data = { + ATTR_DEVICE_ID: "not a device", + KEY_TEXT_OVERLAY_LEFT: KEY_TEXT_OVERLAY_TIMESTAMP, + } + client.reset_mock() + client.async_get_camera = AsyncMock(return_value=copy.deepcopy(TEST_CAMERA)) + await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, data) + await hass.async_block_till_done() + assert not client.async_get_camera.called + assert not client.async_set_camera.called + + +async def test_set_text_overlay_no_such_camera(hass: HomeAssistant) -> None: + """Test a working text overlay.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + data = { + ATTR_ENTITY_ID: TEST_CAMERA_ENTITY_ID, + KEY_TEXT_OVERLAY_LEFT: KEY_TEXT_OVERLAY_TIMESTAMP, + } + client.reset_mock() + client.async_get_camera = AsyncMock(return_value={}) + await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, data) + await hass.async_block_till_done() + assert not client.async_set_camera.called + + +async def test_request_action(hass: HomeAssistant) -> None: + """Test requesting an action.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + data = { + ATTR_ENTITY_ID: TEST_CAMERA_ENTITY_ID, + CONF_ACTION: "foo", + } + await hass.services.async_call(DOMAIN, SERVICE_ACTION, data) + await hass.async_block_till_done() + assert client.async_action.call_args == call(TEST_CAMERA_ID, data[CONF_ACTION]) + + +async def test_request_snapshot(hass: HomeAssistant) -> None: + """Test requesting a snapshot.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + data = {ATTR_ENTITY_ID: TEST_CAMERA_ENTITY_ID} + + await hass.services.async_call(DOMAIN, SERVICE_SNAPSHOT, data) + await hass.async_block_till_done() + assert client.async_action.call_args == call(TEST_CAMERA_ID, "snapshot") diff --git a/tests/components/motioneye/test_config_flow.py b/tests/components/motioneye/test_config_flow.py index 591bbaa4c7d..57def069d59 100644 --- a/tests/components/motioneye/test_config_flow.py +++ b/tests/components/motioneye/test_config_flow.py @@ -8,9 +8,11 @@ from motioneye_client.client import ( ) from homeassistant import config_entries, data_entry_flow +from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.motioneye.const import ( CONF_ADMIN_PASSWORD, CONF_ADMIN_USERNAME, + CONF_STREAM_URL_TEMPLATE, CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_USERNAME, CONF_WEBHOOK_SET, @@ -73,7 +75,7 @@ async def test_hassio_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data={"addon": "motionEye", "url": TEST_URL}, + data=HassioServiceInfo(config={"addon": "motionEye", "url": TEST_URL}), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -348,7 +350,7 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data={"addon": "motionEye", "url": TEST_URL}, + data=HassioServiceInfo(config={"addon": "motionEye", "url": TEST_URL}), context={"source": config_entries.SOURCE_HASSIO}, ) assert result.get("type") == data_entry_flow.RESULT_TYPE_ABORT @@ -363,7 +365,7 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data={"addon": "motionEye", "url": TEST_URL}, + data=HassioServiceInfo(config={"addon": "motionEye", "url": TEST_URL}), context={"source": config_entries.SOURCE_HASSIO}, ) assert result.get("type") == data_entry_flow.RESULT_TYPE_ABORT @@ -379,7 +381,7 @@ async def test_hassio_abort_if_already_in_progress(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, - data={"addon": "motionEye", "url": TEST_URL}, + data=HassioServiceInfo(config={"addon": "motionEye", "url": TEST_URL}), context={"source": config_entries.SOURCE_HASSIO}, ) assert result2.get("type") == data_entry_flow.RESULT_TYPE_ABORT @@ -391,7 +393,7 @@ async def test_hassio_clean_up_on_user_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data={"addon": "motionEye", "url": TEST_URL}, + data=HassioServiceInfo(config={"addon": "motionEye", "url": TEST_URL}), context={"source": config_entries.SOURCE_HASSIO}, ) assert result.get("type") == data_entry_flow.RESULT_TYPE_FORM @@ -460,3 +462,39 @@ async def test_options(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["data"][CONF_WEBHOOK_SET] assert result["data"][CONF_WEBHOOK_SET_OVERWRITE] + assert CONF_STREAM_URL_TEMPLATE not in result["data"] + + +async def test_advanced_options(hass: HomeAssistant) -> None: + """Check an options flow with advanced options.""" + + config_entry = create_mock_motioneye_config_entry(hass) + + mock_client = create_mock_motioneye_client() + with patch( + "homeassistant.components.motioneye.MotionEyeClient", + return_value=mock_client, + ) as mock_setup, patch( + "homeassistant.components.motioneye.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": True} + ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_WEBHOOK_SET: True, + CONF_WEBHOOK_SET_OVERWRITE: True, + CONF_STREAM_URL_TEMPLATE: "http://moo", + }, + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_WEBHOOK_SET] + assert result["data"][CONF_WEBHOOK_SET_OVERWRITE] + assert result["data"][CONF_STREAM_URL_TEMPLATE] == "http://moo" + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/motioneye/test_media_source.py b/tests/components/motioneye/test_media_source.py new file mode 100644 index 00000000000..f2c8db3879b --- /dev/null +++ b/tests/components/motioneye/test_media_source.py @@ -0,0 +1,482 @@ +"""Test Local Media Source.""" +import logging +from unittest.mock import AsyncMock, Mock, call + +from motioneye_client.client import MotionEyeClientPathError +import pytest + +from homeassistant.components import media_source +from homeassistant.components.media_source import const +from homeassistant.components.media_source.error import MediaSourceError, Unresolvable +from homeassistant.components.media_source.models import PlayMedia +from homeassistant.components.motioneye.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import ( + TEST_CAMERA_DEVICE_IDENTIFIER, + TEST_CAMERA_ID, + TEST_CONFIG_ENTRY_ID, + create_mock_motioneye_client, + setup_mock_motioneye_config_entry, +) + +TEST_MOVIES = { + "mediaList": [ + { + "mimeType": "video/mp4", + "sizeStr": "4.7 MB", + "momentStrShort": "25 Apr, 00:26", + "timestamp": 1619335614.0353653, + "momentStr": "25 April 2021, 00:26", + "path": "/2021-04-25/00-26-22.mp4", + }, + { + "mimeType": "video/mp4", + "sizeStr": "9.2 MB", + "momentStrShort": "25 Apr, 00:37", + "timestamp": 1619336268.0683491, + "momentStr": "25 April 2021, 00:37", + "path": "/2021-04-25/00-36-49.mp4", + }, + { + "mimeType": "video/mp4", + "sizeStr": "28.3 MB", + "momentStrShort": "25 Apr, 00:03", + "timestamp": 1619334211.0403328, + "momentStr": "25 April 2021, 00:03", + "path": "/2021-04-25/00-02-27.mp4", + }, + ] +} + +TEST_IMAGES = { + "mediaList": [ + { + "mimeType": "image/jpeg", + "sizeStr": "216.5 kB", + "momentStrShort": "12 Apr, 20:13", + "timestamp": 1618283619.6541321, + "momentStr": "12 April 2021, 20:13", + "path": "/2021-04-12/20-13-39.jpg", + } + ], +} + + +_LOGGER = logging.getLogger(__name__) + + +async def test_async_browse_media_success(hass: HomeAssistant) -> None: + """Test successful browse media.""" + + client = create_mock_motioneye_client() + config = await setup_mock_motioneye_config_entry(hass, client=client) + + device_registry = await dr.async_get_registry(hass) + device = device_registry.async_get_or_create( + config_entry_id=config.entry_id, + identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}, + ) + + media = await media_source.async_browse_media( + hass, + f"{const.URI_SCHEME}{DOMAIN}", + ) + + assert media.as_dict() == { + "title": "motionEye Media", + "media_class": "directory", + "media_content_type": "", + "media_content_id": "media-source://motioneye", + "can_play": False, + "can_expand": True, + "children_media_class": "directory", + "thumbnail": None, + "children": [ + { + "title": "http://test:8766", + "media_class": "directory", + "media_content_type": "", + "media_content_id": ( + "media-source://motioneye/74565ad414754616000674c87bdc876c" + ), + "can_play": False, + "can_expand": True, + "children_media_class": "directory", + "thumbnail": None, + } + ], + } + + media = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{config.entry_id}" + ) + + assert media.as_dict() == { + "title": "http://test:8766", + "media_class": "directory", + "media_content_type": "", + "media_content_id": ( + "media-source://motioneye/74565ad414754616000674c87bdc876c" + ), + "can_play": False, + "can_expand": True, + "children_media_class": "directory", + "thumbnail": None, + "children": [ + { + "title": "Test Camera", + "media_class": "directory", + "media_content_type": "", + "media_content_id": ( + "media-source://motioneye" + f"/74565ad414754616000674c87bdc876c#{device.id}" + ), + "can_play": False, + "can_expand": True, + "children_media_class": "directory", + "thumbnail": None, + } + ], + } + + media = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{config.entry_id}#{device.id}" + ) + assert media.as_dict() == { + "title": "http://test:8766 Test Camera", + "media_class": "directory", + "media_content_type": "", + "media_content_id": ( + f"media-source://motioneye/74565ad414754616000674c87bdc876c#{device.id}" + ), + "can_play": False, + "can_expand": True, + "children_media_class": "directory", + "thumbnail": None, + "children": [ + { + "title": "Movies", + "media_class": "directory", + "media_content_type": "video", + "media_content_id": ( + "media-source://motioneye" + f"/74565ad414754616000674c87bdc876c#{device.id}#movies" + ), + "can_play": False, + "can_expand": True, + "children_media_class": "video", + "thumbnail": None, + }, + { + "title": "Images", + "media_class": "directory", + "media_content_type": "image", + "media_content_id": ( + "media-source://motioneye" + f"/74565ad414754616000674c87bdc876c#{device.id}#images" + ), + "can_play": False, + "can_expand": True, + "children_media_class": "image", + "thumbnail": None, + }, + ], + } + + client.async_get_movies = AsyncMock(return_value=TEST_MOVIES) + media = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{config.entry_id}#{device.id}#movies" + ) + + assert media.as_dict() == { + "title": "http://test:8766 Test Camera Movies", + "media_class": "directory", + "media_content_type": "video", + "media_content_id": ( + "media-source://motioneye" + f"/74565ad414754616000674c87bdc876c#{device.id}#movies" + ), + "can_play": False, + "can_expand": True, + "children_media_class": "video", + "thumbnail": None, + "children": [ + { + "title": "2021-04-25", + "media_class": "directory", + "media_content_type": "video", + "media_content_id": ( + "media-source://motioneye" + f"/74565ad414754616000674c87bdc876c#{device.id}#movies#/2021-04-25" + ), + "can_play": False, + "can_expand": True, + "children_media_class": "directory", + "thumbnail": None, + } + ], + } + + client.get_movie_url = Mock(return_value="http://movie") + media = await media_source.async_browse_media( + hass, + f"{const.URI_SCHEME}{DOMAIN}/{config.entry_id}#{device.id}#movies#/2021-04-25", + ) + assert media.as_dict() == { + "title": "http://test:8766 Test Camera Movies 2021-04-25", + "media_class": "directory", + "media_content_type": "video", + "media_content_id": ( + "media-source://motioneye" + f"/74565ad414754616000674c87bdc876c#{device.id}#movies" + ), + "can_play": False, + "can_expand": True, + "children_media_class": "video", + "thumbnail": None, + "children": [ + { + "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-26-22.mp4" + ), + "can_play": True, + "can_expand": False, + "children_media_class": None, + "thumbnail": "http://movie", + }, + { + "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-36-49.mp4" + ), + "can_play": True, + "can_expand": False, + "children_media_class": None, + "thumbnail": "http://movie", + }, + { + "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-02-27.mp4" + ), + "can_play": True, + "can_expand": False, + "children_media_class": None, + "thumbnail": "http://movie", + }, + ], + } + + +async def test_async_browse_media_images_success(hass: HomeAssistant) -> None: + """Test successful browse media of images.""" + + client = create_mock_motioneye_client() + config = await setup_mock_motioneye_config_entry(hass, client=client) + + device_registry = await dr.async_get_registry(hass) + device = device_registry.async_get_or_create( + config_entry_id=config.entry_id, + identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}, + ) + + client.async_get_images = AsyncMock(return_value=TEST_IMAGES) + client.get_image_url = Mock(return_value="http://image") + + media = await media_source.async_browse_media( + hass, + f"{const.URI_SCHEME}{DOMAIN}/{config.entry_id}#{device.id}#images#/2021-04-12", + ) + assert media.as_dict() == { + "title": "http://test:8766 Test Camera Images 2021-04-12", + "media_class": "directory", + "media_content_type": "image", + "media_content_id": ( + "media-source://motioneye" + f"/74565ad414754616000674c87bdc876c#{device.id}#images" + ), + "can_play": False, + "can_expand": True, + "children_media_class": "image", + "thumbnail": None, + "children": [ + { + "title": "20-13-39.jpg", + "media_class": "image", + "media_content_type": "image/jpeg", + "media_content_id": ( + "media-source://motioneye" + f"/74565ad414754616000674c87bdc876c#{device.id}#images#" + "/2021-04-12/20-13-39.jpg" + ), + "can_play": False, + "can_expand": False, + "children_media_class": None, + "thumbnail": "http://image", + } + ], + } + + +async def test_async_resolve_media_success(hass: HomeAssistant) -> None: + """Test successful resolve media.""" + + client = create_mock_motioneye_client() + + config = await setup_mock_motioneye_config_entry(hass, client=client) + + device_registry = await dr.async_get_registry(hass) + device = device_registry.async_get_or_create( + config_entry_id=config.entry_id, + identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}, + ) + + # Test successful resolve for a movie. + client.get_movie_url = Mock(return_value="http://movie-url") + media = await media_source.async_resolve_media( + hass, + ( + f"{const.URI_SCHEME}{DOMAIN}" + f"/{TEST_CONFIG_ENTRY_ID}#{device.id}#movies#/foo.mp4" + ), + ) + assert media == PlayMedia(url="http://movie-url", mime_type="video/mp4") + assert client.get_movie_url.call_args == call(TEST_CAMERA_ID, "/foo.mp4") + + # Test successful resolve for an image. + client.get_image_url = Mock(return_value="http://image-url") + media = await media_source.async_resolve_media( + hass, + ( + f"{const.URI_SCHEME}{DOMAIN}" + f"/{TEST_CONFIG_ENTRY_ID}#{device.id}#images#/foo.jpg" + ), + ) + assert media == PlayMedia(url="http://image-url", mime_type="image/jpeg") + assert client.get_image_url.call_args == call(TEST_CAMERA_ID, "/foo.jpg") + + +async def test_async_resolve_media_failure(hass: HomeAssistant) -> None: + """Test failed resolve media calls.""" + + client = create_mock_motioneye_client() + + config = await setup_mock_motioneye_config_entry(hass, client=client) + + device_registry = await dr.async_get_registry(hass) + device = device_registry.async_get_or_create( + config_entry_id=config.entry_id, + identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}, + ) + + broken_device_1 = device_registry.async_get_or_create( + config_entry_id=config.entry_id, + identifiers={(DOMAIN, config.entry_id)}, + ) + broken_device_2 = device_registry.async_get_or_create( + config_entry_id=config.entry_id, + identifiers={(DOMAIN, f"{config.entry_id}_NOTINT")}, + ) + client.get_movie_url = Mock(return_value="http://url") + + # URI doesn't contain necessary components. + with pytest.raises(Unresolvable): + await media_source.async_resolve_media(hass, f"{const.URI_SCHEME}{DOMAIN}/foo") + + # Config entry doesn't exist. + with pytest.raises(MediaSourceError): + await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/1#2#3#4" + ) + + # Device doesn't exist. + with pytest.raises(MediaSourceError): + await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{TEST_CONFIG_ENTRY_ID}#2#3#4" + ) + + # Device identifiers are incorrect (no camera id) + with pytest.raises(MediaSourceError): + await media_source.async_resolve_media( + hass, + ( + f"{const.URI_SCHEME}{DOMAIN}" + f"/{TEST_CONFIG_ENTRY_ID}#{broken_device_1.id}#images#4" + ), + ) + + # Device identifiers are incorrect (non integer camera id) + with pytest.raises(MediaSourceError): + await media_source.async_resolve_media( + hass, + ( + f"{const.URI_SCHEME}{DOMAIN}" + f"/{TEST_CONFIG_ENTRY_ID}#{broken_device_2.id}#images#4" + ), + ) + + # Kind is incorrect. + with pytest.raises(MediaSourceError): + await media_source.async_resolve_media( + hass, + f"{const.URI_SCHEME}{DOMAIN}/{TEST_CONFIG_ENTRY_ID}#{device.id}#games#moo", + ) + + # Playback URL raises exception. + client.get_movie_url = Mock(side_effect=MotionEyeClientPathError) + with pytest.raises(Unresolvable): + await media_source.async_resolve_media( + hass, + ( + f"{const.URI_SCHEME}{DOMAIN}" + f"/{TEST_CONFIG_ENTRY_ID}#{device.id}#movies#/foo.mp4" + ), + ) + + # Media path does not start with '/' + client.get_movie_url = Mock(side_effect=MotionEyeClientPathError) + with pytest.raises(MediaSourceError): + await media_source.async_resolve_media( + hass, + ( + f"{const.URI_SCHEME}{DOMAIN}" + f"/{TEST_CONFIG_ENTRY_ID}#{device.id}#movies#foo.mp4" + ), + ) + + # Media missing path. + broken_movies = {"mediaList": [{}, {"path": "something", "mimeType": "NOT_A_MIME"}]} + client.async_get_movies = AsyncMock(return_value=broken_movies) + media = await media_source.async_browse_media( + hass, + f"{const.URI_SCHEME}{DOMAIN}/{config.entry_id}#{device.id}#movies#/2021-04-25", + ) + assert media.as_dict() == { + "title": "http://test:8766 Test Camera Movies 2021-04-25", + "media_class": "directory", + "media_content_type": "video", + "media_content_id": ( + f"media-source://motioneye" + f"/74565ad414754616000674c87bdc876c#{device.id}#movies" + ), + "can_play": False, + "can_expand": True, + "children_media_class": "video", + "thumbnail": None, + "children": [], + } diff --git a/tests/components/motioneye/test_sensor.py b/tests/components/motioneye/test_sensor.py new file mode 100644 index 00000000000..474b8690308 --- /dev/null +++ b/tests/components/motioneye/test_sensor.py @@ -0,0 +1,132 @@ +"""Tests for the motionEye switch platform.""" +import copy +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from motioneye_client.const import KEY_ACTIONS + +from homeassistant.components.motioneye import get_motioneye_device_identifier +from homeassistant.components.motioneye.const import ( + DEFAULT_SCAN_INTERVAL, + TYPE_MOTIONEYE_ACTION_SENSOR, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +import homeassistant.util.dt as dt_util + +from . import ( + TEST_CAMERA, + TEST_CAMERA_ID, + TEST_SENSOR_ACTION_ENTITY_ID, + create_mock_motioneye_client, + register_test_entity, + setup_mock_motioneye_config_entry, +) + +from tests.common import async_fire_time_changed + + +async def test_sensor_actions(hass: HomeAssistant) -> None: + """Test the actions sensor.""" + register_test_entity( + hass, + SENSOR_DOMAIN, + TEST_CAMERA_ID, + TYPE_MOTIONEYE_ACTION_SENSOR, + TEST_SENSOR_ACTION_ENTITY_ID, + ) + + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + entity_state = hass.states.get(TEST_SENSOR_ACTION_ENTITY_ID) + assert entity_state + assert entity_state.state == "3" + assert entity_state.attributes.get(KEY_ACTIONS) == ["one", "two", "three"] + + updated_camera = copy.deepcopy(TEST_CAMERA) + updated_camera[KEY_ACTIONS] = ["one"] + + # When the next refresh is called return the updated values. + client.async_get_cameras = AsyncMock(return_value={"cameras": [updated_camera]}) + async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + await hass.async_block_till_done() + + entity_state = hass.states.get(TEST_SENSOR_ACTION_ENTITY_ID) + assert entity_state + assert entity_state.state == "1" + assert entity_state.attributes.get(KEY_ACTIONS) == ["one"] + + del updated_camera[KEY_ACTIONS] + async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + await hass.async_block_till_done() + + entity_state = hass.states.get(TEST_SENSOR_ACTION_ENTITY_ID) + assert entity_state + assert entity_state.state == "0" + assert entity_state.attributes.get(KEY_ACTIONS) is None + + +async def test_sensor_device_info(hass: HomeAssistant) -> None: + """Verify device information includes expected details.""" + + # Enable the action sensor (it is disabled by default). + register_test_entity( + hass, + SENSOR_DOMAIN, + TEST_CAMERA_ID, + TYPE_MOTIONEYE_ACTION_SENSOR, + TEST_SENSOR_ACTION_ENTITY_ID, + ) + + config_entry = await setup_mock_motioneye_config_entry(hass) + + device_identifer = get_motioneye_device_identifier( + config_entry.entry_id, TEST_CAMERA_ID + ) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({device_identifer}) + assert device + + entity_registry = await er.async_get_registry(hass) + entities_from_device = [ + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, device.id) + ] + assert TEST_SENSOR_ACTION_ENTITY_ID in entities_from_device + + +async def test_sensor_actions_can_be_enabled(hass: HomeAssistant) -> None: + """Verify the action sensor can be enabled.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get(TEST_SENSOR_ACTION_ENTITY_ID) + assert entry + assert entry.disabled + assert entry.disabled_by == er.DISABLED_INTEGRATION + entity_state = hass.states.get(TEST_SENSOR_ACTION_ENTITY_ID) + assert not entity_state + + with patch( + "homeassistant.components.motioneye.MotionEyeClient", + return_value=client, + ): + updated_entry = entity_registry.async_update_entity( + TEST_SENSOR_ACTION_ENTITY_ID, disabled_by=None + ) + assert not updated_entry.disabled + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + entity_state = hass.states.get(TEST_SENSOR_ACTION_ENTITY_ID) + assert entity_state diff --git a/tests/components/motioneye/test_web_hooks.py b/tests/components/motioneye/test_web_hooks.py index cf4fe46c73a..c7aaa4a8638 100644 --- a/tests/components/motioneye/test_web_hooks.py +++ b/tests/components/motioneye/test_web_hooks.py @@ -2,11 +2,12 @@ import copy from http import HTTPStatus from typing import Any -from unittest.mock import AsyncMock, call, patch +from unittest.mock import AsyncMock, Mock, call, patch from motioneye_client.const import ( KEY_CAMERAS, KEY_HTTP_METHOD_POST_JSON, + KEY_ROOT_DIRECTORY, KEY_WEB_HOOK_NOTIFICATIONS_ENABLED, KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD, KEY_WEB_HOOK_NOTIFICATIONS_URL, @@ -18,6 +19,7 @@ from motioneye_client.const import ( from homeassistant.components.motioneye.const import ( ATTR_EVENT_TYPE, CONF_WEBHOOK_SET_OVERWRITE, + DEFAULT_SCAN_INTERVAL, DOMAIN, EVENT_FILE_STORED, EVENT_MOTION_DETECTED, @@ -28,6 +30,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.network import NoURLAvailableError from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util from . import ( TEST_CAMERA, @@ -36,13 +39,14 @@ from . import ( TEST_CAMERA_ID, TEST_CAMERA_NAME, TEST_CAMERAS, + TEST_CONFIG_ENTRY_ID, TEST_URL, create_mock_motioneye_client, create_mock_motioneye_config_entry, setup_mock_motioneye_config_entry, ) -from tests.common import async_capture_events +from tests.common import async_capture_events, async_fire_time_changed WEB_HOOK_MOTION_DETECTED_QUERY_STRING = ( "camera_id=%t&changed_pixels=%D&despeckle_labels=%Q&event=%v&fps=%{fps}" @@ -73,7 +77,7 @@ async def test_setup_camera_without_webhook(hass: HomeAssistant) -> None: expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_ENABLED] = True expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD] = KEY_HTTP_METHOD_POST_JSON expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_URL] = ( - "https://example.com" + "https://internal.url" + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]) + f"?{WEB_HOOK_MOTION_DETECTED_QUERY_STRING}&device_id={device.id}" ) @@ -81,7 +85,7 @@ async def test_setup_camera_without_webhook(hass: HomeAssistant) -> None: expected_camera[KEY_WEB_HOOK_STORAGE_ENABLED] = True expected_camera[KEY_WEB_HOOK_STORAGE_HTTP_METHOD] = KEY_HTTP_METHOD_POST_JSON expected_camera[KEY_WEB_HOOK_STORAGE_URL] = ( - "https://example.com" + "https://internal.url" + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]) + f"?{WEB_HOOK_FILE_STORED_QUERY_STRING}&device_id={device.id}" ) @@ -128,7 +132,7 @@ async def test_setup_camera_with_wrong_webhook( expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_ENABLED] = True expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD] = KEY_HTTP_METHOD_POST_JSON expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_URL] = ( - "https://example.com" + "https://internal.url" + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]) + f"?{WEB_HOOK_MOTION_DETECTED_QUERY_STRING}&device_id={device.id}" ) @@ -136,7 +140,7 @@ async def test_setup_camera_with_wrong_webhook( expected_camera[KEY_WEB_HOOK_STORAGE_ENABLED] = True expected_camera[KEY_WEB_HOOK_STORAGE_HTTP_METHOD] = KEY_HTTP_METHOD_POST_JSON expected_camera[KEY_WEB_HOOK_STORAGE_URL] = ( - "https://example.com" + "https://internal.url" + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]) + f"?{WEB_HOOK_FILE_STORED_QUERY_STRING}&device_id={device.id}" ) @@ -181,7 +185,7 @@ async def test_setup_camera_with_old_webhook( expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_ENABLED] = True expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD] = KEY_HTTP_METHOD_POST_JSON expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_URL] = ( - "https://example.com" + "https://internal.url" + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]) + f"?{WEB_HOOK_MOTION_DETECTED_QUERY_STRING}&device_id={device.id}" ) @@ -189,7 +193,7 @@ async def test_setup_camera_with_old_webhook( expected_camera[KEY_WEB_HOOK_STORAGE_ENABLED] = True expected_camera[KEY_WEB_HOOK_STORAGE_HTTP_METHOD] = KEY_HTTP_METHOD_POST_JSON expected_camera[KEY_WEB_HOOK_STORAGE_URL] = ( - "https://example.com" + "https://internal.url" + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]) + f"?{WEB_HOOK_FILE_STORED_QUERY_STRING}&device_id={device.id}" ) @@ -219,7 +223,7 @@ async def test_setup_camera_with_correct_webhook( KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD ] = KEY_HTTP_METHOD_POST_JSON cameras[KEY_CAMERAS][0][KEY_WEB_HOOK_NOTIFICATIONS_URL] = ( - "https://example.com" + "https://internal.url" + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]) + f"?{WEB_HOOK_MOTION_DETECTED_QUERY_STRING}&device_id={device.id}" ) @@ -228,7 +232,7 @@ async def test_setup_camera_with_correct_webhook( KEY_WEB_HOOK_STORAGE_HTTP_METHOD ] = KEY_HTTP_METHOD_POST_JSON cameras[KEY_CAMERAS][0][KEY_WEB_HOOK_STORAGE_URL] = ( - "https://example.com" + "https://internal.url" + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]) + f"?{WEB_HOOK_FILE_STORED_QUERY_STRING}&device_id={device.id}" ) @@ -368,3 +372,151 @@ async def test_bad_query_cannot_decode( assert resp.status == HTTPStatus.BAD_REQUEST assert not motion_events assert not storage_events + + +async def test_event_media_data(hass: HomeAssistant, hass_client_no_auth: Any) -> None: + """Test an event with a file path generates media data.""" + await async_setup_component(hass, "http", {"http": {}}) + + device_registry = await dr.async_get_registry(hass) + client = create_mock_motioneye_client() + config_entry = await setup_mock_motioneye_config_entry(hass, client=client) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}, + ) + + hass_client = await hass_client_no_auth() + + events = async_capture_events(hass, f"{DOMAIN}.{EVENT_FILE_STORED}") + + client.get_movie_url = Mock(return_value="http://movie-url") + client.get_image_url = Mock(return_value="http://image-url") + + # Test: Movie storage. + client.is_file_type_image = Mock(return_value=False) + resp = await hass_client.post( + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]), + json={ + ATTR_DEVICE_ID: device.id, + ATTR_EVENT_TYPE: EVENT_FILE_STORED, + "file_path": f"/var/lib/motioneye/{TEST_CAMERA_NAME}/dir/one", + "file_type": "8", + }, + ) + assert resp.status == HTTPStatus.OK + assert len(events) == 1 + assert events[-1].data["file_url"] == "http://movie-url" + assert ( + events[-1].data["media_content_id"] + == f"media-source://motioneye/{TEST_CONFIG_ENTRY_ID}#{device.id}#movies#/dir/one" + ) + assert client.get_movie_url.call_args == call(TEST_CAMERA_ID, "/dir/one") + + # Test: Image storage. + client.is_file_type_image = Mock(return_value=True) + resp = await hass_client.post( + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]), + json={ + ATTR_DEVICE_ID: device.id, + ATTR_EVENT_TYPE: EVENT_FILE_STORED, + "file_path": f"/var/lib/motioneye/{TEST_CAMERA_NAME}/dir/two", + "file_type": "4", + }, + ) + assert resp.status == HTTPStatus.OK + assert len(events) == 2 + assert events[-1].data["file_url"] == "http://image-url" + assert ( + events[-1].data["media_content_id"] + == f"media-source://motioneye/{TEST_CONFIG_ENTRY_ID}#{device.id}#images#/dir/two" + ) + assert client.get_image_url.call_args == call(TEST_CAMERA_ID, "/dir/two") + + # Test: Invalid file type. + resp = await hass_client.post( + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]), + json={ + ATTR_DEVICE_ID: device.id, + ATTR_EVENT_TYPE: EVENT_FILE_STORED, + "file_path": f"/var/lib/motioneye/{TEST_CAMERA_NAME}/dir/three", + "file_type": "NOT_AN_INT", + }, + ) + assert resp.status == HTTPStatus.OK + assert len(events) == 3 + assert "file_url" not in events[-1].data + assert "media_content_id" not in events[-1].data + + # Test: Different file path. + resp = await hass_client.post( + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]), + json={ + ATTR_DEVICE_ID: device.id, + ATTR_EVENT_TYPE: EVENT_FILE_STORED, + "file_path": "/var/random", + "file_type": "8", + }, + ) + assert resp.status == HTTPStatus.OK + assert len(events) == 4 + assert "file_url" not in events[-1].data + assert "media_content_id" not in events[-1].data + + # Test: Not a loaded motionEye config entry. + wrong_device = device_registry.async_get_or_create( + config_entry_id="wrong_config_id", identifiers={("motioneye", "a_1")} + ) + resp = await hass_client.post( + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]), + json={ + ATTR_DEVICE_ID: wrong_device.id, + ATTR_EVENT_TYPE: EVENT_FILE_STORED, + "file_path": "/var/random", + "file_type": "8", + }, + ) + assert resp.status == HTTPStatus.OK + assert len(events) == 5 + assert "file_url" not in events[-1].data + assert "media_content_id" not in events[-1].data + + # Test: No root directory. + camera = copy.deepcopy(TEST_CAMERA) + del camera[KEY_ROOT_DIRECTORY] + client.async_get_cameras = AsyncMock(return_value={"cameras": [camera]}) + async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + await hass.async_block_till_done() + + resp = await hass_client.post( + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]), + json={ + ATTR_DEVICE_ID: device.id, + ATTR_EVENT_TYPE: EVENT_FILE_STORED, + "file_path": f"/var/lib/motioneye/{TEST_CAMERA_NAME}/dir/four", + "file_type": "8", + }, + ) + assert resp.status == HTTPStatus.OK + assert len(events) == 6 + assert "file_url" not in events[-1].data + assert "media_content_id" not in events[-1].data + + # Test: Device has incorrect device identifiers. + device_registry.async_update_device( + device_id=device.id, new_identifiers={("not", "motioneye")} + ) + resp = await hass_client.post( + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]), + json={ + ATTR_DEVICE_ID: device.id, + ATTR_EVENT_TYPE: EVENT_FILE_STORED, + "file_path": f"/var/lib/motioneye/{TEST_CAMERA_NAME}/dir/five", + "file_type": "8", + }, + ) + assert resp.status == HTTPStatus.OK + assert len(events) == 7 + assert "file_url" not in events[-1].data + assert "media_content_id" not in events[-1].data diff --git a/tests/fixtures/mqtt/configuration.yaml b/tests/components/mqtt/fixtures/configuration.yaml similarity index 100% rename from tests/fixtures/mqtt/configuration.yaml rename to tests/components/mqtt/fixtures/configuration.yaml diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 1351ae59496..2a74a75c241 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -18,6 +18,7 @@ from homeassistant.const import ( SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_ARM_VACATION, SERVICE_ALARM_DISARM, + SERVICE_ALARM_TRIGGER, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, @@ -192,6 +193,7 @@ async def test_ignore_update_state_if_unknown_via_state_topic(hass, mqtt_mock): (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), (SERVICE_ALARM_DISARM, "DISARM"), + (SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_no_code(hass, mqtt_mock, service, payload): @@ -222,6 +224,7 @@ async def test_publish_mqtt_no_code(hass, mqtt_mock, service, payload): (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), (SERVICE_ALARM_DISARM, "DISARM"), + (SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_with_code(hass, mqtt_mock, service, payload): @@ -271,6 +274,7 @@ async def test_publish_mqtt_with_code(hass, mqtt_mock, service, payload): (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), (SERVICE_ALARM_DISARM, "DISARM"), + (SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_with_remote_code(hass, mqtt_mock, service, payload): @@ -311,6 +315,7 @@ async def test_publish_mqtt_with_remote_code(hass, mqtt_mock, service, payload): (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), (SERVICE_ALARM_DISARM, "DISARM"), + (SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_with_remote_code_text(hass, mqtt_mock, service, payload): @@ -351,6 +356,7 @@ async def test_publish_mqtt_with_remote_code_text(hass, mqtt_mock, service, payl (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION", "code_arm_required"), (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS", "code_arm_required"), (SERVICE_ALARM_DISARM, "DISARM", "code_disarm_required"), + (SERVICE_ALARM_TRIGGER, "TRIGGER", "code_trigger_required"), ], ) async def test_publish_mqtt_with_code_required_false( @@ -358,7 +364,8 @@ async def test_publish_mqtt_with_code_required_false( ): """Test publishing of MQTT messages when code is configured. - code_arm_required = False / code_disarm_required = false + code_arm_required = False / code_disarm_required = False / + code_trigger_required = False """ config = copy.deepcopy(DEFAULT_CONFIG_CODE) config[alarm_control_panel.DOMAIN][disable_code] = False diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index ba601fd094d..aebdc8b692e 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -373,6 +373,39 @@ async def test_setting_sensor_value_via_mqtt_message_and_template2( assert "template output: 'ILLEGAL'" in caplog.text +async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_encoding( + hass, mqtt_mock, caplog +): + """Test processing a raw value via MQTT.""" + assert await async_setup_component( + hass, + binary_sensor.DOMAIN, + { + binary_sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "encoding": "", + "state_topic": "test-topic", + "payload_on": "ON", + "payload_off": "OFF", + "value_template": "{%if value|unpack('b')-%}ON{%else%}OFF{%-endif-%}", + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF + + async_fire_mqtt_message(hass, "test-topic", b"\x01") + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_ON + + async_fire_mqtt_message(hass, "test-topic", b"\x00") + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF + + async def test_setting_sensor_value_via_mqtt_message_empty_template( hass, mqtt_mock, caplog ): diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py new file mode 100644 index 00000000000..5eb92db7767 --- /dev/null +++ b/tests/components/mqtt/test_button.py @@ -0,0 +1,323 @@ +"""The tests for the MQTT button platform.""" +import copy +from unittest.mock import patch + +import pytest + +from homeassistant.components import button +from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_UNKNOWN +from homeassistant.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_setting_blocked_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_update_with_json_attrs_bad_JSON, + help_test_update_with_json_attrs_not_dict, +) + +DEFAULT_CONFIG = { + button.DOMAIN: {"platform": "mqtt", "name": "test", "command_topic": "test-topic"} +} + + +@pytest.mark.freeze_time("2021-11-08 13:31:44+00:00") +async def test_sending_mqtt_commands(hass, mqtt_mock): + """Test the sending MQTT commands.""" + assert await async_setup_component( + hass, + button.DOMAIN, + { + button.DOMAIN: { + "command_topic": "command-topic", + "name": "test", + "object_id": "test_button", + "payload_press": "beer press", + "platform": "mqtt", + "qos": "2", + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("button.test_button") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "test" + + await hass.services.async_call( + button.DOMAIN, + button.SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_button"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", "beer press", 2, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("button.test_button") + assert state.state == "2021-11-08T13:31:44+00:00" + + +async def test_availability_when_connection_lost(hass, mqtt_mock): + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_availability_without_topic(hass, mqtt_mock): + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload(hass, mqtt_mock): + """Test availability by default payload with defined topic.""" + config = { + button.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + "payload_press": 1, + } + } + + await help_test_default_availability_payload( + hass, mqtt_mock, button.DOMAIN, config, True, "state-topic", "1" + ) + + +async def test_custom_availability_payload(hass, mqtt_mock): + """Test availability by custom payload with defined topic.""" + config = { + button.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + "payload_press": 1, + } + } + + await help_test_custom_availability_payload( + hass, mqtt_mock, button.DOMAIN, config, True, "state-topic", "1" + ) + + +async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG, None + ) + + +async def test_setting_attribute_with_template(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock, caplog, button.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock, caplog, button.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_discovery_update_attr(hass, mqtt_mock, caplog): + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, mqtt_mock, caplog, button.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_unique_id(hass, mqtt_mock): + """Test unique id option only creates one button per unique_id.""" + config = { + button.DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "platform": "mqtt", + "name": "Test 2", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + await help_test_unique_id(hass, mqtt_mock, button.DOMAIN, config) + + +async def test_discovery_removal_button(hass, mqtt_mock, caplog): + """Test removal of discovered button.""" + data = '{ "name": "test", "command_topic": "test_topic" }' + await help_test_discovery_removal(hass, mqtt_mock, caplog, button.DOMAIN, data) + + +async def test_discovery_update_button(hass, mqtt_mock, caplog): + """Test update of discovered button.""" + config1 = copy.deepcopy(DEFAULT_CONFIG[button.DOMAIN]) + config2 = copy.deepcopy(DEFAULT_CONFIG[button.DOMAIN]) + config1["name"] = "Beer" + config2["name"] = "Milk" + + await help_test_discovery_update( + hass, + mqtt_mock, + caplog, + button.DOMAIN, + config1, + config2, + ) + + +async def test_discovery_update_unchanged_button(hass, mqtt_mock, caplog): + """Test update of discovered button.""" + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + with patch( + "homeassistant.components.mqtt.button.MqttButton.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, button.DOMAIN, data1, discovery_update + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic" }' + await help_test_discovery_broken( + hass, mqtt_mock, caplog, button.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection(hass, mqtt_mock): + """Test MQTT button device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier(hass, mqtt_mock): + """Test MQTT button device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove(hass, mqtt_mock): + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock): + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_invalid_device_class(hass, mqtt_mock): + """Test device_class option with invalid value.""" + assert await async_setup_component( + hass, + button.DOMAIN, + { + button.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "device_class": "foobarnotreal", + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("button.test") + assert state is None + + +async def test_valid_device_class(hass, mqtt_mock): + """Test device_class option with valid values.""" + assert await async_setup_component( + hass, + button.DOMAIN, + { + button.DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "command_topic": "test-topic", + "device_class": "update", + }, + { + "platform": "mqtt", + "name": "Test 2", + "command_topic": "test-topic", + "device_class": "restart", + }, + { + "platform": "mqtt", + "name": "Test 3", + "command_topic": "test-topic", + }, + ] + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("button.test_1") + assert state.attributes["device_class"] == button.ButtonDeviceClass.UPDATE + state = hass.states.get("button.test_2") + assert state.attributes["device_class"] == button.ButtonDeviceClass.RESTART + state = hass.states.get("button.test_3") + assert "device_class" not in state.attributes diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 323d75ae091..61f04db99d9 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -8,6 +8,8 @@ import voluptuous as vol from homeassistant.components.climate import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP from homeassistant.components.climate.const import ( + ATTR_HVAC_ACTION, + CURRENT_HVAC_ACTIONS, DOMAIN as CLIMATE_DOMAIN, HVAC_MODE_AUTO, HVAC_MODE_COOL, @@ -432,6 +434,28 @@ async def test_receive_mqtt_temperature(hass, mqtt_mock): assert state.attributes.get("current_temperature") == 47 +async def test_handle_action_received(hass, mqtt_mock): + """Test getting the action received via MQTT.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["climate"]["action_topic"] = "action" + assert await async_setup_component(hass, CLIMATE_DOMAIN, config) + await hass.async_block_till_done() + + # Cycle through valid modes and also check for wrong input such as "None" (str(None)) + async_fire_mqtt_message(hass, "action", "None") + state = hass.states.get(ENTITY_CLIMATE) + hvac_action = state.attributes.get(ATTR_HVAC_ACTION) + assert hvac_action is None + # Redefine actions according to https://developers.home-assistant.io/docs/core/entity/climate/#hvac-action + actions = ["off", "heating", "cooling", "drying", "idle", "fan"] + assert all(elem in actions for elem in CURRENT_HVAC_ACTIONS) + for action in actions: + async_fire_mqtt_message(hass, "action", action) + state = hass.states.get(ENTITY_CLIMATE) + hvac_action = state.attributes.get(ATTR_HVAC_ACTION) + assert hvac_action == action + + async def test_set_away_mode_pessimistic(hass, mqtt_mock): """Test setting of the away mode.""" config = copy.deepcopy(DEFAULT_CONFIG) @@ -492,21 +516,6 @@ async def test_set_away_mode(hass, mqtt_mock): assert state.attributes.get("preset_mode") == "away" -async def test_set_hvac_action(hass, mqtt_mock): - """Test setting of the HVAC action.""" - config = copy.deepcopy(DEFAULT_CONFIG) - config["climate"]["action_topic"] = "action" - assert await async_setup_component(hass, CLIMATE_DOMAIN, config) - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get("hvac_action") is None - - async_fire_mqtt_message(hass, "action", "cool") - state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get("hvac_action") == "cool" - - async def test_set_hold_pessimistic(hass, mqtt_mock): """Test setting the hold mode in pessimistic mode.""" config = copy.deepcopy(DEFAULT_CONFIG) @@ -779,9 +788,9 @@ async def test_get_with_templates(hass, mqtt_mock, caplog): assert state.attributes.get("current_temperature") == 74656 # Action - async_fire_mqtt_message(hass, "action", '"cool"') + async_fire_mqtt_message(hass, "action", '"cooling"') state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get("hvac_action") == "cool" + assert state.attributes.get("hvac_action") == "cooling" async def test_set_with_templates(hass, mqtt_mock, caplog): diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 208541d702c..befdc139eeb 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant import config_entries, data_entry_flow from homeassistant.components import mqtt +from homeassistant.components.hassio import HassioServiceInfo from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -124,7 +125,9 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( mqtt.DOMAIN, - data={"addon": "Mosquitto", "host": "mock-mosquitto", "port": "1883"}, + data=HassioServiceInfo( + config={"addon": "Mosquitto", "host": "mock-mosquitto", "port": "1883"} + ), context={"source": config_entries.SOURCE_HASSIO}, ) assert result @@ -140,14 +143,16 @@ async def test_hassio_confirm( result = await hass.config_entries.flow.async_init( "mqtt", - data={ - "addon": "Mock Addon", - "host": "mock-broker", - "port": 1883, - "username": "mock-user", - "password": "mock-pass", - "protocol": "3.1.1", - }, + data=HassioServiceInfo( + config={ + "addon": "Mock Addon", + "host": "mock-broker", + "port": 1883, + "username": "mock-user", + "password": "mock-pass", + "protocol": "3.1.1", + } + ), context={"source": config_entries.SOURCE_HASSIO}, ) assert result["type"] == "form" diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 60c3961477b..de9150de1a2 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -191,6 +191,165 @@ async def test_discover_alarm_control_panel(hass, mqtt_mock, caplog): assert ("alarm_control_panel", "bla") in hass.data[ALREADY_DISCOVERED] +@pytest.mark.parametrize( + "topic, config, entity_id, name, domain", + [ + ( + "homeassistant/alarm_control_panel/object/bla/config", + '{ "name": "Hello World 1", "obj_id": "hello_id", "state_topic": "test-topic", "command_topic": "test-topic" }', + "alarm_control_panel.hello_id", + "Hello World 1", + "alarm_control_panel", + ), + ( + "homeassistant/binary_sensor/object/bla/config", + '{ "name": "Hello World 2", "obj_id": "hello_id", "state_topic": "test-topic" }', + "binary_sensor.hello_id", + "Hello World 2", + "binary_sensor", + ), + ( + "homeassistant/button/object/bla/config", + '{ "name": "Hello World button", "obj_id": "hello_id", "command_topic": "test-topic" }', + "button.hello_id", + "Hello World button", + "button", + ), + ( + "homeassistant/camera/object/bla/config", + '{ "name": "Hello World 3", "obj_id": "hello_id", "state_topic": "test-topic", "topic": "test-topic" }', + "camera.hello_id", + "Hello World 3", + "camera", + ), + ( + "homeassistant/climate/object/bla/config", + '{ "name": "Hello World 4", "obj_id": "hello_id", "state_topic": "test-topic" }', + "climate.hello_id", + "Hello World 4", + "climate", + ), + ( + "homeassistant/cover/object/bla/config", + '{ "name": "Hello World 5", "obj_id": "hello_id", "state_topic": "test-topic" }', + "cover.hello_id", + "Hello World 5", + "cover", + ), + ( + "homeassistant/fan/object/bla/config", + '{ "name": "Hello World 6", "obj_id": "hello_id", "state_topic": "test-topic", "command_topic": "test-topic" }', + "fan.hello_id", + "Hello World 6", + "fan", + ), + ( + "homeassistant/humidifier/object/bla/config", + '{ "name": "Hello World 7", "obj_id": "hello_id", "state_topic": "test-topic", "target_humidity_command_topic": "test-topic", "command_topic": "test-topic" }', + "humidifier.hello_id", + "Hello World 7", + "humidifier", + ), + ( + "homeassistant/number/object/bla/config", + '{ "name": "Hello World 8", "obj_id": "hello_id", "state_topic": "test-topic", "command_topic": "test-topic" }', + "number.hello_id", + "Hello World 8", + "number", + ), + ( + "homeassistant/scene/object/bla/config", + '{ "name": "Hello World 9", "obj_id": "hello_id", "state_topic": "test-topic", "command_topic": "test-topic" }', + "scene.hello_id", + "Hello World 9", + "scene", + ), + ( + "homeassistant/select/object/bla/config", + '{ "name": "Hello World 10", "obj_id": "hello_id", "state_topic": "test-topic", "options": [ "opt1", "opt2" ], "command_topic": "test-topic" }', + "select.hello_id", + "Hello World 10", + "select", + ), + ( + "homeassistant/sensor/object/bla/config", + '{ "name": "Hello World 11", "obj_id": "hello_id", "state_topic": "test-topic" }', + "sensor.hello_id", + "Hello World 11", + "sensor", + ), + ( + "homeassistant/switch/object/bla/config", + '{ "name": "Hello World 12", "obj_id": "hello_id", "state_topic": "test-topic", "command_topic": "test-topic" }', + "switch.hello_id", + "Hello World 12", + "switch", + ), + ( + "homeassistant/light/object/bla/config", + '{ "name": "Hello World 13", "obj_id": "hello_id", "state_topic": "test-topic", "command_topic": "test-topic" }', + "light.hello_id", + "Hello World 13", + "light", + ), + ( + "homeassistant/light/object/bla/config", + '{ "name": "Hello World 14", "obj_id": "hello_id", "state_topic": "test-topic", "command_topic": "test-topic", "schema": "json" }', + "light.hello_id", + "Hello World 14", + "light", + ), + ( + "homeassistant/light/object/bla/config", + '{ "name": "Hello World 15", "obj_id": "hello_id", "state_topic": "test-topic", "command_off_template": "template", "command_on_template": "template", "command_topic": "test-topic", "schema": "template" }', + "light.hello_id", + "Hello World 15", + "light", + ), + ( + "homeassistant/vacuum/object/bla/config", + '{ "name": "Hello World 16", "obj_id": "hello_id", "state_topic": "test-topic", "schema": "state" }', + "vacuum.hello_id", + "Hello World 16", + "vacuum", + ), + ( + "homeassistant/vacuum/object/bla/config", + '{ "name": "Hello World 17", "obj_id": "hello_id", "state_topic": "test-topic", "schema": "legacy" }', + "vacuum.hello_id", + "Hello World 17", + "vacuum", + ), + ( + "homeassistant/lock/object/bla/config", + '{ "name": "Hello World 18", "obj_id": "hello_id", "state_topic": "test-topic", "command_topic": "test-topic" }', + "lock.hello_id", + "Hello World 18", + "lock", + ), + ( + "homeassistant/device_tracker/object/bla/config", + '{ "name": "Hello World 19", "obj_id": "hello_id", "state_topic": "test-topic" }', + "device_tracker.hello_id", + "Hello World 19", + "device_tracker", + ), + ], +) +async def test_discovery_with_object_id( + hass, mqtt_mock, caplog, topic, config, entity_id, name, domain +): + """Test discovering an MQTT entity with object_id.""" + async_fire_mqtt_message(hass, topic, config) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state is not None + assert state.name == name + assert (domain, "object bla") in hass.data[ALREADY_DISCOVERED] + + async def test_discovery_incl_nodeid(hass, mqtt_mock, caplog): """Test sending in correct JSON with optional node_id included.""" async_fire_mqtt_message( @@ -574,6 +733,100 @@ async def test_discovery_expansion_3(hass, mqtt_mock, caplog): ) +async def test_discovery_expansion_without_encoding_and_value_template_1( + hass, mqtt_mock, caplog +): + """Test expansion of raw availability payload with a template as list.""" + data = ( + '{ "~": "some/base/topic",' + ' "name": "DiscoveryExpansionTest1",' + ' "stat_t": "test_topic/~",' + ' "cmd_t": "~/test_topic",' + ' "encoding":"",' + ' "availability": [{' + ' "topic":"~/avail_item1",' + ' "payload_available": "1",' + ' "payload_not_available": "0",' + ' "value_template":"{{value|unpack(\'b\')}}"' + " }]," + ' "dev":{' + ' "ids":["5706DF"],' + ' "name":"DiscoveryExpansionTest1 Device",' + ' "mdl":"Generic",' + ' "sw":"1.2.3.4",' + ' "mf":"None",' + ' "sa":"default_area"' + " }" + "}" + ) + + async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data) + await hass.async_block_till_done() + + state = hass.states.get("switch.DiscoveryExpansionTest1") + assert state.state == STATE_UNAVAILABLE + + async_fire_mqtt_message(hass, "some/base/topic/avail_item1", b"\x01") + await hass.async_block_till_done() + + state = hass.states.get("switch.DiscoveryExpansionTest1") + assert state is not None + assert state.name == "DiscoveryExpansionTest1" + assert ("switch", "bla") in hass.data[ALREADY_DISCOVERED] + assert state.state == STATE_OFF + + async_fire_mqtt_message(hass, "some/base/topic/avail_item1", b"\x00") + + state = hass.states.get("switch.DiscoveryExpansionTest1") + assert state.state == STATE_UNAVAILABLE + + +async def test_discovery_expansion_without_encoding_and_value_template_2( + hass, mqtt_mock, caplog +): + """Test expansion of raw availability payload with a template directly.""" + data = ( + '{ "~": "some/base/topic",' + ' "name": "DiscoveryExpansionTest1",' + ' "stat_t": "test_topic/~",' + ' "cmd_t": "~/test_topic",' + ' "availability_topic":"~/avail_item1",' + ' "payload_available": "1",' + ' "payload_not_available": "0",' + ' "encoding":"",' + ' "availability_template":"{{ value | unpack(\'b\') }}",' + ' "dev":{' + ' "ids":["5706DF"],' + ' "name":"DiscoveryExpansionTest1 Device",' + ' "mdl":"Generic",' + ' "sw":"1.2.3.4",' + ' "mf":"None",' + ' "sa":"default_area"' + " }" + "}" + ) + + async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data) + await hass.async_block_till_done() + + state = hass.states.get("switch.DiscoveryExpansionTest1") + assert state.state == STATE_UNAVAILABLE + + async_fire_mqtt_message(hass, "some/base/topic/avail_item1", b"\x01") + await hass.async_block_till_done() + + state = hass.states.get("switch.DiscoveryExpansionTest1") + assert state is not None + assert state.name == "DiscoveryExpansionTest1" + assert ("switch", "bla") in hass.data[ALREADY_DISCOVERED] + assert state.state == STATE_OFF + + async_fire_mqtt_message(hass, "some/base/topic/avail_item1", b"\x00") + + state = hass.states.get("switch.DiscoveryExpansionTest1") + assert state.state == STATE_UNAVAILABLE + + ABBREVIATIONS_WHITE_LIST = [ # MQTT client/server/trigger settings "CONF_BIRTH_MESSAGE", diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 7c6d482c7ec..acc25b59442 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -12,7 +12,6 @@ from homeassistant.components import mqtt, websocket_api from homeassistant.components.mqtt import debug_info from homeassistant.components.mqtt.mixins import MQTT_ENTITY_DEVICE_INFO_SCHEMA from homeassistant.const import ( - EVENT_CALL_SERVICE, EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, @@ -92,29 +91,51 @@ async def test_mqtt_disconnects_on_home_assistant_stop(hass, mqtt_mock): assert mqtt_mock.async_disconnect.called -async def test_publish_calls_service(hass, mqtt_mock, calls, record_calls): - """Test the publishing of call to services.""" - hass.bus.async_listen_once(EVENT_CALL_SERVICE, record_calls) - - mqtt.async_publish(hass, "test-topic", "test-payload") +async def test_publish_(hass, mqtt_mock): + """Test the publish function.""" + await mqtt.async_publish(hass, "test-topic", "test-payload") await hass.async_block_till_done() + assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0] == ( + "test-topic", + "test-payload", + 0, + False, + ) + mqtt_mock.reset_mock() - assert len(calls) == 1 - assert calls[0][0].data["service_data"][mqtt.ATTR_TOPIC] == "test-topic" - assert calls[0][0].data["service_data"][mqtt.ATTR_PAYLOAD] == "test-payload" - assert mqtt.ATTR_QOS not in calls[0][0].data["service_data"] - assert mqtt.ATTR_RETAIN not in calls[0][0].data["service_data"] - - hass.bus.async_listen_once(EVENT_CALL_SERVICE, record_calls) - - mqtt.async_publish(hass, "test-topic", "test-payload", 2, True) + await mqtt.async_publish(hass, "test-topic", "test-payload", 2, True) await hass.async_block_till_done() + assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0] == ( + "test-topic", + "test-payload", + 2, + True, + ) + mqtt_mock.reset_mock() - assert len(calls) == 2 - assert calls[1][0].data["service_data"][mqtt.ATTR_TOPIC] == "test-topic" - assert calls[1][0].data["service_data"][mqtt.ATTR_PAYLOAD] == "test-payload" - assert calls[1][0].data["service_data"][mqtt.ATTR_QOS] == 2 - assert calls[1][0].data["service_data"][mqtt.ATTR_RETAIN] is True + mqtt.publish(hass, "test-topic2", "test-payload2") + await hass.async_block_till_done() + assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0] == ( + "test-topic2", + "test-payload2", + 0, + False, + ) + mqtt_mock.reset_mock() + + mqtt.publish(hass, "test-topic2", "test-payload2", 2, True) + await hass.async_block_till_done() + assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0] == ( + "test-topic2", + "test-payload2", + 2, + True, + ) + mqtt_mock.reset_mock() async def test_service_call_without_topic_does_not_publish(hass, mqtt_mock): @@ -129,23 +150,108 @@ async def test_service_call_without_topic_does_not_publish(hass, mqtt_mock): assert not mqtt_mock.async_publish.called +async def test_service_call_with_topic_and_topic_template_does_not_publish( + hass, mqtt_mock +): + """Test the service call with topic/topic template. + + If both 'topic' and 'topic_template' are provided then fail. + """ + topic = "test/topic" + topic_template = "test/{{ 'topic' }}" + with pytest.raises(vol.Invalid): + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + { + mqtt.ATTR_TOPIC: topic, + mqtt.ATTR_TOPIC_TEMPLATE: topic_template, + mqtt.ATTR_PAYLOAD: "payload", + }, + blocking=True, + ) + assert not mqtt_mock.async_publish.called + + +async def test_service_call_with_invalid_topic_template_does_not_publish( + hass, mqtt_mock +): + """Test the service call with a problematic topic template.""" + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + { + mqtt.ATTR_TOPIC_TEMPLATE: "test/{{ 1 | no_such_filter }}", + mqtt.ATTR_PAYLOAD: "payload", + }, + blocking=True, + ) + assert not mqtt_mock.async_publish.called + + +async def test_service_call_with_template_topic_renders_template(hass, mqtt_mock): + """Test the service call with rendered topic template. + + If 'topic_template' is provided and 'topic' is not, then render it. + """ + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + { + mqtt.ATTR_TOPIC_TEMPLATE: "test/{{ 1+1 }}", + mqtt.ATTR_PAYLOAD: "payload", + }, + blocking=True, + ) + assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0][0] == "test/2" + + +async def test_service_call_with_template_topic_renders_invalid_topic(hass, mqtt_mock): + """Test the service call with rendered, invalid topic template. + + If a wildcard topic is rendered, then fail. + """ + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + { + mqtt.ATTR_TOPIC_TEMPLATE: "test/{{ '+' if True else 'topic' }}/topic", + mqtt.ATTR_PAYLOAD: "payload", + }, + blocking=True, + ) + assert not mqtt_mock.async_publish.called + + +async def test_service_call_with_invalid_rendered_template_topic_doesnt_render_template( + hass, mqtt_mock +): + """Test the service call with unrendered template. + + If both 'payload' and 'payload_template' are provided then fail. + """ + payload = "not a template" + payload_template = "a template" + with pytest.raises(vol.Invalid): + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + { + mqtt.ATTR_TOPIC: "test/topic", + mqtt.ATTR_PAYLOAD: payload, + mqtt.ATTR_PAYLOAD_TEMPLATE: payload_template, + }, + blocking=True, + ) + assert not mqtt_mock.async_publish.called + + async def test_service_call_with_template_payload_renders_template(hass, mqtt_mock): """Test the service call with rendered template. If 'payload_template' is provided and 'payload' is not, then render it. """ - mqtt.publish_template(hass, "test/topic", "{{ 1+1 }}") - await hass.async_block_till_done() - assert mqtt_mock.async_publish.called - assert mqtt_mock.async_publish.call_args[0][1] == "2" - mqtt_mock.reset_mock() - - mqtt.async_publish_template(hass, "test/topic", "{{ 2+2 }}") - await hass.async_block_till_done() - assert mqtt_mock.async_publish.called - assert mqtt_mock.async_publish.call_args[0][1] == "4" - mqtt_mock.reset_mock() - await hass.services.async_call( mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, @@ -1706,3 +1812,27 @@ async def test_publish_json_from_template(hass, mqtt_mock): assert mqtt_mock.async_publish.called assert mqtt_mock.async_publish.call_args[0][1] == test_str + + +async def test_service_info_compatibility(hass, caplog): + """Test compatibility with old-style dict. + + To be removed in 2022.6 + """ + discovery_info = mqtt.MqttServiceInfo( + topic="tasmota/discovery/DC4F220848A2/config", + payload="", + qos=0, + retain=False, + subscribed_topic="tasmota/discovery/#", + timestamp=None, + ) + + # Ensure first call get logged + assert discovery_info["topic"] == "tasmota/discovery/DC4F220848A2/config" + assert "Detected code that accessed discovery_info['topic']" in caplog.text + + # Ensure second call doesn't get logged + caplog.clear() + assert discovery_info["topic"] == "tasmota/discovery/DC4F220848A2/config" + assert "Detected code that accessed discovery_info['topic']" not in caplog.text diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index bf327796f57..58b607bb777 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -153,7 +153,6 @@ light: payload_off: "off" """ -from os import path from unittest.mock import call, patch import pytest @@ -198,7 +197,11 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) -from tests.common import assert_setup_component, async_fire_mqtt_message +from tests.common import ( + assert_setup_component, + async_fire_mqtt_message, + get_fixture_path, +) from tests.components.light import common DEFAULT_CONFIG = { @@ -3389,11 +3392,8 @@ async def test_reloadable(hass, mqtt_mock): assert hass.states.get("light.test") assert len(hass.states.async_all("light")) == 1 - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "mqtt/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "mqtt") + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( "mqtt", @@ -3407,7 +3407,3 @@ async def test_reloadable(hass, mqtt_mock): assert hass.states.get("light.test") is None assert hass.states.get("light.reload") - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 97f524d5d82..8d76e46f32b 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -6,12 +6,18 @@ import pytest from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, + SERVICE_OPEN, SERVICE_UNLOCK, STATE_LOCKED, STATE_UNLOCKED, + SUPPORT_OPEN, ) from homeassistant.components.mqtt.lock import MQTT_LOCK_ATTRIBUTES_BLOCKED -from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, +) from homeassistant.setup import async_setup_component from .test_common import ( @@ -69,6 +75,7 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED assert not state.attributes.get(ATTR_ASSUMED_STATE) + assert not state.attributes.get(ATTR_SUPPORTED_FEATURES) async_fire_mqtt_message(hass, "state-topic", "LOCKED") @@ -278,6 +285,122 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock): assert state.attributes.get(ATTR_ASSUMED_STATE) +async def test_sending_mqtt_commands_support_open_and_optimistic(hass, mqtt_mock): + """Test open function of the lock without state topic.""" + assert await async_setup_component( + hass, + LOCK_DOMAIN, + { + LOCK_DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "payload_open": "OPEN", + "state_locked": "LOCKED", + "state_unlocked": "UNLOCKED", + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("lock.test") + assert state.state is STATE_UNLOCKED + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == SUPPORT_OPEN + + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: "lock.test"}, blocking=True + ) + + mqtt_mock.async_publish.assert_called_once_with("command-topic", "LOCK", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lock.test") + assert state.state is STATE_LOCKED + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: "lock.test"}, blocking=True + ) + + mqtt_mock.async_publish.assert_called_once_with("command-topic", "UNLOCK", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lock.test") + assert state.state is STATE_UNLOCKED + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: "lock.test"}, blocking=True + ) + + mqtt_mock.async_publish.assert_called_once_with("command-topic", "OPEN", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lock.test") + assert state.state is STATE_UNLOCKED + assert state.attributes.get(ATTR_ASSUMED_STATE) + + +async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( + hass, mqtt_mock +): + """Test open function of the lock without state topic.""" + assert await async_setup_component( + hass, + LOCK_DOMAIN, + { + LOCK_DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "payload_open": "OPEN", + "state_locked": "LOCKED", + "state_unlocked": "UNLOCKED", + "optimistic": True, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("lock.test") + assert state.state is STATE_UNLOCKED + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == SUPPORT_OPEN + + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: "lock.test"}, blocking=True + ) + + mqtt_mock.async_publish.assert_called_once_with("command-topic", "LOCK", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lock.test") + assert state.state is STATE_LOCKED + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: "lock.test"}, blocking=True + ) + + mqtt_mock.async_publish.assert_called_once_with("command-topic", "UNLOCK", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lock.test") + assert state.state is STATE_UNLOCKED + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: "lock.test"}, blocking=True + ) + + mqtt_mock.async_publish.assert_called_once_with("command-topic", "OPEN", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lock.test") + assert state.state is STATE_UNLOCKED + assert state.attributes.get(ATTR_ASSUMED_STATE) + + async def test_availability_when_connection_lost(hass, mqtt_mock): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index 3b8b370eff8..797a7b894fc 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -209,6 +209,76 @@ async def test_run_number_service_optimistic(hass, mqtt_mock): assert state.state == "42.1" +async def test_run_number_service_optimistic_with_command_template(hass, mqtt_mock): + """Test that set_value service works in optimistic mode and with a command_template.""" + topic = "test/number" + + fake_state = ha.State("switch.test", "3") + + with patch( + "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", + return_value=fake_state, + ): + assert await async_setup_component( + hass, + number.DOMAIN, + { + "number": { + "platform": "mqtt", + "command_topic": topic, + "name": "Test Number", + "command_template": '{"number": {{ value }} }', + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.test_number") + assert state.state == "3" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + # Integer + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number", ATTR_VALUE: 30}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with(topic, '{"number": 30 }', 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("number.test_number") + assert state.state == "30" + + # Float with no decimal -> integer + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number", ATTR_VALUE: 42.0}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with(topic, '{"number": 42 }', 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("number.test_number") + assert state.state == "42" + + # Float with decimal -> float + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number", ATTR_VALUE: 42.1}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + topic, '{"number": 42.1 }', 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("number.test_number") + assert state.state == "42.1" + + async def test_run_number_service(hass, mqtt_mock): """Test that set_value service works in non optimistic mode.""" cmd_topic = "test/number/set" @@ -243,6 +313,43 @@ async def test_run_number_service(hass, mqtt_mock): assert state.state == "32" +async def test_run_number_service_with_command_template(hass, mqtt_mock): + """Test that set_value service works in non optimistic mode and with a command_template.""" + cmd_topic = "test/number/set" + state_topic = "test/number" + + assert await async_setup_component( + hass, + number.DOMAIN, + { + "number": { + "platform": "mqtt", + "command_topic": cmd_topic, + "state_topic": state_topic, + "name": "Test Number", + "command_template": '{"number": {{ value }} }', + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, state_topic, "32") + state = hass.states.get("number.test_number") + assert state.state == "32" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number", ATTR_VALUE: 30}, + blocking=True, + ) + mqtt_mock.async_publish.assert_called_once_with( + cmd_topic, '{"number": 30 }', 0, False + ) + state = hass.states.get("number.test_number") + assert state.state == "32" + + async def test_availability_when_connection_lost(hass, mqtt_mock): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 4843631f98b..2f31ce788fd 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -5,10 +5,7 @@ from unittest.mock import patch import pytest from homeassistant.components import select -from homeassistant.components.mqtt.select import ( - CONF_OPTIONS, - MQTT_SELECT_ATTRIBUTES_BLOCKED, -) +from homeassistant.components.mqtt.select import MQTT_SELECT_ATTRIBUTES_BLOCKED from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, @@ -171,6 +168,50 @@ async def test_run_select_service_optimistic(hass, mqtt_mock): assert state.state == "beer" +async def test_run_select_service_optimistic_with_command_template(hass, mqtt_mock): + """Test that set_value service works in optimistic mode and with a command_template.""" + topic = "test/select" + + fake_state = ha.State("select.test", "milk") + + with patch( + "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", + return_value=fake_state, + ): + assert await async_setup_component( + hass, + select.DOMAIN, + { + "select": { + "platform": "mqtt", + "command_topic": topic, + "name": "Test Select", + "options": ["milk", "beer"], + "command_template": '{"option": "{{ value }}"}', + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.test_select") + assert state.state == "milk" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_select", ATTR_OPTION: "beer"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + topic, '{"option": "beer"}', 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("select.test_select") + assert state.state == "beer" + + async def test_run_select_service(hass, mqtt_mock): """Test that set_value service works in non optimistic mode.""" cmd_topic = "test/select/set" @@ -206,6 +247,42 @@ async def test_run_select_service(hass, mqtt_mock): assert state.state == "beer" +async def test_run_select_service_with_command_template(hass, mqtt_mock): + """Test that set_value service works in non optimistic mode and with a command_template.""" + cmd_topic = "test/select/set" + state_topic = "test/select" + + assert await async_setup_component( + hass, + select.DOMAIN, + { + "select": { + "platform": "mqtt", + "command_topic": cmd_topic, + "state_topic": state_topic, + "name": "Test Select", + "options": ["milk", "beer"], + "command_template": '{"option": "{{ value }}"}', + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, state_topic, "beer") + state = hass.states.get("select.test_select") + assert state.state == "beer" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_select", ATTR_OPTION: "milk"}, + blocking=True, + ) + mqtt_mock.async_publish.assert_called_once_with( + cmd_topic, '{"option": "milk"}', 0, False + ) + + async def test_availability_when_connection_lost(hass, mqtt_mock): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( @@ -398,7 +475,8 @@ async def test_entity_debug_info_message(hass, mqtt_mock): ) -async def test_options_attributes(hass, mqtt_mock): +@pytest.mark.parametrize("options", [["milk", "beer"], ["milk"], []]) +async def test_options_attributes(hass, mqtt_mock, options): """Test options attribute.""" topic = "test/select" await async_setup_component( @@ -410,35 +488,14 @@ async def test_options_attributes(hass, mqtt_mock): "state_topic": topic, "command_topic": topic, "name": "Test select", - "options": ["milk", "beer"], + "options": options, } }, ) await hass.async_block_till_done() state = hass.states.get("select.test_select") - assert state.attributes.get(ATTR_OPTIONS) == ["milk", "beer"] - - -async def test_invalid_options(hass, caplog, mqtt_mock): - """Test invalid options.""" - topic = "test/select" - await async_setup_component( - hass, - "select", - { - "select": { - "platform": "mqtt", - "state_topic": topic, - "command_topic": topic, - "name": "Test Select", - "options": "beer", - } - }, - ) - await hass.async_block_till_done() - - assert f"'{CONF_OPTIONS}' must include at least 2 options" in caplog.text + assert state.attributes.get(ATTR_OPTIONS) == options async def test_mqtt_payload_not_an_option_warning(hass, caplog, mqtt_mock): diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 752b1cfa48a..680bafb3b2b 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.mqtt.sensor import MQTT_SENSOR_ATTRIBUTES_BLOCKED import homeassistant.components.sensor as sensor -from homeassistant.const import EVENT_STATE_CHANGED, STATE_UNAVAILABLE +from homeassistant.const import EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN import homeassistant.core as ha from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -80,6 +80,58 @@ async def test_setting_sensor_value_via_mqtt_message(hass, mqtt_mock): assert state.attributes.get("unit_of_measurement") == "fav unit" +@pytest.mark.parametrize( + "device_class,native_value,state_value,log", + [ + (sensor.DEVICE_CLASS_DATE, "2021-11-18", "2021-11-18", False), + (sensor.DEVICE_CLASS_DATE, "invalid", STATE_UNKNOWN, True), + ( + sensor.DEVICE_CLASS_TIMESTAMP, + "2021-11-18T20:25:00+00:00", + "2021-11-18T20:25:00+00:00", + False, + ), + ( + sensor.DEVICE_CLASS_TIMESTAMP, + "2021-11-18 20:25:00+00:00", + "2021-11-18T20:25:00+00:00", + False, + ), + ( + sensor.DEVICE_CLASS_TIMESTAMP, + "2021-11-18 20:25:00+01:00", + "2021-11-18T19:25:00+00:00", + False, + ), + (sensor.DEVICE_CLASS_TIMESTAMP, "invalid", STATE_UNKNOWN, True), + ], +) +async def test_setting_sensor_native_value_handling_via_mqtt_message( + hass, mqtt_mock, caplog, device_class, native_value, state_value, log +): + """Test the setting of the value via MQTT.""" + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "device_class": device_class, + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "test-topic", native_value) + state = hass.states.get("sensor.test") + + assert state.state == state_value + assert state.attributes.get("device_class") == device_class + assert log == ("Invalid state message" in caplog.text) + + async def test_setting_sensor_value_expires_availability_topic(hass, mqtt_mock, caplog): """Test the expiration of the value.""" assert await async_setup_component( diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index 263ec0a2825..a3ef29d0d08 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -6,7 +6,12 @@ import pytest from homeassistant.components import switch from homeassistant.components.mqtt.switch import MQTT_SWITCH_ATTRIBUTES_BLOCKED -from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_DEVICE_CLASS, + STATE_OFF, + STATE_ON, +) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -56,6 +61,7 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): "command_topic": "command-topic", "payload_on": 1, "payload_off": 0, + "device_class": "switch", } }, ) @@ -63,6 +69,7 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): state = hass.states.get("switch.test") assert state.state == STATE_OFF + assert state.attributes.get(ATTR_DEVICE_CLASS) == "switch" assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "state-topic", "1") @@ -387,6 +394,7 @@ async def test_discovery_update_unchanged_switch(hass, mqtt_mock, caplog): """Test update of discovered switch.""" data1 = ( '{ "name": "Beer",' + ' "device_class": "switch",' ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) diff --git a/tests/fixtures/myq/devices.json b/tests/components/myq/fixtures/devices.json similarity index 100% rename from tests/fixtures/myq/devices.json rename to tests/components/myq/fixtures/devices.json diff --git a/tests/fixtures/mysensors/distance_sensor_state.json b/tests/components/mysensors/fixtures/distance_sensor_state.json similarity index 100% rename from tests/fixtures/mysensors/distance_sensor_state.json rename to tests/components/mysensors/fixtures/distance_sensor_state.json diff --git a/tests/fixtures/mysensors/energy_sensor_state.json b/tests/components/mysensors/fixtures/energy_sensor_state.json similarity index 100% rename from tests/fixtures/mysensors/energy_sensor_state.json rename to tests/components/mysensors/fixtures/energy_sensor_state.json diff --git a/tests/fixtures/mysensors/gps_sensor_state.json b/tests/components/mysensors/fixtures/gps_sensor_state.json similarity index 100% rename from tests/fixtures/mysensors/gps_sensor_state.json rename to tests/components/mysensors/fixtures/gps_sensor_state.json diff --git a/tests/fixtures/mysensors/power_sensor_state.json b/tests/components/mysensors/fixtures/power_sensor_state.json similarity index 100% rename from tests/fixtures/mysensors/power_sensor_state.json rename to tests/components/mysensors/fixtures/power_sensor_state.json diff --git a/tests/fixtures/mysensors/sound_sensor_state.json b/tests/components/mysensors/fixtures/sound_sensor_state.json similarity index 100% rename from tests/fixtures/mysensors/sound_sensor_state.json rename to tests/components/mysensors/fixtures/sound_sensor_state.json diff --git a/tests/fixtures/mysensors/temperature_sensor_state.json b/tests/components/mysensors/fixtures/temperature_sensor_state.json similarity index 100% rename from tests/fixtures/mysensors/temperature_sensor_state.json rename to tests/components/mysensors/fixtures/temperature_sensor_state.json diff --git a/tests/components/nam/__init__.py b/tests/components/nam/__init__.py index 8106f97ef31..eb723405076 100644 --- a/tests/components/nam/__init__.py +++ b/tests/components/nam/__init__.py @@ -1,5 +1,5 @@ """Tests for the Nettigo Air Monitor integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, Mock, patch from homeassistant.components.nam.const import DOMAIN @@ -52,9 +52,11 @@ async def init_integration(hass, co2_sensor=True) -> MockConfigEntry: # Remove conc_co2_ppm value nam_data["sensordatavalues"].pop(6) - with patch( - "homeassistant.components.nam.NettigoAirMonitor._async_get_data", - return_value=nam_data, + update_response = Mock(json=AsyncMock(return_value=nam_data)) + + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor._async_http_request", + return_value=update_response, ): entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/nam/test_button.py b/tests/components/nam/test_button.py new file mode 100644 index 00000000000..c8ed00b3376 --- /dev/null +++ b/tests/components/nam/test_button.py @@ -0,0 +1,48 @@ +"""Test button of Nettigo Air Monitor integration.""" +from unittest.mock import patch + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonDeviceClass +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from tests.components.nam import init_integration + + +async def test_button(hass): + """Test states of the button.""" + registry = er.async_get(hass) + + await init_integration(hass) + + state = hass.states.get("button.nettigo_air_monitor_restart") + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.RESTART + + entry = registry.async_get("button.nettigo_air_monitor_restart") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-restart" + + +async def test_button_press(hass): + """Test button press.""" + await init_integration(hass) + + now = dt_util.utcnow() + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_restart" + ) as mock_restart, patch("homeassistant.core.dt_util.utcnow", return_value=now): + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: "button.nettigo_air_monitor_restart"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_restart.assert_called_once() + + state = hass.states.get("button.nettigo_air_monitor_restart") + assert state + assert state.state == now.isoformat() diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index b0ecb20da11..015c645a3e7 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -2,21 +2,30 @@ import asyncio from unittest.mock import patch -from nettigo_air_monitor import ApiError, CannotGetMac +from nettigo_air_monitor import ApiError, AuthFailed, CannotGetMac import pytest from homeassistant import data_entry_flow +from homeassistant.components import zeroconf from homeassistant.components.nam.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF from tests.common import MockConfigEntry -DISCOVERY_INFO = {"host": "10.10.2.3", "name": "NAM-12345"} +DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( + host="10.10.2.3", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={}, + type="mock_type", +) VALID_CONFIG = {"host": "10.10.2.3"} +VALID_AUTH = {"username": "fake_username", "password": "fake_password"} -async def test_form_create_entry(hass): - """Test that the user step works.""" +async def test_form_create_entry_without_auth(hass): + """Test that the user step without auth works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -24,13 +33,12 @@ async def test_form_create_entry(hass): assert result["step_id"] == SOURCE_USER assert result["errors"] == {} - with patch( + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ), patch( "homeassistant.components.nam.async_setup_entry", return_value=True ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( result["flow_id"], VALID_CONFIG, @@ -43,10 +51,153 @@ async def test_form_create_entry(hass): assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_create_entry_with_auth(hass): + """Test that the user step with auth works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + assert result["errors"] == {} + + with patch( + "homeassistant.components.nam.NettigoAirMonitor.initialize", + side_effect=AuthFailed("Auth Error"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "credentials" + + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", + ), patch( + "homeassistant.components.nam.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_AUTH, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "10.10.2.3" + assert result["data"]["host"] == "10.10.2.3" + assert result["data"]["username"] == "fake_username" + assert result["data"]["password"] == "fake_password" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_successful(hass): + """Test starting a reauthentication flow.""" + 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_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": entry.entry_id}, + data=entry.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=VALID_AUTH, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_unsuccessful(hass): + """Test starting a reauthentication flow.""" + 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", + side_effect=ApiError("API Error"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": entry.entry_id}, + data=entry.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=VALID_AUTH, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_unsuccessful" + + @pytest.mark.parametrize( "error", [ - (ApiError("Invalid response from device 10.10.2.3: 404"), "cannot_connect"), + (ApiError("API Error"), "cannot_connect"), + (AuthFailed("Auth Error"), "invalid_auth"), + (asyncio.TimeoutError, "cannot_connect"), + (ValueError, "unknown"), + ], +) +async def test_form_with_auth_errors(hass, error): + """Test we handle errors when auth is required.""" + exc, base_error = error + with patch( + "homeassistant.components.nam.NettigoAirMonitor.initialize", + side_effect=AuthFailed("Auth Error"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=VALID_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "credentials" + + with patch( + "homeassistant.components.nam.NettigoAirMonitor.initialize", + side_effect=exc, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_AUTH, + ) + + assert result["errors"] == {"base": base_error} + + +@pytest.mark.parametrize( + "error", + [ + (ApiError("API Error"), "cannot_connect"), (asyncio.TimeoutError, "cannot_connect"), (ValueError, "unknown"), ], @@ -55,7 +206,7 @@ async def test_form_errors(hass, error): """Test we handle errors.""" exc, base_error = error with patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + "homeassistant.components.nam.NettigoAirMonitor.initialize", side_effect=exc, ): @@ -70,11 +221,10 @@ async def test_form_errors(hass, error): async def test_form_abort(hass): """Test we handle abort after error.""" - with patch( + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=CannotGetMac("Cannot get MAC address from device"), ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -85,6 +235,34 @@ async def test_form_abort(hass): assert result["reason"] == "device_unsupported" +async def test_form_with_auth_abort(hass): + """Test we handle abort after error.""" + with patch( + "homeassistant.components.nam.NettigoAirMonitor.initialize", + side_effect=AuthFailed("Auth Error"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=VALID_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "credentials" + + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=CannotGetMac("Cannot get MAC address from device"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_AUTH, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "device_unsupported" + + async def test_form_already_configured(hass): """Test that errors are shown when duplicates are added.""" entry = MockConfigEntry( @@ -96,7 +274,7 @@ async def test_form_already_configured(hass): DOMAIN, context={"source": SOURCE_USER} ) - with patch( + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ): @@ -114,7 +292,7 @@ async def test_form_already_configured(hass): async def test_zeroconf(hass): """Test we get the form.""" - with patch( + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ): @@ -131,7 +309,7 @@ async def test_zeroconf(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {} - assert context["title_placeholders"]["name"] == "NAM-12345" + assert context["title_placeholders"]["host"] == "10.10.2.3" assert context["confirm_only"] is True with patch( @@ -150,6 +328,48 @@ async def test_zeroconf(hass): assert len(mock_setup_entry.mock_calls) == 1 +async def test_zeroconf_with_auth(hass): + """Test that the zeroconf step with auth works.""" + with patch( + "homeassistant.components.nam.NettigoAirMonitor.initialize", + side_effect=AuthFailed("Auth Error"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO, + context={"source": SOURCE_ZEROCONF}, + ) + context = next( + flow["context"] + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "credentials" + assert result["errors"] == {} + assert context["title_placeholders"]["host"] == "10.10.2.3" + + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", + ), patch( + "homeassistant.components.nam.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_AUTH, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "10.10.2.3" + assert result["data"]["host"] == "10.10.2.3" + assert result["data"]["username"] == "fake_username" + assert result["data"]["password"] == "fake_password" + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_zeroconf_host_already_configured(hass): """Test that errors are shown when host is already configured.""" entry = MockConfigEntry( @@ -170,7 +390,7 @@ async def test_zeroconf_host_already_configured(hass): @pytest.mark.parametrize( "error", [ - (ApiError("Invalid response from device 10.10.2.3: 404"), "cannot_connect"), + (ApiError("API Error"), "cannot_connect"), (CannotGetMac("Cannot get MAC address from device"), "device_unsupported"), ], ) @@ -178,10 +398,9 @@ async def test_zeroconf_errors(hass, error): """Test we handle errors.""" exc, reason = error with patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + "homeassistant.components.nam.NettigoAirMonitor.initialize", side_effect=exc, ): - result = await hass.config_entries.flow.async_init( DOMAIN, data=DISCOVERY_INFO, diff --git a/tests/components/nam/test_init.py b/tests/components/nam/test_init.py index 97392cbaff8..3223a394f68 100644 --- a/tests/components/nam/test_init.py +++ b/tests/components/nam/test_init.py @@ -1,7 +1,7 @@ """Test init of Nettigo Air Monitor integration.""" from unittest.mock import patch -from nettigo_air_monitor import ApiError +from nettigo_air_monitor import ApiError, AuthFailed from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.components.nam.const import DOMAIN @@ -33,7 +33,7 @@ async def test_config_not_ready(hass): ) with patch( - "homeassistant.components.nam.NettigoAirMonitor._async_get_data", + "homeassistant.components.nam.NettigoAirMonitor.initialize", side_effect=ApiError("API Error"), ): entry.add_to_hass(hass) @@ -41,6 +41,24 @@ async def test_config_not_ready(hass): assert entry.state is ConfigEntryState.SETUP_RETRY +async def test_config_auth_failed(hass): + """Test for setup failure if the auth fails.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="10.10.2.3", + unique_id="aa:bb:cc:dd:ee:ff", + data={"host": "10.10.2.3"}, + ) + + with patch( + "homeassistant.components.nam.NettigoAirMonitor.initialize", + 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 + + async def test_unload_entry(hass): """Test successful unload of entry.""" entry = await init_integration(hass) diff --git a/tests/components/nam/test_sensor.py b/tests/components/nam/test_sensor.py index 68c0044e590..aa05930f727 100644 --- a/tests/components/nam/test_sensor.py +++ b/tests/components/nam/test_sensor.py @@ -1,6 +1,6 @@ """Test sensor of Nettigo Air Monitor integration.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, Mock, patch from nettigo_air_monitor import ApiError @@ -373,9 +373,10 @@ async def test_incompleta_data_after_device_restart(hass): assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS future = utcnow() + timedelta(minutes=6) - with patch( - "homeassistant.components.nam.NettigoAirMonitor._async_get_data", - return_value=INCOMPLETE_NAM_DATA, + update_response = Mock(json=AsyncMock(return_value=INCOMPLETE_NAM_DATA)) + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor._async_http_request", + return_value=update_response, ): async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -395,8 +396,8 @@ async def test_availability(hass): assert state.state == "7.6" future = utcnow() + timedelta(minutes=6) - with patch( - "homeassistant.components.nam.NettigoAirMonitor._async_get_data", + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor._async_http_request", side_effect=ApiError("API Error"), ): async_fire_time_changed(hass, future) @@ -407,9 +408,10 @@ async def test_availability(hass): assert state.state == STATE_UNAVAILABLE future = utcnow() + timedelta(minutes=12) - with patch( - "homeassistant.components.nam.NettigoAirMonitor._async_get_data", - return_value=nam_data, + update_response = Mock(json=AsyncMock(return_value=nam_data)) + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor._async_http_request", + return_value=update_response, ): async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -426,9 +428,10 @@ async def test_manual_update_entity(hass): await async_setup_component(hass, "homeassistant", {}) - with patch( - "homeassistant.components.nam.NettigoAirMonitor._async_get_data", - return_value=nam_data, + update_response = Mock(json=AsyncMock(return_value=nam_data)) + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor._async_http_request", + return_value=update_response, ) as mock_get_data: await hass.services.async_call( "homeassistant", diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py index ba0eff4abe3..4e4a48e9bfe 100644 --- a/tests/components/nanoleaf/test_config_flow.py +++ b/tests/components/nanoleaf/test_config_flow.py @@ -7,6 +7,7 @@ from aionanoleaf import InvalidToken, NanoleafException, Unauthorized, Unavailab import pytest from homeassistant import config_entries +from homeassistant.components import ssdp, zeroconf from homeassistant.components.nanoleaf.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant @@ -235,12 +236,14 @@ async def test_discovery_link_unavailable( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data={ - "host": TEST_HOST, - "name": f"{TEST_NAME}.{type_in_discovery_info}", - "type": type_in_discovery_info, - "properties": {"id": TEST_DEVICE_ID}, - }, + data=zeroconf.ZeroconfServiceInfo( + host=TEST_HOST, + hostname="mock_hostname", + name=f"{TEST_NAME}.{type_in_discovery_info}", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, + type=type_in_discovery_info, + ), ) assert result["type"] == "form" assert result["step_id"] == "link" @@ -417,12 +420,14 @@ async def test_import_discovery_integration( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data={ - "host": TEST_HOST, - "name": f"{TEST_NAME}.{type_in_discovery}", - "type": type_in_discovery, - "properties": {"id": TEST_DEVICE_ID}, - }, + data=zeroconf.ZeroconfServiceInfo( + host=TEST_HOST, + hostname="mock_hostname", + name=f"{TEST_NAME}.{type_in_discovery}", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, + type=type_in_discovery, + ), ) assert result["type"] == "create_entry" assert result["title"] == TEST_NAME @@ -457,11 +462,16 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - "_host": TEST_HOST, - "nl-devicename": TEST_NAME, - "nl-deviceid": TEST_DEVICE_ID, - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + upnp={}, + ssdp_headers={ + "_host": TEST_HOST, + "nl-devicename": TEST_NAME, + "nl-deviceid": TEST_DEVICE_ID, + }, + ), ) assert result["type"] == "form" diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index d6dc730ec11..35183a441a5 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -6,6 +6,7 @@ from unittest.mock import patch from google_nest_sdm.device_manager import DeviceManager from google_nest_sdm.event import EventMessage +from google_nest_sdm.event_media import CachePolicy from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber from homeassistant.components.nest import DOMAIN @@ -32,7 +33,7 @@ FAKE_TOKEN = "some-token" FAKE_REFRESH_TOKEN = "some-refresh-token" -def create_config_entry(hass, token_expiration_time=None): +def create_config_entry(hass, token_expiration_time=None) -> MockConfigEntry: """Create a ConfigEntry and add it to Home Assistant.""" if token_expiration_time is None: token_expiration_time = time.time() + 86400 @@ -47,39 +48,30 @@ def create_config_entry(hass, token_expiration_time=None): "expires_at": token_expiration_time, }, } - MockConfigEntry(domain=DOMAIN, data=config_entry_data).add_to_hass(hass) - - -class FakeDeviceManager(DeviceManager): - """Fake DeviceManager that can supply a list of devices and structures.""" - - def __init__(self, devices: dict, structures: dict): - """Initialize FakeDeviceManager.""" - super().__init__() - self._devices = devices - - @property - def structures(self) -> dict: - """Override structures with fake result.""" - return self._structures - - @property - def devices(self) -> dict: - """Override devices with fake result.""" - return self._devices + config_entry = MockConfigEntry(domain=DOMAIN, data=config_entry_data) + config_entry.add_to_hass(hass) + return config_entry class FakeSubscriber(GoogleNestSubscriber): """Fake subscriber that supplies a FakeDeviceManager.""" - def __init__(self, device_manager: FakeDeviceManager): + def __init__(self): """Initialize Fake Subscriber.""" - self._device_manager = device_manager + self._device_manager = DeviceManager() def set_update_callback(self, callback: Callable[[EventMessage], Awaitable[None]]): """Capture the callback set by Home Assistant.""" self._callback = callback + async def create_subscription(self): + """Create the subscription.""" + return + + async def delete_subscription(self): + """Delete the subscription.""" + return + async def start_async(self): """Return the fake device manager.""" return self._device_manager @@ -88,6 +80,11 @@ class FakeSubscriber(GoogleNestSubscriber): """Return the fake device manager.""" return self._device_manager + @property + def cache_policy(self) -> CachePolicy: + """Return the cache policy.""" + return self._device_manager.cache_policy + def stop_async(self): """No-op to stop the subscriber.""" return None @@ -99,16 +96,29 @@ class FakeSubscriber(GoogleNestSubscriber): await self._callback(event_message) -async def async_setup_sdm_platform(hass, platform, devices={}, structures={}): +async def async_setup_sdm_platform( + hass, platform, devices={}, structures={}, with_config=True +): """Set up the platform and prerequisites.""" - create_config_entry(hass) - device_manager = FakeDeviceManager(devices=devices, structures=structures) - subscriber = FakeSubscriber(device_manager) + if with_config: + create_config_entry(hass) + subscriber = FakeSubscriber() + device_manager = await subscriber.async_get_device_manager() + if devices: + for device in devices.values(): + device_manager.add_device(device) + if structures: + for structure in structures.values(): + device_manager.add_structure(structure) with patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" ), patch("homeassistant.components.nest.PLATFORMS", [platform]), patch( - "homeassistant.components.nest.GoogleNestSubscriber", return_value=subscriber + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=subscriber, ): assert await async_setup_component(hass, DOMAIN, CONFIG) await hass.async_block_till_done() + # Disabled to reduce setup burden, and enabled manually by tests that + # need to exercise this + subscriber.cache_policy.fetch = False return subscriber diff --git a/tests/components/nest/test_config_flow_sdm.py b/tests/components/nest/test_config_flow_sdm.py index a8f892045f5..d4af62cb255 100644 --- a/tests/components/nest/test_config_flow_sdm.py +++ b/tests/components/nest/test_config_flow_sdm.py @@ -1,20 +1,29 @@ """Test the Google Nest Device Access config flow.""" +import copy from unittest.mock import patch +from google_nest_sdm.exceptions import ( + AuthException, + ConfigurationException, + GoogleNestException, +) import pytest from homeassistant import config_entries, setup from homeassistant.components.nest.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow -from .common import MockConfigEntry +from .common import FakeSubscriber, MockConfigEntry CLIENT_ID = "1234" CLIENT_SECRET = "5678" PROJECT_ID = "project-id-4321" -SUBSCRIBER_ID = "projects/example/subscriptions/subscriber-id-9876" +SUBSCRIBER_ID = "projects/cloud-id-9876/subscriptions/subscriber-id-9876" +CLOUD_PROJECT_ID = "cloud-id-9876" CONFIG = { DOMAIN: { @@ -26,6 +35,18 @@ CONFIG = { "http": {"base_url": "https://example.com"}, } +ORIG_AUTH_DOMAIN = DOMAIN +WEB_AUTH_DOMAIN = DOMAIN +APP_AUTH_DOMAIN = f"{DOMAIN}.installed" +WEB_REDIRECT_URL = "https://example.com/auth/external/callback" +APP_REDIRECT_URL = "urn:ietf:wg:oauth:2.0:oob" + + +@pytest.fixture +def subscriber() -> FakeSubscriber: + """Create FakeSubscriber.""" + return FakeSubscriber() + def get_config_entry(hass): """Return a single config entry.""" @@ -34,6 +55,17 @@ def get_config_entry(hass): return entries[0] +def create_config_entry(hass: HomeAssistant, data: dict) -> ConfigEntry: + """Create the ConfigEntry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=data, + unique_id=DOMAIN, + ) + entry.add_to_hass(hass) + return entry + + class OAuthFixture: """Simulate the oauth flow used by the config flow.""" @@ -43,31 +75,83 @@ class OAuthFixture: self.hass_client = hass_client_no_auth self.aioclient_mock = aioclient_mock - async def async_oauth_flow(self, result): - """Invoke the oauth flow with fake responses.""" - state = config_entry_oauth2_flow._encode_jwt( - self.hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, + async def async_pick_flow(self, result: dict, auth_domain: str) -> dict: + """Invoke flow to puth the auth type to use for this flow.""" + assert result["type"] == "form" + assert result["step_id"] == "pick_implementation" + + return await self.hass.config_entries.flow.async_configure( + result["flow_id"], {"implementation": auth_domain} ) - oauth_authorize = OAUTH2_AUTHORIZE.format(project_id=PROJECT_ID) - assert result["type"] == "external" - assert result["url"] == ( - f"{oauth_authorize}?response_type=code&client_id={CLIENT_ID}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}&scope=https://www.googleapis.com/auth/sdm.service" - "+https://www.googleapis.com/auth/pubsub" - "&access_type=offline&prompt=consent" - ) + async def async_oauth_web_flow(self, result: dict) -> None: + """Invoke the oauth flow for Web Auth with fake responses.""" + state = self.create_state(result, WEB_REDIRECT_URL) + assert result["url"] == self.authorize_url(state, WEB_REDIRECT_URL) + # Simulate user redirect back with auth code client = await self.hass_client() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" + await self.async_mock_refresh(result) + + async def async_oauth_app_flow(self, result: dict) -> None: + """Invoke the oauth flow for Installed Auth with fake responses.""" + # Render form with a link to get an auth token + assert result["type"] == "form" + assert result["step_id"] == "auth" + assert "description_placeholders" in result + assert "url" in result["description_placeholders"] + state = self.create_state(result, APP_REDIRECT_URL) + assert result["description_placeholders"]["url"] == self.authorize_url( + state, APP_REDIRECT_URL + ) + # Simulate user entering auth token in form + await self.async_mock_refresh(result, {"code": "abcd"}) + + async def async_reauth(self, old_data: dict) -> dict: + """Initiate a reuath flow.""" + result = await self.hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=old_data + ) + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + # Advance through the reauth flow + flows = self.hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + # Advance to the oauth flow + return await self.hass.config_entries.flow.async_configure( + flows[0]["flow_id"], {} + ) + + def create_state(self, result: dict, redirect_url: str) -> str: + """Create state object based on redirect url.""" + return config_entry_oauth2_flow._encode_jwt( + self.hass, + { + "flow_id": result["flow_id"], + "redirect_uri": redirect_url, + }, + ) + + def authorize_url(self, state: str, redirect_url: str) -> str: + """Generate the expected authorization url.""" + oauth_authorize = OAUTH2_AUTHORIZE.format(project_id=PROJECT_ID) + return ( + f"{oauth_authorize}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={redirect_url}" + f"&state={state}&scope=https://www.googleapis.com/auth/sdm.service" + "+https://www.googleapis.com/auth/pubsub" + "&access_type=offline&prompt=consent" + ) + + async def async_mock_refresh(self, result, user_input: dict = None) -> None: + """Finish the OAuth flow exchanging auth token for refresh token.""" self.aioclient_mock.post( OAUTH2_TOKEN, json={ @@ -78,11 +162,38 @@ class OAuthFixture: }, ) + async def async_finish_setup( + self, result: dict, user_input: dict = None + ) -> ConfigEntry: + """Finish the OAuth flow exchanging auth token for refresh token.""" with patch( "homeassistant.components.nest.async_setup_entry", return_value=True ) as mock_setup: - await self.hass.config_entries.flow.async_configure(result["flow_id"]) + await self.hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) assert len(mock_setup.mock_calls) == 1 + await self.hass.async_block_till_done() + return self.get_config_entry() + + async def async_configure(self, result: dict, user_input: dict) -> dict: + """Advance to the next step in the config flow.""" + return await self.hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + async def async_pubsub_flow(self, result: dict, cloud_project_id="") -> ConfigEntry: + """Verify the pubsub creation step.""" + # Render form with a link to get an auth token + assert result["type"] == "form" + assert result["step_id"] == "pubsub" + assert "description_placeholders" in result + assert "url" in result["description_placeholders"] + assert result["data_schema"]({}) == {"cloud_project_id": cloud_project_id} + + def get_config_entry(self) -> ConfigEntry: + """Get the config entry.""" + return get_config_entry(self.hass) @pytest.fixture @@ -91,17 +202,26 @@ async def oauth(hass, hass_client_no_auth, aioclient_mock, current_request_with_ return OAuthFixture(hass, hass_client_no_auth, aioclient_mock) -async def test_full_flow(hass, oauth): +async def async_setup_configflow(hass): + """Set up component so the pubsub subscriber is managed by config flow.""" + config = copy.deepcopy(CONFIG) + del config[DOMAIN]["subscriber_id"] # Create in config flow instead + return await setup.async_setup_component(hass, DOMAIN, config) + + +async def test_web_full_flow(hass, oauth): """Check full flow.""" assert await setup.async_setup_component(hass, DOMAIN, CONFIG) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - await oauth.async_oauth_flow(result) - entry = get_config_entry(hass) - assert entry.title == "Configuration.yaml" + result = await oauth.async_pick_flow(result, WEB_AUTH_DOMAIN) + + await oauth.async_oauth_web_flow(result) + entry = await oauth.async_finish_setup(result) + assert entry.title == "OAuth for Web" assert "token" in entry.data entry.data["token"].pop("expires_at") assert entry.unique_id == DOMAIN @@ -111,47 +231,37 @@ async def test_full_flow(hass, oauth): "type": "Bearer", "expires_in": 60, } + # Subscriber from configuration.yaml + assert "subscriber_id" not in entry.data -async def test_reauth(hass, oauth): +async def test_web_reauth(hass, oauth): """Test Nest reauthentication.""" assert await setup.async_setup_component(hass, DOMAIN, CONFIG) - old_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "auth_implementation": DOMAIN, + old_entry = create_config_entry( + hass, + { + "auth_implementation": WEB_AUTH_DOMAIN, "token": { # Verify this is replaced at end of the test "access_token": "some-revoked-token", }, "sdm": {}, }, - unique_id=DOMAIN, ) - old_entry.add_to_hass(hass) entry = get_config_entry(hass) assert entry.data["token"] == { "access_token": "some-revoked-token", } - await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=old_entry.data - ) - - # Advance through the reauth flow - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["step_id"] == "reauth_confirm" - - # Run the oauth flow - result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {}) - await oauth.async_oauth_flow(result) + result = await oauth.async_reauth(old_entry.data) + await oauth.async_oauth_web_flow(result) + entry = await oauth.async_finish_setup(result) # Verify existing tokens are replaced - entry = get_config_entry(hass) entry.data["token"].pop("expires_at") assert entry.unique_id == DOMAIN assert entry.data["token"] == { @@ -160,14 +270,13 @@ async def test_reauth(hass, oauth): "type": "Bearer", "expires_in": 60, } + assert entry.data["auth_implementation"] == WEB_AUTH_DOMAIN + assert "subscriber_id" not in entry.data # not updated async def test_single_config_entry(hass): """Test that only a single config entry is allowed.""" - old_entry = MockConfigEntry( - domain=DOMAIN, data={"auth_implementation": DOMAIN, "sdm": {}} - ) - old_entry.add_to_hass(hass) + create_config_entry(hass, {"auth_implementation": WEB_AUTH_DOMAIN, "sdm": {}}) assert await setup.async_setup_component(hass, DOMAIN, CONFIG) @@ -187,12 +296,12 @@ async def test_unexpected_existing_config_entries(hass, oauth): assert await setup.async_setup_component(hass, DOMAIN, CONFIG) old_entry = MockConfigEntry( - domain=DOMAIN, data={"auth_implementation": DOMAIN, "sdm": {}} + domain=DOMAIN, data={"auth_implementation": WEB_AUTH_DOMAIN, "sdm": {}} ) old_entry.add_to_hass(hass) old_entry = MockConfigEntry( - domain=DOMAIN, data={"auth_implementation": DOMAIN, "sdm": {}} + domain=DOMAIN, data={"auth_implementation": WEB_AUTH_DOMAIN, "sdm": {}} ) old_entry.add_to_hass(hass) @@ -200,16 +309,11 @@ async def test_unexpected_existing_config_entries(hass, oauth): assert len(entries) == 2 # Invoke the reauth flow - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=old_entry.data - ) - assert result["type"] == "form" - assert result["step_id"] == "reauth_confirm" + result = await oauth.async_reauth(old_entry.data) - flows = hass.config_entries.flow.async_progress() + await oauth.async_oauth_web_flow(result) - result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {}) - await oauth.async_oauth_flow(result) + await oauth.async_finish_setup(result) # Only a single entry now exists, and the other was cleaned up entries = hass.config_entries.async_entries(DOMAIN) @@ -223,3 +327,257 @@ async def test_unexpected_existing_config_entries(hass, oauth): "type": "Bearer", "expires_in": 60, } + assert "subscriber_id" not in entry.data # not updated + + +async def test_reauth_missing_config_entry(hass): + """Test the reauth flow invoked missing existing data.""" + assert await setup.async_setup_component(hass, DOMAIN, CONFIG) + + # Invoke the reauth flow with no existing data + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=None + ) + assert result["type"] == "abort" + assert result["reason"] == "missing_configuration" + + +async def test_app_full_flow(hass, oauth): + """Check full flow.""" + assert await setup.async_setup_component(hass, DOMAIN, CONFIG) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) + + await oauth.async_oauth_app_flow(result) + entry = await oauth.async_finish_setup(result, {"code": "1234"}) + assert entry.title == "OAuth for Apps" + assert "token" in entry.data + entry.data["token"].pop("expires_at") + assert entry.unique_id == DOMAIN + assert entry.data["token"] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + } + # Subscriber from configuration.yaml + assert "subscriber_id" not in entry.data + + +async def test_app_reauth(hass, oauth): + """Test Nest reauthentication for Installed App Auth.""" + + assert await setup.async_setup_component(hass, DOMAIN, CONFIG) + + old_entry = create_config_entry( + hass, + { + "auth_implementation": APP_AUTH_DOMAIN, + "token": { + # Verify this is replaced at end of the test + "access_token": "some-revoked-token", + }, + "sdm": {}, + }, + ) + + result = await oauth.async_reauth(old_entry.data) + await oauth.async_oauth_app_flow(result) + + # Verify existing tokens are replaced + entry = await oauth.async_finish_setup(result, {"code": "1234"}) + entry.data["token"].pop("expires_at") + assert entry.unique_id == DOMAIN + assert entry.data["token"] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + } + assert entry.data["auth_implementation"] == APP_AUTH_DOMAIN + assert "subscriber_id" not in entry.data # not updated + + +async def test_pubsub_subscription(hass, oauth, subscriber): + """Check flow that creates a pub/sub subscription.""" + assert await async_setup_configflow(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) + await oauth.async_oauth_app_flow(result) + + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=subscriber, + ): + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + entry = await oauth.async_finish_setup( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) + await hass.async_block_till_done() + + assert entry.title == "OAuth for Apps" + assert "token" in entry.data + entry.data["token"].pop("expires_at") + assert entry.unique_id == DOMAIN + assert entry.data["token"] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + } + assert "subscriber_id" in entry.data + assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID + + +async def test_pubsub_subscription_auth_failure(hass, oauth): + """Check flow that creates a pub/sub subscription.""" + assert await async_setup_configflow(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) + await oauth.async_oauth_app_flow(result) + result = await oauth.async_configure(result, {"code": "1234"}) + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber.create_subscription", + side_effect=AuthException(), + ): + await oauth.async_pubsub_flow(result) + result = await oauth.async_configure( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "invalid_access_token" + + +async def test_pubsub_subscription_failure(hass, oauth): + """Check flow that creates a pub/sub subscription.""" + assert await async_setup_configflow(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) + await oauth.async_oauth_app_flow(result) + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber.create_subscription", + side_effect=GoogleNestException(), + ): + result = await oauth.async_configure( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) + await hass.async_block_till_done() + + assert result["type"] == "form" + assert "errors" in result + assert "cloud_project_id" in result["errors"] + assert result["errors"]["cloud_project_id"] == "subscriber_error" + + +async def test_pubsub_subscription_configuration_failure(hass, oauth): + """Check flow that creates a pub/sub subscription.""" + assert await async_setup_configflow(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) + await oauth.async_oauth_app_flow(result) + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber.create_subscription", + side_effect=ConfigurationException(), + ): + result = await oauth.async_configure( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) + await hass.async_block_till_done() + + assert result["type"] == "form" + assert "errors" in result + assert "cloud_project_id" in result["errors"] + assert result["errors"]["cloud_project_id"] == "bad_project_id" + + +async def test_pubsub_with_wrong_project_id(hass, oauth): + """Test a possible common misconfiguration mixing up project ids.""" + assert await async_setup_configflow(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) + await oauth.async_oauth_app_flow(result) + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + result = await oauth.async_configure( + result, {"cloud_project_id": PROJECT_ID} # SDM project id + ) + await hass.async_block_till_done() + + assert result["type"] == "form" + assert "errors" in result + assert "cloud_project_id" in result["errors"] + assert result["errors"]["cloud_project_id"] == "wrong_project_id" + + +async def test_pubsub_subscriber_config_entry_reauth(hass, oauth, subscriber): + """Test the pubsub subscriber id is preserved during reauth.""" + assert await async_setup_configflow(hass) + + old_entry = create_config_entry( + hass, + { + "auth_implementation": APP_AUTH_DOMAIN, + "subscription_id": SUBSCRIBER_ID, + "cloud_project_id": CLOUD_PROJECT_ID, + "token": { + "access_token": "some-revoked-token", + }, + "sdm": {}, + }, + ) + result = await oauth.async_reauth(old_entry.data) + await oauth.async_oauth_app_flow(result) + result = await oauth.async_configure(result, {"code": "1234"}) + + # Configure Pub/Sub + await oauth.async_pubsub_flow(result, cloud_project_id=CLOUD_PROJECT_ID) + + # Verify existing tokens are replaced + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=subscriber, + ): + entry = await oauth.async_finish_setup( + result, {"cloud_project_id": "other-cloud-project-id"} + ) + await hass.async_block_till_done() + + entry = oauth.get_config_entry() + entry.data["token"].pop("expires_at") + assert entry.unique_id == DOMAIN + assert entry.data["token"] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + } + assert entry.data["auth_implementation"] == APP_AUTH_DOMAIN + assert ( + "projects/other-cloud-project-id/subscriptions" in entry.data["subscriber_id"] + ) + assert entry.data["cloud_project_id"] == "other-cloud-project-id" diff --git a/tests/components/nest/test_events.py b/tests/components/nest/test_events.py index 6e9dd7dd40d..a2f5c21fdac 100644 --- a/tests/components/nest/test_events.py +++ b/tests/components/nest/test_events.py @@ -117,7 +117,7 @@ async def test_doorbell_chime_event(hass): "device_id": entry.device_id, "type": "doorbell_chime", "timestamp": event_time, - "nest_event_id": EVENT_ID, + "nest_event_id": EVENT_SESSION_ID, } @@ -145,7 +145,7 @@ async def test_camera_motion_event(hass): "device_id": entry.device_id, "type": "camera_motion", "timestamp": event_time, - "nest_event_id": EVENT_ID, + "nest_event_id": EVENT_SESSION_ID, } @@ -173,7 +173,7 @@ async def test_camera_sound_event(hass): "device_id": entry.device_id, "type": "camera_sound", "timestamp": event_time, - "nest_event_id": EVENT_ID, + "nest_event_id": EVENT_SESSION_ID, } @@ -201,7 +201,7 @@ async def test_camera_person_event(hass): "device_id": entry.device_id, "type": "camera_person", "timestamp": event_time, - "nest_event_id": EVENT_ID, + "nest_event_id": EVENT_SESSION_ID, } @@ -238,13 +238,13 @@ async def test_camera_multiple_event(hass): "device_id": entry.device_id, "type": "camera_motion", "timestamp": event_time, - "nest_event_id": EVENT_ID, + "nest_event_id": EVENT_SESSION_ID, } assert events[1].data == { "device_id": entry.device_id, "type": "camera_person", "timestamp": event_time, - "nest_event_id": EVENT_ID, + "nest_event_id": EVENT_SESSION_ID, } diff --git a/tests/components/nest/test_init_sdm.py b/tests/components/nest/test_init_sdm.py index 205cc34fe20..fbfd6305487 100644 --- a/tests/components/nest/test_init_sdm.py +++ b/tests/components/nest/test_init_sdm.py @@ -9,7 +9,11 @@ import copy import logging from unittest.mock import patch -from google_nest_sdm.exceptions import AuthException, GoogleNestException +from google_nest_sdm.exceptions import ( + AuthException, + ConfigurationException, + GoogleNestException, +) from homeassistant.components.nest import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -31,9 +35,10 @@ async def test_setup_success(hass, caplog): assert entries[0].state is ConfigEntryState.LOADED -async def async_setup_sdm(hass, config=CONFIG): +async def async_setup_sdm(hass, config=CONFIG, with_config=True): """Prepare test setup.""" - create_config_entry(hass) + if with_config: + create_config_entry(hass) with patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" ): @@ -60,7 +65,7 @@ async def test_setup_configuration_failure(hass, caplog): async def test_setup_susbcriber_failure(hass, caplog): """Test configuration error.""" with patch( - "homeassistant.components.nest.GoogleNestSubscriber.start_async", + "homeassistant.components.nest.api.GoogleNestSubscriber.start_async", side_effect=GoogleNestException(), ), caplog.at_level(logging.ERROR, logger="homeassistant.components.nest"): result = await async_setup_sdm(hass) @@ -74,10 +79,14 @@ async def test_setup_susbcriber_failure(hass, caplog): async def test_setup_device_manager_failure(hass, caplog): """Test configuration error.""" - with patch("homeassistant.components.nest.GoogleNestSubscriber.start_async"), patch( - "homeassistant.components.nest.GoogleNestSubscriber.async_get_device_manager", + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber.start_async" + ), patch( + "homeassistant.components.nest.api.GoogleNestSubscriber.async_get_device_manager", side_effect=GoogleNestException(), - ), caplog.at_level(logging.ERROR, logger="homeassistant.components.nest"): + ), caplog.at_level( + logging.ERROR, logger="homeassistant.components.nest" + ): result = await async_setup_sdm(hass) assert result assert len(caplog.messages) == 1 @@ -91,7 +100,7 @@ async def test_setup_device_manager_failure(hass, caplog): async def test_subscriber_auth_failure(hass, caplog): """Test configuration error.""" with patch( - "homeassistant.components.nest.GoogleNestSubscriber.start_async", + "homeassistant.components.nest.api.GoogleNestSubscriber.start_async", side_effect=AuthException(), ): result = await async_setup_sdm(hass, CONFIG) @@ -107,17 +116,53 @@ async def test_subscriber_auth_failure(hass, caplog): async def test_setup_missing_subscriber_id(hass, caplog): - """Test successful setup.""" + """Test missing susbcriber id from config and config entry.""" config = copy.deepcopy(CONFIG) del config[DOMAIN]["subscriber_id"] - with caplog.at_level(logging.ERROR, logger="homeassistant.components.nest"): + + with caplog.at_level(logging.WARNING, logger="homeassistant.components.nest"): result = await async_setup_sdm(hass, config) - assert not result + assert result assert "Configuration option" in caplog.text entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.NOT_LOADED + assert entries[0].state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_subscriber_id_config_entry(hass, caplog): + """Test successful setup with subscriber id in ConfigEntry.""" + config = copy.deepcopy(CONFIG) + subscriber_id = config[DOMAIN]["subscriber_id"] + del config[DOMAIN]["subscriber_id"] + + config_entry = create_config_entry(hass) + data = {**config_entry.data} + data["subscriber_id"] = subscriber_id + hass.config_entries.async_update_entry(config_entry, data=data) + + with caplog.at_level(logging.ERROR, logger="homeassistant.components.nest"): + await async_setup_sdm_platform(hass, PLATFORM, with_config=False) + assert not caplog.records + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + +async def test_subscriber_configuration_failure(hass, caplog): + """Test configuration error.""" + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber.start_async", + side_effect=ConfigurationException(), + ), caplog.at_level(logging.ERROR, logger="homeassistant.components.nest"): + result = await async_setup_sdm(hass, CONFIG) + assert result + assert "Configuration error: " in caplog.text + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_ERROR async def test_empty_config(hass, caplog): @@ -129,3 +174,87 @@ async def test_empty_config(hass, caplog): entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 0 + + +async def test_unload_entry(hass, caplog): + """Test successful unload of a ConfigEntry.""" + await async_setup_sdm_platform(hass, PLATFORM) + + 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_unload(entry.entry_id) + assert entry.state == ConfigEntryState.NOT_LOADED + + +async def test_remove_entry(hass, caplog): + """Test successful unload of a ConfigEntry.""" + await async_setup_sdm_platform(hass, PLATFORM) + + 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) + + entries = hass.config_entries.async_entries(DOMAIN) + assert not entries + + +async def test_remove_entry_deletes_subscriber(hass, caplog): + """Test ConfigEntry unload deletes a subscription.""" + config = copy.deepcopy(CONFIG) + subscriber_id = config[DOMAIN]["subscriber_id"] + del config[DOMAIN]["subscriber_id"] + + config_entry = create_config_entry(hass) + data = {**config_entry.data} + data["subscriber_id"] = subscriber_id + hass.config_entries.async_update_entry(config_entry, data=data) + + await async_setup_sdm_platform(hass, PLATFORM, with_config=False) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.state is ConfigEntryState.LOADED + + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber.delete_subscription", + ) as delete: + assert await hass.config_entries.async_remove(entry.entry_id) + assert delete.called + + entries = hass.config_entries.async_entries(DOMAIN) + assert not entries + + +async def test_remove_entry_delete_subscriber_failure(hass, caplog): + """Test a failure when deleting the subscription.""" + config = copy.deepcopy(CONFIG) + subscriber_id = config[DOMAIN]["subscriber_id"] + del config[DOMAIN]["subscriber_id"] + + config_entry = create_config_entry(hass) + data = {**config_entry.data} + data["subscriber_id"] = subscriber_id + hass.config_entries.async_update_entry(config_entry, data=data) + + await async_setup_sdm_platform(hass, PLATFORM, with_config=False) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.state is ConfigEntryState.LOADED + + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber.delete_subscription", + side_effect=GoogleNestException(), + ): + assert await hass.config_entries.async_remove(entry.entry_id) + + entries = hass.config_entries.async_entries(DOMAIN) + assert not entries diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py new file mode 100644 index 00000000000..22ed0721eb2 --- /dev/null +++ b/tests/components/nest/test_media_source.py @@ -0,0 +1,658 @@ +"""Test for Nest Media Source. + +These tests simulate recent camera events received by the subscriber exposed +as media in the media source. +""" + +import datetime +from http import HTTPStatus + +import aiohttp +from google_nest_sdm.device import Device +from google_nest_sdm.event import EventMessage +import pytest + +from homeassistant.components import media_source +from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.media_source import const +from homeassistant.components.media_source.error import Unresolvable +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.template import DATE_STR_FORMAT +import homeassistant.util.dt as dt_util + +from .common import async_setup_sdm_platform + +DOMAIN = "nest" +DEVICE_ID = "example/api/device/id" +DEVICE_NAME = "Front" +PLATFORM = "camera" +NEST_EVENT = "nest_event" +EVENT_ID = "1aXEvi9ajKVTdDsXdJda8fzfCa..." +EVENT_SESSION_ID = "CjY5Y3VKaTZwR3o4Y19YbTVfMF..." +CAMERA_DEVICE_TYPE = "sdm.devices.types.CAMERA" +CAMERA_TRAITS = { + "sdm.devices.traits.Info": { + "customName": DEVICE_NAME, + }, + "sdm.devices.traits.CameraImage": {}, + "sdm.devices.traits.CameraEventImage": {}, + "sdm.devices.traits.CameraPerson": {}, + "sdm.devices.traits.CameraMotion": {}, +} +BATTERY_CAMERA_TRAITS = { + "sdm.devices.traits.Info": { + "customName": DEVICE_NAME, + }, + "sdm.devices.traits.CameraClipPreview": {}, + "sdm.devices.traits.CameraLiveStream": {}, + "sdm.devices.traits.CameraPerson": {}, + "sdm.devices.traits.CameraMotion": {}, +} +PERSON_EVENT = "sdm.devices.events.CameraPerson.Person" +MOTION_EVENT = "sdm.devices.events.CameraMotion.Motion" + +TEST_IMAGE_URL = "https://domain/sdm_event_snapshot/dGTZwR3o4Y1..." +GENERATE_IMAGE_URL_RESPONSE = { + "results": { + "url": TEST_IMAGE_URL, + "token": "g.0.eventToken", + }, +} +IMAGE_BYTES_FROM_EVENT = b"test url image bytes" +IMAGE_AUTHORIZATION_HEADERS = {"Authorization": "Basic g.0.eventToken"} + + +async def async_setup_devices(hass, auth, device_type, traits={}, events=[]): + """Set up the platform and prerequisites.""" + devices = { + DEVICE_ID: Device.MakeDevice( + { + "name": DEVICE_ID, + "type": device_type, + "traits": traits, + }, + auth=auth, + ), + } + subscriber = await async_setup_sdm_platform(hass, PLATFORM, devices=devices) + if events: + for event in events: + await subscriber.async_receive_event(event) + await hass.async_block_till_done() + return subscriber + + +def create_event( + event_session_id, event_id, event_type, timestamp=None, device_id=None +): + """Create an EventMessage for a single event type.""" + if not timestamp: + timestamp = dt_util.now() + event_data = { + event_type: { + "eventSessionId": event_session_id, + "eventId": event_id, + }, + } + return create_event_message(event_data, timestamp, device_id=device_id) + + +def create_event_message(event_data, timestamp, device_id=None): + """Create an EventMessage for a single event type.""" + if device_id is None: + device_id = DEVICE_ID + return EventMessage( + { + "eventId": f"{EVENT_ID}-{timestamp}", + "timestamp": timestamp.isoformat(timespec="seconds"), + "resourceUpdate": { + "name": device_id, + "events": event_data, + }, + }, + auth=None, + ) + + +async def test_no_eligible_devices(hass, auth): + """Test a media source with no eligible camera devices.""" + await async_setup_devices( + hass, + auth, + "sdm.devices.types.THERMOSTAT", + { + "sdm.devices.traits.Temperature": {}, + }, + ) + + browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert browse.identifier == "" + assert browse.title == "Nest" + assert not browse.children + + +async def test_supported_device(hass, auth): + """Test a media source with a supported camera.""" + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + + assert len(hass.states.async_all()) == 1 + camera = hass.states.get("camera.front") + assert camera is not None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert browse.title == "Nest" + assert browse.identifier == "" + assert browse.can_expand + assert len(browse.children) == 1 + assert browse.children[0].domain == DOMAIN + assert browse.children[0].identifier == device.id + assert browse.children[0].title == "Front: Recent Events" + + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == device.id + assert browse.title == "Front: Recent Events" + assert len(browse.children) == 0 + + +async def test_camera_event(hass, auth, hass_client): + """Test a media source and image created for an event.""" + event_timestamp = dt_util.now() + await async_setup_devices( + hass, + auth, + CAMERA_DEVICE_TYPE, + CAMERA_TRAITS, + events=[ + create_event( + EVENT_SESSION_ID, + EVENT_ID, + PERSON_EVENT, + timestamp=event_timestamp, + ), + ], + ) + + assert len(hass.states.async_all()) == 1 + camera = hass.states.get("camera.front") + assert camera is not None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + # Media root directory + browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}") + assert browse.title == "Nest" + assert browse.identifier == "" + assert browse.can_expand + # A device is represented as a child directory + assert len(browse.children) == 1 + assert browse.children[0].domain == DOMAIN + assert browse.children[0].identifier == device.id + assert browse.children[0].title == "Front: Recent Events" + assert browse.children[0].can_expand + # Expanding the root does not expand the device + assert len(browse.children[0].children) == 0 + + # Browse to the device + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == device.id + assert browse.title == "Front: Recent Events" + assert browse.can_expand + # The device expands recent events + assert len(browse.children) == 1 + assert browse.children[0].domain == DOMAIN + assert browse.children[0].identifier == f"{device.id}/{EVENT_SESSION_ID}" + event_timestamp_string = event_timestamp.strftime(DATE_STR_FORMAT) + assert browse.children[0].title == f"Person @ {event_timestamp_string}" + assert not browse.children[0].can_expand + assert len(browse.children[0].children) == 0 + + # Browse to the event + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{EVENT_SESSION_ID}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == f"{device.id}/{EVENT_SESSION_ID}" + assert "Person" in browse.title + assert not browse.can_expand + assert not browse.children + + # Resolving the event links to the media + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{EVENT_SESSION_ID}" + ) + assert media.url == f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}" + assert media.mime_type == "image/jpeg" + + auth.responses = [ + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] + + client = await hass_client() + response = await client.get(media.url) + assert response.status == HTTPStatus.OK, "Response not matched: %s" % response + contents = await response.read() + assert contents == IMAGE_BYTES_FROM_EVENT + + +async def test_event_order(hass, auth): + """Test multiple events are in descending timestamp order.""" + event_session_id1 = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." + event_timestamp1 = dt_util.now() + event_session_id2 = "GXXWRWVeHNUlUU3V3MGV3bUOYW..." + event_timestamp2 = event_timestamp1 + datetime.timedelta(seconds=5) + await async_setup_devices( + hass, + auth, + CAMERA_DEVICE_TYPE, + CAMERA_TRAITS, + events=[ + create_event( + event_session_id1, + EVENT_ID + "1", + PERSON_EVENT, + timestamp=event_timestamp1, + ), + create_event( + event_session_id2, + EVENT_ID + "2", + MOTION_EVENT, + timestamp=event_timestamp2, + ), + ], + ) + + assert len(hass.states.async_all()) == 1 + camera = hass.states.get("camera.front") + assert camera is not None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == device.id + assert browse.title == "Front: Recent Events" + assert browse.can_expand + + # Motion event is most recent + assert len(browse.children) == 2 + assert browse.children[0].domain == DOMAIN + assert browse.children[0].identifier == f"{device.id}/{event_session_id2}" + event_timestamp_string = event_timestamp2.strftime(DATE_STR_FORMAT) + assert browse.children[0].title == f"Motion @ {event_timestamp_string}" + assert not browse.children[0].can_expand + + # Person event is next + assert browse.children[1].domain == DOMAIN + + assert browse.children[1].identifier == f"{device.id}/{event_session_id1}" + event_timestamp_string = event_timestamp1.strftime(DATE_STR_FORMAT) + assert browse.children[1].title == f"Person @ {event_timestamp_string}" + assert not browse.children[1].can_expand + + +async def test_browse_invalid_device_id(hass, auth): + """Test a media source request for an invalid device id.""" + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + with pytest.raises(BrowseError): + await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/invalid-device-id" + ) + + with pytest.raises(BrowseError): + await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/invalid-device-id/invalid-event-id" + ) + + +async def test_browse_invalid_event_id(hass, auth): + """Test a media source browsing for an invalid event id.""" + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == device.id + assert browse.title == "Front: Recent Events" + + with pytest.raises(BrowseError): + await media_source.async_browse_media( + hass, + f"{const.URI_SCHEME}{DOMAIN}/{device.id}/GXXWRWVeHNUlUU3V3MGV3bUOYW...", + ) + + +async def test_resolve_missing_event_id(hass, auth): + """Test a media source request missing an event id.""" + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + with pytest.raises(Unresolvable): + await media_source.async_resolve_media( + hass, + f"{const.URI_SCHEME}{DOMAIN}/{device.id}", + ) + + +async def test_resolve_invalid_device_id(hass, auth): + """Test resolving media for an invalid event id.""" + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + + with pytest.raises(Unresolvable): + await media_source.async_resolve_media( + hass, + f"{const.URI_SCHEME}{DOMAIN}/invalid-device-id/GXXWRWVeHNUlUU3V3MGV3bUOYW...", + ) + + +async def test_resolve_invalid_event_id(hass, auth): + """Test resolving media for an invalid event id.""" + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + with pytest.raises(Unresolvable): + await media_source.async_resolve_media( + hass, + f"{const.URI_SCHEME}{DOMAIN}/{device.id}/GXXWRWVeHNUlUU3V3MGV3bUOYW...", + ) + + +async def test_camera_event_clip_preview(hass, auth, hass_client): + """Test an event for a battery camera video clip.""" + event_timestamp = dt_util.now() + event_data = { + "sdm.devices.events.CameraMotion.Motion": { + "eventSessionId": EVENT_SESSION_ID, + "eventId": "n:2", + }, + "sdm.devices.events.CameraClipPreview.ClipPreview": { + "eventSessionId": EVENT_SESSION_ID, + "previewUrl": "https://127.0.0.1/example", + }, + } + await async_setup_devices( + hass, + auth, + CAMERA_DEVICE_TYPE, + BATTERY_CAMERA_TRAITS, + events=[ + create_event_message( + event_data, + timestamp=event_timestamp, + ), + ], + ) + + assert len(hass.states.async_all()) == 1 + camera = hass.states.get("camera.front") + assert camera is not None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + # Browse to the device + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == device.id + assert browse.title == "Front: Recent Events" + assert browse.can_expand + # The device expands recent events + assert len(browse.children) == 1 + assert browse.children[0].domain == DOMAIN + actual_event_id = browse.children[0].identifier + event_timestamp_string = event_timestamp.strftime(DATE_STR_FORMAT) + assert browse.children[0].title == f"Motion @ {event_timestamp_string}" + assert not browse.children[0].can_expand + assert len(browse.children[0].children) == 0 + + # Resolving the event links to the media + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{actual_event_id}" + ) + assert media.url == f"/api/nest/event_media/{actual_event_id}" + assert media.mime_type == "video/mp4" + + auth.responses = [ + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] + + client = await hass_client() + response = await client.get(media.url) + assert response.status == HTTPStatus.OK, "Response not matched: %s" % response + contents = await response.read() + assert contents == IMAGE_BYTES_FROM_EVENT + + +async def test_event_media_render_invalid_device_id(hass, auth, hass_client): + """Test event media API called with an invalid device id.""" + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + + client = await hass_client() + response = await client.get("/api/nest/event_media/invalid-device-id") + assert response.status == HTTPStatus.NOT_FOUND, ( + "Response not matched: %s" % response + ) + + +async def test_event_media_render_invalid_event_id(hass, auth, hass_client): + """Test event media API called with an invalid device id.""" + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + client = await hass_client() + response = await client.get("/api/nest/event_media/{device.id}/invalid-event-id") + assert response.status == HTTPStatus.NOT_FOUND, ( + "Response not matched: %s" % response + ) + + +async def test_event_media_failure(hass, auth, hass_client): + """Test event media fetch sees a failure from the server.""" + event_timestamp = dt_util.now() + await async_setup_devices( + hass, + auth, + CAMERA_DEVICE_TYPE, + CAMERA_TRAITS, + events=[ + create_event( + EVENT_SESSION_ID, + EVENT_ID, + PERSON_EVENT, + timestamp=event_timestamp, + ), + ], + ) + + assert len(hass.states.async_all()) == 1 + camera = hass.states.get("camera.front") + assert camera is not None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + # Resolving the event links to the media + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{EVENT_SESSION_ID}" + ) + assert media.url == f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}" + assert media.mime_type == "image/jpeg" + + auth.responses = [ + aiohttp.web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR), + ] + + client = await hass_client() + response = await client.get(media.url) + assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR, ( + "Response not matched: %s" % response + ) + + +async def test_media_permission_unauthorized(hass, auth, hass_client, hass_admin_user): + """Test case where user does not have permissions to view media.""" + event_timestamp = dt_util.now() + await async_setup_devices( + hass, + auth, + CAMERA_DEVICE_TYPE, + CAMERA_TRAITS, + events=[ + create_event( + EVENT_SESSION_ID, + EVENT_ID, + PERSON_EVENT, + timestamp=event_timestamp, + ), + ], + ) + + assert len(hass.states.async_all()) == 1 + camera = hass.states.get("camera.front") + assert camera is not None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + media_url = f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}" + + # Empty policy with no access to the entity + hass_admin_user.mock_policy({}) + + client = await hass_client() + response = await client.get(media_url) + assert response.status == HTTPStatus.UNAUTHORIZED, ( + "Response not matched: %s" % response + ) + + +async def test_multiple_devices(hass, auth, hass_client): + """Test events received for multiple devices.""" + device_id1 = f"{DEVICE_ID}-1" + device_id2 = f"{DEVICE_ID}-2" + + devices = { + device_id1: Device.MakeDevice( + { + "name": device_id1, + "type": CAMERA_DEVICE_TYPE, + "traits": CAMERA_TRAITS, + }, + auth=auth, + ), + device_id2: Device.MakeDevice( + { + "name": device_id2, + "type": CAMERA_DEVICE_TYPE, + "traits": CAMERA_TRAITS, + }, + auth=auth, + ), + } + subscriber = await async_setup_sdm_platform(hass, PLATFORM, devices=devices) + + device_registry = dr.async_get(hass) + device1 = device_registry.async_get_device({(DOMAIN, device_id1)}) + assert device1 + device2 = device_registry.async_get_device({(DOMAIN, device_id2)}) + assert device2 + + # Very no events have been received yet + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device1.id}" + ) + assert len(browse.children) == 0 + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device2.id}" + ) + assert len(browse.children) == 0 + + # Send events for device #1 + for i in range(0, 5): + await subscriber.async_receive_event( + create_event( + f"event-session-id-{i}", + f"event-id-{i}", + PERSON_EVENT, + device_id=device_id1, + ) + ) + + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device1.id}" + ) + assert len(browse.children) == 5 + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device2.id}" + ) + assert len(browse.children) == 0 + + # Send events for device #2 + for i in range(0, 3): + await subscriber.async_receive_event( + create_event( + f"other-id-{i}", f"event-id{i}", PERSON_EVENT, device_id=device_id2 + ) + ) + + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device1.id}" + ) + assert len(browse.children) == 5 + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device2.id}" + ) + assert len(browse.children) == 3 diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index f2c03ac7de1..55083171a1a 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -51,7 +51,7 @@ async def fake_post_request(*args, **kwargs): if endpoint in "snapshot_720.jpg": return b"test stream image bytes" - elif endpoint in [ + if endpoint in [ "setpersonsaway", "setpersonshome", "setstate", @@ -61,6 +61,10 @@ async def fake_post_request(*args, **kwargs): ]: payload = f'{{"{endpoint}": true}}' + elif endpoint == "homestatus": + home_id = kwargs.get("params", {}).get("home_id") + payload = json.loads(load_fixture(f"netatmo/{endpoint}_{home_id}.json")) + else: payload = json.loads(load_fixture(f"netatmo/{endpoint}.json")) diff --git a/tests/fixtures/netatmo/events.txt b/tests/components/netatmo/fixtures/events.txt similarity index 100% rename from tests/fixtures/netatmo/events.txt rename to tests/components/netatmo/fixtures/events.txt diff --git a/tests/fixtures/netatmo/gethomecoachsdata.json b/tests/components/netatmo/fixtures/gethomecoachsdata.json similarity index 100% rename from tests/fixtures/netatmo/gethomecoachsdata.json rename to tests/components/netatmo/fixtures/gethomecoachsdata.json diff --git a/tests/fixtures/netatmo/gethomedata.json b/tests/components/netatmo/fixtures/gethomedata.json similarity index 100% rename from tests/fixtures/netatmo/gethomedata.json rename to tests/components/netatmo/fixtures/gethomedata.json diff --git a/tests/fixtures/netatmo/getpublicdata.json b/tests/components/netatmo/fixtures/getpublicdata.json similarity index 100% rename from tests/fixtures/netatmo/getpublicdata.json rename to tests/components/netatmo/fixtures/getpublicdata.json diff --git a/tests/fixtures/netatmo/getstationsdata.json b/tests/components/netatmo/fixtures/getstationsdata.json similarity index 100% rename from tests/fixtures/netatmo/getstationsdata.json rename to tests/components/netatmo/fixtures/getstationsdata.json diff --git a/tests/components/netatmo/fixtures/homesdata.json b/tests/components/netatmo/fixtures/homesdata.json new file mode 100644 index 00000000000..8c6587ca973 --- /dev/null +++ b/tests/components/netatmo/fixtures/homesdata.json @@ -0,0 +1,497 @@ +{ + "body": { + "homes": [ + { + "id": "91763b24c43d3e344f424e8b", + "name": "MYHOME", + "altitude": 112, + "coordinates": [ + 52.516263, + 13.377726 + ], + "country": "DE", + "timezone": "Europe/Berlin", + "rooms": [ + { + "id": "2746182631", + "name": "Livingroom", + "type": "livingroom", + "module_ids": [ + "12:34:56:00:01:ae" + ] + }, + { + "id": "3688132631", + "name": "Hall", + "type": "custom", + "module_ids": [ + "12:34:56:00:f1:62" + ] + }, + { + "id": "2833524037", + "name": "Entrada", + "type": "lobby", + "module_ids": [ + "12:34:56:03:a5:54" + ] + }, + { + "id": "2940411577", + "name": "Cocina", + "type": "kitchen", + "module_ids": [ + "12:34:56:03:a0:ac" + ] + } + ], + "modules": [ + { + "id": "12:34:56:00:fa:d0", + "type": "NAPlug", + "name": "Thermostat", + "setup_date": 1494963356, + "modules_bridged": [ + "12:34:56:00:01:ae", + "12:34:56:03:a0:ac", + "12:34:56:03:a5:54" + ] + }, + { + "id": "12:34:56:00:01:ae", + "type": "NATherm1", + "name": "Livingroom", + "setup_date": 1494963356, + "room_id": "2746182631", + "bridge": "12:34:56:00:fa:d0" + }, + { + "id": "12:34:56:03:a5:54", + "type": "NRV", + "name": "Valve1", + "setup_date": 1554549767, + "room_id": "2833524037", + "bridge": "12:34:56:00:fa:d0" + }, + { + "id": "12:34:56:03:a0:ac", + "type": "NRV", + "name": "Valve2", + "setup_date": 1554554444, + "room_id": "2940411577", + "bridge": "12:34:56:00:fa:d0" + }, + { + "id": "12:34:56:00:f1:62", + "type": "NACamera", + "name": "Hall", + "setup_date": 1544828430, + "room_id": "3688132631" + } + ], + "schedules": [ + { + "zones": [ + { + "type": 0, + "name": "Comfort", + "rooms_temp": [ + { + "temp": 21, + "room_id": "2746182631" + } + ], + "id": 0 + }, + { + "type": 1, + "name": "Night", + "rooms_temp": [ + { + "temp": 17, + "room_id": "2746182631" + } + ], + "id": 1 + }, + { + "type": 5, + "name": "Eco", + "rooms_temp": [ + { + "temp": 17, + "room_id": "2746182631" + } + ], + "id": 4 + } + ], + "timetable": [ + { + "zone_id": 1, + "m_offset": 0 + }, + { + "zone_id": 0, + "m_offset": 360 + }, + { + "zone_id": 4, + "m_offset": 420 + }, + { + "zone_id": 0, + "m_offset": 960 + }, + { + "zone_id": 1, + "m_offset": 1410 + }, + { + "zone_id": 0, + "m_offset": 1800 + }, + { + "zone_id": 4, + "m_offset": 1860 + }, + { + "zone_id": 0, + "m_offset": 2400 + }, + { + "zone_id": 1, + "m_offset": 2850 + }, + { + "zone_id": 0, + "m_offset": 3240 + }, + { + "zone_id": 4, + "m_offset": 3300 + }, + { + "zone_id": 0, + "m_offset": 3840 + }, + { + "zone_id": 1, + "m_offset": 4290 + }, + { + "zone_id": 0, + "m_offset": 4680 + }, + { + "zone_id": 4, + "m_offset": 4740 + }, + { + "zone_id": 0, + "m_offset": 5280 + }, + { + "zone_id": 1, + "m_offset": 5730 + }, + { + "zone_id": 0, + "m_offset": 6120 + }, + { + "zone_id": 4, + "m_offset": 6180 + }, + { + "zone_id": 0, + "m_offset": 6720 + }, + { + "zone_id": 1, + "m_offset": 7170 + }, + { + "zone_id": 0, + "m_offset": 7620 + }, + { + "zone_id": 1, + "m_offset": 8610 + }, + { + "zone_id": 0, + "m_offset": 9060 + }, + { + "zone_id": 1, + "m_offset": 10050 + } + ], + "hg_temp": 7, + "away_temp": 14, + "name": "Default", + "selected": true, + "id": "591b54a2764ff4d50d8b5795", + "type": "therm" + }, + { + "zones": [ + { + "type": 0, + "name": "Comfort", + "rooms_temp": [ + { + "temp": 21, + "room_id": "2746182631" + } + ], + "id": 0 + }, + { + "type": 1, + "name": "Night", + "rooms_temp": [ + { + "temp": 17, + "room_id": "2746182631" + } + ], + "id": 1 + }, + { + "type": 5, + "name": "Eco", + "rooms_temp": [ + { + "temp": 17, + "room_id": "2746182631" + } + ], + "id": 4 + } + ], + "timetable": [ + { + "zone_id": 1, + "m_offset": 0 + }, + { + "zone_id": 0, + "m_offset": 360 + }, + { + "zone_id": 4, + "m_offset": 420 + }, + { + "zone_id": 0, + "m_offset": 960 + }, + { + "zone_id": 1, + "m_offset": 1410 + }, + { + "zone_id": 0, + "m_offset": 1800 + }, + { + "zone_id": 4, + "m_offset": 1860 + }, + { + "zone_id": 0, + "m_offset": 2400 + }, + { + "zone_id": 1, + "m_offset": 2850 + }, + { + "zone_id": 0, + "m_offset": 3240 + }, + { + "zone_id": 4, + "m_offset": 3300 + }, + { + "zone_id": 0, + "m_offset": 3840 + }, + { + "zone_id": 1, + "m_offset": 4290 + }, + { + "zone_id": 0, + "m_offset": 4680 + }, + { + "zone_id": 4, + "m_offset": 4740 + }, + { + "zone_id": 0, + "m_offset": 5280 + }, + { + "zone_id": 1, + "m_offset": 5730 + }, + { + "zone_id": 0, + "m_offset": 6120 + }, + { + "zone_id": 4, + "m_offset": 6180 + }, + { + "zone_id": 0, + "m_offset": 6720 + }, + { + "zone_id": 1, + "m_offset": 7170 + }, + { + "zone_id": 0, + "m_offset": 7620 + }, + { + "zone_id": 1, + "m_offset": 8610 + }, + { + "zone_id": 0, + "m_offset": 9060 + }, + { + "zone_id": 1, + "m_offset": 10050 + } + ], + "hg_temp": 7, + "away_temp": 14, + "name": "Winter", + "id": "b1b54a2f45795764f59d50d8", + "type": "therm" + } + ], + "therm_setpoint_default_duration": 120, + "persons": [ + { + "id": "91827374-7e04-5298-83ad-a0cb8372dff1", + "pseudo": "John Doe", + "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c24b808a89f8d1730fb69ecdf2bb8b72039d2c69928b029d67fc40cb2d7" + }, + { + "id": "91827375-7e04-5298-83ae-a0cb8372dff2", + "pseudo": "Jane Doe", + "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c24b808a89f8d1730039d2c69928b029d67fc40cb2d7fb69ecdf2bb8b72" + }, + { + "id": "91827376-7e04-5298-83af-a0cb8372dff3", + "pseudo": "Richard Doe", + "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c2d1730fb69ecdf2bb8b72039d2c69928b029d67fc40cb2d74b808a89f8" + } + ], + "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" + } + ] + } + ], + "user": { + "email": "john@doe.com", + "language": "de-DE", + "locale": "de-DE", + "feel_like_algorithm": 0, + "unit_pressure": 0, + "unit_system": 0, + "unit_wind": 0, + "id": "91763b24c43d3e344f424e8b" + } + }, + "status": "ok", + "time_exec": 0.056135892868042, + "time_server": 1559171003 +} \ No newline at end of file diff --git a/tests/components/netatmo/fixtures/homestatus_111111111111111111111401.json b/tests/components/netatmo/fixtures/homestatus_111111111111111111111401.json new file mode 100644 index 00000000000..2ae65dc0d21 --- /dev/null +++ b/tests/components/netatmo/fixtures/homestatus_111111111111111111111401.json @@ -0,0 +1,4 @@ +{ + "status": "ok", + "time_server": 1638873670 +} \ No newline at end of file diff --git a/tests/fixtures/netatmo/homestatus.json b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json similarity index 100% rename from tests/fixtures/netatmo/homestatus.json rename to tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json diff --git a/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8c.json b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8c.json new file mode 100644 index 00000000000..d950c82a6a5 --- /dev/null +++ b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8c.json @@ -0,0 +1,12 @@ +{ + "status": "ok", + "time_server": 1559292041, + "body": { + "home": { + "modules": [], + "rooms": [], + "id": "91763b24c43d3e344f424e8c", + "persons": [] + } + } +} diff --git a/tests/fixtures/netatmo/ping.json b/tests/components/netatmo/fixtures/ping.json similarity index 100% rename from tests/fixtures/netatmo/ping.json rename to tests/components/netatmo/fixtures/ping.json diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index ef7f8884e2e..b61081252a0 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -1,5 +1,5 @@ """The tests for the Netatmo climate platform.""" -from unittest.mock import Mock, patch +from unittest.mock import patch from homeassistant.components.climate import ( DOMAIN as CLIMATE_DOMAIN, @@ -18,7 +18,6 @@ from homeassistant.components.climate.const import ( PRESET_AWAY, PRESET_BOOST, ) -from homeassistant.components.netatmo import climate from homeassistant.components.netatmo.climate import PRESET_FROST_GUARD, PRESET_SCHEDULE from homeassistant.components.netatmo.const import ( ATTR_SCHEDULE_NAME, @@ -37,7 +36,7 @@ async def test_webhook_event_handling_thermostats(hass, config_entry, netatmo_au await hass.async_block_till_done() webhook_id = config_entry.data[CONF_WEBHOOK_ID] - climate_entity_livingroom = "climate.netatmo_livingroom" + climate_entity_livingroom = "climate.livingroom" assert hass.states.get(climate_entity_livingroom).state == "auto" assert ( @@ -214,7 +213,7 @@ async def test_service_preset_mode_frost_guard_thermostat( await hass.async_block_till_done() webhook_id = config_entry.data[CONF_WEBHOOK_ID] - climate_entity_livingroom = "climate.netatmo_livingroom" + climate_entity_livingroom = "climate.livingroom" assert hass.states.get(climate_entity_livingroom).state == "auto" assert ( @@ -287,7 +286,7 @@ async def test_service_preset_modes_thermostat(hass, config_entry, netatmo_auth) await hass.async_block_till_done() webhook_id = config_entry.data[CONF_WEBHOOK_ID] - climate_entity_livingroom = "climate.netatmo_livingroom" + climate_entity_livingroom = "climate.livingroom" assert hass.states.get(climate_entity_livingroom).state == "auto" assert ( @@ -415,11 +414,11 @@ async def test_service_schedule_thermostats(hass, config_entry, caplog, netatmo_ await hass.async_block_till_done() webhook_id = config_entry.data[CONF_WEBHOOK_ID] - climate_entity_livingroom = "climate.netatmo_livingroom" + climate_entity_livingroom = "climate.livingroom" # Test setting a valid schedule with patch( - "pyatmo.thermostat.AsyncHomeData.async_switch_home_schedule" + "pyatmo.climate.AsyncClimate.async_switch_home_schedule" ) as mock_switch_home_schedule: await hass.services.async_call( "netatmo", @@ -429,7 +428,7 @@ async def test_service_schedule_thermostats(hass, config_entry, caplog, netatmo_ ) await hass.async_block_till_done() mock_switch_home_schedule.assert_called_once_with( - home_id="91763b24c43d3e344f424e8b", schedule_id="b1b54a2f45795764f59d50d8" + schedule_id="b1b54a2f45795764f59d50d8" ) # Fake backend response for valve being turned on @@ -448,7 +447,7 @@ async def test_service_schedule_thermostats(hass, config_entry, caplog, netatmo_ # Test setting an invalid schedule with patch( - "pyatmo.thermostat.AsyncHomeData.async_switch_home_schedule" + "pyatmo.climate.AsyncClimate.async_switch_home_schedule" ) as mock_switch_home_schedule: await hass.services.async_call( "netatmo", @@ -472,7 +471,7 @@ async def test_service_preset_mode_already_boost_valves( await hass.async_block_till_done() webhook_id = config_entry.data[CONF_WEBHOOK_ID] - climate_entity_entrada = "climate.netatmo_entrada" + climate_entity_entrada = "climate.entrada" assert hass.states.get(climate_entity_entrada).state == "auto" assert ( @@ -550,7 +549,7 @@ async def test_service_preset_mode_boost_valves(hass, config_entry, netatmo_auth await hass.async_block_till_done() webhook_id = config_entry.data[CONF_WEBHOOK_ID] - climate_entity_entrada = "climate.netatmo_entrada" + climate_entity_entrada = "climate.entrada" # Test service setting the preset mode to "boost" assert hass.states.get(climate_entity_entrada).state == "auto" @@ -602,7 +601,7 @@ async def test_service_preset_mode_invalid(hass, config_entry, caplog, netatmo_a await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.netatmo_cocina", ATTR_PRESET_MODE: "invalid"}, + {ATTR_ENTITY_ID: "climate.cocina", ATTR_PRESET_MODE: "invalid"}, blocking=True, ) await hass.async_block_till_done() @@ -618,7 +617,12 @@ async def test_valves_service_turn_off(hass, config_entry, netatmo_auth): await hass.async_block_till_done() webhook_id = config_entry.data[CONF_WEBHOOK_ID] - climate_entity_entrada = "climate.netatmo_entrada" + climate_entity_entrada = "climate.entrada" + + assert hass.states.get(climate_entity_entrada).attributes["hvac_modes"] == [ + "auto", + "heat", + ] # Test turning valve off await hass.services.async_call( @@ -663,7 +667,7 @@ async def test_valves_service_turn_on(hass, config_entry, netatmo_auth): await hass.async_block_till_done() webhook_id = config_entry.data[CONF_WEBHOOK_ID] - climate_entity_entrada = "climate.netatmo_entrada" + climate_entity_entrada = "climate.entrada" # Test turning valve on await hass.services.async_call( @@ -700,21 +704,6 @@ async def test_valves_service_turn_on(hass, config_entry, netatmo_auth): assert hass.states.get(climate_entity_entrada).state == "auto" -async def test_get_all_home_ids(): - """Test extracting all home ids returned by NetAtmo API.""" - # Test with backend returning no data - assert climate.get_all_home_ids(None) == [] - - # Test with fake data - home_data = Mock() - home_data.homes = { - "123": {"id": "123", "name": "Home 1", "modules": [], "therm_schedules": []}, - "987": {"id": "987", "name": "Home 2", "modules": [], "therm_schedules": []}, - } - expected = ["123", "987"] - assert climate.get_all_home_ids(home_data) == expected - - async def test_webhook_home_id_mismatch(hass, config_entry, netatmo_auth): """Test service turn on for valves.""" with selected_platforms(["climate"]): @@ -723,7 +712,7 @@ async def test_webhook_home_id_mismatch(hass, config_entry, netatmo_auth): await hass.async_block_till_done() webhook_id = config_entry.data[CONF_WEBHOOK_ID] - climate_entity_entrada = "climate.netatmo_entrada" + climate_entity_entrada = "climate.entrada" assert hass.states.get(climate_entity_entrada).state == "auto" @@ -761,7 +750,7 @@ async def test_webhook_set_point(hass, config_entry, netatmo_auth): await hass.async_block_till_done() webhook_id = config_entry.data[CONF_WEBHOOK_ID] - climate_entity_entrada = "climate.netatmo_entrada" + climate_entity_entrada = "climate.entrada" # Fake backend response for valve being turned on response = { diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index fbc1c62c0b1..b97f4c8b4ec 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import patch from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components import zeroconf from homeassistant.components.netatmo import config_flow from homeassistant.components.netatmo.const import ( CONF_NEW_AREA, @@ -39,7 +40,14 @@ async def test_abort_if_existing_entry(hass): result = await hass.config_entries.flow.async_init( "netatmo", context={"source": config_entries.SOURCE_HOMEKIT}, - data={"host": "0.0.0.0", "properties": {"id": "aa:bb:cc:dd:ee:ff"}}, + data=zeroconf.ZeroconfServiceInfo( + host="0.0.0.0", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + type="mock_type", + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 2d0c43ac3f6..418854a61a2 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -136,7 +136,7 @@ async def test_setup_component_with_webhook(hass, config_entry, netatmo_auth): await simulate_webhook(hass, webhook_id, FAKE_WEBHOOK_ACTIVATION) # Assert webhook is established successfully - climate_entity_livingroom = "climate.netatmo_livingroom" + climate_entity_livingroom = "climate.livingroom" assert hass.states.get(climate_entity_livingroom).state == "auto" await simulate_webhook(hass, webhook_id, FAKE_WEBHOOK) assert hass.states.get(climate_entity_livingroom).state == "heat" @@ -440,7 +440,6 @@ async def test_setup_component_invalid_token(hass, config_entry): """Test handling of invalid token.""" async def fake_ensure_valid_token(*args, **kwargs): - print("fake_ensure_valid_token") raise aiohttp.ClientResponseError( request_info=aiohttp.client.RequestInfo( url="http://example.com", diff --git a/tests/components/netatmo/test_select.py b/tests/components/netatmo/test_select.py index f0e7cde7359..de357ffda89 100644 --- a/tests/components/netatmo/test_select.py +++ b/tests/components/netatmo/test_select.py @@ -38,7 +38,7 @@ async def test_select_schedule_thermostats(hass, config_entry, caplog, netatmo_a # Test setting a different schedule with patch( - "pyatmo.thermostat.AsyncHomeData.async_switch_home_schedule" + "pyatmo.climate.AsyncClimate.async_switch_home_schedule" ) as mock_switch_home_schedule: await hass.services.async_call( SELECT_DOMAIN, @@ -51,7 +51,7 @@ async def test_select_schedule_thermostats(hass, config_entry, caplog, netatmo_a ) await hass.async_block_till_done() mock_switch_home_schedule.assert_called_once_with( - home_id="91763b24c43d3e344f424e8b", schedule_id="591b54a2764ff4d50d8b5795" + schedule_id="591b54a2764ff4d50d8b5795" ) # Fake backend response changing schedule diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index bebd8e0191c..b1b5b11265a 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -233,3 +233,17 @@ async def test_weather_sensor_enabling( assert len(hass.states.async_all()) > states_before assert hass.states.get(f"sensor.{name}").state == expected + + +async def test_climate_battery_sensor(hass, config_entry, netatmo_auth): + """Test climate device battery sensor.""" + with patch("time.time", return_value=TEST_TIME), selected_platforms( + ["sensor", "climate"] + ): + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + prefix = "sensor.livingroom_" + + assert hass.states.get(f"{prefix}battery_percent").state == "75" diff --git a/tests/components/netgear/test_config_flow.py b/tests/components/netgear/test_config_flow.py index b81171196c0..b26dce8d936 100644 --- a/tests/components/netgear/test_config_flow.py +++ b/tests/components/netgear/test_config_flow.py @@ -211,12 +211,16 @@ async def test_ssdp_already_configured(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: SSDP_URL_SLL, - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=SSDP_URL_SLL, + upnp={ + ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", + ssdp.ATTR_UPNP_PRESENTATION_URL: URL, + ssdp.ATTR_UPNP_SERIAL: SERIAL, + }, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" @@ -227,12 +231,16 @@ async def test_ssdp(hass, service): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: SSDP_URL, - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=SSDP_URL, + upnp={ + ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", + ssdp.ATTR_UPNP_PRESENTATION_URL: URL, + ssdp.ATTR_UPNP_SERIAL: SERIAL, + }, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" diff --git a/tests/components/network/conftest.py b/tests/components/network/conftest.py new file mode 100644 index 00000000000..9c1bf232d7b --- /dev/null +++ b/tests/components/network/conftest.py @@ -0,0 +1,9 @@ +"""Tests for the Network Configuration integration.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_get_source_ip(): + """Override mock of network util's async_get_source_ip.""" + return diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index 12d317e826a..5a6802a14fb 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -8,6 +8,7 @@ from homeassistant.components import network from homeassistant.components.network.const import ( ATTR_ADAPTERS, ATTR_CONFIGURED_ADAPTERS, + DOMAIN, MDNS_TARGET_IP, STORAGE_KEY, STORAGE_VERSION, @@ -59,10 +60,10 @@ async def test_async_detect_interfaces_setting_non_loopback_route(hass, hass_sto "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), ): - assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() - network_obj = hass.data[network.DOMAIN] + network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == [] assert network_obj.adapters == [ @@ -121,10 +122,10 @@ async def test_async_detect_interfaces_setting_loopback_route(hass, hass_storage "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), ): - assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() - network_obj = hass.data[network.DOMAIN] + network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == [] assert network_obj.adapters == [ { @@ -182,10 +183,10 @@ async def test_async_detect_interfaces_setting_empty_route(hass, hass_storage): "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), ): - assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() - network_obj = hass.data[network.DOMAIN] + network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == [] assert network_obj.adapters == [ { @@ -243,10 +244,10 @@ async def test_async_detect_interfaces_setting_exception(hass, hass_storage): "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), ): - assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() - network_obj = hass.data[network.DOMAIN] + network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == [] assert network_obj.adapters == [ { @@ -309,10 +310,10 @@ async def test_interfaces_configured_from_storage(hass, hass_storage): "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), ): - assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() - network_obj = hass.data[network.DOMAIN] + network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == ["eth0", "eth1", "vtun0"] assert network_obj.adapters == [ @@ -378,10 +379,10 @@ async def test_interfaces_configured_from_storage_websocket_update( "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), ): - assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() - network_obj = hass.data[network.DOMAIN] + network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == ["eth0", "eth1", "vtun0"] ws_client = await hass_ws_client(hass) await ws_client.send_json({"id": 1, "type": "network"}) @@ -507,7 +508,7 @@ async def test_async_get_source_ip_matching_interface(hass, hass_storage): "homeassistant.components.network.util.socket.socket", return_value=_mock_socket(["192.168.1.5"]), ): - assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" @@ -528,7 +529,7 @@ async def test_async_get_source_ip_interface_not_match(hass, hass_storage): "homeassistant.components.network.util.socket.socket", return_value=_mock_socket(["192.168.1.5"]), ): - assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "169.254.3.2" @@ -549,7 +550,7 @@ async def test_async_get_source_ip_cannot_determine_target(hass, hass_storage): "homeassistant.components.network.util.socket.socket", return_value=_mock_socket([None]), ): - assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" @@ -570,7 +571,7 @@ async def test_async_get_ipv4_broadcast_addresses_default(hass, hass_storage): "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), ): - assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() assert await network.async_get_ipv4_broadcast_addresses(hass) == { @@ -593,7 +594,7 @@ async def test_async_get_ipv4_broadcast_addresses_multiple(hass, hass_storage): "homeassistant.components.network.util.ifaddr.get_adapters", return_value=_generate_mock_adapters(), ): - assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() assert await network.async_get_ipv4_broadcast_addresses(hass) == { diff --git a/tests/fixtures/nexia/mobile_houses_123456.json b/tests/components/nexia/fixtures/mobile_houses_123456.json similarity index 100% rename from tests/fixtures/nexia/mobile_houses_123456.json rename to tests/components/nexia/fixtures/mobile_houses_123456.json diff --git a/tests/fixtures/nexia/session_123456.json b/tests/components/nexia/fixtures/session_123456.json similarity index 100% rename from tests/fixtures/nexia/session_123456.json rename to tests/components/nexia/fixtures/session_123456.json diff --git a/tests/fixtures/nexia/sign_in.json b/tests/components/nexia/fixtures/sign_in.json similarity index 100% rename from tests/fixtures/nexia/sign_in.json rename to tests/components/nexia/fixtures/sign_in.json diff --git a/tests/components/nuki/test_config_flow.py b/tests/components/nuki/test_config_flow.py index 8e3636dbdef..fd7bfa2137b 100644 --- a/tests/components/nuki/test_config_flow.py +++ b/tests/components/nuki/test_config_flow.py @@ -5,7 +5,7 @@ from pynuki.bridge import InvalidCredentialsException from requests.exceptions import RequestException from homeassistant import config_entries, data_entry_flow -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp from homeassistant.components.nuki.const import DOMAIN from homeassistant.const import CONF_TOKEN @@ -178,7 +178,7 @@ async def test_dhcp_flow(hass): """Test that DHCP discovery for new bridge works.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data={HOSTNAME: NAME, IP_ADDRESS: HOST, MAC_ADDRESS: MAC}, + data=dhcp.DhcpServiceInfo(hostname=NAME, ip=HOST, macaddress=MAC), context={"source": config_entries.SOURCE_DHCP}, ) @@ -221,7 +221,7 @@ async def test_dhcp_flow_already_configured(hass): await setup_nuki_integration(hass) result = await hass.config_entries.flow.async_init( DOMAIN, - data={HOSTNAME: NAME, IP_ADDRESS: HOST, MAC_ADDRESS: MAC}, + data=dhcp.DhcpServiceInfo(hostname=NAME, ip=HOST, macaddress=MAC), context={"source": config_entries.SOURCE_DHCP}, ) diff --git a/tests/fixtures/nut/5E650I.json b/tests/components/nut/fixtures/5E650I.json similarity index 100% rename from tests/fixtures/nut/5E650I.json rename to tests/components/nut/fixtures/5E650I.json diff --git a/tests/fixtures/nut/5E850I.json b/tests/components/nut/fixtures/5E850I.json similarity index 100% rename from tests/fixtures/nut/5E850I.json rename to tests/components/nut/fixtures/5E850I.json diff --git a/tests/fixtures/nut/BACKUPSES600M1.json b/tests/components/nut/fixtures/BACKUPSES600M1.json similarity index 100% rename from tests/fixtures/nut/BACKUPSES600M1.json rename to tests/components/nut/fixtures/BACKUPSES600M1.json diff --git a/tests/fixtures/nut/CP1350C.json b/tests/components/nut/fixtures/CP1350C.json similarity index 100% rename from tests/fixtures/nut/CP1350C.json rename to tests/components/nut/fixtures/CP1350C.json diff --git a/tests/fixtures/nut/CP1500PFCLCD.json b/tests/components/nut/fixtures/CP1500PFCLCD.json similarity index 100% rename from tests/fixtures/nut/CP1500PFCLCD.json rename to tests/components/nut/fixtures/CP1500PFCLCD.json diff --git a/tests/fixtures/nut/DL650ELCD.json b/tests/components/nut/fixtures/DL650ELCD.json similarity index 100% rename from tests/fixtures/nut/DL650ELCD.json rename to tests/components/nut/fixtures/DL650ELCD.json diff --git a/tests/fixtures/nut/PR3000RT2U.json b/tests/components/nut/fixtures/PR3000RT2U.json similarity index 100% rename from tests/fixtures/nut/PR3000RT2U.json rename to tests/components/nut/fixtures/PR3000RT2U.json diff --git a/tests/fixtures/nut/blazer_usb.json b/tests/components/nut/fixtures/blazer_usb.json similarity index 100% rename from tests/fixtures/nut/blazer_usb.json rename to tests/components/nut/fixtures/blazer_usb.json diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index 68b5356a32c..733d5807c5f 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -5,6 +5,7 @@ from unittest.mock import patch from pynut2.nut2 import PyNUTError from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components import zeroconf from homeassistant.components.nut.const import DOMAIN from homeassistant.const import ( CONF_ALIAS, @@ -34,7 +35,14 @@ async def test_form_zeroconf(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={CONF_HOST: "192.168.1.5", CONF_PORT: 1234}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.5", + hostname="mock_hostname", + name="mock_name", + port=1234, + properties={}, + type="mock_type", + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" @@ -44,18 +52,6 @@ async def test_form_zeroconf(hass): list_vars={"battery.voltage": "voltage", "ups.status": "OL"}, list_ups=["ups1"] ) - with patch( - "homeassistant.components.nut.PyNUTClient", - return_value=mock_pynut, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, - ) - - assert result2["step_id"] == "resources" - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - with patch( "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut, @@ -63,22 +59,21 @@ async def test_form_zeroconf(hass): "homeassistant.components.nut.async_setup_entry", return_value=True, ) as mock_setup_entry: - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {CONF_RESOURCES: ["battery.voltage", "ups.status", "ups.status.display"]}, + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, ) await hass.async_block_till_done() - assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result3["title"] == "192.168.1.5:1234" - assert result3["data"] == { + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "192.168.1.5:1234" + assert result2["data"] == { CONF_HOST: "192.168.1.5", CONF_PASSWORD: "test-password", CONF_PORT: 1234, - CONF_RESOURCES: ["battery.voltage", "ups.status", "ups.status.display"], CONF_USERNAME: "test-username", } - assert result3["result"].unique_id is None + assert result2["result"].unique_id is None assert len(mock_setup_entry.mock_calls) == 1 @@ -98,7 +93,10 @@ async def test_form_user_one_ups(hass): with patch( "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut, - ): + ), patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -108,30 +106,14 @@ async def test_form_user_one_ups(hass): CONF_PORT: 2222, }, ) - - assert result2["step_id"] == "resources" - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - - with patch( - "homeassistant.components.nut.PyNUTClient", - return_value=mock_pynut, - ), patch( - "homeassistant.components.nut.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {CONF_RESOURCES: ["battery.voltage", "ups.status", "ups.status.display"]}, - ) await hass.async_block_till_done() - assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result3["title"] == "1.1.1.1:2222" - assert result3["data"] == { + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "1.1.1.1:2222" + assert result2["data"] == { CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password", CONF_PORT: 2222, - CONF_RESOURCES: ["battery.voltage", "ups.status", "ups.status.display"], CONF_USERNAME: "test-username", } assert len(mock_setup_entry.mock_calls) == 1 @@ -175,18 +157,6 @@ async def test_form_user_multiple_ups(hass): assert result2["step_id"] == "ups" assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - with patch( - "homeassistant.components.nut.PyNUTClient", - return_value=mock_pynut, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {CONF_ALIAS: "ups2"}, - ) - - assert result3["step_id"] == "resources" - assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM - with patch( "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut, @@ -194,20 +164,19 @@ async def test_form_user_multiple_ups(hass): "homeassistant.components.nut.async_setup_entry", return_value=True, ) as mock_setup_entry: - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - {CONF_RESOURCES: ["battery.voltage"]}, + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: "ups2"}, ) await hass.async_block_till_done() - assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result4["title"] == "ups2@1.1.1.1:2222" - assert result4["data"] == { + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == "ups2@1.1.1.1:2222" + assert result3["data"] == { CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password", CONF_ALIAS: "ups2", CONF_PORT: 2222, - CONF_RESOURCES: ["battery.voltage"], CONF_USERNAME: "test-username", } assert len(mock_setup_entry.mock_calls) == 2 @@ -234,7 +203,10 @@ async def test_form_user_one_ups_with_ignored_entry(hass): with patch( "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut, - ): + ), patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -244,30 +216,14 @@ async def test_form_user_one_ups_with_ignored_entry(hass): CONF_PORT: 2222, }, ) - - assert result2["step_id"] == "resources" - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - - with patch( - "homeassistant.components.nut.PyNUTClient", - return_value=mock_pynut, - ), patch( - "homeassistant.components.nut.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {CONF_RESOURCES: ["battery.voltage", "ups.status", "ups.status.display"]}, - ) await hass.async_block_till_done() - assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result3["title"] == "1.1.1.1:2222" - assert result3["data"] == { + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "1.1.1.1:2222" + assert result2["data"] == { CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password", CONF_PORT: 2222, - CONF_RESOURCES: ["battery.voltage", "ups.status", "ups.status.display"], CONF_USERNAME: "test-username", } assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/nut/test_init.py b/tests/components/nut/test_init.py index b9b5441a86c..24b23d00f2a 100644 --- a/tests/components/nut/test_init.py +++ b/tests/components/nut/test_init.py @@ -3,7 +3,7 @@ from unittest.mock import patch from homeassistant.components.nut.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_RESOURCES, STATE_UNAVAILABLE +from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE from .util import _get_mock_pynutclient @@ -14,11 +14,7 @@ async def test_async_setup_entry(hass): """Test a successful setup entry.""" entry = MockConfigEntry( domain=DOMAIN, - data={ - CONF_HOST: "mock", - CONF_PORT: "mock", - CONF_RESOURCES: ["ups.status"], - }, + data={CONF_HOST: "mock", CONF_PORT: "mock"}, ) entry.add_to_hass(hass) @@ -52,11 +48,7 @@ async def test_config_not_ready(hass): """Test for setup failure if connection to broker is missing.""" entry = MockConfigEntry( domain=DOMAIN, - data={ - CONF_HOST: "mock", - CONF_PORT: "mock", - CONF_RESOURCES: ["ups.status"], - }, + data={CONF_HOST: "mock", CONF_PORT: "mock"}, ) entry.add_to_hass(hass) diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index 5afc662251c..b36c6e8bcc4 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -20,7 +20,7 @@ from tests.common import MockConfigEntry async def test_pr3000rt2u(hass): """Test creation of PR3000RT2U sensors.""" - await async_init_integration(hass, "PR3000RT2U", ["battery.charge"]) + await async_init_integration(hass, "PR3000RT2U") registry = er.async_get(hass) entry = registry.async_get("sensor.ups1_battery_charge") assert entry @@ -44,7 +44,7 @@ async def test_pr3000rt2u(hass): async def test_cp1350c(hass): """Test creation of CP1350C sensors.""" - config_entry = await async_init_integration(hass, "CP1350C", ["battery.charge"]) + config_entry = await async_init_integration(hass, "CP1350C") registry = er.async_get(hass) entry = registry.async_get("sensor.ups1_battery_charge") @@ -69,7 +69,7 @@ async def test_cp1350c(hass): async def test_5e850i(hass): """Test creation of 5E850I sensors.""" - config_entry = await async_init_integration(hass, "5E850I", ["battery.charge"]) + config_entry = await async_init_integration(hass, "5E850I") registry = er.async_get(hass) entry = registry.async_get("sensor.ups1_battery_charge") assert entry @@ -93,7 +93,7 @@ async def test_5e850i(hass): async def test_5e650i(hass): """Test creation of 5E650I sensors.""" - config_entry = await async_init_integration(hass, "5E650I", ["battery.charge"]) + config_entry = await async_init_integration(hass, "5E650I") registry = er.async_get(hass) entry = registry.async_get("sensor.ups1_battery_charge") assert entry @@ -117,7 +117,7 @@ async def test_5e650i(hass): async def test_backupsses600m1(hass): """Test creation of BACKUPSES600M1 sensors.""" - await async_init_integration(hass, "BACKUPSES600M1", ["battery.charge"]) + await async_init_integration(hass, "BACKUPSES600M1") registry = er.async_get(hass) entry = registry.async_get("sensor.ups1_battery_charge") assert entry @@ -144,9 +144,7 @@ async def test_backupsses600m1(hass): async def test_cp1500pfclcd(hass): """Test creation of CP1500PFCLCD sensors.""" - config_entry = await async_init_integration( - hass, "CP1500PFCLCD", ["battery.charge"] - ) + config_entry = await async_init_integration(hass, "CP1500PFCLCD") registry = er.async_get(hass) entry = registry.async_get("sensor.ups1_battery_charge") assert entry @@ -170,7 +168,7 @@ async def test_cp1500pfclcd(hass): async def test_dl650elcd(hass): """Test creation of DL650ELCD sensors.""" - config_entry = await async_init_integration(hass, "DL650ELCD", ["battery.charge"]) + config_entry = await async_init_integration(hass, "DL650ELCD") registry = er.async_get(hass) entry = registry.async_get("sensor.ups1_battery_charge") assert entry @@ -194,7 +192,7 @@ async def test_dl650elcd(hass): async def test_blazer_usb(hass): """Test creation of blazer_usb sensors.""" - config_entry = await async_init_integration(hass, "blazer_usb", ["battery.charge"]) + config_entry = await async_init_integration(hass, "blazer_usb") registry = er.async_get(hass) entry = registry.async_get("sensor.ups1_battery_charge") assert entry @@ -219,11 +217,7 @@ async def test_state_sensors(hass): """Test creation of status display sensors.""" entry = MockConfigEntry( domain=DOMAIN, - data={ - CONF_HOST: "mock", - CONF_PORT: "mock", - CONF_RESOURCES: ["ups.status", "ups.status.display"], - }, + data={CONF_HOST: "mock", CONF_PORT: "mock"}, ) entry.add_to_hass(hass) @@ -248,11 +242,7 @@ async def test_unknown_state_sensors(hass): """Test creation of unknown status display sensors.""" entry = MockConfigEntry( domain=DOMAIN, - data={ - CONF_HOST: "mock", - CONF_PORT: "mock", - CONF_RESOURCES: ["ups.status", "ups.status.display"], - }, + data={CONF_HOST: "mock", CONF_PORT: "mock"}, ) entry.add_to_hass(hass) @@ -275,12 +265,34 @@ async def test_unknown_state_sensors(hass): async def test_stale_options(hass): """Test creation of sensors with stale options to remove.""" - - config_entry = await async_init_integration( - hass, "blazer_usb", ["battery.charge"], True + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "mock", + CONF_PORT: "mock", + CONF_RESOURCES: ["ups.load"], + }, + options={CONF_RESOURCES: ["battery.charge"]}, ) - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") - assert entry - assert entry.unique_id == f"{config_entry.entry_id}_battery.charge" - assert config_entry.options == {} + config_entry.add_to_hass(hass) + + mock_pynut = _get_mock_pynutclient( + list_ups={"ups1": "UPS 1"}, list_vars={"battery.charge": "10"} + ) + + with patch( + "homeassistant.components.nut.PyNUTClient", + return_value=mock_pynut, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + registry = er.async_get(hass) + entry = registry.async_get("sensor.ups1_battery_charge") + assert entry + assert entry.unique_id == f"{config_entry.entry_id}_battery.charge" + assert config_entry.data[CONF_RESOURCES] == ["battery.charge"] + assert config_entry.options == {} + + state = hass.states.get("sensor.ups1_battery_charge") + assert state.state == "10" diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py index 8ac1d110512..df8b78be7bd 100644 --- a/tests/components/nut/util.py +++ b/tests/components/nut/util.py @@ -4,7 +4,7 @@ import json from unittest.mock import MagicMock, patch from homeassistant.components.nut.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_RESOURCES +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -18,7 +18,7 @@ def _get_mock_pynutclient(list_vars=None, list_ups=None): async def async_init_integration( - hass: HomeAssistant, ups_fixture: str, resources: list, add_options: bool = False + hass: HomeAssistant, ups_fixture: str ) -> MockConfigEntry: """Set up the nexia integration in Home Assistant.""" @@ -33,8 +33,7 @@ async def async_init_integration( ): entry = MockConfigEntry( domain=DOMAIN, - data={CONF_HOST: "mock", CONF_PORT: "mock", CONF_RESOURCES: resources}, - options={CONF_RESOURCES: resources} if add_options else {}, + data={CONF_HOST: "mock", CONF_PORT: "mock"}, ) entry.add_to_hass(hass) diff --git a/tests/components/nws/const.py b/tests/components/nws/const.py index 4f4b140dbf9..c7387d4bf8c 100644 --- a/tests/components/nws/const.py +++ b/tests/components/nws/const.py @@ -25,11 +25,14 @@ from homeassistant.const import ( PRESSURE_HPA, PRESSURE_INHG, PRESSURE_PA, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) 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 NWS_CONFIG = { @@ -80,8 +83,12 @@ SENSOR_EXPECTED_OBSERVATION_IMPERIAL = { "windChill": str(round(convert_temperature(5, TEMP_CELSIUS, TEMP_FAHRENHEIT))), "heatIndex": str(round(convert_temperature(15, TEMP_CELSIUS, TEMP_FAHRENHEIT))), "relativeHumidity": "10", - "windSpeed": str(round(convert_distance(10, LENGTH_KILOMETERS, LENGTH_MILES))), - "windGust": str(round(convert_distance(20, LENGTH_KILOMETERS, LENGTH_MILES))), + "windSpeed": str( + round(convert_speed(10, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR)) + ), + "windGust": str( + round(convert_speed(20, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR)) + ), "windDirection": "180", "barometricPressure": str( round(convert_pressure(100000, PRESSURE_PA, PRESSURE_INHG), 2) @@ -98,7 +105,7 @@ WEATHER_EXPECTED_OBSERVATION_IMPERIAL = { ), ATTR_WEATHER_WIND_BEARING: 180, ATTR_WEATHER_WIND_SPEED: round( - convert_distance(10, LENGTH_KILOMETERS, LENGTH_MILES) + convert_speed(10, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR) ), ATTR_WEATHER_PRESSURE: round( convert_pressure(100000, PRESSURE_PA, PRESSURE_INHG), 2 @@ -152,7 +159,7 @@ EXPECTED_FORECAST_METRIC = { ATTR_FORECAST_TIME: "2019-08-12T20:00:00-04:00", ATTR_FORECAST_TEMP: round(convert_temperature(10, TEMP_FAHRENHEIT, TEMP_CELSIUS)), ATTR_FORECAST_WIND_SPEED: round( - convert_distance(10, LENGTH_MILES, LENGTH_KILOMETERS) + convert_speed(10, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR) ), ATTR_FORECAST_WIND_BEARING: 180, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 90, diff --git a/tests/components/nzbget/test_sensor.py b/tests/components/nzbget/test_sensor.py index de5e2382a63..d47e67c96c6 100644 --- a/tests/components/nzbget/test_sensor.py +++ b/tests/components/nzbget/test_sensor.py @@ -45,7 +45,7 @@ async def test_sensors(hass, nzbget_api) -> None: for (sensor_id, data) in sensors.items(): entity_entry = registry.async_get(f"sensor.nzbgettest_{sensor_id}") assert entity_entry - assert entity_entry.device_class == data[3] + assert entity_entry.original_device_class == data[3] assert entity_entry.unique_id == f"{entry.entry_id}_{data[0]}" state = hass.states.get(f"sensor.nzbgettest_{sensor_id}") diff --git a/tests/components/octoprint/test_config_flow.py b/tests/components/octoprint/test_config_flow.py index f176e5ab288..57e89955d58 100644 --- a/tests/components/octoprint/test_config_flow.py +++ b/tests/components/octoprint/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import patch from pyoctoprintapi import ApiError, DiscoverySettings from homeassistant import config_entries, data_entry_flow +from homeassistant.components import ssdp, zeroconf from homeassistant.components.octoprint.const import DOMAIN from homeassistant.core import HomeAssistant @@ -169,13 +170,14 @@ async def test_show_zerconf_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "host": "192.168.1.123", - "port": 80, - "hostname": "example.local.", - "uuid": "83747482", - "properties": {"uuid": "83747482", "path": "/foo/"}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.123", + hostname="example.local.", + name="mock_name", + port=80, + properties={"uuid": "83747482", "path": "/foo/"}, + type="mock_type", + ), ) assert result["type"] == "form" assert not result["errors"] @@ -233,11 +235,15 @@ async def test_show_ssdp_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - "presentationURL": "http://192.168.1.123:80/discovery/device.xml", - "port": 80, - "UDN": "uuid:83747482", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + upnp={ + "presentationURL": "http://192.168.1.123:80/discovery/device.xml", + "port": 80, + "UDN": "uuid:83747482", + }, + ), ) assert result["type"] == "form" assert not result["errors"] @@ -485,13 +491,14 @@ async def test_duplicate_zerconf_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "host": "192.168.1.123", - "port": 80, - "hostname": "example.local.", - "uuid": "83747482", - "properties": {"uuid": "83747482", "path": "/foo/"}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.123", + hostname="example.local.", + name="mock_name", + port=80, + properties={"uuid": "83747482", "path": "/foo/"}, + type="mock_type", + ), ) assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -509,11 +516,15 @@ async def test_duplicate_ssdp_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - "presentationURL": "http://192.168.1.123:80/discovery/device.xml", - "port": 80, - "UDN": "uuid:83747482", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + upnp={ + "presentationURL": "http://192.168.1.123:80/discovery/device.xml", + "port": 80, + "UDN": "uuid:83747482", + }, + ), ) assert result["type"] == "abort" assert result["reason"] == "already_configured" diff --git a/tests/components/octoprint/test_sensor.py b/tests/components/octoprint/test_sensor.py index c3a02c1bab5..a7da0579c10 100644 --- a/tests/components/octoprint/test_sensor.py +++ b/tests/components/octoprint/test_sensor.py @@ -1,7 +1,9 @@ """The tests for Octoptint binary sensor module.""" -from datetime import datetime +from datetime import datetime, timezone from unittest.mock import patch +from homeassistant.helpers import entity_registry as er + from . import init_integration @@ -9,7 +11,7 @@ async def test_sensors(hass): """Test the underlying sensors.""" printer = { "state": { - "flags": {}, + "flags": {"printing": True}, "text": "Operational", }, "temperature": {"tool1": {"actual": 18.83136, "target": 37.83136}}, @@ -20,11 +22,12 @@ async def test_sensors(hass): "state": "Printing", } with patch( - "homeassistant.util.dt.utcnow", return_value=datetime(2020, 2, 20, 9, 10, 0) + "homeassistant.util.dt.utcnow", + return_value=datetime(2020, 2, 20, 9, 10, 0, tzinfo=timezone.utc), ): await init_integration(hass, "sensor", printer=printer, job=job) - entity_registry = await hass.helpers.entity_registry.async_get_registry() + entity_registry = er.async_get(hass) state = hass.states.get("sensor.octoprint_job_percentage") assert state is not None @@ -63,14 +66,81 @@ async def test_sensors(hass): state = hass.states.get("sensor.octoprint_start_time") assert state is not None - assert state.state == "2020-02-20T09:00:00" + assert state.state == "2020-02-20T09:00:00+00:00" assert state.name == "OctoPrint Start Time" entry = entity_registry.async_get("sensor.octoprint_start_time") assert entry.unique_id == "Start Time-uuid" state = hass.states.get("sensor.octoprint_estimated_finish_time") assert state is not None - assert state.state == "2020-02-20T10:50:00" + assert state.state == "2020-02-20T10:50:00+00:00" + assert state.name == "OctoPrint Estimated Finish Time" + entry = entity_registry.async_get("sensor.octoprint_estimated_finish_time") + assert entry.unique_id == "Estimated Finish Time-uuid" + + +async def test_sensors_no_target_temp(hass): + """Test the underlying sensors.""" + printer = { + "state": { + "flags": {"printing": True, "paused": False}, + "text": "Operational", + }, + "temperature": {"tool1": {"actual": 18.83136, "target": None}}, + } + with patch( + "homeassistant.util.dt.utcnow", return_value=datetime(2020, 2, 20, 9, 10, 0) + ): + await init_integration(hass, "sensor", printer=printer) + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.octoprint_actual_tool1_temp") + assert state is not None + assert state.state == "18.83" + assert state.name == "OctoPrint actual tool1 temp" + entry = entity_registry.async_get("sensor.octoprint_actual_tool1_temp") + assert entry.unique_id == "actual tool1 temp-uuid" + + state = hass.states.get("sensor.octoprint_target_tool1_temp") + assert state is not None + assert state.state == "unknown" + assert state.name == "OctoPrint target tool1 temp" + entry = entity_registry.async_get("sensor.octoprint_target_tool1_temp") + assert entry.unique_id == "target tool1 temp-uuid" + + +async def test_sensors_paused(hass): + """Test the underlying sensors.""" + printer = { + "state": { + "flags": {"printing": False}, + "text": "Operational", + }, + "temperature": {"tool1": {"actual": 18.83136, "target": None}}, + } + job = { + "job": {}, + "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, + "state": "Paused", + } + with patch( + "homeassistant.util.dt.utcnow", return_value=datetime(2020, 2, 20, 9, 10, 0) + ): + await init_integration(hass, "sensor", printer=printer, job=job) + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.octoprint_start_time") + assert state is not None + assert state.state == "unknown" + assert state.name == "OctoPrint Start Time" + entry = entity_registry.async_get("sensor.octoprint_start_time") + assert entry.unique_id == "Start Time-uuid" + + state = hass.states.get("sensor.octoprint_estimated_finish_time") + assert state is not None + assert state.state == "unknown" assert state.name == "OctoPrint Estimated Finish Time" entry = entity_registry.async_get("sensor.octoprint_estimated_finish_time") assert entry.unique_id == "Estimated Finish Time-uuid" diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index 36035c1c85b..3ae6dbab050 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -24,6 +24,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry from .const import ( ATTR_DEFAULT_DISABLED, ATTR_DEVICE_FILE, + ATTR_ENTITY_CATEGORY, ATTR_INJECT_READS, ATTR_UNIQUE_ID, FIXED_ATTRIBUTES, @@ -77,6 +78,9 @@ def check_entities( entity_id = expected_entity[ATTR_ENTITY_ID] registry_entry = entity_registry.entities.get(entity_id) assert registry_entry is not None + assert registry_entry.entity_category == expected_entity.get( + ATTR_ENTITY_CATEGORY + ) assert registry_entry.unique_id == expected_entity[ATTR_UNIQUE_ID] state = hass.states.get(entity_id) assert state.state == expected_entity[ATTR_STATE] diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index 777cb1f3d25..2153e153961 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -2,21 +2,18 @@ from pi1wire import InvalidCRCException, UnsupportResponseException from pyownet.protocol import Error as ProtocolError -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.onewire.const import ( DOMAIN, MANUFACTURER_EDS, MANUFACTURER_HOBBYBOARDS, MANUFACTURER_MAXIM, - PRESSURE_CBAR, + Platform, ) from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, + SensorStateClass, ) -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -27,28 +24,25 @@ from homeassistant.const import ( ATTR_STATE, ATTR_UNIT_OF_MEASUREMENT, ATTR_VIA_DEVICE, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_VOLTAGE, - ELECTRIC_CURRENT_AMPERE, ELECTRIC_POTENTIAL_VOLT, LIGHT_LUX, PERCENTAGE, + PRESSURE_CBAR, PRESSURE_MBAR, STATE_OFF, STATE_ON, STATE_UNKNOWN, TEMP_CELSIUS, ) +from homeassistant.helpers.entity import EntityCategory ATTR_DEFAULT_DISABLED = "default_disabled" ATTR_DEVICE_FILE = "device_file" ATTR_DEVICE_INFO = "device_info" +ATTR_ENTITY_CATEGORY = "entity_category" ATTR_INJECT_READS = "inject_reads" ATTR_UNIQUE_ID = "unique_id" +ATTR_UNKNOWN_DEVICE = "unknown_device" FIXED_ATTRIBUTES = ( ATTR_DEVICE_CLASS, @@ -62,7 +56,7 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: [ b"", # read device type ], - SENSOR_DOMAIN: [], + ATTR_UNKNOWN_DEVICE: True, }, "05.111111111111": { ATTR_INJECT_READS: [ @@ -74,7 +68,7 @@ MOCK_OWPROXY_DEVICES = { ATTR_MODEL: "DS2405", ATTR_NAME: "05.111111111111", }, - SWITCH_DOMAIN: [ + Platform.SWITCH: [ { ATTR_DEFAULT_DISABLED: True, ATTR_ENTITY_ID: "switch.05_111111111111_pio", @@ -94,13 +88,13 @@ MOCK_OWPROXY_DEVICES = { ATTR_MODEL: "DS18S20", ATTR_NAME: "10.111111111111", }, - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.my_ds18b20_temperature", ATTR_INJECT_READS: b" 25.123", ATTR_STATE: "25.1", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/10.111111111111/temperature", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, @@ -116,7 +110,7 @@ MOCK_OWPROXY_DEVICES = { ATTR_MODEL: "DS2406", ATTR_NAME: "12.111111111111", }, - BINARY_SENSOR_DOMAIN: [ + Platform.BINARY_SENSOR: [ { ATTR_DEFAULT_DISABLED: True, ATTR_ENTITY_ID: "binary_sensor.12_111111111111_sensed_a", @@ -132,29 +126,29 @@ MOCK_OWPROXY_DEVICES = { ATTR_UNIQUE_ID: "/12.111111111111/sensed.B", }, ], - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.12_111111111111_temperature", ATTR_INJECT_READS: b" 25.123", ATTR_STATE: "25.1", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/12.111111111111/TAI8570/temperature", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, ATTR_ENTITY_ID: "sensor.12_111111111111_pressure", ATTR_INJECT_READS: b" 1025.123", ATTR_STATE: "1025.1", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/12.111111111111/TAI8570/pressure", ATTR_UNIT_OF_MEASUREMENT: PRESSURE_MBAR, }, ], - SWITCH_DOMAIN: [ + Platform.SWITCH: [ { ATTR_DEFAULT_DISABLED: True, ATTR_ENTITY_ID: "switch.12_111111111111_pio_a", @@ -195,12 +189,12 @@ MOCK_OWPROXY_DEVICES = { ATTR_MODEL: "DS2423", ATTR_NAME: "1D.111111111111", }, - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { ATTR_ENTITY_ID: "sensor.1d_111111111111_counter_a", ATTR_INJECT_READS: b" 251123", ATTR_STATE: "251123", - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, ATTR_UNIQUE_ID: "/1D.111111111111/counter.A", ATTR_UNIT_OF_MEASUREMENT: "count", }, @@ -208,7 +202,7 @@ MOCK_OWPROXY_DEVICES = { ATTR_ENTITY_ID: "sensor.1d_111111111111_counter_b", ATTR_INJECT_READS: b" 248125", ATTR_STATE: "248125", - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, ATTR_UNIQUE_ID: "/1D.111111111111/counter.B", ATTR_UNIT_OF_MEASUREMENT: "count", }, @@ -240,13 +234,13 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: [ b"DS2423", # read device type ], - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { ATTR_DEVICE_FILE: "/1F.111111111111/main/1D.111111111111/counter.A", ATTR_ENTITY_ID: "sensor.1d_111111111111_counter_a", ATTR_INJECT_READS: b" 251123", ATTR_STATE: "251123", - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, ATTR_UNIQUE_ID: "/1D.111111111111/counter.A", ATTR_UNIT_OF_MEASUREMENT: "count", }, @@ -255,7 +249,7 @@ MOCK_OWPROXY_DEVICES = { ATTR_ENTITY_ID: "sensor.1d_111111111111_counter_b", ATTR_INJECT_READS: b" 248125", ATTR_STATE: "248125", - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, ATTR_UNIQUE_ID: "/1D.111111111111/counter.B", ATTR_UNIT_OF_MEASUREMENT: "count", }, @@ -274,13 +268,13 @@ MOCK_OWPROXY_DEVICES = { ATTR_MODEL: "DS1822", ATTR_NAME: "22.111111111111", }, - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.22_111111111111_temperature", ATTR_INJECT_READS: ProtocolError, ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/22.111111111111/temperature", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, @@ -296,115 +290,125 @@ MOCK_OWPROXY_DEVICES = { ATTR_MODEL: "DS2438", ATTR_NAME: "26.111111111111", }, - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.26_111111111111_temperature", ATTR_INJECT_READS: b" 25.123", ATTR_STATE: "25.1", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/26.111111111111/temperature", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, ATTR_ENTITY_ID: "sensor.26_111111111111_humidity", ATTR_INJECT_READS: b" 72.7563", ATTR_STATE: "72.8", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/26.111111111111/humidity", ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, ATTR_ENTITY_ID: "sensor.26_111111111111_humidity_hih3600", ATTR_INJECT_READS: b" 73.7563", ATTR_STATE: "73.8", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/26.111111111111/HIH3600/humidity", ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, ATTR_ENTITY_ID: "sensor.26_111111111111_humidity_hih4000", ATTR_INJECT_READS: b" 74.7563", ATTR_STATE: "74.8", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/26.111111111111/HIH4000/humidity", ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, ATTR_ENTITY_ID: "sensor.26_111111111111_humidity_hih5030", ATTR_INJECT_READS: b" 75.7563", ATTR_STATE: "75.8", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/26.111111111111/HIH5030/humidity", ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, ATTR_ENTITY_ID: "sensor.26_111111111111_humidity_htm1735", ATTR_INJECT_READS: ProtocolError, ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/26.111111111111/HTM1735/humidity", ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, ATTR_ENTITY_ID: "sensor.26_111111111111_pressure", ATTR_INJECT_READS: b" 969.265", ATTR_STATE: "969.3", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/26.111111111111/B1-R1-A/pressure", ATTR_UNIT_OF_MEASUREMENT: PRESSURE_MBAR, }, { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ILLUMINANCE, + ATTR_DEVICE_CLASS: SensorDeviceClass.ILLUMINANCE, ATTR_ENTITY_ID: "sensor.26_111111111111_illuminance", ATTR_INJECT_READS: b" 65.8839", ATTR_STATE: "65.9", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/26.111111111111/S3-R1-A/illuminance", ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX, }, { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, + ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, ATTR_ENTITY_ID: "sensor.26_111111111111_voltage_vad", ATTR_INJECT_READS: b" 2.97", ATTR_STATE: "3.0", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/26.111111111111/VAD", ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, }, { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, + ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, ATTR_ENTITY_ID: "sensor.26_111111111111_voltage_vdd", ATTR_INJECT_READS: b" 4.74", ATTR_STATE: "4.7", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/26.111111111111/VDD", ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, }, { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, - ATTR_ENTITY_ID: "sensor.26_111111111111_current", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: "1.0", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, + ATTR_ENTITY_ID: "sensor.26_111111111111_vis", + ATTR_INJECT_READS: b" 0.12", + ATTR_STATE: "0.1", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + ATTR_UNIQUE_ID: "/26.111111111111/vis", + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, + }, + ], + Platform.SWITCH: [ + { + ATTR_DEFAULT_DISABLED: True, + ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, + ATTR_ENTITY_ID: "switch.26_111111111111_iad", + ATTR_INJECT_READS: b" 1", + ATTR_STATE: STATE_ON, ATTR_UNIQUE_ID: "/26.111111111111/IAD", - ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, }, ], }, @@ -418,13 +422,13 @@ MOCK_OWPROXY_DEVICES = { ATTR_MODEL: "DS18B20", ATTR_NAME: "28.111111111111", }, - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.28_111111111111_temperature", ATTR_INJECT_READS: b" 26.984", ATTR_STATE: "27.0", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/28.111111111111/temperature", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, @@ -440,7 +444,7 @@ MOCK_OWPROXY_DEVICES = { ATTR_MODEL: "DS2408", ATTR_NAME: "29.111111111111", }, - BINARY_SENSOR_DOMAIN: [ + Platform.BINARY_SENSOR: [ { ATTR_DEFAULT_DISABLED: True, ATTR_ENTITY_ID: "binary_sensor.29_111111111111_sensed_0", @@ -498,7 +502,7 @@ MOCK_OWPROXY_DEVICES = { ATTR_UNIQUE_ID: "/29.111111111111/sensed.7", }, ], - SWITCH_DOMAIN: [ + Platform.SWITCH: [ { ATTR_DEFAULT_DISABLED: True, ATTR_ENTITY_ID: "switch.29_111111111111_pio_0", @@ -623,7 +627,7 @@ MOCK_OWPROXY_DEVICES = { ATTR_MODEL: "DS2413", ATTR_NAME: "3A.111111111111", }, - BINARY_SENSOR_DOMAIN: [ + Platform.BINARY_SENSOR: [ { ATTR_DEFAULT_DISABLED: True, ATTR_ENTITY_ID: "binary_sensor.3a_111111111111_sensed_a", @@ -639,7 +643,7 @@ MOCK_OWPROXY_DEVICES = { ATTR_UNIQUE_ID: "/3A.111111111111/sensed.B", }, ], - SWITCH_DOMAIN: [ + Platform.SWITCH: [ { ATTR_DEFAULT_DISABLED: True, ATTR_ENTITY_ID: "switch.3a_111111111111_pio_a", @@ -666,13 +670,13 @@ MOCK_OWPROXY_DEVICES = { ATTR_MODEL: "DS1825", ATTR_NAME: "3B.111111111111", }, - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.3b_111111111111_temperature", ATTR_INJECT_READS: b" 28.243", ATTR_STATE: "28.2", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/3B.111111111111/temperature", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, @@ -688,13 +692,13 @@ MOCK_OWPROXY_DEVICES = { ATTR_MODEL: "DS28EA00", ATTR_NAME: "42.111111111111", }, - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.42_111111111111_temperature", ATTR_INJECT_READS: b" 29.123", ATTR_STATE: "29.1", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/42.111111111111/temperature", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, @@ -710,31 +714,31 @@ MOCK_OWPROXY_DEVICES = { ATTR_MODEL: "HobbyBoards_EF", ATTR_NAME: "EF.111111111111", }, - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, ATTR_ENTITY_ID: "sensor.ef_111111111111_humidity", ATTR_INJECT_READS: b" 67.745", ATTR_STATE: "67.7", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/EF.111111111111/humidity/humidity_corrected", ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, ATTR_ENTITY_ID: "sensor.ef_111111111111_humidity_raw", ATTR_INJECT_READS: b" 65.541", ATTR_STATE: "65.5", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/EF.111111111111/humidity/humidity_raw", ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.ef_111111111111_temperature", ATTR_INJECT_READS: b" 25.123", ATTR_STATE: "25.1", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/EF.111111111111/humidity/temperature", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, @@ -754,40 +758,40 @@ MOCK_OWPROXY_DEVICES = { ATTR_MODEL: "HB_MOISTURE_METER", ATTR_NAME: "EF.111111111112", }, - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, ATTR_ENTITY_ID: "sensor.ef_111111111112_wetness_0", ATTR_INJECT_READS: b" 41.745", ATTR_STATE: "41.7", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/EF.111111111112/moisture/sensor.0", ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, ATTR_ENTITY_ID: "sensor.ef_111111111112_wetness_1", ATTR_INJECT_READS: b" 42.541", ATTR_STATE: "42.5", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/EF.111111111112/moisture/sensor.1", ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, ATTR_ENTITY_ID: "sensor.ef_111111111112_moisture_2", ATTR_INJECT_READS: b" 43.123", ATTR_STATE: "43.1", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/EF.111111111112/moisture/sensor.2", ATTR_UNIT_OF_MEASUREMENT: PRESSURE_CBAR, }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, ATTR_ENTITY_ID: "sensor.ef_111111111112_moisture_3", ATTR_INJECT_READS: b" 44.123", ATTR_STATE: "44.1", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/EF.111111111112/moisture/sensor.3", ATTR_UNIT_OF_MEASUREMENT: PRESSURE_CBAR, }, @@ -804,40 +808,40 @@ MOCK_OWPROXY_DEVICES = { ATTR_MODEL: "EDS0068", ATTR_NAME: "7E.111111111111", }, - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.7e_111111111111_temperature", ATTR_INJECT_READS: b" 13.9375", ATTR_STATE: "13.9", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/7E.111111111111/EDS0068/temperature", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, ATTR_ENTITY_ID: "sensor.7e_111111111111_pressure", ATTR_INJECT_READS: b" 1012.21", ATTR_STATE: "1012.2", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/7E.111111111111/EDS0068/pressure", ATTR_UNIT_OF_MEASUREMENT: PRESSURE_MBAR, }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_ILLUMINANCE, + ATTR_DEVICE_CLASS: SensorDeviceClass.ILLUMINANCE, ATTR_ENTITY_ID: "sensor.7e_111111111111_illuminance", ATTR_INJECT_READS: b" 65.8839", ATTR_STATE: "65.9", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/7E.111111111111/EDS0068/light", ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX, }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, ATTR_ENTITY_ID: "sensor.7e_111111111111_humidity", ATTR_INJECT_READS: b" 41.375", ATTR_STATE: "41.4", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/7E.111111111111/EDS0068/humidity", ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, @@ -854,22 +858,22 @@ MOCK_OWPROXY_DEVICES = { ATTR_MODEL: "EDS0066", ATTR_NAME: "7E.222222222222", }, - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.7e_222222222222_temperature", ATTR_INJECT_READS: b" 13.9375", ATTR_STATE: "13.9", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/7E.222222222222/EDS0066/temperature", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, ATTR_ENTITY_ID: "sensor.7e_222222222222_pressure", ATTR_INJECT_READS: b" 1012.21", ATTR_STATE: "1012.2", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/7E.222222222222/EDS0066/pressure", ATTR_UNIT_OF_MEASUREMENT: PRESSURE_MBAR, }, @@ -879,7 +883,7 @@ MOCK_OWPROXY_DEVICES = { MOCK_SYSBUS_DEVICES = { "00-111111111111": { - SENSOR_DOMAIN: [], + ATTR_UNKNOWN_DEVICE: True, }, "10-111111111111": { ATTR_DEVICE_INFO: { @@ -888,24 +892,18 @@ MOCK_SYSBUS_DEVICES = { ATTR_MODEL: "10", ATTR_NAME: "10-111111111111", }, - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.my_ds18b20_temperature", ATTR_INJECT_READS: 25.123, ATTR_STATE: "25.1", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/sys/bus/w1/devices/10-111111111111/w1_slave", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, ], }, - "12-111111111111": { - SENSOR_DOMAIN: [], - }, - "1D-111111111111": { - SENSOR_DOMAIN: [], - }, "22-111111111111": { ATTR_DEVICE_INFO: { ATTR_IDENTIFIERS: {(DOMAIN, "22-111111111111")}, @@ -913,21 +911,18 @@ MOCK_SYSBUS_DEVICES = { ATTR_MODEL: "22", ATTR_NAME: "22-111111111111", }, - "sensor": [ + Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.22_111111111111_temperature", ATTR_INJECT_READS: FileNotFoundError, ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/sys/bus/w1/devices/22-111111111111/w1_slave", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, ], }, - "26-111111111111": { - SENSOR_DOMAIN: [], - }, "28-111111111111": { ATTR_DEVICE_INFO: { ATTR_IDENTIFIERS: {(DOMAIN, "28-111111111111")}, @@ -935,24 +930,18 @@ MOCK_SYSBUS_DEVICES = { ATTR_MODEL: "28", ATTR_NAME: "28-111111111111", }, - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.28_111111111111_temperature", ATTR_INJECT_READS: InvalidCRCException, ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/sys/bus/w1/devices/28-111111111111/w1_slave", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, ], }, - "29-111111111111": { - SENSOR_DOMAIN: [], - }, - "3A-111111111111": { - SENSOR_DOMAIN: [], - }, "3B-111111111111": { ATTR_DEVICE_INFO: { ATTR_IDENTIFIERS: {(DOMAIN, "3B-111111111111")}, @@ -960,13 +949,13 @@ MOCK_SYSBUS_DEVICES = { ATTR_MODEL: "3B", ATTR_NAME: "3B-111111111111", }, - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.3b_111111111111_temperature", ATTR_INJECT_READS: 29.993, ATTR_STATE: "30.0", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/sys/bus/w1/devices/3B-111111111111/w1_slave", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, @@ -979,13 +968,13 @@ MOCK_SYSBUS_DEVICES = { ATTR_MODEL: "42", ATTR_NAME: "42-111111111111", }, - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.42_111111111111_temperature", ATTR_INJECT_READS: UnsupportResponseException, ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/sys/bus/w1/devices/42-111111111111/w1_slave", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, @@ -998,13 +987,13 @@ MOCK_SYSBUS_DEVICES = { ATTR_MODEL: "42", ATTR_NAME: "42-111111111112", }, - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.42_111111111112_temperature", ATTR_INJECT_READS: [UnsupportResponseException] * 9 + [27.993], ATTR_STATE: "28.0", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/sys/bus/w1/devices/42-111111111112/w1_slave", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, @@ -1017,22 +1006,16 @@ MOCK_SYSBUS_DEVICES = { ATTR_MODEL: "42", ATTR_NAME: "42-111111111113", }, - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.42_111111111113_temperature", ATTR_INJECT_READS: [UnsupportResponseException] * 10 + [27.993], ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "/sys/bus/w1/devices/42-111111111113/w1_slave", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, ], }, - "EF-111111111111": { - SENSOR_DOMAIN: [], - }, - "EF-111111111112": { - SENSOR_DOMAIN: [], - }, } diff --git a/tests/components/onewire/test_binary_sensor.py b/tests/components/onewire/test_binary_sensor.py index 90e53924cab..ee55a550cea 100644 --- a/tests/components/onewire/test_binary_sensor.py +++ b/tests/components/onewire/test_binary_sensor.py @@ -1,10 +1,11 @@ """Tests for 1-Wire devices connected on OWServer.""" +import logging from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ensure_list @@ -14,7 +15,7 @@ from . import ( check_entities, setup_owproxy_mock_devices, ) -from .const import ATTR_DEVICE_INFO, MOCK_OWPROXY_DEVICES +from .const import ATTR_DEVICE_INFO, ATTR_UNKNOWN_DEVICE, MOCK_OWPROXY_DEVICES from tests.common import mock_device_registry, mock_registry @@ -22,12 +23,16 @@ from tests.common import mock_device_registry, mock_registry @pytest.fixture(autouse=True) def override_platforms(): """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [BINARY_SENSOR_DOMAIN]): + with patch("homeassistant.components.onewire.PLATFORMS", [Platform.BINARY_SENSOR]): yield async def test_owserver_binary_sensor( - hass: HomeAssistant, config_entry: ConfigEntry, owproxy: MagicMock, device_id: str + hass: HomeAssistant, + config_entry: ConfigEntry, + owproxy: MagicMock, + device_id: str, + caplog: pytest.LogCaptureFixture, ): """Test for 1-Wire binary sensor. @@ -37,18 +42,23 @@ async def test_owserver_binary_sensor( entity_registry = mock_registry(hass) mock_device = MOCK_OWPROXY_DEVICES[device_id] - expected_entities = mock_device.get(BINARY_SENSOR_DOMAIN, []) + expected_entities = mock_device.get(Platform.BINARY_SENSOR, []) expected_devices = ensure_list(mock_device.get(ATTR_DEVICE_INFO)) - setup_owproxy_mock_devices(owproxy, BINARY_SENSOR_DOMAIN, [device_id]) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + setup_owproxy_mock_devices(owproxy, Platform.BINARY_SENSOR, [device_id]) + with caplog.at_level(logging.WARNING, logger="homeassistant.components.onewire"): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + if mock_device.get(ATTR_UNKNOWN_DEVICE): + assert "Ignoring unknown device family/type" in caplog.text + else: + assert "Ignoring unknown device family/type" not in caplog.text check_device_registry(device_registry, expected_devices) assert len(entity_registry.entities) == len(expected_entities) check_and_enable_disabled_entities(entity_registry, expected_entities) - setup_owproxy_mock_devices(owproxy, BINARY_SENSOR_DOMAIN, [device_id]) + setup_owproxy_mock_devices(owproxy, Platform.BINARY_SENSOR, [device_id]) await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 1bf95ee5c0c..e3a3fdcc564 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -1,17 +1,11 @@ """Tests for 1-Wire config flow.""" -from unittest.mock import MagicMock, patch +import logging import pytest from homeassistant.components.onewire.const import DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er - -from . import setup_owproxy_mock_devices - -from tests.common import mock_device_registry, mock_registry @pytest.mark.usefixtures("owproxy_with_connerror") @@ -41,6 +35,19 @@ async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): assert not hass.data.get(DOMAIN) +@pytest.mark.usefixtures("sysbus") +async def test_warning_no_devices( + hass: HomeAssistant, + sysbus_config_entry: ConfigEntry, + caplog: pytest.LogCaptureFixture, +): + """Test warning is generated when no sysbus devices found.""" + with caplog.at_level(logging.WARNING, logger="homeassistant.components.onewire"): + await hass.config_entries.async_setup(sysbus_config_entry.entry_id) + await hass.async_block_till_done() + assert "No onewire sensor found. Check if dtoverlay=w1-gpio" in caplog.text + + @pytest.mark.usefixtures("sysbus") async def test_unload_sysbus_entry( hass: HomeAssistant, sysbus_config_entry: ConfigEntry @@ -57,40 +64,3 @@ async def test_unload_sysbus_entry( assert sysbus_config_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) - - -@patch("homeassistant.components.onewire.PLATFORMS", [SENSOR_DOMAIN]) -async def test_registry_cleanup( - hass: HomeAssistant, config_entry: ConfigEntry, owproxy: MagicMock -): - """Test for 1-Wire device. - - As they would be on a clean setup: all binary-sensors and switches disabled. - """ - entity_registry = mock_registry(hass) - device_registry = mock_device_registry(hass) - - # Initialise with two components - setup_owproxy_mock_devices( - owproxy, SENSOR_DOMAIN, ["10.111111111111", "28.111111111111"] - ) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert len(dr.async_entries_for_config_entry(device_registry, "2")) == 2 - assert len(er.async_entries_for_config_entry(entity_registry, "2")) == 2 - - # Second item has disappeared from bus, and was removed manually from the front-end - setup_owproxy_mock_devices(owproxy, SENSOR_DOMAIN, ["10.111111111111"]) - entity_registry.async_remove("sensor.28_111111111111_temperature") - await hass.async_block_till_done() - - assert len(er.async_entries_for_config_entry(entity_registry, "2")) == 1 - assert len(dr.async_entries_for_config_entry(device_registry, "2")) == 2 - - # Second item has disappeared from bus, and was removed manually from the front-end - await hass.config_entries.async_reload("2") - await hass.async_block_till_done() - - assert len(er.async_entries_for_config_entry(entity_registry, "2")) == 1 - assert len(dr.async_entries_for_config_entry(device_registry, "2")) == 1 diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index ffa9d0b5319..6bfc68d85c8 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -1,10 +1,11 @@ """Tests for 1-Wire sensor platform.""" +import logging from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ensure_list @@ -15,7 +16,12 @@ from . import ( setup_owproxy_mock_devices, setup_sysbus_mock_devices, ) -from .const import ATTR_DEVICE_INFO, MOCK_OWPROXY_DEVICES, MOCK_SYSBUS_DEVICES +from .const import ( + ATTR_DEVICE_INFO, + ATTR_UNKNOWN_DEVICE, + MOCK_OWPROXY_DEVICES, + MOCK_SYSBUS_DEVICES, +) from tests.common import mock_device_registry, mock_registry @@ -23,12 +29,16 @@ from tests.common import mock_device_registry, mock_registry @pytest.fixture(autouse=True) def override_platforms(): """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [SENSOR_DOMAIN]): + with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SENSOR]): yield async def test_owserver_sensor( - hass: HomeAssistant, config_entry: ConfigEntry, owproxy: MagicMock, device_id: str + hass: HomeAssistant, + config_entry: ConfigEntry, + owproxy: MagicMock, + device_id: str, + caplog: pytest.LogCaptureFixture, ): """Test for 1-Wire device. @@ -38,22 +48,27 @@ async def test_owserver_sensor( entity_registry = mock_registry(hass) mock_device = MOCK_OWPROXY_DEVICES[device_id] - expected_entities = mock_device.get(SENSOR_DOMAIN, []) + expected_entities = mock_device.get(Platform.SENSOR, []) if "branches" in mock_device: for branch_details in mock_device["branches"].values(): for sub_device in branch_details.values(): - expected_entities += sub_device[SENSOR_DOMAIN] + expected_entities += sub_device[Platform.SENSOR] expected_devices = ensure_list(mock_device.get(ATTR_DEVICE_INFO)) - setup_owproxy_mock_devices(owproxy, SENSOR_DOMAIN, [device_id]) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) + with caplog.at_level(logging.WARNING, logger="homeassistant.components.onewire"): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + if mock_device.get(ATTR_UNKNOWN_DEVICE): + assert "Ignoring unknown device family/type" in caplog.text + else: + assert "Ignoring unknown device family/type" not in caplog.text check_device_registry(device_registry, expected_devices) assert len(entity_registry.entities) == len(expected_entities) check_and_enable_disabled_entities(entity_registry, expected_entities) - setup_owproxy_mock_devices(owproxy, SENSOR_DOMAIN, [device_id]) + setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() @@ -63,26 +78,38 @@ async def test_owserver_sensor( @pytest.mark.usefixtures("sysbus") @pytest.mark.parametrize("device_id", MOCK_SYSBUS_DEVICES.keys(), indirect=True) async def test_onewiredirect_setup_valid_device( - hass: HomeAssistant, sysbus_config_entry: ConfigEntry, device_id: str + hass: HomeAssistant, + sysbus_config_entry: ConfigEntry, + device_id: str, + caplog: pytest.LogCaptureFixture, ): """Test that sysbus config entry works correctly.""" device_registry = mock_device_registry(hass) entity_registry = mock_registry(hass) glob_result, read_side_effect = setup_sysbus_mock_devices( - SENSOR_DOMAIN, [device_id] + Platform.SENSOR, [device_id] ) mock_device = MOCK_SYSBUS_DEVICES[device_id] - expected_entities = mock_device.get(SENSOR_DOMAIN, []) + expected_entities = mock_device.get(Platform.SENSOR, []) expected_devices = ensure_list(mock_device.get(ATTR_DEVICE_INFO)) with patch("pi1wire._finder.glob.glob", return_value=glob_result,), patch( "pi1wire.OneWire.get_temperature", side_effect=read_side_effect, + ), caplog.at_level( + logging.WARNING, logger="homeassistant.components.onewire" + ), patch( + "homeassistant.components.onewire.sensor.asyncio.sleep" ): await hass.config_entries.async_setup(sysbus_config_entry.entry_id) await hass.async_block_till_done() + assert "No onewire sensor found. Check if dtoverlay=w1-gpio" not in caplog.text + if mock_device.get(ATTR_UNKNOWN_DEVICE): + assert "Ignoring unknown device family" in caplog.text + else: + assert "Ignoring unknown device family" not in caplog.text check_device_registry(device_registry, expected_devices) assert len(entity_registry.entities) == len(expected_entities) diff --git a/tests/components/onewire/test_switch.py b/tests/components/onewire/test_switch.py index ffe5042b514..336dafb15a1 100644 --- a/tests/components/onewire/test_switch.py +++ b/tests/components/onewire/test_switch.py @@ -1,9 +1,9 @@ """Tests for 1-Wire devices connected on OWServer.""" +import logging from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, @@ -11,6 +11,7 @@ from homeassistant.const import ( SERVICE_TOGGLE, STATE_OFF, STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ensure_list @@ -21,7 +22,7 @@ from . import ( check_entities, setup_owproxy_mock_devices, ) -from .const import ATTR_DEVICE_INFO, MOCK_OWPROXY_DEVICES +from .const import ATTR_DEVICE_INFO, ATTR_UNKNOWN_DEVICE, MOCK_OWPROXY_DEVICES from tests.common import mock_device_registry, mock_registry @@ -29,12 +30,16 @@ from tests.common import mock_device_registry, mock_registry @pytest.fixture(autouse=True) def override_platforms(): """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [SWITCH_DOMAIN]): + with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SWITCH]): yield async def test_owserver_switch( - hass: HomeAssistant, config_entry: ConfigEntry, owproxy: MagicMock, device_id: str + hass: HomeAssistant, + config_entry: ConfigEntry, + owproxy: MagicMock, + device_id: str, + caplog: pytest.LogCaptureFixture, ): """Test for 1-Wire switch. @@ -44,18 +49,23 @@ async def test_owserver_switch( entity_registry = mock_registry(hass) mock_device = MOCK_OWPROXY_DEVICES[device_id] - expected_entities = mock_device.get(SWITCH_DOMAIN, []) + expected_entities = mock_device.get(Platform.SWITCH, []) expected_devices = ensure_list(mock_device.get(ATTR_DEVICE_INFO)) - setup_owproxy_mock_devices(owproxy, SWITCH_DOMAIN, [device_id]) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) + with caplog.at_level(logging.WARNING, logger="homeassistant.components.onewire"): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + if mock_device.get(ATTR_UNKNOWN_DEVICE): + assert "Ignoring unknown device family/type" in caplog.text + else: + assert "Ignoring unknown device family/type" not in caplog.text check_device_registry(device_registry, expected_devices) assert len(entity_registry.entities) == len(expected_entities) check_and_enable_disabled_entities(entity_registry, expected_entities) - setup_owproxy_mock_devices(owproxy, SWITCH_DOMAIN, [device_id]) + setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() @@ -73,7 +83,7 @@ async def test_owserver_switch( expected_entity[ATTR_STATE] = STATE_ON await hass.services.async_call( - SWITCH_DOMAIN, + Platform.SWITCH, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity_id}, blocking=True, diff --git a/tests/fixtures/ozw/binary_sensor.json b/tests/components/ozw/fixtures/binary_sensor.json similarity index 100% rename from tests/fixtures/ozw/binary_sensor.json rename to tests/components/ozw/fixtures/binary_sensor.json diff --git a/tests/fixtures/ozw/binary_sensor_alt.json b/tests/components/ozw/fixtures/binary_sensor_alt.json similarity index 100% rename from tests/fixtures/ozw/binary_sensor_alt.json rename to tests/components/ozw/fixtures/binary_sensor_alt.json diff --git a/tests/fixtures/ozw/climate.json b/tests/components/ozw/fixtures/climate.json similarity index 100% rename from tests/fixtures/ozw/climate.json rename to tests/components/ozw/fixtures/climate.json diff --git a/tests/fixtures/ozw/climate_network_dump.csv b/tests/components/ozw/fixtures/climate_network_dump.csv similarity index 100% rename from tests/fixtures/ozw/climate_network_dump.csv rename to tests/components/ozw/fixtures/climate_network_dump.csv diff --git a/tests/fixtures/ozw/cover.json b/tests/components/ozw/fixtures/cover.json similarity index 100% rename from tests/fixtures/ozw/cover.json rename to tests/components/ozw/fixtures/cover.json diff --git a/tests/fixtures/ozw/cover_gdo.json b/tests/components/ozw/fixtures/cover_gdo.json similarity index 100% rename from tests/fixtures/ozw/cover_gdo.json rename to tests/components/ozw/fixtures/cover_gdo.json diff --git a/tests/fixtures/ozw/cover_gdo_network_dump.csv b/tests/components/ozw/fixtures/cover_gdo_network_dump.csv similarity index 100% rename from tests/fixtures/ozw/cover_gdo_network_dump.csv rename to tests/components/ozw/fixtures/cover_gdo_network_dump.csv diff --git a/tests/fixtures/ozw/cover_network_dump.csv b/tests/components/ozw/fixtures/cover_network_dump.csv similarity index 100% rename from tests/fixtures/ozw/cover_network_dump.csv rename to tests/components/ozw/fixtures/cover_network_dump.csv diff --git a/tests/fixtures/ozw/fan.json b/tests/components/ozw/fixtures/fan.json similarity index 100% rename from tests/fixtures/ozw/fan.json rename to tests/components/ozw/fixtures/fan.json diff --git a/tests/fixtures/ozw/fan_network_dump.csv b/tests/components/ozw/fixtures/fan_network_dump.csv similarity index 100% rename from tests/fixtures/ozw/fan_network_dump.csv rename to tests/components/ozw/fixtures/fan_network_dump.csv diff --git a/tests/fixtures/ozw/generic_network_dump.csv b/tests/components/ozw/fixtures/generic_network_dump.csv similarity index 100% rename from tests/fixtures/ozw/generic_network_dump.csv rename to tests/components/ozw/fixtures/generic_network_dump.csv diff --git a/tests/fixtures/ozw/light.json b/tests/components/ozw/fixtures/light.json similarity index 100% rename from tests/fixtures/ozw/light.json rename to tests/components/ozw/fixtures/light.json diff --git a/tests/fixtures/ozw/light_network_dump.csv b/tests/components/ozw/fixtures/light_network_dump.csv similarity index 100% rename from tests/fixtures/ozw/light_network_dump.csv rename to tests/components/ozw/fixtures/light_network_dump.csv diff --git a/tests/fixtures/ozw/light_new_ozw_network_dump.csv b/tests/components/ozw/fixtures/light_new_ozw_network_dump.csv similarity index 100% rename from tests/fixtures/ozw/light_new_ozw_network_dump.csv rename to tests/components/ozw/fixtures/light_new_ozw_network_dump.csv diff --git a/tests/fixtures/ozw/light_no_cw_network_dump.csv b/tests/components/ozw/fixtures/light_no_cw_network_dump.csv similarity index 100% rename from tests/fixtures/ozw/light_no_cw_network_dump.csv rename to tests/components/ozw/fixtures/light_no_cw_network_dump.csv diff --git a/tests/fixtures/ozw/light_no_rgb.json b/tests/components/ozw/fixtures/light_no_rgb.json similarity index 100% rename from tests/fixtures/ozw/light_no_rgb.json rename to tests/components/ozw/fixtures/light_no_rgb.json diff --git a/tests/fixtures/ozw/light_no_ww_network_dump.csv b/tests/components/ozw/fixtures/light_no_ww_network_dump.csv similarity index 100% rename from tests/fixtures/ozw/light_no_ww_network_dump.csv rename to tests/components/ozw/fixtures/light_no_ww_network_dump.csv diff --git a/tests/fixtures/ozw/light_pure_rgb.json b/tests/components/ozw/fixtures/light_pure_rgb.json similarity index 100% rename from tests/fixtures/ozw/light_pure_rgb.json rename to tests/components/ozw/fixtures/light_pure_rgb.json diff --git a/tests/fixtures/ozw/light_rgb.json b/tests/components/ozw/fixtures/light_rgb.json similarity index 100% rename from tests/fixtures/ozw/light_rgb.json rename to tests/components/ozw/fixtures/light_rgb.json diff --git a/tests/fixtures/ozw/light_wc_network_dump.csv b/tests/components/ozw/fixtures/light_wc_network_dump.csv similarity index 100% rename from tests/fixtures/ozw/light_wc_network_dump.csv rename to tests/components/ozw/fixtures/light_wc_network_dump.csv diff --git a/tests/fixtures/ozw/lock.json b/tests/components/ozw/fixtures/lock.json similarity index 100% rename from tests/fixtures/ozw/lock.json rename to tests/components/ozw/fixtures/lock.json diff --git a/tests/fixtures/ozw/lock_network_dump.csv b/tests/components/ozw/fixtures/lock_network_dump.csv similarity index 100% rename from tests/fixtures/ozw/lock_network_dump.csv rename to tests/components/ozw/fixtures/lock_network_dump.csv diff --git a/tests/fixtures/ozw/migration_fixture.csv b/tests/components/ozw/fixtures/migration_fixture.csv similarity index 100% rename from tests/fixtures/ozw/migration_fixture.csv rename to tests/components/ozw/fixtures/migration_fixture.csv diff --git a/tests/fixtures/ozw/sensor.json b/tests/components/ozw/fixtures/sensor.json similarity index 100% rename from tests/fixtures/ozw/sensor.json rename to tests/components/ozw/fixtures/sensor.json diff --git a/tests/fixtures/ozw/sensor_string_value_network_dump.csv b/tests/components/ozw/fixtures/sensor_string_value_network_dump.csv similarity index 100% rename from tests/fixtures/ozw/sensor_string_value_network_dump.csv rename to tests/components/ozw/fixtures/sensor_string_value_network_dump.csv diff --git a/tests/fixtures/ozw/switch.json b/tests/components/ozw/fixtures/switch.json similarity index 100% rename from tests/fixtures/ozw/switch.json rename to tests/components/ozw/fixtures/switch.json diff --git a/tests/components/ozw/test_config_flow.py b/tests/components/ozw/test_config_flow.py index 384e16b57ed..9c65372ca98 100644 --- a/tests/components/ozw/test_config_flow.py +++ b/tests/components/ozw/test_config_flow.py @@ -4,19 +4,22 @@ from unittest.mock import patch import pytest from homeassistant import config_entries +from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.ozw.config_flow import TITLE from homeassistant.components.ozw.const import DOMAIN from tests.common import MockConfigEntry -ADDON_DISCOVERY_INFO = { - "addon": "OpenZWave", - "host": "host1", - "port": 1234, - "username": "name1", - "password": "pass1", -} +ADDON_DISCOVERY_INFO = HassioServiceInfo( + config={ + "addon": "OpenZWave", + "host": "host1", + "port": 1234, + "username": "name1", + "password": "pass1", + } +) @pytest.fixture(name="supervisor") diff --git a/tests/components/ozw/test_websocket_api.py b/tests/components/ozw/test_websocket_api.py index ad3b568b62a..2cbe69f1c98 100644 --- a/tests/components/ozw/test_websocket_api.py +++ b/tests/components/ozw/test_websocket_api.py @@ -43,7 +43,7 @@ from homeassistant.components.websocket_api.const import ( from .common import MQTTMessage, setup_ozw -async def test_websocket_api(hass, generic_data, hass_ws_client): +async def test_websocket_api(hass, generic_data, hass_ws_client, mqtt_mock): """Test the ozw websocket api.""" await setup_ozw(hass, fixture=generic_data) client = await hass_ws_client(hass) @@ -280,7 +280,7 @@ async def test_websocket_api(hass, generic_data, hass_ws_client): assert result["code"] == ERR_NOT_FOUND -async def test_ws_locks(hass, lock_data, hass_ws_client): +async def test_ws_locks(hass, lock_data, hass_ws_client, mqtt_mock): """Test lock websocket apis.""" await setup_ozw(hass, fixture=lock_data) client = await hass_ws_client(hass) @@ -319,7 +319,9 @@ async def test_ws_locks(hass, lock_data, hass_ws_client): assert msg["success"] -async def test_refresh_node(hass, generic_data, sent_messages, hass_ws_client): +async def test_refresh_node( + hass, generic_data, sent_messages, hass_ws_client, mqtt_mock +): """Test the ozw refresh node api.""" receive_message = await setup_ozw(hass, fixture=generic_data) client = await hass_ws_client(hass) @@ -368,7 +370,7 @@ async def test_refresh_node(hass, generic_data, sent_messages, hass_ws_client): assert result["node_query_stage"] == "versions" -async def test_refresh_node_unsubscribe(hass, generic_data, hass_ws_client): +async def test_refresh_node_unsubscribe(hass, generic_data, hass_ws_client, mqtt_mock): """Test unsubscribing the ozw refresh node api.""" await setup_ozw(hass, fixture=generic_data) client = await hass_ws_client(hass) diff --git a/tests/fixtures/p1_monitor/phases.json b/tests/components/p1_monitor/fixtures/phases.json similarity index 100% rename from tests/fixtures/p1_monitor/phases.json rename to tests/components/p1_monitor/fixtures/phases.json diff --git a/tests/fixtures/p1_monitor/settings.json b/tests/components/p1_monitor/fixtures/settings.json similarity index 100% rename from tests/fixtures/p1_monitor/settings.json rename to tests/components/p1_monitor/fixtures/settings.json diff --git a/tests/fixtures/p1_monitor/smartmeter.json b/tests/components/p1_monitor/fixtures/smartmeter.json similarity index 100% rename from tests/fixtures/p1_monitor/smartmeter.json rename to tests/components/p1_monitor/fixtures/smartmeter.json diff --git a/tests/components/p1_monitor/test_sensor.py b/tests/components/p1_monitor/test_sensor.py index 960f68315e5..61f3f027b6b 100644 --- a/tests/components/p1_monitor/test_sensor.py +++ b/tests/components/p1_monitor/test_sensor.py @@ -1,7 +1,7 @@ """Tests for the sensors provided by the P1 Monitor integration.""" import pytest -from homeassistant.components.p1_monitor.const import DOMAIN, ENTRY_TYPE_SERVICE +from homeassistant.components.p1_monitor.const import DOMAIN from homeassistant.components.sensor import ( ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT, @@ -80,7 +80,7 @@ async def test_smartmeter( assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_smartmeter")} assert device_entry.manufacturer == "P1 Monitor" assert device_entry.name == "SmartMeter" - assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert device_entry.entry_type is dr.DeviceEntryType.SERVICE assert not device_entry.model assert not device_entry.sw_version @@ -136,7 +136,7 @@ async def test_phases( assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_phases")} assert device_entry.manufacturer == "P1 Monitor" assert device_entry.name == "Phases" - assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert device_entry.entry_type is dr.DeviceEntryType.SERVICE assert not device_entry.model assert not device_entry.sw_version @@ -182,7 +182,7 @@ async def test_settings( assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_settings")} assert device_entry.manufacturer == "P1 Monitor" assert device_entry.name == "Settings" - assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert device_entry.entry_type is dr.DeviceEntryType.SERVICE assert not device_entry.model assert not device_entry.sw_version diff --git a/tests/components/pi_hole/__init__.py b/tests/components/pi_hole/__init__.py index f02cd0c8a7a..235cce92a4b 100644 --- a/tests/components/pi_hole/__init__.py +++ b/tests/components/pi_hole/__init__.py @@ -26,6 +26,18 @@ ZERO_DATA = { "unique_domains": 0, } +SAMPLE_VERSIONS = { + "core_current": "v5.5", + "core_latest": "v5.6", + "core_update": True, + "web_current": "v5.7", + "web_latest": "v5.8", + "web_update": True, + "FTL_current": "v5.10", + "FTL_latest": "v5.11", + "FTL_update": True, +} + HOST = "1.2.3.4" PORT = 80 LOCATION = "location" @@ -75,9 +87,13 @@ def _create_mocked_hole(raise_exception=False): type(mocked_hole).get_data = AsyncMock( side_effect=HoleError("") if raise_exception else None ) + type(mocked_hole).get_versions = AsyncMock( + side_effect=HoleError("") if raise_exception else None + ) type(mocked_hole).enable = AsyncMock() type(mocked_hole).disable = AsyncMock() mocked_hole.data = ZERO_DATA + mocked_hole.versions = SAMPLE_VERSIONS return mocked_hole diff --git a/tests/components/pi_hole/test_init.py b/tests/components/pi_hole/test_init.py index a14e155b3da..e96f0d7b33f 100644 --- a/tests/components/pi_hole/test_init.py +++ b/tests/components/pi_hole/test_init.py @@ -94,6 +94,60 @@ async def test_setup_minimal_config(hass): assert hass.states.get("binary_sensor.pi_hole").name == "Pi-Hole" assert hass.states.get("binary_sensor.pi_hole").state == "off" + assert ( + hass.states.get("binary_sensor.pi_hole_core_update_available").name + == "Pi-Hole Core Update Available" + ) + assert hass.states.get("binary_sensor.pi_hole_core_update_available").state == "on" + assert ( + hass.states.get("binary_sensor.pi_hole_core_update_available").attributes[ + "current_version" + ] + == "v5.5" + ) + assert ( + hass.states.get("binary_sensor.pi_hole_core_update_available").attributes[ + "latest_version" + ] + == "v5.6" + ) + + assert ( + hass.states.get("binary_sensor.pi_hole_ftl_update_available").name + == "Pi-Hole FTL Update Available" + ) + assert hass.states.get("binary_sensor.pi_hole_ftl_update_available").state == "on" + assert ( + hass.states.get("binary_sensor.pi_hole_ftl_update_available").attributes[ + "current_version" + ] + == "v5.10" + ) + assert ( + hass.states.get("binary_sensor.pi_hole_ftl_update_available").attributes[ + "latest_version" + ] + == "v5.11" + ) + + assert ( + hass.states.get("binary_sensor.pi_hole_web_update_available").name + == "Pi-Hole Web Update Available" + ) + assert hass.states.get("binary_sensor.pi_hole_web_update_available").state == "on" + assert ( + hass.states.get("binary_sensor.pi_hole_web_update_available").attributes[ + "current_version" + ] + == "v5.7" + ) + assert ( + hass.states.get("binary_sensor.pi_hole_web_update_available").attributes[ + "latest_version" + ] + == "v5.8" + ) + async def test_setup_name_config(hass): """Tests component setup with a custom name.""" diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index 2f8fb4cec53..b0d12f1f080 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( DEVICE_CLASS_TIMESTAMP, STATE_UNAVAILABLE, ) +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.util import dt from tests.common import ( @@ -210,44 +211,44 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): ) self._assert_sensor( "sensor.picnic_selected_slot_start", - "2021-03-03T14:45:00.000+01:00", + "2021-03-03T13:45:00+00:00", cls=DEVICE_CLASS_TIMESTAMP, ) self._assert_sensor( "sensor.picnic_selected_slot_end", - "2021-03-03T15:45:00.000+01:00", + "2021-03-03T14:45:00+00:00", cls=DEVICE_CLASS_TIMESTAMP, ) self._assert_sensor( "sensor.picnic_selected_slot_max_order_time", - "2021-03-02T22:00:00.000+01:00", + "2021-03-02T21:00:00+00:00", cls=DEVICE_CLASS_TIMESTAMP, ) self._assert_sensor("sensor.picnic_selected_slot_min_order_value", "35.0") self._assert_sensor( "sensor.picnic_last_order_slot_start", - "2021-02-26T20:15:00.000+01:00", + "2021-02-26T19:15:00+00:00", cls=DEVICE_CLASS_TIMESTAMP, ) self._assert_sensor( "sensor.picnic_last_order_slot_end", - "2021-02-26T21:15:00.000+01:00", + "2021-02-26T20:15:00+00:00", cls=DEVICE_CLASS_TIMESTAMP, ) self._assert_sensor("sensor.picnic_last_order_status", "COMPLETED") self._assert_sensor( "sensor.picnic_last_order_eta_start", - "2021-02-26T20:54:00.000+01:00", + "2021-02-26T19:54:00+00:00", cls=DEVICE_CLASS_TIMESTAMP, ) self._assert_sensor( "sensor.picnic_last_order_eta_end", - "2021-02-26T21:14:00.000+01:00", + "2021-02-26T20:14:00+00:00", cls=DEVICE_CLASS_TIMESTAMP, ) self._assert_sensor( "sensor.picnic_last_order_delivery_time", - "2021-02-26T20:54:05.221+01:00", + "2021-02-26T19:54:05+00:00", cls=DEVICE_CLASS_TIMESTAMP, ) self._assert_sensor( @@ -305,10 +306,10 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): # Assert delivery time is not available, but eta is self._assert_sensor("sensor.picnic_last_order_delivery_time", STATE_UNAVAILABLE) self._assert_sensor( - "sensor.picnic_last_order_eta_start", "2021-02-26T20:54:00.000+01:00" + "sensor.picnic_last_order_eta_start", "2021-02-26T19:54:00+00:00" ) self._assert_sensor( - "sensor.picnic_last_order_eta_end", "2021-02-26T21:14:00.000+01:00" + "sensor.picnic_last_order_eta_end", "2021-02-26T20:14:00+00:00" ) async def test_sensors_use_detailed_eta_if_available(self): @@ -322,8 +323,8 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): self.picnic_mock().get_deliveries.return_value = [delivery_response] self.picnic_mock().get_delivery_position.return_value = { "eta_window": { - "start": "2021-03-05T11:19:20.452+01:00", - "end": "2021-03-05T11:39:20.452+01:00", + "start": "2021-03-05T10:19:20.452+00:00", + "end": "2021-03-05T10:39:20.452+00:00", } } await self._coordinator.async_refresh() @@ -333,10 +334,10 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): delivery_response["delivery_id"] ) self._assert_sensor( - "sensor.picnic_last_order_eta_start", "2021-03-05T11:19:20.452+01:00" + "sensor.picnic_last_order_eta_start", "2021-03-05T10:19:20+00:00" ) self._assert_sensor( - "sensor.picnic_last_order_eta_end", "2021-03-05T11:39:20.452+01:00" + "sensor.picnic_last_order_eta_end", "2021-03-05T10:39:20+00:00" ) async def test_sensors_no_data(self): @@ -390,7 +391,7 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): ) assert picnic_service.model == DEFAULT_USER_RESPONSE["user_id"] assert picnic_service.name == "Picnic: Commonstreet 123a" - assert picnic_service.entry_type == "service" + assert picnic_service.entry_type is DeviceEntryType.SERVICE async def test_auth_token_is_saved_on_update(self): """Test that auth-token changes in the session object are reflected by the config entry.""" diff --git a/tests/fixtures/ping/configuration.yaml b/tests/components/ping/fixtures/configuration.yaml similarity index 100% rename from tests/fixtures/ping/configuration.yaml rename to tests/components/ping/fixtures/configuration.yaml diff --git a/tests/components/ping/test_binary_sensor.py b/tests/components/ping/test_binary_sensor.py index 3ffb2bb95d5..e5021ad6ac2 100644 --- a/tests/components/ping/test_binary_sensor.py +++ b/tests/components/ping/test_binary_sensor.py @@ -1,5 +1,4 @@ """The test for the ping binary_sensor platform.""" -from os import path from unittest.mock import patch import pytest @@ -8,6 +7,8 @@ from homeassistant import config as hass_config, setup from homeassistant.components.ping import DOMAIN from homeassistant.const import SERVICE_RELOAD +from tests.common import get_fixture_path + @pytest.fixture def mock_ping(): @@ -37,11 +38,7 @@ async def test_reload(hass, mock_ping): assert hass.states.get("binary_sensor.test") - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "ping/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "ping") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( DOMAIN, @@ -55,7 +52,3 @@ async def test_reload(hass, mock_ping): assert hass.states.get("binary_sensor.test") is None assert hass.states.get("binary_sensor.test2") - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/fixtures/plex/album.xml b/tests/components/plex/fixtures/album.xml similarity index 100% rename from tests/fixtures/plex/album.xml rename to tests/components/plex/fixtures/album.xml diff --git a/tests/fixtures/plex/artist_albums.xml b/tests/components/plex/fixtures/artist_albums.xml similarity index 100% rename from tests/fixtures/plex/artist_albums.xml rename to tests/components/plex/fixtures/artist_albums.xml diff --git a/tests/fixtures/plex/children_20.xml b/tests/components/plex/fixtures/children_20.xml similarity index 100% rename from tests/fixtures/plex/children_20.xml rename to tests/components/plex/fixtures/children_20.xml diff --git a/tests/fixtures/plex/children_200.xml b/tests/components/plex/fixtures/children_200.xml similarity index 100% rename from tests/fixtures/plex/children_200.xml rename to tests/components/plex/fixtures/children_200.xml diff --git a/tests/fixtures/plex/children_30.xml b/tests/components/plex/fixtures/children_30.xml similarity index 100% rename from tests/fixtures/plex/children_30.xml rename to tests/components/plex/fixtures/children_30.xml diff --git a/tests/fixtures/plex/children_300.xml b/tests/components/plex/fixtures/children_300.xml similarity index 100% rename from tests/fixtures/plex/children_300.xml rename to tests/components/plex/fixtures/children_300.xml diff --git a/tests/fixtures/plex/empty_library.xml b/tests/components/plex/fixtures/empty_library.xml similarity index 100% rename from tests/fixtures/plex/empty_library.xml rename to tests/components/plex/fixtures/empty_library.xml diff --git a/tests/fixtures/plex/empty_payload.xml b/tests/components/plex/fixtures/empty_payload.xml similarity index 100% rename from tests/fixtures/plex/empty_payload.xml rename to tests/components/plex/fixtures/empty_payload.xml diff --git a/tests/fixtures/plex/grandchildren_300.xml b/tests/components/plex/fixtures/grandchildren_300.xml similarity index 100% rename from tests/fixtures/plex/grandchildren_300.xml rename to tests/components/plex/fixtures/grandchildren_300.xml diff --git a/tests/fixtures/plex/library.xml b/tests/components/plex/fixtures/library.xml similarity index 100% rename from tests/fixtures/plex/library.xml rename to tests/components/plex/fixtures/library.xml diff --git a/tests/fixtures/plex/library_movies_all.xml b/tests/components/plex/fixtures/library_movies_all.xml similarity index 100% rename from tests/fixtures/plex/library_movies_all.xml rename to tests/components/plex/fixtures/library_movies_all.xml diff --git a/tests/fixtures/plex/library_movies_collections.xml b/tests/components/plex/fixtures/library_movies_collections.xml similarity index 100% rename from tests/fixtures/plex/library_movies_collections.xml rename to tests/components/plex/fixtures/library_movies_collections.xml diff --git a/tests/fixtures/plex/library_movies_filtertypes.xml b/tests/components/plex/fixtures/library_movies_filtertypes.xml similarity index 100% rename from tests/fixtures/plex/library_movies_filtertypes.xml rename to tests/components/plex/fixtures/library_movies_filtertypes.xml diff --git a/tests/fixtures/plex/library_movies_metadata.xml b/tests/components/plex/fixtures/library_movies_metadata.xml similarity index 100% rename from tests/fixtures/plex/library_movies_metadata.xml rename to tests/components/plex/fixtures/library_movies_metadata.xml diff --git a/tests/fixtures/plex/library_movies_size.xml b/tests/components/plex/fixtures/library_movies_size.xml similarity index 100% rename from tests/fixtures/plex/library_movies_size.xml rename to tests/components/plex/fixtures/library_movies_size.xml diff --git a/tests/fixtures/plex/library_movies_sort.xml b/tests/components/plex/fixtures/library_movies_sort.xml similarity index 100% rename from tests/fixtures/plex/library_movies_sort.xml rename to tests/components/plex/fixtures/library_movies_sort.xml diff --git a/tests/fixtures/plex/library_music_all.xml b/tests/components/plex/fixtures/library_music_all.xml similarity index 100% rename from tests/fixtures/plex/library_music_all.xml rename to tests/components/plex/fixtures/library_music_all.xml diff --git a/tests/fixtures/plex/library_music_collections.xml b/tests/components/plex/fixtures/library_music_collections.xml similarity index 100% rename from tests/fixtures/plex/library_music_collections.xml rename to tests/components/plex/fixtures/library_music_collections.xml diff --git a/tests/fixtures/plex/library_music_metadata.xml b/tests/components/plex/fixtures/library_music_metadata.xml similarity index 100% rename from tests/fixtures/plex/library_music_metadata.xml rename to tests/components/plex/fixtures/library_music_metadata.xml diff --git a/tests/fixtures/plex/library_music_size.xml b/tests/components/plex/fixtures/library_music_size.xml similarity index 100% rename from tests/fixtures/plex/library_music_size.xml rename to tests/components/plex/fixtures/library_music_size.xml diff --git a/tests/fixtures/plex/library_music_sort.xml b/tests/components/plex/fixtures/library_music_sort.xml similarity index 100% rename from tests/fixtures/plex/library_music_sort.xml rename to tests/components/plex/fixtures/library_music_sort.xml diff --git a/tests/fixtures/plex/library_sections.xml b/tests/components/plex/fixtures/library_sections.xml similarity index 100% rename from tests/fixtures/plex/library_sections.xml rename to tests/components/plex/fixtures/library_sections.xml diff --git a/tests/fixtures/plex/library_tvshows_all.xml b/tests/components/plex/fixtures/library_tvshows_all.xml similarity index 100% rename from tests/fixtures/plex/library_tvshows_all.xml rename to tests/components/plex/fixtures/library_tvshows_all.xml diff --git a/tests/fixtures/plex/library_tvshows_collections.xml b/tests/components/plex/fixtures/library_tvshows_collections.xml similarity index 100% rename from tests/fixtures/plex/library_tvshows_collections.xml rename to tests/components/plex/fixtures/library_tvshows_collections.xml diff --git a/tests/fixtures/plex/library_tvshows_metadata.xml b/tests/components/plex/fixtures/library_tvshows_metadata.xml similarity index 100% rename from tests/fixtures/plex/library_tvshows_metadata.xml rename to tests/components/plex/fixtures/library_tvshows_metadata.xml diff --git a/tests/fixtures/plex/library_tvshows_most_recent.xml b/tests/components/plex/fixtures/library_tvshows_most_recent.xml similarity index 100% rename from tests/fixtures/plex/library_tvshows_most_recent.xml rename to tests/components/plex/fixtures/library_tvshows_most_recent.xml diff --git a/tests/fixtures/plex/library_tvshows_size.xml b/tests/components/plex/fixtures/library_tvshows_size.xml similarity index 100% rename from tests/fixtures/plex/library_tvshows_size.xml rename to tests/components/plex/fixtures/library_tvshows_size.xml diff --git a/tests/fixtures/plex/library_tvshows_size_episodes.xml b/tests/components/plex/fixtures/library_tvshows_size_episodes.xml similarity index 100% rename from tests/fixtures/plex/library_tvshows_size_episodes.xml rename to tests/components/plex/fixtures/library_tvshows_size_episodes.xml diff --git a/tests/fixtures/plex/library_tvshows_size_seasons.xml b/tests/components/plex/fixtures/library_tvshows_size_seasons.xml similarity index 100% rename from tests/fixtures/plex/library_tvshows_size_seasons.xml rename to tests/components/plex/fixtures/library_tvshows_size_seasons.xml diff --git a/tests/fixtures/plex/library_tvshows_sort.xml b/tests/components/plex/fixtures/library_tvshows_sort.xml similarity index 100% rename from tests/fixtures/plex/library_tvshows_sort.xml rename to tests/components/plex/fixtures/library_tvshows_sort.xml diff --git a/tests/fixtures/plex/livetv_sessions.xml b/tests/components/plex/fixtures/livetv_sessions.xml similarity index 100% rename from tests/fixtures/plex/livetv_sessions.xml rename to tests/components/plex/fixtures/livetv_sessions.xml diff --git a/tests/fixtures/plex/media_1.xml b/tests/components/plex/fixtures/media_1.xml similarity index 100% rename from tests/fixtures/plex/media_1.xml rename to tests/components/plex/fixtures/media_1.xml diff --git a/tests/fixtures/plex/media_100.xml b/tests/components/plex/fixtures/media_100.xml similarity index 100% rename from tests/fixtures/plex/media_100.xml rename to tests/components/plex/fixtures/media_100.xml diff --git a/tests/fixtures/plex/media_200.xml b/tests/components/plex/fixtures/media_200.xml similarity index 100% rename from tests/fixtures/plex/media_200.xml rename to tests/components/plex/fixtures/media_200.xml diff --git a/tests/fixtures/plex/media_30.xml b/tests/components/plex/fixtures/media_30.xml similarity index 100% rename from tests/fixtures/plex/media_30.xml rename to tests/components/plex/fixtures/media_30.xml diff --git a/tests/fixtures/plex/player_plexweb_resources.xml b/tests/components/plex/fixtures/player_plexweb_resources.xml similarity index 100% rename from tests/fixtures/plex/player_plexweb_resources.xml rename to tests/components/plex/fixtures/player_plexweb_resources.xml diff --git a/tests/fixtures/plex/playlist_500.xml b/tests/components/plex/fixtures/playlist_500.xml similarity index 100% rename from tests/fixtures/plex/playlist_500.xml rename to tests/components/plex/fixtures/playlist_500.xml diff --git a/tests/fixtures/plex/playlists.xml b/tests/components/plex/fixtures/playlists.xml similarity index 100% rename from tests/fixtures/plex/playlists.xml rename to tests/components/plex/fixtures/playlists.xml diff --git a/tests/fixtures/plex/playqueue_1234.xml b/tests/components/plex/fixtures/playqueue_1234.xml similarity index 100% rename from tests/fixtures/plex/playqueue_1234.xml rename to tests/components/plex/fixtures/playqueue_1234.xml diff --git a/tests/fixtures/plex/playqueue_created.xml b/tests/components/plex/fixtures/playqueue_created.xml similarity index 100% rename from tests/fixtures/plex/playqueue_created.xml rename to tests/components/plex/fixtures/playqueue_created.xml diff --git a/tests/fixtures/plex/plex_server_accounts.xml b/tests/components/plex/fixtures/plex_server_accounts.xml similarity index 100% rename from tests/fixtures/plex/plex_server_accounts.xml rename to tests/components/plex/fixtures/plex_server_accounts.xml diff --git a/tests/fixtures/plex/plex_server_base.xml b/tests/components/plex/fixtures/plex_server_base.xml similarity index 100% rename from tests/fixtures/plex/plex_server_base.xml rename to tests/components/plex/fixtures/plex_server_base.xml diff --git a/tests/fixtures/plex/plex_server_clients.xml b/tests/components/plex/fixtures/plex_server_clients.xml similarity index 100% rename from tests/fixtures/plex/plex_server_clients.xml rename to tests/components/plex/fixtures/plex_server_clients.xml diff --git a/tests/fixtures/plex/plextv_account.xml b/tests/components/plex/fixtures/plextv_account.xml similarity index 100% rename from tests/fixtures/plex/plextv_account.xml rename to tests/components/plex/fixtures/plextv_account.xml diff --git a/tests/fixtures/plex/plextv_resources_base.xml b/tests/components/plex/fixtures/plextv_resources_base.xml similarity index 100% rename from tests/fixtures/plex/plextv_resources_base.xml rename to tests/components/plex/fixtures/plextv_resources_base.xml diff --git a/tests/fixtures/plex/plextv_shared_users.xml b/tests/components/plex/fixtures/plextv_shared_users.xml similarity index 100% rename from tests/fixtures/plex/plextv_shared_users.xml rename to tests/components/plex/fixtures/plextv_shared_users.xml diff --git a/tests/fixtures/plex/security_token.xml b/tests/components/plex/fixtures/security_token.xml similarity index 100% rename from tests/fixtures/plex/security_token.xml rename to tests/components/plex/fixtures/security_token.xml diff --git a/tests/fixtures/plex/session_base.xml b/tests/components/plex/fixtures/session_base.xml similarity index 100% rename from tests/fixtures/plex/session_base.xml rename to tests/components/plex/fixtures/session_base.xml diff --git a/tests/fixtures/plex/session_live_tv.xml b/tests/components/plex/fixtures/session_live_tv.xml similarity index 100% rename from tests/fixtures/plex/session_live_tv.xml rename to tests/components/plex/fixtures/session_live_tv.xml diff --git a/tests/fixtures/plex/session_photo.xml b/tests/components/plex/fixtures/session_photo.xml similarity index 100% rename from tests/fixtures/plex/session_photo.xml rename to tests/components/plex/fixtures/session_photo.xml diff --git a/tests/fixtures/plex/session_plexweb.xml b/tests/components/plex/fixtures/session_plexweb.xml similarity index 100% rename from tests/fixtures/plex/session_plexweb.xml rename to tests/components/plex/fixtures/session_plexweb.xml diff --git a/tests/fixtures/plex/session_transient.xml b/tests/components/plex/fixtures/session_transient.xml similarity index 100% rename from tests/fixtures/plex/session_transient.xml rename to tests/components/plex/fixtures/session_transient.xml diff --git a/tests/fixtures/plex/session_unknown.xml b/tests/components/plex/fixtures/session_unknown.xml similarity index 100% rename from tests/fixtures/plex/session_unknown.xml rename to tests/components/plex/fixtures/session_unknown.xml diff --git a/tests/fixtures/plex/show_seasons.xml b/tests/components/plex/fixtures/show_seasons.xml similarity index 100% rename from tests/fixtures/plex/show_seasons.xml rename to tests/components/plex/fixtures/show_seasons.xml diff --git a/tests/fixtures/plex/sonos_resources.xml b/tests/components/plex/fixtures/sonos_resources.xml similarity index 100% rename from tests/fixtures/plex/sonos_resources.xml rename to tests/components/plex/fixtures/sonos_resources.xml diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_all_devices.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_all_devices.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_all_devices.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_all_devices.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/02cf28bfec924855854c544690a609ef.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/02cf28bfec924855854c544690a609ef.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/02cf28bfec924855854c544690a609ef.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/02cf28bfec924855854c544690a609ef.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/21f2b542c49845e6bb416884c55778d6.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/21f2b542c49845e6bb416884c55778d6.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/21f2b542c49845e6bb416884c55778d6.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/21f2b542c49845e6bb416884c55778d6.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/4a810418d5394b3f82727340b91ba740.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/4a810418d5394b3f82727340b91ba740.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/4a810418d5394b3f82727340b91ba740.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/4a810418d5394b3f82727340b91ba740.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/675416a629f343c495449970e2ca37b5.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/675416a629f343c495449970e2ca37b5.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/675416a629f343c495449970e2ca37b5.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/675416a629f343c495449970e2ca37b5.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/680423ff840043738f42cc7f1ff97a36.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/680423ff840043738f42cc7f1ff97a36.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/680423ff840043738f42cc7f1ff97a36.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/680423ff840043738f42cc7f1ff97a36.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/6a3bf693d05e48e0b460c815a4fdd09d.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/6a3bf693d05e48e0b460c815a4fdd09d.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/6a3bf693d05e48e0b460c815a4fdd09d.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/6a3bf693d05e48e0b460c815a4fdd09d.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/78d1126fc4c743db81b61c20e88342a7.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/78d1126fc4c743db81b61c20e88342a7.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/78d1126fc4c743db81b61c20e88342a7.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/78d1126fc4c743db81b61c20e88342a7.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/90986d591dcd426cae3ec3e8111ff730.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/90986d591dcd426cae3ec3e8111ff730.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/90986d591dcd426cae3ec3e8111ff730.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/90986d591dcd426cae3ec3e8111ff730.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/a28f588dc4a049a483fd03a30361ad3a.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/a28f588dc4a049a483fd03a30361ad3a.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/a28f588dc4a049a483fd03a30361ad3a.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/a28f588dc4a049a483fd03a30361ad3a.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/a2c3583e0a6349358998b760cea82d2a.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/a2c3583e0a6349358998b760cea82d2a.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/a2c3583e0a6349358998b760cea82d2a.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/a2c3583e0a6349358998b760cea82d2a.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/b310b72a0e354bfab43089919b9a88bf.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/b310b72a0e354bfab43089919b9a88bf.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/b310b72a0e354bfab43089919b9a88bf.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/b310b72a0e354bfab43089919b9a88bf.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/b59bcebaf94b499ea7d46e4a66fb62d8.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/b59bcebaf94b499ea7d46e4a66fb62d8.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/b59bcebaf94b499ea7d46e4a66fb62d8.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/b59bcebaf94b499ea7d46e4a66fb62d8.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/cd0ddb54ef694e11ac18ed1cbce5dbbd.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/cd0ddb54ef694e11ac18ed1cbce5dbbd.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/cd0ddb54ef694e11ac18ed1cbce5dbbd.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/cd0ddb54ef694e11ac18ed1cbce5dbbd.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/d3da73bde12a47d5a6b8f9dad971f2ec.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/d3da73bde12a47d5a6b8f9dad971f2ec.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/d3da73bde12a47d5a6b8f9dad971f2ec.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/d3da73bde12a47d5a6b8f9dad971f2ec.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/df4a4a8169904cdb9c03d61a21f42140.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/df4a4a8169904cdb9c03d61a21f42140.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/df4a4a8169904cdb9c03d61a21f42140.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/df4a4a8169904cdb9c03d61a21f42140.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/e7693eb9582644e5b865dba8d4447cf1.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/e7693eb9582644e5b865dba8d4447cf1.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/e7693eb9582644e5b865dba8d4447cf1.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/e7693eb9582644e5b865dba8d4447cf1.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/f1fee6043d3642a9b0a65297455f008e.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/f1fee6043d3642a9b0a65297455f008e.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/f1fee6043d3642a9b0a65297455f008e.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/f1fee6043d3642a9b0a65297455f008e.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/fe799307f1624099878210aa0b9f1475.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/fe799307f1624099878210aa0b9f1475.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/fe799307f1624099878210aa0b9f1475.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/get_device_data/fe799307f1624099878210aa0b9f1475.json diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/notifications.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/notifications.json similarity index 100% rename from tests/fixtures/plugwise/adam_multiple_devices_per_zone/notifications.json rename to tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/notifications.json diff --git a/tests/fixtures/plugwise/anna_heatpump/get_all_devices.json b/tests/components/plugwise/fixtures/anna_heatpump/get_all_devices.json similarity index 100% rename from tests/fixtures/plugwise/anna_heatpump/get_all_devices.json rename to tests/components/plugwise/fixtures/anna_heatpump/get_all_devices.json diff --git a/tests/fixtures/plugwise/anna_heatpump/get_device_data/015ae9ea3f964e668e490fa39da3870b.json b/tests/components/plugwise/fixtures/anna_heatpump/get_device_data/015ae9ea3f964e668e490fa39da3870b.json similarity index 100% rename from tests/fixtures/plugwise/anna_heatpump/get_device_data/015ae9ea3f964e668e490fa39da3870b.json rename to tests/components/plugwise/fixtures/anna_heatpump/get_device_data/015ae9ea3f964e668e490fa39da3870b.json diff --git a/tests/fixtures/plugwise/anna_heatpump/get_device_data/1cbf783bb11e4a7c8a6843dee3a86927.json b/tests/components/plugwise/fixtures/anna_heatpump/get_device_data/1cbf783bb11e4a7c8a6843dee3a86927.json similarity index 100% rename from tests/fixtures/plugwise/anna_heatpump/get_device_data/1cbf783bb11e4a7c8a6843dee3a86927.json rename to tests/components/plugwise/fixtures/anna_heatpump/get_device_data/1cbf783bb11e4a7c8a6843dee3a86927.json diff --git a/tests/fixtures/plugwise/anna_heatpump/get_device_data/3cb70739631c4d17a86b8b12e8a5161b.json b/tests/components/plugwise/fixtures/anna_heatpump/get_device_data/3cb70739631c4d17a86b8b12e8a5161b.json similarity index 100% rename from tests/fixtures/plugwise/anna_heatpump/get_device_data/3cb70739631c4d17a86b8b12e8a5161b.json rename to tests/components/plugwise/fixtures/anna_heatpump/get_device_data/3cb70739631c4d17a86b8b12e8a5161b.json diff --git a/tests/fixtures/plugwise/anna_heatpump/notifications.json b/tests/components/plugwise/fixtures/anna_heatpump/notifications.json similarity index 100% rename from tests/fixtures/plugwise/anna_heatpump/notifications.json rename to tests/components/plugwise/fixtures/anna_heatpump/notifications.json diff --git a/tests/fixtures/plugwise/p1v3_full_option/get_all_devices.json b/tests/components/plugwise/fixtures/p1v3_full_option/get_all_devices.json similarity index 100% rename from tests/fixtures/plugwise/p1v3_full_option/get_all_devices.json rename to tests/components/plugwise/fixtures/p1v3_full_option/get_all_devices.json diff --git a/tests/fixtures/plugwise/p1v3_full_option/get_device_data/e950c7d5e1ee407a858e2a8b5016c8b3.json b/tests/components/plugwise/fixtures/p1v3_full_option/get_device_data/e950c7d5e1ee407a858e2a8b5016c8b3.json similarity index 100% rename from tests/fixtures/plugwise/p1v3_full_option/get_device_data/e950c7d5e1ee407a858e2a8b5016c8b3.json rename to tests/components/plugwise/fixtures/p1v3_full_option/get_device_data/e950c7d5e1ee407a858e2a8b5016c8b3.json diff --git a/tests/fixtures/plugwise/p1v3_full_option/notifications.json b/tests/components/plugwise/fixtures/p1v3_full_option/notifications.json similarity index 100% rename from tests/fixtures/plugwise/p1v3_full_option/notifications.json rename to tests/components/plugwise/fixtures/p1v3_full_option/notifications.json diff --git a/tests/fixtures/plugwise/stretch_v31/get_all_devices.json b/tests/components/plugwise/fixtures/stretch_v31/get_all_devices.json similarity index 100% rename from tests/fixtures/plugwise/stretch_v31/get_all_devices.json rename to tests/components/plugwise/fixtures/stretch_v31/get_all_devices.json diff --git a/tests/fixtures/plugwise/stretch_v31/get_device_data/059e4d03c7a34d278add5c7a4a781d19.json b/tests/components/plugwise/fixtures/stretch_v31/get_device_data/059e4d03c7a34d278add5c7a4a781d19.json similarity index 100% rename from tests/fixtures/plugwise/stretch_v31/get_device_data/059e4d03c7a34d278add5c7a4a781d19.json rename to tests/components/plugwise/fixtures/stretch_v31/get_device_data/059e4d03c7a34d278add5c7a4a781d19.json diff --git a/tests/fixtures/plugwise/stretch_v31/get_device_data/5871317346d045bc9f6b987ef25ee638.json b/tests/components/plugwise/fixtures/stretch_v31/get_device_data/5871317346d045bc9f6b987ef25ee638.json similarity index 100% rename from tests/fixtures/plugwise/stretch_v31/get_device_data/5871317346d045bc9f6b987ef25ee638.json rename to tests/components/plugwise/fixtures/stretch_v31/get_device_data/5871317346d045bc9f6b987ef25ee638.json diff --git a/tests/fixtures/plugwise/stretch_v31/get_device_data/5ca521ac179d468e91d772eeeb8a2117.json b/tests/components/plugwise/fixtures/stretch_v31/get_device_data/5ca521ac179d468e91d772eeeb8a2117.json similarity index 100% rename from tests/fixtures/plugwise/stretch_v31/get_device_data/5ca521ac179d468e91d772eeeb8a2117.json rename to tests/components/plugwise/fixtures/stretch_v31/get_device_data/5ca521ac179d468e91d772eeeb8a2117.json diff --git a/tests/fixtures/plugwise/stretch_v31/get_device_data/71e1944f2a944b26ad73323e399efef0.json b/tests/components/plugwise/fixtures/stretch_v31/get_device_data/71e1944f2a944b26ad73323e399efef0.json similarity index 100% rename from tests/fixtures/plugwise/stretch_v31/get_device_data/71e1944f2a944b26ad73323e399efef0.json rename to tests/components/plugwise/fixtures/stretch_v31/get_device_data/71e1944f2a944b26ad73323e399efef0.json diff --git a/tests/fixtures/plugwise/stretch_v31/get_device_data/99f89d097be34fca88d8598c6dbc18ea.json b/tests/components/plugwise/fixtures/stretch_v31/get_device_data/99f89d097be34fca88d8598c6dbc18ea.json similarity index 100% rename from tests/fixtures/plugwise/stretch_v31/get_device_data/99f89d097be34fca88d8598c6dbc18ea.json rename to tests/components/plugwise/fixtures/stretch_v31/get_device_data/99f89d097be34fca88d8598c6dbc18ea.json diff --git a/tests/fixtures/plugwise/stretch_v31/get_device_data/aac7b735042c4832ac9ff33aae4f453b.json b/tests/components/plugwise/fixtures/stretch_v31/get_device_data/aac7b735042c4832ac9ff33aae4f453b.json similarity index 100% rename from tests/fixtures/plugwise/stretch_v31/get_device_data/aac7b735042c4832ac9ff33aae4f453b.json rename to tests/components/plugwise/fixtures/stretch_v31/get_device_data/aac7b735042c4832ac9ff33aae4f453b.json diff --git a/tests/fixtures/plugwise/stretch_v31/get_device_data/cfe95cf3de1948c0b8955125bf754614.json b/tests/components/plugwise/fixtures/stretch_v31/get_device_data/cfe95cf3de1948c0b8955125bf754614.json similarity index 100% rename from tests/fixtures/plugwise/stretch_v31/get_device_data/cfe95cf3de1948c0b8955125bf754614.json rename to tests/components/plugwise/fixtures/stretch_v31/get_device_data/cfe95cf3de1948c0b8955125bf754614.json diff --git a/tests/fixtures/plugwise/stretch_v31/get_device_data/d03738edfcc947f7b8f4573571d90d2d.json b/tests/components/plugwise/fixtures/stretch_v31/get_device_data/d03738edfcc947f7b8f4573571d90d2d.json similarity index 100% rename from tests/fixtures/plugwise/stretch_v31/get_device_data/d03738edfcc947f7b8f4573571d90d2d.json rename to tests/components/plugwise/fixtures/stretch_v31/get_device_data/d03738edfcc947f7b8f4573571d90d2d.json diff --git a/tests/fixtures/plugwise/stretch_v31/get_device_data/d950b314e9d8499f968e6db8d82ef78c.json b/tests/components/plugwise/fixtures/stretch_v31/get_device_data/d950b314e9d8499f968e6db8d82ef78c.json similarity index 100% rename from tests/fixtures/plugwise/stretch_v31/get_device_data/d950b314e9d8499f968e6db8d82ef78c.json rename to tests/components/plugwise/fixtures/stretch_v31/get_device_data/d950b314e9d8499f968e6db8d82ef78c.json diff --git a/tests/fixtures/plugwise/stretch_v31/get_device_data/e1c884e7dede431dadee09506ec4f859.json b/tests/components/plugwise/fixtures/stretch_v31/get_device_data/e1c884e7dede431dadee09506ec4f859.json similarity index 100% rename from tests/fixtures/plugwise/stretch_v31/get_device_data/e1c884e7dede431dadee09506ec4f859.json rename to tests/components/plugwise/fixtures/stretch_v31/get_device_data/e1c884e7dede431dadee09506ec4f859.json diff --git a/tests/fixtures/plugwise/stretch_v31/get_device_data/e309b52ea5684cf1a22f30cf0cd15051.json b/tests/components/plugwise/fixtures/stretch_v31/get_device_data/e309b52ea5684cf1a22f30cf0cd15051.json similarity index 100% rename from tests/fixtures/plugwise/stretch_v31/get_device_data/e309b52ea5684cf1a22f30cf0cd15051.json rename to tests/components/plugwise/fixtures/stretch_v31/get_device_data/e309b52ea5684cf1a22f30cf0cd15051.json diff --git a/tests/fixtures/plugwise/stretch_v31/notifications.json b/tests/components/plugwise/fixtures/stretch_v31/notifications.json similarity index 100% rename from tests/fixtures/plugwise/stretch_v31/notifications.json rename to tests/components/plugwise/fixtures/stretch_v31/notifications.json diff --git a/tests/components/plugwise/test_config_flow.py b/tests/components/plugwise/test_config_flow.py index 7f270e23cc1..9f9be299f84 100644 --- a/tests/components/plugwise/test_config_flow.py +++ b/tests/components/plugwise/test_config_flow.py @@ -8,6 +8,7 @@ from plugwise.exceptions import ( ) import pytest +from homeassistant.components import zeroconf from homeassistant.components.plugwise.const import ( API, DEFAULT_PORT, @@ -39,28 +40,30 @@ TEST_PORT = 81 TEST_USERNAME = "smile" TEST_USERNAME2 = "stretch" -TEST_DISCOVERY = { - "host": TEST_HOST, - "port": DEFAULT_PORT, - "hostname": f"{TEST_HOSTNAME}.local.", - "server": f"{TEST_HOSTNAME}.local.", - "properties": { +TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( + host=TEST_HOST, + hostname=f"{TEST_HOSTNAME}.local.", + name="mock_name", + port=DEFAULT_PORT, + properties={ "product": "smile", "version": "1.2.3", "hostname": f"{TEST_HOSTNAME}.local.", }, -} -TEST_DISCOVERY2 = { - "host": TEST_HOST, - "port": DEFAULT_PORT, - "hostname": f"{TEST_HOSTNAME2}.local.", - "server": f"{TEST_HOSTNAME2}.local.", - "properties": { + type="mock_type", +) +TEST_DISCOVERY2 = zeroconf.ZeroconfServiceInfo( + host=TEST_HOST, + hostname=f"{TEST_HOSTNAME2}.local.", + name="mock_name", + port=DEFAULT_PORT, + properties={ "product": "stretch", "version": "1.2.3", "hostname": f"{TEST_HOSTNAME2}.local.", }, -} + type="mock_type", +) @pytest.fixture(name="mock_smile") diff --git a/tests/fixtures/powerwall/device_type.json b/tests/components/powerwall/fixtures/device_type.json similarity index 100% rename from tests/fixtures/powerwall/device_type.json rename to tests/components/powerwall/fixtures/device_type.json diff --git a/tests/fixtures/powerwall/meters.json b/tests/components/powerwall/fixtures/meters.json similarity index 100% rename from tests/fixtures/powerwall/meters.json rename to tests/components/powerwall/fixtures/meters.json diff --git a/tests/fixtures/powerwall/site_info.json b/tests/components/powerwall/fixtures/site_info.json similarity index 100% rename from tests/fixtures/powerwall/site_info.json rename to tests/components/powerwall/fixtures/site_info.json diff --git a/tests/fixtures/powerwall/sitemaster.json b/tests/components/powerwall/fixtures/sitemaster.json similarity index 100% rename from tests/fixtures/powerwall/sitemaster.json rename to tests/components/powerwall/fixtures/sitemaster.json diff --git a/tests/fixtures/powerwall/status.json b/tests/components/powerwall/fixtures/status.json similarity index 100% rename from tests/fixtures/powerwall/status.json rename to tests/components/powerwall/fixtures/status.json diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py index afad08f3cd2..29a04a70085 100644 --- a/tests/components/powerwall/test_config_flow.py +++ b/tests/components/powerwall/test_config_flow.py @@ -9,7 +9,7 @@ from tesla_powerwall import ( ) from homeassistant import config_entries -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp from homeassistant.components.powerwall.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD @@ -144,11 +144,11 @@ async def test_already_configured(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - IP_ADDRESS: "1.1.1.1", - MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", - HOSTNAME: "any", - }, + data=dhcp.DhcpServiceInfo( + ip="1.1.1.1", + macaddress="AA:BB:CC:DD:EE:FF", + hostname="any", + ), ) assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -165,11 +165,11 @@ async def test_already_configured_with_ignored(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - IP_ADDRESS: "1.1.1.1", - MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", - HOSTNAME: "any", - }, + data=dhcp.DhcpServiceInfo( + ip="1.1.1.1", + macaddress="AA:BB:CC:DD:EE:FF", + hostname="any", + ), ) assert result["type"] == "form" @@ -180,11 +180,11 @@ async def test_dhcp_discovery(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - IP_ADDRESS: "1.1.1.1", - MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", - HOSTNAME: "any", - }, + data=dhcp.DhcpServiceInfo( + ip="1.1.1.1", + macaddress="AA:BB:CC:DD:EE:FF", + hostname="any", + ), ) assert result["type"] == "form" assert result["errors"] == {} diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 827190a4b41..f9f6ebbf0f5 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -7,6 +7,7 @@ import unittest.mock as mock import pytest from homeassistant.components import climate, humidifier, sensor +from homeassistant.components.demo.number import DemoNumber from homeassistant.components.demo.sensor import DemoSensor import homeassistant.components.prometheus as prometheus from homeassistant.const import ( @@ -99,6 +100,32 @@ async def prometheus_client(hass, hass_client, namespace): sensor5.entity_id = "sensor.sps30_pm_1um_weight_concentration" await sensor5.async_update_ha_state() + sensor6 = DemoSensor(None, "Trend Gradient", 0.002, None, None, None, None) + sensor6.hass = hass + sensor6.entity_id = "sensor.trend_gradient" + await sensor6.async_update_ha_state() + + sensor7 = DemoSensor(None, "Text", "should_not_work", None, None, None, None) + sensor7.hass = hass + sensor7.entity_id = "sensor.text" + await sensor7.async_update_ha_state() + + sensor8 = DemoSensor(None, "Text Unit", "should_not_work", None, None, "Text", None) + sensor8.hass = hass + sensor8.entity_id = "sensor.text_unit" + await sensor8.async_update_ha_state() + + number1 = DemoNumber(None, "Threshold", 5.2, None, False, 0, 10, 0.1) + number1.hass = hass + number1.entity_id = "input_number.threshold" + await number1.async_update_ha_state() + + number2 = DemoNumber(None, None, 60, None, False, 0, 100) + number2.hass = hass + number2.entity_id = "input_number.brightness" + number2._attr_name = None + await number2.async_update_ha_state() + return await hass_client() @@ -229,6 +256,36 @@ async def test_view_empty_namespace(hass, hass_client): 'friendly_name="SPS30 PM <1µm Weight concentration"} 3.7069' in body ) + assert ( + 'sensor_state{domain="sensor",' + 'entity="sensor.trend_gradient",' + 'friendly_name="Trend Gradient"} 0.002' in body + ) + + assert ( + 'sensor_state{domain="sensor",' + 'entity="sensor.text",' + 'friendly_name="Text"} 0' not in body + ) + + assert ( + 'sensor_unit_text{domain="sensor",' + 'entity="sensor.text_unit",' + 'friendly_name="Text Unit"} 0' not in body + ) + + assert ( + 'input_number_state{domain="input_number",' + 'entity="input_number.threshold",' + 'friendly_name="Threshold"} 5.2' in body + ) + + assert ( + 'input_number_state{domain="input_number",' + 'entity="input_number.brightness",' + 'friendly_name="None"} 60.0' in body + ) + async def test_view_default_namespace(hass, hass_client): """Test prometheus metrics view.""" diff --git a/tests/fixtures/pushbullet_devices.json b/tests/components/pushbullet/fixtures/devices.json similarity index 100% rename from tests/fixtures/pushbullet_devices.json rename to tests/components/pushbullet/fixtures/devices.json diff --git a/tests/components/pushbullet/test_notify.py b/tests/components/pushbullet/test_notify.py index 6e1de0b9824..a9186652f64 100644 --- a/tests/components/pushbullet/test_notify.py +++ b/tests/components/pushbullet/test_notify.py @@ -18,7 +18,7 @@ def mock_pushbullet(): with patch.object( PushBullet, "_get_data", - return_value=json.loads(load_fixture("pushbullet_devices.json")), + return_value=json.loads(load_fixture("devices.json", "pushbullet")), ): yield diff --git a/tests/fixtures/pvpc_hourly_pricing/PVPC_CURV_DD_2019_10_26.json b/tests/components/pvpc_hourly_pricing/fixtures/PVPC_CURV_DD_2019_10_26.json similarity index 100% rename from tests/fixtures/pvpc_hourly_pricing/PVPC_CURV_DD_2019_10_26.json rename to tests/components/pvpc_hourly_pricing/fixtures/PVPC_CURV_DD_2019_10_26.json diff --git a/tests/fixtures/pvpc_hourly_pricing/PVPC_CURV_DD_2019_10_27.json b/tests/components/pvpc_hourly_pricing/fixtures/PVPC_CURV_DD_2019_10_27.json similarity index 100% rename from tests/fixtures/pvpc_hourly_pricing/PVPC_CURV_DD_2019_10_27.json rename to tests/components/pvpc_hourly_pricing/fixtures/PVPC_CURV_DD_2019_10_27.json diff --git a/tests/fixtures/pvpc_hourly_pricing/PVPC_CURV_DD_2019_10_29.json b/tests/components/pvpc_hourly_pricing/fixtures/PVPC_CURV_DD_2019_10_29.json similarity index 100% rename from tests/fixtures/pvpc_hourly_pricing/PVPC_CURV_DD_2019_10_29.json rename to tests/components/pvpc_hourly_pricing/fixtures/PVPC_CURV_DD_2019_10_29.json diff --git a/tests/fixtures/pvpc_hourly_pricing/PVPC_CURV_DD_2021_06_01.json b/tests/components/pvpc_hourly_pricing/fixtures/PVPC_CURV_DD_2021_06_01.json similarity index 100% rename from tests/fixtures/pvpc_hourly_pricing/PVPC_CURV_DD_2021_06_01.json rename to tests/components/pvpc_hourly_pricing/fixtures/PVPC_CURV_DD_2021_06_01.json diff --git a/tests/components/rachio/test_config_flow.py b/tests/components/rachio/test_config_flow.py index 182f5e45dd7..cf9e811ed5a 100644 --- a/tests/components/rachio/test_config_flow.py +++ b/tests/components/rachio/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.components.rachio.const import ( CONF_CUSTOM_URL, CONF_MANUAL_RUN_MINS, @@ -111,7 +112,14 @@ async def test_form_homekit(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data={"properties": {"id": "AA:BB:CC:DD:EE:FF"}}, + data=zeroconf.ZeroconfServiceInfo( + host="mock_host", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + type="mock_type", + ), ) assert result["type"] == "form" assert result["errors"] == {} @@ -128,7 +136,14 @@ async def test_form_homekit(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data={"properties": {"id": "AA:BB:CC:DD:EE:FF"}}, + data=zeroconf.ZeroconfServiceInfo( + host="mock_host", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + type="mock_type", + ), ) assert result["type"] == "abort" assert result["reason"] == "already_configured" diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 1a015a5b181..35824296cc6 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -4,9 +4,11 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from regenmaschine.errors import RainMachineError -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components import zeroconf from homeassistant.components.rainmachine import CONF_ZONE_RUN_TIME, DOMAIN from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SSL +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry @@ -69,6 +71,68 @@ async def test_invalid_password(hass): assert result["errors"] == {CONF_PASSWORD: "invalid_auth"} +@pytest.mark.parametrize( + "platform,entity_name,entity_id,old_unique_id,new_unique_id", + [ + ( + "binary_sensor", + "Home Flow Sensor", + "binary_sensor.home_flow_sensor", + "60e32719b6cf_flow_sensor", + "60:e3:27:19:b6:cf_flow_sensor", + ), + ( + "switch", + "Home Landscaping", + "switch.home_landscaping", + "60e32719b6cf_RainMachineZone_1", + "60:e3:27:19:b6:cf_zone_1", + ), + ], +) +async def test_migrate_1_2( + hass, platform, entity_name, entity_id, old_unique_id, new_unique_id +): + """Test migration from version 1 to 2 (consistent unique IDs).""" + conf = { + CONF_IP_ADDRESS: "192.168.1.100", + CONF_PASSWORD: "password", + CONF_PORT: 8080, + CONF_SSL: True, + } + + entry = MockConfigEntry(domain=DOMAIN, unique_id="aa:bb:cc:dd:ee:ff", data=conf) + entry.add_to_hass(hass) + + ent_reg = er.async_get(hass) + + # Create entity RegistryEntry using old unique ID format: + entity_entry = ent_reg.async_get_or_create( + platform, + DOMAIN, + old_unique_id, + suggested_object_id=entity_name, + config_entry=entry, + original_name=entity_name, + ) + assert entity_entry.entity_id == entity_id + assert entity_entry.unique_id == old_unique_id + + with patch( + "homeassistant.components.rainmachine.async_setup_entry", return_value=True + ), patch( + "homeassistant.components.rainmachine.config_flow.Client", + return_value=_get_mock_client(), + ): + await setup.async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Check that new RegistryEntry is using new unique ID format + entity_entry = ent_reg.async_get(entity_id) + assert entity_entry.unique_id == new_unique_id + assert ent_reg.async_get_entity_id(platform, DOMAIN, old_unique_id) is None + + async def test_options_flow(hass): """Test config flow options.""" conf = { @@ -171,7 +235,14 @@ async def test_step_homekit_zeroconf_ip_already_exists(hass, source): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data={"host": "192.168.1.100"}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.100", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -200,7 +271,14 @@ async def test_step_homekit_zeroconf_ip_change(hass, source): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data={"host": "192.168.1.2"}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.2", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -231,7 +309,14 @@ async def test_step_homekit_zeroconf_new_controller_when_some_exist(hass, source result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data={"host": "192.168.1.100"}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.100", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -275,7 +360,14 @@ async def test_discovery_by_homekit_and_zeroconf_same_time(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={"host": "192.168.1.100"}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.100", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -288,7 +380,14 @@ async def test_discovery_by_homekit_and_zeroconf_same_time(hass): result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data={"host": "192.168.1.100"}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.100", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), ) assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT diff --git a/tests/components/rdw/__init__.py b/tests/components/rdw/__init__.py new file mode 100644 index 00000000000..6a628ecb94c --- /dev/null +++ b/tests/components/rdw/__init__.py @@ -0,0 +1 @@ +"""Tests for the RDW integration.""" diff --git a/tests/components/rdw/conftest.py b/tests/components/rdw/conftest.py new file mode 100644 index 00000000000..4be17f00264 --- /dev/null +++ b/tests/components/rdw/conftest.py @@ -0,0 +1,69 @@ +"""Fixtures for RDW integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest +from vehicle import Vehicle + +from homeassistant.components.rdw.const import CONF_LICENSE_PLATE, DOMAIN +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="My Car", + domain=DOMAIN, + data={CONF_LICENSE_PLATE: "11ZKZ3"}, + unique_id="11ZKZ3", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None, None, None]: + """Mock setting up a config entry.""" + with patch("homeassistant.components.rdw.async_setup_entry", return_value=True): + yield + + +@pytest.fixture +def mock_rdw_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked RDW client.""" + with patch( + "homeassistant.components.rdw.config_flow.RDW", autospec=True + ) as rdw_mock: + rdw = rdw_mock.return_value + rdw.vehicle.return_value = Vehicle.parse_raw(load_fixture("rdw/11ZKZ3.json")) + yield rdw + + +@pytest.fixture +def mock_rdw(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: + """Return a mocked WLED client.""" + fixture: str = "rdw/11ZKZ3.json" + if hasattr(request, "param") and request.param: + fixture = request.param + + vehicle = Vehicle.parse_raw(load_fixture(fixture)) + with patch("homeassistant.components.rdw.RDW", autospec=True) as rdw_mock: + rdw = rdw_mock.return_value + rdw.vehicle.return_value = vehicle + yield rdw + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_rdw: MagicMock +) -> MockConfigEntry: + """Set up the RDW 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/rdw/fixtures/11ZKZ3.json b/tests/components/rdw/fixtures/11ZKZ3.json new file mode 100644 index 00000000000..caaaf57c19b --- /dev/null +++ b/tests/components/rdw/fixtures/11ZKZ3.json @@ -0,0 +1,52 @@ +{ + "kenteken": "11ZKZ3", + "voertuigsoort": "Personenauto", + "merk": "SKODA", + "handelsbenaming": "CITIGO", + "vervaldatum_apk": "20220104", + "datum_tenaamstelling": "20211104", + "inrichting": "hatchback", + "aantal_zitplaatsen": "4", + "eerste_kleur": "GRIJS", + "tweede_kleur": "Niet geregistreerd", + "aantal_cilinders": "3", + "cilinderinhoud": "999", + "massa_ledig_voertuig": "840", + "toegestane_maximum_massa_voertuig": "1290", + "massa_rijklaar": "940", + "zuinigheidslabel": "A", + "datum_eerste_toelating": "20130104", + "datum_eerste_afgifte_nederland": "20130104", + "wacht_op_keuren": "Geen verstrekking in Open Data", + "catalogusprijs": "10697", + "wam_verzekerd": "Nee", + "aantal_deuren": "0", + "aantal_wielen": "4", + "afstand_hart_koppeling_tot_achterzijde_voertuig": "0", + "afstand_voorzijde_voertuig_tot_hart_koppeling": "0", + "lengte": "356", + "breedte": "0", + "europese_voertuigcategorie": "M1", + "plaats_chassisnummer": "r. motorruimte", + "technische_max_massa_voertuig": "1290", + "type": "AA", + "typegoedkeuringsnummer": "e13*2007/46*1169*05", + "variant": "ABCHYA", + "uitvoering": "FM5FM5CF0037MGVR2N1FA1SK", + "volgnummer_wijziging_eu_typegoedkeuring": "0", + "vermogen_massarijklaar": "0.05", + "wielbasis": "241", + "export_indicator": "Nee", + "openstaande_terugroepactie_indicator": "Nee", + "maximum_massa_samenstelling": "0", + "aantal_rolstoelplaatsen": "0", + "jaar_laatste_registratie_tellerstand": "2021", + "tellerstandoordeel": "Logisch", + "code_toelichting_tellerstandoordeel": "00", + "tenaamstellen_mogelijk": "Ja", + "api_gekentekende_voertuigen_assen": "https://opendata.rdw.nl/resource/3huj-srit.json", + "api_gekentekende_voertuigen_brandstof": "https://opendata.rdw.nl/resource/8ys7-d773.json", + "api_gekentekende_voertuigen_carrosserie": "https://opendata.rdw.nl/resource/vezc-m2t6.json", + "api_gekentekende_voertuigen_carrosserie_specifiek": "https://opendata.rdw.nl/resource/jhie-znh9.json", + "api_gekentekende_voertuigen_voertuigklasse": "https://opendata.rdw.nl/resource/kmfi-hrps.json" +} diff --git a/tests/components/rdw/test_binary_sensor.py b/tests/components/rdw/test_binary_sensor.py new file mode 100644 index 00000000000..b3c4d5a9b3c --- /dev/null +++ b/tests/components/rdw/test_binary_sensor.py @@ -0,0 +1,51 @@ +"""Tests for the sensors provided by the RDW integration.""" +from homeassistant.components.binary_sensor import DEVICE_CLASS_PROBLEM +from homeassistant.components.rdw.const import DOMAIN +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_ICON +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_vehicle_binary_sensors( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the RDW vehicle binary sensors.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("binary_sensor.liability_insured") + entry = entity_registry.async_get("binary_sensor.liability_insured") + assert entry + assert state + assert entry.unique_id == "11ZKZ3_liability_insured" + assert state.state == "off" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Liability Insured" + assert state.attributes.get(ATTR_ICON) == "mdi:shield-car" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.pending_recall") + entry = entity_registry.async_get("binary_sensor.pending_recall") + assert entry + assert state + assert entry.unique_id == "11ZKZ3_pending_recall" + assert state.state == "off" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Pending Recall" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PROBLEM + assert ATTR_ICON not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, "11ZKZ3")} + assert device_entry.manufacturer == "Skoda" + assert device_entry.name == "Skoda: 11ZKZ3" + assert device_entry.entry_type is dr.DeviceEntryType.SERVICE + assert device_entry.model == "Citigo" + assert ( + device_entry.configuration_url + == "https://ovi.rdw.nl/default.aspx?kenteken=11ZKZ3" + ) + assert not device_entry.sw_version diff --git a/tests/components/rdw/test_config_flow.py b/tests/components/rdw/test_config_flow.py new file mode 100644 index 00000000000..20144768abe --- /dev/null +++ b/tests/components/rdw/test_config_flow.py @@ -0,0 +1,92 @@ +"""Tests for the RDW config flow.""" + +from unittest.mock import MagicMock + +from vehicle.exceptions import RDWConnectionError, RDWUnknownLicensePlateError + +from homeassistant.components.rdw.const import CONF_LICENSE_PLATE, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +async def test_full_user_flow( + hass: HomeAssistant, mock_rdw_config_flow: MagicMock, mock_setup_entry: MagicMock +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LICENSE_PLATE: "11-ZKZ-3", + }, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "11-ZKZ-3" + assert result2.get("data") == {CONF_LICENSE_PLATE: "11ZKZ3"} + + +async def test_full_flow_with_authentication_error( + hass: HomeAssistant, mock_rdw_config_flow: MagicMock, mock_setup_entry: MagicMock +) -> None: + """Test the full user configuration flow with incorrect license plate. + + This tests tests a full config flow, with a case the user enters an invalid + license plate, but recover by entering the correct one. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + mock_rdw_config_flow.vehicle.side_effect = RDWUnknownLicensePlateError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LICENSE_PLATE: "0001TJ", + }, + ) + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == SOURCE_USER + assert result2.get("errors") == {"base": "unknown_license_plate"} + assert "flow_id" in result2 + + mock_rdw_config_flow.vehicle.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_LICENSE_PLATE: "11-ZKZ-3", + }, + ) + + assert result3.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result3.get("title") == "11-ZKZ-3" + assert result3.get("data") == {CONF_LICENSE_PLATE: "11ZKZ3"} + + +async def test_connection_error( + hass: HomeAssistant, mock_rdw_config_flow: MagicMock +) -> None: + """Test API connection error.""" + mock_rdw_config_flow.vehicle.side_effect = RDWConnectionError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_LICENSE_PLATE: "0001TJ"}, + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("errors") == {"base": "cannot_connect"} diff --git a/tests/components/rdw/test_init.py b/tests/components/rdw/test_init.py new file mode 100644 index 00000000000..b31b0aa8d81 --- /dev/null +++ b/tests/components/rdw/test_init.py @@ -0,0 +1,45 @@ +"""Tests for the RDW integration.""" +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.components.rdw.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rdw: AsyncMock, +) -> None: + """Test the RDW 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.state is ConfigEntryState.LOADED + + 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 + + +@patch( + "homeassistant.components.rdw.RDW.vehicle", + side_effect=RuntimeError, +) +async def test_config_entry_not_ready( + mock_request: MagicMock, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the RDW configuration entry not ready.""" + 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_request.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/rdw/test_sensor.py b/tests/components/rdw/test_sensor.py new file mode 100644 index 00000000000..5eeea579194 --- /dev/null +++ b/tests/components/rdw/test_sensor.py @@ -0,0 +1,61 @@ +"""Tests for the sensors provided by the RDW integration.""" +from homeassistant.components.rdw.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, + DEVICE_CLASS_DATE, +) +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_vehicle_sensors( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the RDW vehicle sensors.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.apk_expiration") + entry = entity_registry.async_get("sensor.apk_expiration") + assert entry + assert state + assert entry.unique_id == "11ZKZ3_apk_expiration" + assert state.state == "2022-01-04" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "APK Expiration" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_DATE + assert ATTR_ICON not in state.attributes + assert ATTR_STATE_CLASS not in state.attributes + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + state = hass.states.get("sensor.ascription_date") + entry = entity_registry.async_get("sensor.ascription_date") + assert entry + assert state + assert entry.unique_id == "11ZKZ3_ascription_date" + assert state.state == "2021-11-04" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Ascription Date" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_DATE + assert ATTR_ICON not in state.attributes + assert ATTR_STATE_CLASS not in state.attributes + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, "11ZKZ3")} + assert device_entry.manufacturer == "Skoda" + assert device_entry.name == "Skoda: 11ZKZ3" + assert device_entry.entry_type is dr.DeviceEntryType.SERVICE + assert device_entry.model == "Citigo" + assert ( + device_entry.configuration_url + == "https://ovi.rdw.nl/default.aspx?kenteken=11ZKZ3" + ) + assert not device_entry.sw_version diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 7414548c864..d5c9c4f8762 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -1,12 +1,15 @@ """Common test utils for working with recorder.""" from datetime import timedelta +from sqlalchemy import create_engine + from homeassistant import core as ha from homeassistant.components import recorder from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, fire_time_changed +from tests.components.recorder import models_schema_0 DEFAULT_PURGE_TASKS = 3 @@ -80,3 +83,13 @@ def corrupt_db_file(test_db_file): with open(test_db_file, "w+") as fhandle: fhandle.seek(200) fhandle.write("I am a corrupt db" * 100) + + +def create_engine_test(*args, **kwargs): + """Test version of create_engine that initializes with old schema. + + This simulates an existing db with the old schema. + """ + engine = create_engine(*args, **kwargs) + models_schema_0.Base.metadata.create_all(engine) + return engine diff --git a/tests/components/recorder/models_original.py b/tests/components/recorder/models_schema_0.py similarity index 100% rename from tests/components/recorder/models_original.py rename to tests/components/recorder/models_schema_0.py diff --git a/tests/components/recorder/models_schema_16.py b/tests/components/recorder/models_schema_16.py new file mode 100644 index 00000000000..23b0ec1f921 --- /dev/null +++ b/tests/components/recorder/models_schema_16.py @@ -0,0 +1,457 @@ +"""Models for SQLAlchemy. + +This file contains the model definitions for schema version 16, +used by Home Assistant Core 2021.6.0, which was the initial version +to include long term statistics. +It is used to test the schema migration logic. +""" + +import json +import logging + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Float, + ForeignKey, + Identity, + Index, + Integer, + String, + Text, + distinct, +) +from sqlalchemy.dialects import mysql +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship +from sqlalchemy.orm.session import Session + +from homeassistant.const import ( + MAX_LENGTH_EVENT_CONTEXT_ID, + MAX_LENGTH_EVENT_EVENT_TYPE, + MAX_LENGTH_EVENT_ORIGIN, + MAX_LENGTH_STATE_DOMAIN, + MAX_LENGTH_STATE_ENTITY_ID, + MAX_LENGTH_STATE_STATE, +) +from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id +from homeassistant.helpers.json import JSONEncoder +import homeassistant.util.dt as dt_util + +# SQLAlchemy Schema +# pylint: disable=invalid-name +Base = declarative_base() + +SCHEMA_VERSION = 16 + +_LOGGER = logging.getLogger(__name__) + +DB_TIMEZONE = "+00:00" + +TABLE_EVENTS = "events" +TABLE_STATES = "states" +TABLE_RECORDER_RUNS = "recorder_runs" +TABLE_SCHEMA_CHANGES = "schema_changes" +TABLE_STATISTICS = "statistics" + +ALL_TABLES = [ + TABLE_STATES, + TABLE_EVENTS, + TABLE_RECORDER_RUNS, + TABLE_SCHEMA_CHANGES, + TABLE_STATISTICS, +] + +DATETIME_TYPE = DateTime(timezone=True).with_variant( + mysql.DATETIME(timezone=True, fsp=6), "mysql" +) + + +class Events(Base): # type: ignore + """Event history data.""" + + __table_args__ = { + "mysql_default_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + } + __tablename__ = TABLE_EVENTS + event_id = Column(Integer, Identity(), primary_key=True) + event_type = Column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + event_data = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) + origin = Column(String(MAX_LENGTH_EVENT_ORIGIN)) + time_fired = Column(DATETIME_TYPE, index=True) + created = Column(DATETIME_TYPE, default=dt_util.utcnow) + context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) + context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) + context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) + + __table_args__ = ( + # Used for fetching events at a specific time + # see logbook + Index("ix_events_event_type_time_fired", "event_type", "time_fired"), + ) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + @staticmethod + def from_event(event, event_data=None): + """Create an event database object from a native event.""" + return Events( + event_type=event.event_type, + event_data=event_data or json.dumps(event.data, cls=JSONEncoder), + origin=str(event.origin.value), + time_fired=event.time_fired, + context_id=event.context.id, + context_user_id=event.context.user_id, + context_parent_id=event.context.parent_id, + ) + + def to_native(self, validate_entity_id=True): + """Convert to a natve HA Event.""" + context = Context( + id=self.context_id, + user_id=self.context_user_id, + parent_id=self.context_parent_id, + ) + try: + return Event( + self.event_type, + json.loads(self.event_data), + EventOrigin(self.origin), + process_timestamp(self.time_fired), + context=context, + ) + except ValueError: + # When json.loads fails + _LOGGER.exception("Error converting to event: %s", self) + return None + + +class States(Base): # type: ignore + """State change history.""" + + __table_args__ = { + "mysql_default_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + } + __tablename__ = TABLE_STATES + state_id = Column(Integer, Identity(), primary_key=True) + domain = Column(String(MAX_LENGTH_STATE_DOMAIN)) + entity_id = Column(String(MAX_LENGTH_STATE_ENTITY_ID)) + state = Column(String(MAX_LENGTH_STATE_STATE)) + attributes = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) + event_id = Column( + Integer, ForeignKey("events.event_id", ondelete="CASCADE"), index=True + ) + last_changed = Column(DATETIME_TYPE, default=dt_util.utcnow) + last_updated = Column(DATETIME_TYPE, default=dt_util.utcnow, index=True) + created = Column(DATETIME_TYPE, default=dt_util.utcnow) + old_state_id = Column(Integer, ForeignKey("states.state_id"), index=True) + event = relationship("Events", uselist=False) + old_state = relationship("States", remote_side=[state_id]) + + __table_args__ = ( + # Used for fetching the state of entities at a specific time + # (get_states in history.py) + Index("ix_states_entity_id_last_updated", "entity_id", "last_updated"), + ) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + @staticmethod + def from_event(event): + """Create object from a state_changed event.""" + entity_id = event.data["entity_id"] + state = event.data.get("new_state") + + dbstate = States(entity_id=entity_id) + + # State got deleted + if state is None: + dbstate.state = "" + dbstate.domain = split_entity_id(entity_id)[0] + dbstate.attributes = "{}" + dbstate.last_changed = event.time_fired + dbstate.last_updated = event.time_fired + else: + dbstate.domain = state.domain + dbstate.state = state.state + dbstate.attributes = json.dumps(dict(state.attributes), cls=JSONEncoder) + dbstate.last_changed = state.last_changed + dbstate.last_updated = state.last_updated + + return dbstate + + def to_native(self, validate_entity_id=True): + """Convert to an HA state object.""" + try: + return State( + self.entity_id, + self.state, + json.loads(self.attributes), + process_timestamp(self.last_changed), + process_timestamp(self.last_updated), + # Join the events table on event_id to get the context instead + # as it will always be there for state_changed events + context=Context(id=None), + validate_entity_id=validate_entity_id, + ) + except ValueError: + # When json.loads fails + _LOGGER.exception("Error converting row to state: %s", self) + return None + + +class Statistics(Base): # type: ignore + """Statistics.""" + + __table_args__ = { + "mysql_default_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + } + __tablename__ = TABLE_STATISTICS + id = Column(Integer, primary_key=True) + created = Column(DATETIME_TYPE, default=dt_util.utcnow) + source = Column(String(32)) + statistic_id = Column(String(255)) + start = Column(DATETIME_TYPE, index=True) + mean = Column(Float()) + min = Column(Float()) + max = Column(Float()) + last_reset = Column(DATETIME_TYPE) + state = Column(Float()) + sum = Column(Float()) + + __table_args__ = ( + # Used for fetching statistics for a certain entity at a specific time + Index("ix_statistics_statistic_id_start", "statistic_id", "start"), + ) + + @staticmethod + def from_stats(source, statistic_id, start, stats): + """Create object from a statistics.""" + return Statistics( + source=source, + statistic_id=statistic_id, + start=start, + **stats, + ) + + +class RecorderRuns(Base): # type: ignore + """Representation of recorder run.""" + + __tablename__ = TABLE_RECORDER_RUNS + run_id = Column(Integer, Identity(), primary_key=True) + start = Column(DateTime(timezone=True), default=dt_util.utcnow) + end = Column(DateTime(timezone=True)) + closed_incorrect = Column(Boolean, default=False) + created = Column(DateTime(timezone=True), default=dt_util.utcnow) + + __table_args__ = (Index("ix_recorder_runs_start_end", "start", "end"),) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + end = ( + f"'{self.end.isoformat(sep=' ', timespec='seconds')}'" if self.end else None + ) + return ( + f"" + ) + + def entity_ids(self, point_in_time=None): + """Return the entity ids that existed in this run. + + Specify point_in_time if you want to know which existed at that point + in time inside the run. + """ + session = Session.object_session(self) + + assert session is not None, "RecorderRuns need to be persisted" + + query = session.query(distinct(States.entity_id)).filter( + States.last_updated >= self.start + ) + + if point_in_time is not None: + query = query.filter(States.last_updated < point_in_time) + elif self.end is not None: + query = query.filter(States.last_updated < self.end) + + return [row[0] for row in query] + + def to_native(self, validate_entity_id=True): + """Return self, native format is this model.""" + return self + + +class SchemaChanges(Base): # type: ignore + """Representation of schema version changes.""" + + __tablename__ = TABLE_SCHEMA_CHANGES + change_id = Column(Integer, Identity(), primary_key=True) + schema_version = Column(Integer) + changed = Column(DateTime(timezone=True), default=dt_util.utcnow) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + +def process_timestamp(ts): + """Process a timestamp into datetime object.""" + if ts is None: + return None + if ts.tzinfo is None: + return ts.replace(tzinfo=dt_util.UTC) + + return dt_util.as_utc(ts) + + +def process_timestamp_to_utc_isoformat(ts): + """Process a timestamp into UTC isotime.""" + if ts is None: + return None + if ts.tzinfo == dt_util.UTC: + return ts.isoformat() + if ts.tzinfo is None: + return f"{ts.isoformat()}{DB_TIMEZONE}" + return ts.astimezone(dt_util.UTC).isoformat() + + +class LazyState(State): + """A lazy version of core State.""" + + __slots__ = [ + "_row", + "entity_id", + "state", + "_attributes", + "_last_changed", + "_last_updated", + "_context", + ] + + def __init__(self, row): # pylint: disable=super-init-not-called + """Init the lazy state.""" + self._row = row + self.entity_id = self._row.entity_id + self.state = self._row.state or "" + self._attributes = None + self._last_changed = None + self._last_updated = None + self._context = None + + @property # type: ignore + def attributes(self): + """State attributes.""" + if not self._attributes: + try: + self._attributes = json.loads(self._row.attributes) + except ValueError: + # When json.loads fails + _LOGGER.exception("Error converting row to state: %s", self._row) + self._attributes = {} + return self._attributes + + @attributes.setter + def attributes(self, value): + """Set attributes.""" + self._attributes = value + + @property # type: ignore + def context(self): + """State context.""" + if not self._context: + self._context = Context(id=None) + return self._context + + @context.setter + def context(self, value): + """Set context.""" + self._context = value + + @property # type: ignore + def last_changed(self): + """Last changed datetime.""" + if not self._last_changed: + self._last_changed = process_timestamp(self._row.last_changed) + return self._last_changed + + @last_changed.setter + def last_changed(self, value): + """Set last changed datetime.""" + self._last_changed = value + + @property # type: ignore + def last_updated(self): + """Last updated datetime.""" + if not self._last_updated: + self._last_updated = process_timestamp(self._row.last_updated) + return self._last_updated + + @last_updated.setter + def last_updated(self, value): + """Set last updated datetime.""" + self._last_updated = value + + def as_dict(self): + """Return a dict representation of the LazyState. + + Async friendly. + To be used for JSON serialization. + """ + if self._last_changed: + last_changed_isoformat = self._last_changed.isoformat() + else: + last_changed_isoformat = process_timestamp_to_utc_isoformat( + self._row.last_changed + ) + if self._last_updated: + last_updated_isoformat = self._last_updated.isoformat() + else: + last_updated_isoformat = process_timestamp_to_utc_isoformat( + self._row.last_updated + ) + return { + "entity_id": self.entity_id, + "state": self.state, + "attributes": self._attributes or self.attributes, + "last_changed": last_changed_isoformat, + "last_updated": last_updated_isoformat, + } + + def __eq__(self, other): + """Return the comparison.""" + return ( + other.__class__ in [self.__class__, State] + and self.entity_id == other.entity_id + and self.state == other.state + and self.attributes == other.attributes + ) diff --git a/tests/components/recorder/models_schema_18.py b/tests/components/recorder/models_schema_18.py new file mode 100644 index 00000000000..3eeebc8e649 --- /dev/null +++ b/tests/components/recorder/models_schema_18.py @@ -0,0 +1,471 @@ +"""Models for SQLAlchemy. + +This file contains the model definitions for schema version 18, +used by Home Assistant Core 2021.7.0, which did a major refactoring +of long term statistics database models. +It is used to test the schema migration logic. +""" + +import json +import logging + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Float, + ForeignKey, + Identity, + Index, + Integer, + String, + Text, + distinct, +) +from sqlalchemy.dialects import mysql +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship +from sqlalchemy.orm.session import Session + +from homeassistant.const import ( + MAX_LENGTH_EVENT_CONTEXT_ID, + MAX_LENGTH_EVENT_EVENT_TYPE, + MAX_LENGTH_EVENT_ORIGIN, + MAX_LENGTH_STATE_DOMAIN, + MAX_LENGTH_STATE_ENTITY_ID, + MAX_LENGTH_STATE_STATE, +) +from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id +from homeassistant.helpers.json import JSONEncoder +import homeassistant.util.dt as dt_util + +# SQLAlchemy Schema +# pylint: disable=invalid-name +Base = declarative_base() + +SCHEMA_VERSION = 18 + +_LOGGER = logging.getLogger(__name__) + +DB_TIMEZONE = "+00:00" + +TABLE_EVENTS = "events" +TABLE_STATES = "states" +TABLE_RECORDER_RUNS = "recorder_runs" +TABLE_SCHEMA_CHANGES = "schema_changes" +TABLE_STATISTICS = "statistics" +TABLE_STATISTICS_META = "statistics_meta" + +ALL_TABLES = [ + TABLE_STATES, + TABLE_EVENTS, + TABLE_RECORDER_RUNS, + TABLE_SCHEMA_CHANGES, + TABLE_STATISTICS, + TABLE_STATISTICS_META, +] + +DATETIME_TYPE = DateTime(timezone=True).with_variant( + mysql.DATETIME(timezone=True, fsp=6), "mysql" +) + + +class Events(Base): # type: ignore + """Event history data.""" + + __table_args__ = ( + # Used for fetching events at a specific time + # see logbook + Index("ix_events_event_type_time_fired", "event_type", "time_fired"), + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_EVENTS + event_id = Column(Integer, Identity(), primary_key=True) + event_type = Column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + event_data = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) + origin = Column(String(MAX_LENGTH_EVENT_ORIGIN)) + time_fired = Column(DATETIME_TYPE, index=True) + created = Column(DATETIME_TYPE, default=dt_util.utcnow) + context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) + context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) + context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + @staticmethod + def from_event(event, event_data=None): + """Create an event database object from a native event.""" + return Events( + event_type=event.event_type, + event_data=event_data or json.dumps(event.data, cls=JSONEncoder), + origin=str(event.origin.value), + time_fired=event.time_fired, + context_id=event.context.id, + context_user_id=event.context.user_id, + context_parent_id=event.context.parent_id, + ) + + def to_native(self, validate_entity_id=True): + """Convert to a natve HA Event.""" + context = Context( + id=self.context_id, + user_id=self.context_user_id, + parent_id=self.context_parent_id, + ) + try: + return Event( + self.event_type, + json.loads(self.event_data), + EventOrigin(self.origin), + process_timestamp(self.time_fired), + context=context, + ) + except ValueError: + # When json.loads fails + _LOGGER.exception("Error converting to event: %s", self) + return None + + +class States(Base): # type: ignore + """State change history.""" + + __table_args__ = ( + # Used for fetching the state of entities at a specific time + # (get_states in history.py) + Index("ix_states_entity_id_last_updated", "entity_id", "last_updated"), + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_STATES + state_id = Column(Integer, Identity(), primary_key=True) + domain = Column(String(MAX_LENGTH_STATE_DOMAIN)) + entity_id = Column(String(MAX_LENGTH_STATE_ENTITY_ID)) + state = Column(String(MAX_LENGTH_STATE_STATE)) + attributes = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) + event_id = Column( + Integer, ForeignKey("events.event_id", ondelete="CASCADE"), index=True + ) + last_changed = Column(DATETIME_TYPE, default=dt_util.utcnow) + last_updated = Column(DATETIME_TYPE, default=dt_util.utcnow, index=True) + created = Column(DATETIME_TYPE, default=dt_util.utcnow) + old_state_id = Column(Integer, ForeignKey("states.state_id"), index=True) + event = relationship("Events", uselist=False) + old_state = relationship("States", remote_side=[state_id]) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + @staticmethod + def from_event(event): + """Create object from a state_changed event.""" + entity_id = event.data["entity_id"] + state = event.data.get("new_state") + + dbstate = States(entity_id=entity_id) + + # State got deleted + if state is None: + dbstate.state = "" + dbstate.domain = split_entity_id(entity_id)[0] + dbstate.attributes = "{}" + dbstate.last_changed = event.time_fired + dbstate.last_updated = event.time_fired + else: + dbstate.domain = state.domain + dbstate.state = state.state + dbstate.attributes = json.dumps(dict(state.attributes), cls=JSONEncoder) + dbstate.last_changed = state.last_changed + dbstate.last_updated = state.last_updated + + return dbstate + + def to_native(self, validate_entity_id=True): + """Convert to an HA state object.""" + try: + return State( + self.entity_id, + self.state, + json.loads(self.attributes), + process_timestamp(self.last_changed), + process_timestamp(self.last_updated), + # Join the events table on event_id to get the context instead + # as it will always be there for state_changed events + context=Context(id=None), + validate_entity_id=validate_entity_id, + ) + except ValueError: + # When json.loads fails + _LOGGER.exception("Error converting row to state: %s", self) + return None + + +class Statistics(Base): # type: ignore + """Statistics.""" + + __table_args__ = ( + # Used for fetching statistics for a certain entity at a specific time + Index("ix_statistics_statistic_id_start", "metadata_id", "start"), + ) + __tablename__ = TABLE_STATISTICS + id = Column(Integer, primary_key=True) + created = Column(DATETIME_TYPE, default=dt_util.utcnow) + metadata_id = Column( + Integer, + ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), + index=True, + ) + start = Column(DATETIME_TYPE, index=True) + mean = Column(Float()) + min = Column(Float()) + max = Column(Float()) + last_reset = Column(DATETIME_TYPE) + state = Column(Float()) + sum = Column(Float()) + + @staticmethod + def from_stats(metadata_id, start, stats): + """Create object from a statistics.""" + return Statistics( + metadata_id=metadata_id, + start=start, + **stats, + ) + + +class StatisticsMeta(Base): # type: ignore + """Statistics meta data.""" + + __tablename__ = TABLE_STATISTICS_META + id = Column(Integer, primary_key=True) + statistic_id = Column(String(255), index=True) + source = Column(String(32)) + unit_of_measurement = Column(String(255)) + has_mean = Column(Boolean) + has_sum = Column(Boolean) + + @staticmethod + def from_meta(source, statistic_id, unit_of_measurement, has_mean, has_sum): + """Create object from meta data.""" + return StatisticsMeta( + source=source, + statistic_id=statistic_id, + unit_of_measurement=unit_of_measurement, + has_mean=has_mean, + has_sum=has_sum, + ) + + +class RecorderRuns(Base): # type: ignore + """Representation of recorder run.""" + + __table_args__ = (Index("ix_recorder_runs_start_end", "start", "end"),) + __tablename__ = TABLE_RECORDER_RUNS + run_id = Column(Integer, Identity(), primary_key=True) + start = Column(DateTime(timezone=True), default=dt_util.utcnow) + end = Column(DateTime(timezone=True)) + closed_incorrect = Column(Boolean, default=False) + created = Column(DateTime(timezone=True), default=dt_util.utcnow) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + end = ( + f"'{self.end.isoformat(sep=' ', timespec='seconds')}'" if self.end else None + ) + return ( + f"" + ) + + def entity_ids(self, point_in_time=None): + """Return the entity ids that existed in this run. + + Specify point_in_time if you want to know which existed at that point + in time inside the run. + """ + session = Session.object_session(self) + + assert session is not None, "RecorderRuns need to be persisted" + + query = session.query(distinct(States.entity_id)).filter( + States.last_updated >= self.start + ) + + if point_in_time is not None: + query = query.filter(States.last_updated < point_in_time) + elif self.end is not None: + query = query.filter(States.last_updated < self.end) + + return [row[0] for row in query] + + def to_native(self, validate_entity_id=True): + """Return self, native format is this model.""" + return self + + +class SchemaChanges(Base): # type: ignore + """Representation of schema version changes.""" + + __tablename__ = TABLE_SCHEMA_CHANGES + change_id = Column(Integer, Identity(), primary_key=True) + schema_version = Column(Integer) + changed = Column(DateTime(timezone=True), default=dt_util.utcnow) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + +def process_timestamp(ts): + """Process a timestamp into datetime object.""" + if ts is None: + return None + if ts.tzinfo is None: + return ts.replace(tzinfo=dt_util.UTC) + + return dt_util.as_utc(ts) + + +def process_timestamp_to_utc_isoformat(ts): + """Process a timestamp into UTC isotime.""" + if ts is None: + return None + if ts.tzinfo == dt_util.UTC: + return ts.isoformat() + if ts.tzinfo is None: + return f"{ts.isoformat()}{DB_TIMEZONE}" + return ts.astimezone(dt_util.UTC).isoformat() + + +class LazyState(State): + """A lazy version of core State.""" + + __slots__ = [ + "_row", + "entity_id", + "state", + "_attributes", + "_last_changed", + "_last_updated", + "_context", + ] + + def __init__(self, row): # pylint: disable=super-init-not-called + """Init the lazy state.""" + self._row = row + self.entity_id = self._row.entity_id + self.state = self._row.state or "" + self._attributes = None + self._last_changed = None + self._last_updated = None + self._context = None + + @property # type: ignore + def attributes(self): + """State attributes.""" + if not self._attributes: + try: + self._attributes = json.loads(self._row.attributes) + except ValueError: + # When json.loads fails + _LOGGER.exception("Error converting row to state: %s", self._row) + self._attributes = {} + return self._attributes + + @attributes.setter + def attributes(self, value): + """Set attributes.""" + self._attributes = value + + @property # type: ignore + def context(self): + """State context.""" + if not self._context: + self._context = Context(id=None) + return self._context + + @context.setter + def context(self, value): + """Set context.""" + self._context = value + + @property # type: ignore + def last_changed(self): + """Last changed datetime.""" + if not self._last_changed: + self._last_changed = process_timestamp(self._row.last_changed) + return self._last_changed + + @last_changed.setter + def last_changed(self, value): + """Set last changed datetime.""" + self._last_changed = value + + @property # type: ignore + def last_updated(self): + """Last updated datetime.""" + if not self._last_updated: + self._last_updated = process_timestamp(self._row.last_updated) + return self._last_updated + + @last_updated.setter + def last_updated(self, value): + """Set last updated datetime.""" + self._last_updated = value + + def as_dict(self): + """Return a dict representation of the LazyState. + + Async friendly. + + To be used for JSON serialization. + """ + if self._last_changed: + last_changed_isoformat = self._last_changed.isoformat() + else: + last_changed_isoformat = process_timestamp_to_utc_isoformat( + self._row.last_changed + ) + if self._last_updated: + last_updated_isoformat = self._last_updated.isoformat() + else: + last_updated_isoformat = process_timestamp_to_utc_isoformat( + self._row.last_updated + ) + return { + "entity_id": self.entity_id, + "state": self.state, + "attributes": self._attributes or self.attributes, + "last_changed": last_changed_isoformat, + "last_updated": last_updated_isoformat, + } + + def __eq__(self, other): + """Return the comparison.""" + return ( + other.__class__ in [self.__class__, State] + and self.entity_id == other.entity_id + and self.state == other.state + and self.attributes == other.attributes + ) diff --git a/tests/components/recorder/models_schema_22.py b/tests/components/recorder/models_schema_22.py new file mode 100644 index 00000000000..3bcef248e0f --- /dev/null +++ b/tests/components/recorder/models_schema_22.py @@ -0,0 +1,593 @@ +"""Models for SQLAlchemy. + +This file contains the model definitions for schema version 22, +used by Home Assistant Core 2021.10.0, which adds a table for +5-minute statistics. +It is used to test the schema migration logic. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from datetime import datetime, timedelta +import json +import logging +from typing import TypedDict, overload + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Float, + ForeignKey, + Identity, + Index, + Integer, + String, + Text, + distinct, +) +from sqlalchemy.dialects import mysql, oracle, postgresql +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.orm import declarative_base, relationship +from sqlalchemy.orm.session import Session + +from homeassistant.const import ( + MAX_LENGTH_EVENT_CONTEXT_ID, + MAX_LENGTH_EVENT_EVENT_TYPE, + MAX_LENGTH_EVENT_ORIGIN, + MAX_LENGTH_STATE_DOMAIN, + MAX_LENGTH_STATE_ENTITY_ID, + MAX_LENGTH_STATE_STATE, +) +from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id +from homeassistant.helpers.json import JSONEncoder +import homeassistant.util.dt as dt_util + +# SQLAlchemy Schema +# pylint: disable=invalid-name +Base = declarative_base() + +SCHEMA_VERSION = 22 + +_LOGGER = logging.getLogger(__name__) + +DB_TIMEZONE = "+00:00" + +TABLE_EVENTS = "events" +TABLE_STATES = "states" +TABLE_RECORDER_RUNS = "recorder_runs" +TABLE_SCHEMA_CHANGES = "schema_changes" +TABLE_STATISTICS = "statistics" +TABLE_STATISTICS_META = "statistics_meta" +TABLE_STATISTICS_RUNS = "statistics_runs" +TABLE_STATISTICS_SHORT_TERM = "statistics_short_term" + +ALL_TABLES = [ + TABLE_STATES, + TABLE_EVENTS, + TABLE_RECORDER_RUNS, + TABLE_SCHEMA_CHANGES, + TABLE_STATISTICS, + TABLE_STATISTICS_META, + TABLE_STATISTICS_RUNS, + TABLE_STATISTICS_SHORT_TERM, +] + +DATETIME_TYPE = DateTime(timezone=True).with_variant( + mysql.DATETIME(timezone=True, fsp=6), "mysql" +) +DOUBLE_TYPE = ( + Float() + .with_variant(mysql.DOUBLE(asdecimal=False), "mysql") + .with_variant(oracle.DOUBLE_PRECISION(), "oracle") + .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") +) + + +class Events(Base): # type: ignore + """Event history data.""" + + __table_args__ = ( + # Used for fetching events at a specific time + # see logbook + Index("ix_events_event_type_time_fired", "event_type", "time_fired"), + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_EVENTS + event_id = Column(Integer, Identity(), primary_key=True) + event_type = Column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + event_data = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) + origin = Column(String(MAX_LENGTH_EVENT_ORIGIN)) + time_fired = Column(DATETIME_TYPE, index=True) + created = Column(DATETIME_TYPE, default=dt_util.utcnow) + context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) + context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) + context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + @staticmethod + def from_event(event, event_data=None): + """Create an event database object from a native event.""" + return Events( + event_type=event.event_type, + event_data=event_data + or json.dumps(event.data, cls=JSONEncoder, separators=(",", ":")), + origin=str(event.origin.value), + time_fired=event.time_fired, + context_id=event.context.id, + context_user_id=event.context.user_id, + context_parent_id=event.context.parent_id, + ) + + def to_native(self, validate_entity_id=True): + """Convert to a native HA Event.""" + context = Context( + id=self.context_id, + user_id=self.context_user_id, + parent_id=self.context_parent_id, + ) + try: + return Event( + self.event_type, + json.loads(self.event_data), + EventOrigin(self.origin), + process_timestamp(self.time_fired), + context=context, + ) + except ValueError: + # When json.loads fails + _LOGGER.exception("Error converting to event: %s", self) + return None + + +class States(Base): # type: ignore + """State change history.""" + + __table_args__ = ( + # Used for fetching the state of entities at a specific time + # (get_states in history.py) + Index("ix_states_entity_id_last_updated", "entity_id", "last_updated"), + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_STATES + state_id = Column(Integer, Identity(), primary_key=True) + domain = Column(String(MAX_LENGTH_STATE_DOMAIN)) + entity_id = Column(String(MAX_LENGTH_STATE_ENTITY_ID)) + state = Column(String(MAX_LENGTH_STATE_STATE)) + attributes = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) + event_id = Column( + Integer, ForeignKey("events.event_id", ondelete="CASCADE"), index=True + ) + last_changed = Column(DATETIME_TYPE, default=dt_util.utcnow) + last_updated = Column(DATETIME_TYPE, default=dt_util.utcnow, index=True) + created = Column(DATETIME_TYPE, default=dt_util.utcnow) + old_state_id = Column(Integer, ForeignKey("states.state_id"), index=True) + event = relationship("Events", uselist=False) + old_state = relationship("States", remote_side=[state_id]) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + @staticmethod + def from_event(event): + """Create object from a state_changed event.""" + entity_id = event.data["entity_id"] + state = event.data.get("new_state") + + dbstate = States(entity_id=entity_id) + + # State got deleted + if state is None: + dbstate.state = "" + dbstate.domain = split_entity_id(entity_id)[0] + dbstate.attributes = "{}" + dbstate.last_changed = event.time_fired + dbstate.last_updated = event.time_fired + else: + dbstate.domain = state.domain + dbstate.state = state.state + dbstate.attributes = json.dumps( + dict(state.attributes), cls=JSONEncoder, separators=(",", ":") + ) + dbstate.last_changed = state.last_changed + dbstate.last_updated = state.last_updated + + return dbstate + + def to_native(self, validate_entity_id=True): + """Convert to an HA state object.""" + try: + return State( + self.entity_id, + self.state, + json.loads(self.attributes), + process_timestamp(self.last_changed), + process_timestamp(self.last_updated), + # Join the events table on event_id to get the context instead + # as it will always be there for state_changed events + context=Context(id=None), + validate_entity_id=validate_entity_id, + ) + except ValueError: + # When json.loads fails + _LOGGER.exception("Error converting row to state: %s", self) + return None + + +class StatisticResult(TypedDict): + """Statistic result data class. + + Allows multiple datapoints for the same statistic_id. + """ + + meta: StatisticMetaData + stat: Iterable[StatisticData] + + +class StatisticDataBase(TypedDict): + """Mandatory fields for statistic data class.""" + + start: datetime + + +class StatisticData(StatisticDataBase, total=False): + """Statistic data class.""" + + mean: float + min: float + max: float + last_reset: datetime | None + state: float + sum: float + + +class StatisticsBase: + """Statistics base class.""" + + id = Column(Integer, Identity(), primary_key=True) + created = Column(DATETIME_TYPE, default=dt_util.utcnow) + + @declared_attr + def metadata_id(self): + """Define the metadata_id column for sub classes.""" + return Column( + Integer, + ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), + index=True, + ) + + start = Column(DATETIME_TYPE, index=True) + mean = Column(DOUBLE_TYPE) + min = Column(DOUBLE_TYPE) + max = Column(DOUBLE_TYPE) + last_reset = Column(DATETIME_TYPE) + state = Column(DOUBLE_TYPE) + sum = Column(DOUBLE_TYPE) + + @classmethod + def from_stats(cls, metadata_id: int, stats: StatisticData): + """Create object from a statistics.""" + return cls( # type: ignore + metadata_id=metadata_id, + **stats, + ) + + +class Statistics(Base, StatisticsBase): # type: ignore + """Long term statistics.""" + + duration = timedelta(hours=1) + + __table_args__ = ( + # Used for fetching statistics for a certain entity at a specific time + Index("ix_statistics_statistic_id_start", "metadata_id", "start"), + ) + __tablename__ = TABLE_STATISTICS + + +class StatisticsShortTerm(Base, StatisticsBase): # type: ignore + """Short term statistics.""" + + duration = timedelta(minutes=5) + + __table_args__ = ( + # Used for fetching statistics for a certain entity at a specific time + Index("ix_statistics_short_term_statistic_id_start", "metadata_id", "start"), + ) + __tablename__ = TABLE_STATISTICS_SHORT_TERM + + +class StatisticMetaData(TypedDict): + """Statistic meta data class.""" + + statistic_id: str + unit_of_measurement: str | None + has_mean: bool + has_sum: bool + + +class StatisticsMeta(Base): # type: ignore + """Statistics meta data.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_STATISTICS_META + id = Column(Integer, Identity(), primary_key=True) + statistic_id = Column(String(255), index=True) + source = Column(String(32)) + unit_of_measurement = Column(String(255)) + has_mean = Column(Boolean) + has_sum = Column(Boolean) + + @staticmethod + def from_meta( + source: str, + statistic_id: str, + unit_of_measurement: str | None, + has_mean: bool, + has_sum: bool, + ) -> StatisticsMeta: + """Create object from meta data.""" + return StatisticsMeta( + source=source, + statistic_id=statistic_id, + unit_of_measurement=unit_of_measurement, + has_mean=has_mean, + has_sum=has_sum, + ) + + +class RecorderRuns(Base): # type: ignore + """Representation of recorder run.""" + + __table_args__ = (Index("ix_recorder_runs_start_end", "start", "end"),) + __tablename__ = TABLE_RECORDER_RUNS + run_id = Column(Integer, Identity(), primary_key=True) + start = Column(DateTime(timezone=True), default=dt_util.utcnow) + end = Column(DateTime(timezone=True)) + closed_incorrect = Column(Boolean, default=False) + created = Column(DateTime(timezone=True), default=dt_util.utcnow) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + end = ( + f"'{self.end.isoformat(sep=' ', timespec='seconds')}'" if self.end else None + ) + return ( + f"" + ) + + def entity_ids(self, point_in_time=None): + """Return the entity ids that existed in this run. + + Specify point_in_time if you want to know which existed at that point + in time inside the run. + """ + session = Session.object_session(self) + + assert session is not None, "RecorderRuns need to be persisted" + + query = session.query(distinct(States.entity_id)).filter( + States.last_updated >= self.start + ) + + if point_in_time is not None: + query = query.filter(States.last_updated < point_in_time) + elif self.end is not None: + query = query.filter(States.last_updated < self.end) + + return [row[0] for row in query] + + def to_native(self, validate_entity_id=True): + """Return self, native format is this model.""" + return self + + +class SchemaChanges(Base): # type: ignore + """Representation of schema version changes.""" + + __tablename__ = TABLE_SCHEMA_CHANGES + change_id = Column(Integer, Identity(), primary_key=True) + schema_version = Column(Integer) + changed = Column(DateTime(timezone=True), default=dt_util.utcnow) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + +class StatisticsRuns(Base): # type: ignore + """Representation of statistics run.""" + + __tablename__ = TABLE_STATISTICS_RUNS + run_id = Column(Integer, Identity(), primary_key=True) + start = Column(DateTime(timezone=True)) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + +@overload +def process_timestamp(ts: None) -> None: + ... + + +@overload +def process_timestamp(ts: datetime) -> datetime: + ... + + +def process_timestamp(ts: datetime | None) -> datetime | None: + """Process a timestamp into datetime object.""" + if ts is None: + return None + if ts.tzinfo is None: + return ts.replace(tzinfo=dt_util.UTC) + + return dt_util.as_utc(ts) + + +@overload +def process_timestamp_to_utc_isoformat(ts: None) -> None: + ... + + +@overload +def process_timestamp_to_utc_isoformat(ts: datetime) -> str: + ... + + +def process_timestamp_to_utc_isoformat(ts: datetime | None) -> str | None: + """Process a timestamp into UTC isotime.""" + if ts is None: + return None + if ts.tzinfo == dt_util.UTC: + return ts.isoformat() + if ts.tzinfo is None: + return f"{ts.isoformat()}{DB_TIMEZONE}" + return ts.astimezone(dt_util.UTC).isoformat() + + +class LazyState(State): + """A lazy version of core State.""" + + __slots__ = [ + "_row", + "entity_id", + "state", + "_attributes", + "_last_changed", + "_last_updated", + "_context", + ] + + def __init__(self, row): # pylint: disable=super-init-not-called + """Init the lazy state.""" + self._row = row + self.entity_id = self._row.entity_id + self.state = self._row.state or "" + self._attributes = None + self._last_changed = None + self._last_updated = None + self._context = None + + @property # type: ignore + def attributes(self): + """State attributes.""" + if not self._attributes: + try: + self._attributes = json.loads(self._row.attributes) + except ValueError: + # When json.loads fails + _LOGGER.exception("Error converting row to state: %s", self._row) + self._attributes = {} + return self._attributes + + @attributes.setter + def attributes(self, value): + """Set attributes.""" + self._attributes = value + + @property # type: ignore + def context(self): + """State context.""" + if not self._context: + self._context = Context(id=None) + return self._context + + @context.setter + def context(self, value): + """Set context.""" + self._context = value + + @property # type: ignore + def last_changed(self): + """Last changed datetime.""" + if not self._last_changed: + self._last_changed = process_timestamp(self._row.last_changed) + return self._last_changed + + @last_changed.setter + def last_changed(self, value): + """Set last changed datetime.""" + self._last_changed = value + + @property # type: ignore + def last_updated(self): + """Last updated datetime.""" + if not self._last_updated: + self._last_updated = process_timestamp(self._row.last_updated) + return self._last_updated + + @last_updated.setter + def last_updated(self, value): + """Set last updated datetime.""" + self._last_updated = value + + def as_dict(self): + """Return a dict representation of the LazyState. + + Async friendly. + + To be used for JSON serialization. + """ + if self._last_changed: + last_changed_isoformat = self._last_changed.isoformat() + else: + last_changed_isoformat = process_timestamp_to_utc_isoformat( + self._row.last_changed + ) + if self._last_updated: + last_updated_isoformat = self._last_updated.isoformat() + else: + last_updated_isoformat = process_timestamp_to_utc_isoformat( + self._row.last_updated + ) + return { + "entity_id": self.entity_id, + "state": self.state, + "attributes": self._attributes or self.attributes, + "last_changed": last_changed_isoformat, + "last_updated": last_updated_isoformat, + } + + def __eq__(self, other): + """Return the comparison.""" + return ( + other.__class__ in [self.__class__, State] + and self.entity_id == other.entity_id + and self.state == other.state + and self.attributes == other.attributes + ) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index e41a0da34ba..7d7c3f27fb6 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -1,5 +1,6 @@ """The tests for the Recorder component.""" # pylint: disable=protected-access +import asyncio from datetime import datetime, timedelta import sqlite3 from unittest.mock import patch @@ -1134,3 +1135,81 @@ def test_entity_id_filter(hass_recorder): db_events = list(session.query(Events).filter_by(event_type="hello")) # Keep referring idx + 1, as no new events are being added assert len(db_events) == idx + 1, data + + +async def test_database_lock_and_unlock(hass: HomeAssistant, tmp_path): + """Test writing events during lock getting written after unlocking.""" + # Use file DB, in memory DB cannot do write locks. + config = {recorder.CONF_DB_URL: "sqlite:///" + str(tmp_path / "pytest.db")} + await async_init_recorder_component(hass, config) + await hass.async_block_till_done() + + instance: Recorder = hass.data[DATA_INSTANCE] + + assert await instance.lock_database() + + assert not await instance.lock_database() + + event_type = "EVENT_TEST" + event_data = {"test_attr": 5, "test_attr_10": "nice"} + hass.bus.fire(event_type, event_data) + task = asyncio.create_task(async_wait_recording_done(hass, instance)) + + # Recording can't be finished while lock is held + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(asyncio.shield(task), timeout=1) + + with session_scope(hass=hass) as session: + db_events = list(session.query(Events).filter_by(event_type=event_type)) + assert len(db_events) == 0 + + assert instance.unlock_database() + + await task + with session_scope(hass=hass) as session: + db_events = list(session.query(Events).filter_by(event_type=event_type)) + assert len(db_events) == 1 + + +async def test_database_lock_and_overflow(hass: HomeAssistant, tmp_path): + """Test writing events during lock leading to overflow the queue causes the database to unlock.""" + # Use file DB, in memory DB cannot do write locks. + config = {recorder.CONF_DB_URL: "sqlite:///" + str(tmp_path / "pytest.db")} + await async_init_recorder_component(hass, config) + await hass.async_block_till_done() + + instance: Recorder = hass.data[DATA_INSTANCE] + + with patch.object(recorder, "MAX_QUEUE_BACKLOG", 1), patch.object( + recorder, "DB_LOCK_QUEUE_CHECK_TIMEOUT", 0.1 + ): + await instance.lock_database() + + event_type = "EVENT_TEST" + event_data = {"test_attr": 5, "test_attr_10": "nice"} + hass.bus.fire(event_type, event_data) + + # Check that this causes the queue to overflow and write succeeds + # even before unlocking. + await async_wait_recording_done(hass, instance) + + with session_scope(hass=hass) as session: + db_events = list(session.query(Events).filter_by(event_type=event_type)) + assert len(db_events) == 1 + + assert not instance.unlock_database() + + +async def test_database_lock_timeout(hass): + """Test locking database timeout when recorder stopped.""" + await async_init_recorder_component(hass) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + instance: Recorder = hass.data[DATA_INSTANCE] + with patch.object(recorder, "DB_LOCK_TIMEOUT", 0.1): + try: + with pytest.raises(TimeoutError): + await instance.lock_database() + finally: + instance.unlock_database() diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 5586e06d337..5c8a1c556c9 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -1,7 +1,10 @@ """The tests for the Recorder component.""" # pylint: disable=protected-access import datetime +import importlib import sqlite3 +import sys +import threading from unittest.mock import ANY, Mock, PropertyMock, call, patch import pytest @@ -23,10 +26,9 @@ from homeassistant.components.recorder.models import States from homeassistant.components.recorder.util import session_scope import homeassistant.util.dt as dt_util -from .common import async_wait_recording_done_without_instance +from .common import async_wait_recording_done_without_instance, create_engine_test from tests.common import async_fire_time_changed -from tests.components.recorder import models_original def _get_native_states(hass, entity_id): @@ -37,19 +39,9 @@ def _get_native_states(hass, entity_id): ] -def create_engine_test(*args, **kwargs): - """Test version of create_engine that initializes with old schema. - - This simulates an existing db with the old schema. - """ - engine = create_engine(*args, **kwargs) - models_original.Base.metadata.create_all(engine) - return engine - - async def test_schema_update_calls(hass): """Test that schema migrations occur in correct order.""" - assert await recorder.async_migration_in_progress(hass) is False + assert recorder.util.async_migration_in_progress(hass) is False with patch( "homeassistant.components.recorder.create_engine", new=create_engine_test @@ -62,7 +54,7 @@ async def test_schema_update_calls(hass): ) await async_wait_recording_done_without_instance(hass) - assert await recorder.async_migration_in_progress(hass) is False + assert recorder.util.async_migration_in_progress(hass) is False update.assert_has_calls( [ call(hass.data[DATA_INSTANCE], ANY, version + 1, 0) @@ -73,7 +65,7 @@ async def test_schema_update_calls(hass): async def test_migration_in_progress(hass): """Test that we can check for migration in progress.""" - assert await recorder.async_migration_in_progress(hass) is False + assert recorder.util.async_migration_in_progress(hass) is False with patch( "homeassistant.components.recorder.create_engine", new=create_engine_test @@ -82,15 +74,15 @@ async def test_migration_in_progress(hass): hass, "recorder", {"recorder": {"db_url": "sqlite://"}} ) await hass.data[DATA_INSTANCE].async_migration_event.wait() - assert await recorder.async_migration_in_progress(hass) is True + assert recorder.util.async_migration_in_progress(hass) is True await async_wait_recording_done_without_instance(hass) - assert await recorder.async_migration_in_progress(hass) is False + assert recorder.util.async_migration_in_progress(hass) is False async def test_database_migration_failed(hass): """Test we notify if the migration fails.""" - assert await recorder.async_migration_in_progress(hass) is False + assert recorder.util.async_migration_in_progress(hass) is False with patch( "homeassistant.components.recorder.create_engine", new=create_engine_test @@ -112,7 +104,7 @@ async def test_database_migration_failed(hass): await hass.async_add_executor_job(hass.data[DATA_INSTANCE].join) await hass.async_block_till_done() - assert await recorder.async_migration_in_progress(hass) is False + assert recorder.util.async_migration_in_progress(hass) is False assert len(mock_create.mock_calls) == 2 assert len(mock_dismiss.mock_calls) == 1 @@ -120,7 +112,7 @@ async def test_database_migration_failed(hass): async def test_database_migration_encounters_corruption(hass): """Test we move away the database if its corrupt.""" - assert await recorder.async_migration_in_progress(hass) is False + assert recorder.util.async_migration_in_progress(hass) is False sqlite3_exception = DatabaseError("statement", {}, []) sqlite3_exception.__cause__ = sqlite3.DatabaseError() @@ -141,13 +133,13 @@ async def test_database_migration_encounters_corruption(hass): hass.states.async_set("my.entity", "off", {}) await async_wait_recording_done_without_instance(hass) - assert await recorder.async_migration_in_progress(hass) is False + assert recorder.util.async_migration_in_progress(hass) is False assert move_away.called async def test_database_migration_encounters_corruption_not_sqlite(hass): """Test we fail on database error when we cannot recover.""" - assert await recorder.async_migration_in_progress(hass) is False + assert recorder.util.async_migration_in_progress(hass) is False with patch( "homeassistant.components.recorder.migration.schema_is_current", @@ -172,7 +164,7 @@ async def test_database_migration_encounters_corruption_not_sqlite(hass): await hass.async_add_executor_job(hass.data[DATA_INSTANCE].join) await hass.async_block_till_done() - assert await recorder.async_migration_in_progress(hass) is False + assert recorder.util.async_migration_in_progress(hass) is False assert not move_away.called assert len(mock_create.mock_calls) == 2 assert len(mock_dismiss.mock_calls) == 1 @@ -181,7 +173,7 @@ async def test_database_migration_encounters_corruption_not_sqlite(hass): async def test_events_during_migration_are_queued(hass): """Test that events during migration are queued.""" - assert await recorder.async_migration_in_progress(hass) is False + assert recorder.util.async_migration_in_progress(hass) is False with patch( "homeassistant.components.recorder.create_engine", new=create_engine_test @@ -198,7 +190,7 @@ async def test_events_during_migration_are_queued(hass): await hass.data[DATA_INSTANCE].async_recorder_ready.wait() await async_wait_recording_done_without_instance(hass) - assert await recorder.async_migration_in_progress(hass) is False + assert recorder.util.async_migration_in_progress(hass) is False db_states = await hass.async_add_executor_job(_get_native_states, hass, "my.entity") assert len(db_states) == 2 @@ -206,7 +198,7 @@ async def test_events_during_migration_are_queued(hass): async def test_events_during_migration_queue_exhausted(hass): """Test that events during migration takes so long the queue is exhausted.""" - assert await recorder.async_migration_in_progress(hass) is False + assert recorder.util.async_migration_in_progress(hass) is False with patch( "homeassistant.components.recorder.create_engine", new=create_engine_test @@ -224,7 +216,7 @@ async def test_events_during_migration_queue_exhausted(hass): await hass.data[DATA_INSTANCE].async_recorder_ready.wait() await async_wait_recording_done_without_instance(hass) - assert await recorder.async_migration_in_progress(hass) is False + assert recorder.util.async_migration_in_progress(hass) is False db_states = await hass.async_add_executor_job(_get_native_states, hass, "my.entity") assert len(db_states) == 1 hass.states.async_set("my.entity", "on", {}) @@ -233,7 +225,8 @@ async def test_events_during_migration_queue_exhausted(hass): assert len(db_states) == 2 -async def test_schema_migrate(hass): +@pytest.mark.parametrize("start_version", [0, 16, 18, 22]) +async def test_schema_migrate(hass, start_version): """Test the full schema migration logic. We're just testing that the logic can execute successfully here without @@ -241,21 +234,76 @@ async def test_schema_migrate(hass): inspection could quickly become quite cumbersome. """ + migration_done = threading.Event() + migration_stall = threading.Event() + migration_version = None + real_migration = recorder.migration.migrate_schema + + def _create_engine_test(*args, **kwargs): + """Test version of create_engine that initializes with old schema. + + This simulates an existing db with the old schema. + """ + module = f"tests.components.recorder.models_schema_{str(start_version)}" + importlib.import_module(module) + old_models = sys.modules[module] + engine = create_engine(*args, **kwargs) + old_models.Base.metadata.create_all(engine) + if start_version > 0: + with Session(engine) as session: + session.add(recorder.models.SchemaChanges(schema_version=start_version)) + session.commit() + return engine + def _mock_setup_run(self): self.run_info = RecorderRuns( start=self.recording_start, created=dt_util.utcnow() ) - with patch("sqlalchemy.create_engine", new=create_engine_test), patch( + def _instrument_migration(*args): + """Control migration progress and check results.""" + nonlocal migration_done + nonlocal migration_version + nonlocal migration_stall + migration_stall.wait() + try: + real_migration(*args) + except Exception: + migration_done.set() + raise + + # Check and report the outcome of the migration; if migration fails + # the recorder will silently create a new database. + with session_scope(hass=hass) as session: + res = ( + session.query(models.SchemaChanges) + .order_by(models.SchemaChanges.change_id.desc()) + .first() + ) + migration_version = res.schema_version + migration_done.set() + + with patch( + "homeassistant.components.recorder.create_engine", new=_create_engine_test + ), patch( "homeassistant.components.recorder.Recorder._setup_run", side_effect=_mock_setup_run, autospec=True, - ) as setup_run: + ) as setup_run, patch( + "homeassistant.components.recorder.migration.migrate_schema", + wraps=_instrument_migration, + ): await async_setup_component( hass, "recorder", {"recorder": {"db_url": "sqlite://"}} ) + assert recorder.util.async_migration_in_progress(hass) is True + migration_stall.set() await hass.async_block_till_done() + migration_done.wait() + await async_wait_recording_done_without_instance(hass) + assert migration_version == models.SCHEMA_VERSION assert setup_run.called + assert recorder.util.async_migration_in_progress(hass) is not True def test_invalid_update(): diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 5b93d1f567d..c4dd33ce840 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -14,6 +14,7 @@ from homeassistant.components.recorder.models import ( ) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, + get_last_short_term_statistics, get_last_statistics, get_metadata, list_statistic_ids, @@ -40,7 +41,7 @@ def test_compile_hourly_statistics(hass_recorder): for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): stats = statistics_during_period(hass, zero, period="5minute", **kwargs) assert stats == {} - stats = get_last_statistics(hass, 0, "sensor.test1", True) + stats = get_last_short_term_statistics(hass, 0, "sensor.test1", True) assert stats == {} recorder.do_adhoc_statistics(start=zero) @@ -91,20 +92,20 @@ def test_compile_hourly_statistics(hass_recorder): ) assert stats == {} - # Test get_last_statistics - stats = get_last_statistics(hass, 0, "sensor.test1", True) + # Test get_last_short_term_statistics + stats = get_last_short_term_statistics(hass, 0, "sensor.test1", True) assert stats == {} - stats = get_last_statistics(hass, 1, "sensor.test1", True) + stats = get_last_short_term_statistics(hass, 1, "sensor.test1", True) assert stats == {"sensor.test1": [{**expected_2, "statistic_id": "sensor.test1"}]} - stats = get_last_statistics(hass, 2, "sensor.test1", True) + stats = get_last_short_term_statistics(hass, 2, "sensor.test1", True) assert stats == {"sensor.test1": expected_stats1[::-1]} - stats = get_last_statistics(hass, 3, "sensor.test1", True) + stats = get_last_short_term_statistics(hass, 3, "sensor.test1", True) assert stats == {"sensor.test1": expected_stats1[::-1]} - stats = get_last_statistics(hass, 1, "sensor.test3", True) + stats = get_last_short_term_statistics(hass, 1, "sensor.test3", True) assert stats == {} @@ -236,7 +237,7 @@ def test_rename_entity(hass_recorder): for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): stats = statistics_during_period(hass, zero, period="5minute", **kwargs) assert stats == {} - stats = get_last_statistics(hass, 0, "sensor.test1", True) + stats = get_last_short_term_statistics(hass, 0, "sensor.test1", True) assert stats == {} recorder.do_adhoc_statistics(start=zero) @@ -392,6 +393,22 @@ def test_external_statistics(hass_recorder, caplog): }, ) } + last_stats = get_last_statistics(hass, 1, "test:total_energy_import", True) + assert last_stats == { + "test:total_energy_import": [ + { + "statistic_id": "test:total_energy_import", + "start": period2.isoformat(), + "end": (period2 + timedelta(hours=1)).isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(1.0), + "sum": approx(3.0), + }, + ] + } # Update the previously inserted statistics external_statistics = { @@ -544,6 +561,95 @@ def test_external_statistics_errors(hass_recorder, caplog): assert get_metadata(hass, statistic_ids=("test:total_energy_import",)) == {} +@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.""" + 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("2021-09-01 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 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="month") + sep_start = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + sep_end = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + oct_start = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + oct_end = dt_util.as_utc(dt_util.parse_datetime("2021-11-01 00:00:00")) + assert stats == { + "test:total_energy_import": [ + { + "statistic_id": "test:total_energy_import", + "start": sep_start.isoformat(), + "end": sep_end.isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(1.0), + "sum": approx(3.0), + }, + { + "statistic_id": "test:total_energy_import", + "start": oct_start.isoformat(), + "end": oct_end.isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(3.0), + "sum": approx(5.0), + }, + ] + } + + dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) + + def record_states(hass): """Record some test states. diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 940925c48ca..fa449aefefc 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -8,6 +8,7 @@ import pytest from sqlalchemy import text from sqlalchemy.sql.elements import TextClause +from homeassistant.components import recorder from homeassistant.components.recorder import run_information_with_session, util from homeassistant.components.recorder.const import DATA_INSTANCE, SQLITE_URL_PREFIX from homeassistant.components.recorder.models import RecorderRuns @@ -556,3 +557,21 @@ def test_perodic_db_cleanups(hass_recorder): ][0] assert isinstance(text_obj, TextClause) assert str(text_obj) == "PRAGMA wal_checkpoint(TRUNCATE);" + + +async def test_write_lock_db(hass, tmp_path): + """Test database write lock.""" + from sqlalchemy.exc import OperationalError + + # Use file DB, in memory DB cannot do write locks. + config = {recorder.CONF_DB_URL: "sqlite:///" + str(tmp_path / "pytest.db")} + await async_init_recorder_component(hass, config) + await hass.async_block_till_done() + + instance = hass.data[DATA_INSTANCE] + + with util.write_lock_db(instance): + # Database should be locked now, try writing SQL command + with instance.engine.connect() as connection: + with pytest.raises(OperationalError): + connection.execute(text("DROP TABLE events;")) diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index d52393fb693..2a9f737e9a5 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -1,18 +1,29 @@ """The tests for sensor recorder platform.""" # pylint: disable=protected-access,invalid-name from datetime import timedelta +import threading +from unittest.mock import patch import pytest from pytest import approx +from homeassistant.components import recorder from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM -from .common import trigger_db_commit +from .common import ( + async_wait_recording_done_without_instance, + create_engine_test, + trigger_db_commit, +) -from tests.common import init_recorder_component +from tests.common import ( + async_fire_time_changed, + async_init_recorder_component, + init_recorder_component, +) POWER_SENSOR_ATTRIBUTES = { "device_class": "power", @@ -237,3 +248,176 @@ async def test_update_statistics_metadata(hass, hass_ws_client, new_unit): "unit_of_measurement": new_unit, } ] + + +async def test_recorder_info(hass, hass_ws_client): + """Test getting recorder status.""" + client = await hass_ws_client() + await async_init_recorder_component(hass) + + # Ensure there are no queued events + await async_wait_recording_done_without_instance(hass) + + await client.send_json({"id": 1, "type": "recorder/info"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "backlog": 0, + "max_backlog": 30000, + "migration_in_progress": False, + "recording": True, + "thread_running": True, + } + + +async def test_recorder_info_no_recorder(hass, hass_ws_client): + """Test getting recorder status when recorder is not present.""" + client = await hass_ws_client() + + await client.send_json({"id": 1, "type": "recorder/info"}) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "unknown_command" + + +async def test_recorder_info_bad_recorder_config(hass, hass_ws_client): + """Test getting recorder status when recorder is not started.""" + config = {recorder.CONF_DB_URL: "sqlite://no_file", recorder.CONF_DB_RETRY_WAIT: 0} + + client = await hass_ws_client() + + with patch("homeassistant.components.recorder.migration.migrate_schema"): + assert not await async_setup_component( + hass, recorder.DOMAIN, {recorder.DOMAIN: config} + ) + assert recorder.DOMAIN not in hass.config.components + await hass.async_block_till_done() + + # Wait for recorder to shut down + await hass.async_add_executor_job(hass.data[DATA_INSTANCE].join) + + await client.send_json({"id": 1, "type": "recorder/info"}) + response = await client.receive_json() + assert response["success"] + assert response["result"]["recording"] is False + assert response["result"]["thread_running"] is False + + +async def test_recorder_info_migration_queue_exhausted(hass, hass_ws_client): + """Test getting recorder status when recorder queue is exhausted.""" + assert recorder.util.async_migration_in_progress(hass) is False + + migration_done = threading.Event() + + real_migration = recorder.migration.migrate_schema + + def stalled_migration(*args): + """Make migration stall.""" + nonlocal migration_done + migration_done.wait() + return real_migration(*args) + + with patch( + "homeassistant.components.recorder.Recorder.async_periodic_statistics" + ), patch( + "homeassistant.components.recorder.create_engine", new=create_engine_test + ), patch.object( + recorder, "MAX_QUEUE_BACKLOG", 1 + ), patch( + "homeassistant.components.recorder.migration.migrate_schema", + wraps=stalled_migration, + ): + await async_setup_component( + hass, "recorder", {"recorder": {"db_url": "sqlite://"}} + ) + hass.states.async_set("my.entity", "on", {}) + await hass.async_block_till_done() + + # Detect queue full + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + + client = await hass_ws_client() + + # Check the status + await client.send_json({"id": 1, "type": "recorder/info"}) + response = await client.receive_json() + assert response["success"] + assert response["result"]["migration_in_progress"] is True + assert response["result"]["recording"] is False + assert response["result"]["thread_running"] is True + + # Let migration finish + migration_done.set() + await async_wait_recording_done_without_instance(hass) + + # Check the status after migration finished + await client.send_json({"id": 2, "type": "recorder/info"}) + response = await client.receive_json() + assert response["success"] + assert response["result"]["migration_in_progress"] is False + assert response["result"]["recording"] is True + assert response["result"]["thread_running"] is True + + +async def test_backup_start_no_recorder( + hass, hass_ws_client, hass_supervisor_access_token +): + """Test getting backup start when recorder is not present.""" + client = await hass_ws_client(hass, hass_supervisor_access_token) + + await client.send_json({"id": 1, "type": "backup/start"}) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "unknown_command" + + +async def test_backup_start_timeout(hass, hass_ws_client, hass_supervisor_access_token): + """Test getting backup start when recorder is not present.""" + client = await hass_ws_client(hass, hass_supervisor_access_token) + await async_init_recorder_component(hass) + + # Ensure there are no queued events + await async_wait_recording_done_without_instance(hass) + + with patch.object(recorder, "DB_LOCK_TIMEOUT", 0): + try: + await client.send_json({"id": 1, "type": "backup/start"}) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "timeout_error" + finally: + await client.send_json({"id": 2, "type": "backup/end"}) + + +async def test_backup_end(hass, hass_ws_client, hass_supervisor_access_token): + """Test backup start.""" + client = await hass_ws_client(hass, hass_supervisor_access_token) + await async_init_recorder_component(hass) + + # Ensure there are no queued events + await async_wait_recording_done_without_instance(hass) + + await client.send_json({"id": 1, "type": "backup/start"}) + response = await client.receive_json() + assert response["success"] + + await client.send_json({"id": 2, "type": "backup/end"}) + response = await client.receive_json() + assert response["success"] + + +async def test_backup_end_without_start( + hass, hass_ws_client, hass_supervisor_access_token +): + """Test backup start.""" + client = await hass_ws_client(hass, hass_supervisor_access_token) + await async_init_recorder_component(hass) + + # Ensure there are no queued events + await async_wait_recording_done_without_instance(hass) + + await client.send_json({"id": 1, "type": "backup/end"}) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "database_unlock_failed" diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index e3703173ad0..e1d7a3fc28c 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -1,10 +1,5 @@ """Constants for the Renault integration tests.""" -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_BATTERY_CHARGING, - DEVICE_CLASS_PLUG, - DOMAIN as BINARY_SENSOR_DOMAIN, -) -from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.renault.const import ( CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, @@ -13,13 +8,11 @@ from homeassistant.components.renault.const import ( DEVICE_CLASS_PLUG_STATE, DOMAIN, ) -from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.select.const import ATTR_OPTIONS from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -34,12 +27,6 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_PASSWORD, CONF_USERNAME, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, ELECTRIC_CURRENT_AMPERE, ENERGY_KILO_WATT_HOUR, LENGTH_KILOMETERS, @@ -52,6 +39,7 @@ from homeassistant.const import ( TEMP_CELSIUS, TIME_MINUTES, VOLUME_LITERS, + Platform, ) ATTR_DEFAULT_DISABLED = "default_disabled" @@ -103,22 +91,36 @@ MOCK_VEHICLES = { "cockpit": "cockpit_ev.json", "hvac_status": "hvac_status.json", }, - BINARY_SENSOR_DOMAIN: [ + Platform.BINARY_SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG, + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PLUG, ATTR_ENTITY_ID: "binary_sensor.reg_number_plugged_in", ATTR_STATE: STATE_ON, ATTR_UNIQUE_ID: "vf1aaaaa555777999_plugged_in", }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING, + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.BATTERY_CHARGING, ATTR_ENTITY_ID: "binary_sensor.reg_number_charging", ATTR_STATE: STATE_ON, ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging", }, ], - DEVICE_TRACKER_DOMAIN: [], - SELECT_DOMAIN: [ + Platform.BUTTON: [ + { + ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", + ATTR_ICON: "mdi:air-conditioner", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_air_conditioner", + }, + { + ATTR_ENTITY_ID: "button.reg_number_start_charge", + ATTR_ICON: "mdi:ev-station", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_charge", + }, + ], + Platform.DEVICE_TRACKER: [], + Platform.SELECT: [ { ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, ATTR_ENTITY_ID: "select.reg_number_charge_mode", @@ -128,43 +130,43 @@ MOCK_VEHICLES = { ATTR_UNIQUE_ID: "vf1aaaaa555777999_charge_mode", }, ], - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { ATTR_ENTITY_ID: "sensor.reg_number_battery_autonomy", ATTR_ICON: "mdi:ev-station", ATTR_STATE: "141", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_autonomy", ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY, ATTR_ENTITY_ID: "sensor.reg_number_battery_available_energy", ATTR_STATE: "31", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_available_energy", ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, + ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY, ATTR_ENTITY_ID: "sensor.reg_number_battery_level", ATTR_STATE: "60", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_level", ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, + ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, ATTR_ENTITY_ID: "sensor.reg_number_battery_last_activity", ATTR_STATE: "2020-01-12T21:40:16+00:00", ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_last_activity", }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.reg_number_battery_temperature", ATTR_STATE: "20", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_temperature", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, @@ -176,10 +178,10 @@ MOCK_VEHICLES = { ATTR_UNIQUE_ID: "vf1aaaaa555777999_charge_state", }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_DEVICE_CLASS: SensorDeviceClass.POWER, ATTR_ENTITY_ID: "sensor.reg_number_charging_power", ATTR_STATE: "0.027", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging_power", ATTR_UNIT_OF_MEASUREMENT: POWER_KILO_WATT, }, @@ -187,7 +189,7 @@ MOCK_VEHICLES = { ATTR_ENTITY_ID: "sensor.reg_number_charging_remaining_time", ATTR_ICON: "mdi:timer", ATTR_STATE: "145", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging_remaining_time", ATTR_UNIT_OF_MEASUREMENT: TIME_MINUTES, }, @@ -195,15 +197,15 @@ MOCK_VEHICLES = { ATTR_ENTITY_ID: "sensor.reg_number_mileage", ATTR_ICON: "mdi:sign-direction", ATTR_STATE: "49114", - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, ATTR_UNIQUE_ID: "vf1aaaaa555777999_mileage", ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.reg_number_outside_temperature", ATTR_STATE: "8.0", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777999_outside_temperature", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, @@ -237,21 +239,35 @@ MOCK_VEHICLES = { "cockpit": "cockpit_ev.json", "location": "location.json", }, - BINARY_SENSOR_DOMAIN: [ + Platform.BINARY_SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG, + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PLUG, ATTR_ENTITY_ID: "binary_sensor.reg_number_plugged_in", ATTR_STATE: STATE_OFF, ATTR_UNIQUE_ID: "vf1aaaaa555777999_plugged_in", }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING, + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.BATTERY_CHARGING, ATTR_ENTITY_ID: "binary_sensor.reg_number_charging", ATTR_STATE: STATE_OFF, ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging", }, ], - DEVICE_TRACKER_DOMAIN: [ + Platform.BUTTON: [ + { + ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", + ATTR_ICON: "mdi:air-conditioner", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_air_conditioner", + }, + { + ATTR_ENTITY_ID: "button.reg_number_start_charge", + ATTR_ICON: "mdi:ev-station", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_charge", + }, + ], + Platform.DEVICE_TRACKER: [ { ATTR_ENTITY_ID: "device_tracker.reg_number_location", ATTR_ICON: "mdi:car", @@ -259,7 +275,7 @@ MOCK_VEHICLES = { ATTR_UNIQUE_ID: "vf1aaaaa555777999_location", } ], - SELECT_DOMAIN: [ + Platform.SELECT: [ { ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, ATTR_ENTITY_ID: "select.reg_number_charge_mode", @@ -269,43 +285,43 @@ MOCK_VEHICLES = { ATTR_UNIQUE_ID: "vf1aaaaa555777999_charge_mode", }, ], - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { ATTR_ENTITY_ID: "sensor.reg_number_battery_autonomy", ATTR_ICON: "mdi:ev-station", ATTR_STATE: "128", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_autonomy", ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY, ATTR_ENTITY_ID: "sensor.reg_number_battery_available_energy", ATTR_STATE: "0", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_available_energy", ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, + ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY, ATTR_ENTITY_ID: "sensor.reg_number_battery_level", ATTR_STATE: "50", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_level", ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, + ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, ATTR_ENTITY_ID: "sensor.reg_number_battery_last_activity", ATTR_STATE: "2020-11-17T08:06:48+00:00", ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_last_activity", }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.reg_number_battery_temperature", ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_temperature", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, @@ -317,10 +333,10 @@ MOCK_VEHICLES = { ATTR_UNIQUE_ID: "vf1aaaaa555777999_charge_state", }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, + ATTR_DEVICE_CLASS: SensorDeviceClass.CURRENT, ATTR_ENTITY_ID: "sensor.reg_number_charging_power", ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging_power", ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, }, @@ -328,7 +344,7 @@ MOCK_VEHICLES = { ATTR_ENTITY_ID: "sensor.reg_number_charging_remaining_time", ATTR_ICON: "mdi:timer", ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging_remaining_time", ATTR_UNIT_OF_MEASUREMENT: TIME_MINUTES, }, @@ -336,7 +352,7 @@ MOCK_VEHICLES = { ATTR_ENTITY_ID: "sensor.reg_number_mileage", ATTR_ICON: "mdi:sign-direction", ATTR_STATE: "49114", - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, ATTR_UNIQUE_ID: "vf1aaaaa555777999_mileage", ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, @@ -349,7 +365,7 @@ MOCK_VEHICLES = { }, { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, + ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, ATTR_ENTITY_ID: "sensor.reg_number_location_last_activity", ATTR_STATE: "2020-02-18T16:58:38+00:00", ATTR_UNIQUE_ID: "vf1aaaaa555777999_location_last_activity", @@ -377,21 +393,35 @@ MOCK_VEHICLES = { "cockpit": "cockpit_fuel.json", "location": "location.json", }, - BINARY_SENSOR_DOMAIN: [ + Platform.BINARY_SENSOR: [ { - ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG, + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PLUG, ATTR_ENTITY_ID: "binary_sensor.reg_number_plugged_in", ATTR_STATE: STATE_ON, ATTR_UNIQUE_ID: "vf1aaaaa555777123_plugged_in", }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING, + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.BATTERY_CHARGING, ATTR_ENTITY_ID: "binary_sensor.reg_number_charging", ATTR_STATE: STATE_ON, ATTR_UNIQUE_ID: "vf1aaaaa555777123_charging", }, ], - DEVICE_TRACKER_DOMAIN: [ + Platform.BUTTON: [ + { + ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", + ATTR_ICON: "mdi:air-conditioner", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777123_start_air_conditioner", + }, + { + ATTR_ENTITY_ID: "button.reg_number_start_charge", + ATTR_ICON: "mdi:ev-station", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777123_start_charge", + }, + ], + Platform.DEVICE_TRACKER: [ { ATTR_ENTITY_ID: "device_tracker.reg_number_location", ATTR_ICON: "mdi:car", @@ -399,7 +429,7 @@ MOCK_VEHICLES = { ATTR_UNIQUE_ID: "vf1aaaaa555777123_location", } ], - SELECT_DOMAIN: [ + Platform.SELECT: [ { ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, ATTR_ENTITY_ID: "select.reg_number_charge_mode", @@ -409,43 +439,43 @@ MOCK_VEHICLES = { ATTR_UNIQUE_ID: "vf1aaaaa555777123_charge_mode", }, ], - SENSOR_DOMAIN: [ + Platform.SENSOR: [ { ATTR_ENTITY_ID: "sensor.reg_number_battery_autonomy", ATTR_ICON: "mdi:ev-station", ATTR_STATE: "141", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777123_battery_autonomy", ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY, ATTR_ENTITY_ID: "sensor.reg_number_battery_available_energy", ATTR_STATE: "31", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777123_battery_available_energy", ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, + ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY, ATTR_ENTITY_ID: "sensor.reg_number_battery_level", ATTR_STATE: "60", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777123_battery_level", ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, + ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, ATTR_ENTITY_ID: "sensor.reg_number_battery_last_activity", ATTR_STATE: "2020-01-12T21:40:16+00:00", ATTR_UNIQUE_ID: "vf1aaaaa555777123_battery_last_activity", }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_ENTITY_ID: "sensor.reg_number_battery_temperature", ATTR_STATE: "20", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777123_battery_temperature", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, @@ -457,10 +487,10 @@ MOCK_VEHICLES = { ATTR_UNIQUE_ID: "vf1aaaaa555777123_charge_state", }, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, + ATTR_DEVICE_CLASS: SensorDeviceClass.CURRENT, ATTR_ENTITY_ID: "sensor.reg_number_charging_power", ATTR_STATE: "27.0", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777123_charging_power", ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, }, @@ -468,7 +498,7 @@ MOCK_VEHICLES = { ATTR_ENTITY_ID: "sensor.reg_number_charging_remaining_time", ATTR_ICON: "mdi:timer", ATTR_STATE: "145", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777123_charging_remaining_time", ATTR_UNIT_OF_MEASUREMENT: TIME_MINUTES, }, @@ -476,7 +506,7 @@ MOCK_VEHICLES = { ATTR_ENTITY_ID: "sensor.reg_number_fuel_autonomy", ATTR_ICON: "mdi:gas-station", ATTR_STATE: "35", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777123_fuel_autonomy", ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, @@ -484,7 +514,7 @@ MOCK_VEHICLES = { ATTR_ENTITY_ID: "sensor.reg_number_fuel_quantity", ATTR_ICON: "mdi:fuel", ATTR_STATE: "3", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777123_fuel_quantity", ATTR_UNIT_OF_MEASUREMENT: VOLUME_LITERS, }, @@ -492,7 +522,7 @@ MOCK_VEHICLES = { ATTR_ENTITY_ID: "sensor.reg_number_mileage", ATTR_ICON: "mdi:sign-direction", ATTR_STATE: "5567", - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, ATTR_UNIQUE_ID: "vf1aaaaa555777123_mileage", ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, @@ -505,7 +535,7 @@ MOCK_VEHICLES = { }, { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, + ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, ATTR_ENTITY_ID: "sensor.reg_number_location_last_activity", ATTR_STATE: "2020-02-18T16:58:38+00:00", ATTR_UNIQUE_ID: "vf1aaaaa555777123_location_last_activity", @@ -531,8 +561,16 @@ MOCK_VEHICLES = { "cockpit": "cockpit_fuel.json", "location": "location.json", }, - BINARY_SENSOR_DOMAIN: [], - DEVICE_TRACKER_DOMAIN: [ + Platform.BINARY_SENSOR: [], + Platform.BUTTON: [ + { + ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", + ATTR_ICON: "mdi:air-conditioner", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777123_start_air_conditioner", + }, + ], + Platform.DEVICE_TRACKER: [ { ATTR_ENTITY_ID: "device_tracker.reg_number_location", ATTR_ICON: "mdi:car", @@ -540,13 +578,13 @@ MOCK_VEHICLES = { ATTR_UNIQUE_ID: "vf1aaaaa555777123_location", } ], - SELECT_DOMAIN: [], - SENSOR_DOMAIN: [ + Platform.SELECT: [], + Platform.SENSOR: [ { ATTR_ENTITY_ID: "sensor.reg_number_fuel_autonomy", ATTR_ICON: "mdi:gas-station", ATTR_STATE: "35", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777123_fuel_autonomy", ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, @@ -554,7 +592,7 @@ MOCK_VEHICLES = { ATTR_ENTITY_ID: "sensor.reg_number_fuel_quantity", ATTR_ICON: "mdi:fuel", ATTR_STATE: "3", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777123_fuel_quantity", ATTR_UNIT_OF_MEASUREMENT: VOLUME_LITERS, }, @@ -562,13 +600,13 @@ MOCK_VEHICLES = { ATTR_ENTITY_ID: "sensor.reg_number_mileage", ATTR_ICON: "mdi:sign-direction", ATTR_STATE: "5567", - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, ATTR_UNIQUE_ID: "vf1aaaaa555777123_mileage", ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, { ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, + ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, ATTR_ENTITY_ID: "sensor.reg_number_location_last_activity", ATTR_STATE: "2020-02-18T16:58:38+00:00", ATTR_UNIQUE_ID: "vf1aaaaa555777123_location_last_activity", diff --git a/tests/fixtures/renault/action.set_ac_start.json b/tests/components/renault/fixtures/action.set_ac_start.json similarity index 100% rename from tests/fixtures/renault/action.set_ac_start.json rename to tests/components/renault/fixtures/action.set_ac_start.json diff --git a/tests/fixtures/renault/action.set_ac_stop.json b/tests/components/renault/fixtures/action.set_ac_stop.json similarity index 100% rename from tests/fixtures/renault/action.set_ac_stop.json rename to tests/components/renault/fixtures/action.set_ac_stop.json diff --git a/tests/fixtures/renault/action.set_charge_mode.json b/tests/components/renault/fixtures/action.set_charge_mode.json similarity index 100% rename from tests/fixtures/renault/action.set_charge_mode.json rename to tests/components/renault/fixtures/action.set_charge_mode.json diff --git a/tests/fixtures/renault/action.set_charge_schedules.json b/tests/components/renault/fixtures/action.set_charge_schedules.json similarity index 100% rename from tests/fixtures/renault/action.set_charge_schedules.json rename to tests/components/renault/fixtures/action.set_charge_schedules.json diff --git a/tests/fixtures/renault/action.set_charge_start.json b/tests/components/renault/fixtures/action.set_charge_start.json similarity index 100% rename from tests/fixtures/renault/action.set_charge_start.json rename to tests/components/renault/fixtures/action.set_charge_start.json diff --git a/tests/fixtures/renault/battery_status_charging.json b/tests/components/renault/fixtures/battery_status_charging.json similarity index 100% rename from tests/fixtures/renault/battery_status_charging.json rename to tests/components/renault/fixtures/battery_status_charging.json diff --git a/tests/fixtures/renault/battery_status_not_charging.json b/tests/components/renault/fixtures/battery_status_not_charging.json similarity index 100% rename from tests/fixtures/renault/battery_status_not_charging.json rename to tests/components/renault/fixtures/battery_status_not_charging.json diff --git a/tests/fixtures/renault/charge_mode_always.json b/tests/components/renault/fixtures/charge_mode_always.json similarity index 100% rename from tests/fixtures/renault/charge_mode_always.json rename to tests/components/renault/fixtures/charge_mode_always.json diff --git a/tests/fixtures/renault/charge_mode_schedule.json b/tests/components/renault/fixtures/charge_mode_schedule.json similarity index 100% rename from tests/fixtures/renault/charge_mode_schedule.json rename to tests/components/renault/fixtures/charge_mode_schedule.json diff --git a/tests/fixtures/renault/charging_settings.json b/tests/components/renault/fixtures/charging_settings.json similarity index 100% rename from tests/fixtures/renault/charging_settings.json rename to tests/components/renault/fixtures/charging_settings.json diff --git a/tests/fixtures/renault/cockpit_ev.json b/tests/components/renault/fixtures/cockpit_ev.json similarity index 100% rename from tests/fixtures/renault/cockpit_ev.json rename to tests/components/renault/fixtures/cockpit_ev.json diff --git a/tests/fixtures/renault/cockpit_fuel.json b/tests/components/renault/fixtures/cockpit_fuel.json similarity index 100% rename from tests/fixtures/renault/cockpit_fuel.json rename to tests/components/renault/fixtures/cockpit_fuel.json diff --git a/tests/fixtures/renault/hvac_status.json b/tests/components/renault/fixtures/hvac_status.json similarity index 100% rename from tests/fixtures/renault/hvac_status.json rename to tests/components/renault/fixtures/hvac_status.json diff --git a/tests/fixtures/renault/location.json b/tests/components/renault/fixtures/location.json similarity index 100% rename from tests/fixtures/renault/location.json rename to tests/components/renault/fixtures/location.json diff --git a/tests/fixtures/renault/no_data.json b/tests/components/renault/fixtures/no_data.json similarity index 100% rename from tests/fixtures/renault/no_data.json rename to tests/components/renault/fixtures/no_data.json diff --git a/tests/fixtures/renault/vehicle_captur_fuel.json b/tests/components/renault/fixtures/vehicle_captur_fuel.json similarity index 100% rename from tests/fixtures/renault/vehicle_captur_fuel.json rename to tests/components/renault/fixtures/vehicle_captur_fuel.json diff --git a/tests/fixtures/renault/vehicle_captur_phev.json b/tests/components/renault/fixtures/vehicle_captur_phev.json similarity index 100% rename from tests/fixtures/renault/vehicle_captur_phev.json rename to tests/components/renault/fixtures/vehicle_captur_phev.json diff --git a/tests/fixtures/renault/vehicle_zoe_40.json b/tests/components/renault/fixtures/vehicle_zoe_40.json similarity index 100% rename from tests/fixtures/renault/vehicle_zoe_40.json rename to tests/components/renault/fixtures/vehicle_zoe_40.json diff --git a/tests/fixtures/renault/vehicle_zoe_50.json b/tests/components/renault/fixtures/vehicle_zoe_50.json similarity index 100% rename from tests/fixtures/renault/vehicle_zoe_50.json rename to tests/components/renault/fixtures/vehicle_zoe_50.json diff --git a/tests/components/renault/test_binary_sensor.py b/tests/components/renault/test_binary_sensor.py index 440018c01c2..0a2460edca1 100644 --- a/tests/components/renault/test_binary_sensor.py +++ b/tests/components/renault/test_binary_sensor.py @@ -3,9 +3,8 @@ from unittest.mock import patch import pytest -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF +from homeassistant.const import STATE_OFF, Platform from homeassistant.core import HomeAssistant from . import ( @@ -24,7 +23,7 @@ pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicle @pytest.fixture(autouse=True) def override_platforms(): """Override PLATFORMS.""" - with patch("homeassistant.components.renault.PLATFORMS", [BINARY_SENSOR_DOMAIN]): + with patch("homeassistant.components.renault.PLATFORMS", [Platform.BINARY_SENSOR]): yield @@ -42,7 +41,7 @@ async def test_binary_sensors( mock_vehicle = MOCK_VEHICLES[vehicle_type] check_device_registry(device_registry, mock_vehicle["expected_device"]) - expected_entities = mock_vehicle[BINARY_SENSOR_DOMAIN] + expected_entities = mock_vehicle[Platform.BINARY_SENSOR] assert len(entity_registry.entities) == len(expected_entities) check_entities(hass, entity_registry, expected_entities) @@ -62,7 +61,7 @@ async def test_binary_sensor_empty( mock_vehicle = MOCK_VEHICLES[vehicle_type] check_device_registry(device_registry, mock_vehicle["expected_device"]) - expected_entities = mock_vehicle[BINARY_SENSOR_DOMAIN] + expected_entities = mock_vehicle[Platform.BINARY_SENSOR] assert len(entity_registry.entities) == len(expected_entities) check_entities_no_data(hass, entity_registry, expected_entities, STATE_OFF) @@ -81,7 +80,7 @@ async def test_binary_sensor_errors( mock_vehicle = MOCK_VEHICLES[vehicle_type] check_device_registry(device_registry, mock_vehicle["expected_device"]) - expected_entities = mock_vehicle[BINARY_SENSOR_DOMAIN] + expected_entities = mock_vehicle[Platform.BINARY_SENSOR] assert len(entity_registry.entities) == len(expected_entities) check_entities_unavailable(hass, entity_registry, expected_entities) diff --git a/tests/components/renault/test_button.py b/tests/components/renault/test_button.py new file mode 100644 index 00000000000..cf6fc1902e9 --- /dev/null +++ b/tests/components/renault/test_button.py @@ -0,0 +1,184 @@ +"""Tests for Renault sensors.""" +from unittest.mock import patch + +import pytest +from renault_api.kamereon import schemas + +from homeassistant.components.button.const import SERVICE_PRESS +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant + +from . import check_device_registry, check_entities_no_data +from .const import ATTR_ENTITY_ID, MOCK_VEHICLES + +from tests.common import load_fixture, mock_device_registry, mock_registry + +pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") + + +@pytest.fixture(autouse=True) +def override_platforms(): + """Override PLATFORMS.""" + with patch("homeassistant.components.renault.PLATFORMS", [Platform.BUTTON]): + yield + + +@pytest.mark.usefixtures("fixtures_with_data") +async def test_buttons( + hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str +): + """Test for Renault device trackers.""" + + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[Platform.BUTTON] + assert len(entity_registry.entities) == len(expected_entities) + + check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + + +@pytest.mark.usefixtures("fixtures_with_no_data") +async def test_button_empty( + hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str +): + """Test for Renault device trackers with empty data from Renault.""" + + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[Platform.BUTTON] + assert len(entity_registry.entities) == len(expected_entities) + check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + + +@pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") +async def test_button_errors( + hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str +): + """Test for Renault device trackers with temporary failure.""" + + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[Platform.BUTTON] + assert len(entity_registry.entities) == len(expected_entities) + + check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + + +@pytest.mark.usefixtures("fixtures_with_access_denied_exception") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_button_access_denied( + hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str +): + """Test for Renault device trackers with access denied failure.""" + + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[Platform.BUTTON] + assert len(entity_registry.entities) == len(expected_entities) + + check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + + +@pytest.mark.usefixtures("fixtures_with_not_supported_exception") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_button_not_supported( + hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str +): + """Test for Renault device trackers with not supported failure.""" + + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[Platform.BUTTON] + assert len(entity_registry.entities) == len(expected_entities) + + check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + + +@pytest.mark.usefixtures("fixtures_with_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_button_start_charge(hass: HomeAssistant, config_entry: ConfigEntry): + """Test that button invokes renault_api with correct data.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + data = { + ATTR_ENTITY_ID: "button.reg_number_start_charge", + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_charge_start", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_charge_start.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + Platform.BUTTON, SERVICE_PRESS, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == () + + +@pytest.mark.usefixtures("fixtures_with_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_button_start_air_conditioner( + hass: HomeAssistant, config_entry: ConfigEntry +): + """Test that button invokes renault_api with correct data.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + data = { + ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_ac_start", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_ac_start.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + Platform.BUTTON, SERVICE_PRESS, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == (21, None) diff --git a/tests/components/renault/test_device_tracker.py b/tests/components/renault/test_device_tracker.py index a2c8b165b32..6d1ace17754 100644 --- a/tests/components/renault/test_device_tracker.py +++ b/tests/components/renault/test_device_tracker.py @@ -3,9 +3,8 @@ from unittest.mock import patch import pytest -from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from . import ( @@ -24,7 +23,7 @@ pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicle @pytest.fixture(autouse=True) def override_platforms(): """Override PLATFORMS.""" - with patch("homeassistant.components.renault.PLATFORMS", [DEVICE_TRACKER_DOMAIN]): + with patch("homeassistant.components.renault.PLATFORMS", [Platform.DEVICE_TRACKER]): yield @@ -43,7 +42,7 @@ async def test_device_trackers( mock_vehicle = MOCK_VEHICLES[vehicle_type] check_device_registry(device_registry, mock_vehicle["expected_device"]) - expected_entities = mock_vehicle[DEVICE_TRACKER_DOMAIN] + expected_entities = mock_vehicle[Platform.DEVICE_TRACKER] assert len(entity_registry.entities) == len(expected_entities) check_entities(hass, entity_registry, expected_entities) @@ -64,7 +63,7 @@ async def test_device_tracker_empty( mock_vehicle = MOCK_VEHICLES[vehicle_type] check_device_registry(device_registry, mock_vehicle["expected_device"]) - expected_entities = mock_vehicle[DEVICE_TRACKER_DOMAIN] + expected_entities = mock_vehicle[Platform.DEVICE_TRACKER] assert len(entity_registry.entities) == len(expected_entities) check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) @@ -84,7 +83,7 @@ async def test_device_tracker_errors( mock_vehicle = MOCK_VEHICLES[vehicle_type] check_device_registry(device_registry, mock_vehicle["expected_device"]) - expected_entities = mock_vehicle[DEVICE_TRACKER_DOMAIN] + expected_entities = mock_vehicle[Platform.DEVICE_TRACKER] assert len(entity_registry.entities) == len(expected_entities) check_entities_unavailable(hass, entity_registry, expected_entities) diff --git a/tests/components/renault/test_select.py b/tests/components/renault/test_select.py index 9d2655bfe1c..e0cb4413a7e 100644 --- a/tests/components/renault/test_select.py +++ b/tests/components/renault/test_select.py @@ -4,10 +4,9 @@ from unittest.mock import patch import pytest from renault_api.kamereon import schemas -from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.select.const import ATTR_OPTION, SERVICE_SELECT_OPTION from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from . import ( @@ -26,7 +25,7 @@ pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicle @pytest.fixture(autouse=True) def override_platforms(): """Override PLATFORMS.""" - with patch("homeassistant.components.renault.PLATFORMS", [SELECT_DOMAIN]): + with patch("homeassistant.components.renault.PLATFORMS", [Platform.SELECT]): yield @@ -44,7 +43,7 @@ async def test_selects( mock_vehicle = MOCK_VEHICLES[vehicle_type] check_device_registry(device_registry, mock_vehicle["expected_device"]) - expected_entities = mock_vehicle[SELECT_DOMAIN] + expected_entities = mock_vehicle[Platform.SELECT] assert len(entity_registry.entities) == len(expected_entities) check_entities(hass, entity_registry, expected_entities) @@ -64,7 +63,7 @@ async def test_select_empty( mock_vehicle = MOCK_VEHICLES[vehicle_type] check_device_registry(device_registry, mock_vehicle["expected_device"]) - expected_entities = mock_vehicle[SELECT_DOMAIN] + expected_entities = mock_vehicle[Platform.SELECT] assert len(entity_registry.entities) == len(expected_entities) check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) @@ -83,7 +82,7 @@ async def test_select_errors( mock_vehicle = MOCK_VEHICLES[vehicle_type] check_device_registry(device_registry, mock_vehicle["expected_device"]) - expected_entities = mock_vehicle[SELECT_DOMAIN] + expected_entities = mock_vehicle[Platform.SELECT] assert len(entity_registry.entities) == len(expected_entities) check_entities_unavailable(hass, entity_registry, expected_entities) @@ -146,7 +145,7 @@ async def test_select_charge_mode(hass: HomeAssistant, config_entry: ConfigEntry ), ) as mock_action: await hass.services.async_call( - SELECT_DOMAIN, SERVICE_SELECT_OPTION, service_data=data, blocking=True + Platform.SELECT, SERVICE_SELECT_OPTION, service_data=data, blocking=True ) assert len(mock_action.mock_calls) == 1 assert mock_action.mock_calls[0][1] == ("always",) diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index c9a70c8e026..2e584326e89 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -4,9 +4,8 @@ from unittest.mock import patch import pytest -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry @@ -26,7 +25,7 @@ pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicle @pytest.fixture(autouse=True) def override_platforms(): """Override PLATFORMS.""" - with patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + with patch("homeassistant.components.renault.PLATFORMS", [Platform.SENSOR]): yield @@ -57,7 +56,7 @@ async def test_sensors( mock_vehicle = MOCK_VEHICLES[vehicle_type] check_device_registry(device_registry, mock_vehicle["expected_device"]) - expected_entities = mock_vehicle[SENSOR_DOMAIN] + expected_entities = mock_vehicle[Platform.SENSOR] assert len(entity_registry.entities) == len(expected_entities) _check_and_enable_disabled_entities(entity_registry, expected_entities) @@ -81,7 +80,7 @@ async def test_sensor_empty( mock_vehicle = MOCK_VEHICLES[vehicle_type] check_device_registry(device_registry, mock_vehicle["expected_device"]) - expected_entities = mock_vehicle[SENSOR_DOMAIN] + expected_entities = mock_vehicle[Platform.SENSOR] assert len(entity_registry.entities) == len(expected_entities) _check_and_enable_disabled_entities(entity_registry, expected_entities) @@ -105,7 +104,7 @@ async def test_sensor_errors( mock_vehicle = MOCK_VEHICLES[vehicle_type] check_device_registry(device_registry, mock_vehicle["expected_device"]) - expected_entities = mock_vehicle[SENSOR_DOMAIN] + expected_entities = mock_vehicle[Platform.SENSOR] assert len(entity_registry.entities) == len(expected_entities) _check_and_enable_disabled_entities(entity_registry, expected_entities) diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index 5a02fd814b9..b7748cafb5d 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -236,7 +236,9 @@ async def test_service_set_charge_schedule_multi( assert mock_action.mock_calls[0][1] == (mock_call_data,) -async def test_service_set_charge_start(hass: HomeAssistant, config_entry: ConfigEntry): +async def test_service_set_charge_start( + hass: HomeAssistant, config_entry: ConfigEntry, caplog: pytest.LogCaptureFixture +): """Test that service invokes renault_api with correct data.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -258,6 +260,7 @@ async def test_service_set_charge_start(hass: HomeAssistant, config_entry: Confi ) assert len(mock_action.mock_calls) == 1 assert mock_action.mock_calls[0][1] == () + assert f"'{DOMAIN}.{SERVICE_CHARGE_START}' service is deprecated" in caplog.text async def test_service_invalid_device_id( diff --git a/tests/fixtures/rest/configuration.yaml b/tests/components/rest/fixtures/configuration.yaml similarity index 100% rename from tests/fixtures/rest/configuration.yaml rename to tests/components/rest/fixtures/configuration.yaml diff --git a/tests/fixtures/rest/configuration_empty.yaml b/tests/components/rest/fixtures/configuration_empty.yaml similarity index 100% rename from tests/fixtures/rest/configuration_empty.yaml rename to tests/components/rest/fixtures/configuration_empty.yaml diff --git a/tests/fixtures/rest/configuration_invalid.notyaml b/tests/components/rest/fixtures/configuration_invalid.notyaml similarity index 100% rename from tests/fixtures/rest/configuration_invalid.notyaml rename to tests/components/rest/fixtures/configuration_invalid.notyaml diff --git a/tests/fixtures/rest/configuration_top_level.yaml b/tests/components/rest/fixtures/configuration_top_level.yaml similarity index 100% rename from tests/fixtures/rest/configuration_top_level.yaml rename to tests/components/rest/fixtures/configuration_top_level.yaml diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py index 8160a5976a7..6daffcb2a5e 100644 --- a/tests/components/rest/test_binary_sensor.py +++ b/tests/components/rest/test_binary_sensor.py @@ -2,7 +2,6 @@ import asyncio from http import HTTPStatus -from os import path from unittest.mock import MagicMock, patch import httpx @@ -21,6 +20,8 @@ from homeassistant.const import ( ) from homeassistant.setup import async_setup_component +from tests.common import get_fixture_path + async def test_setup_missing_basic_config(hass): """Test setup with configuration missing required entries.""" @@ -179,6 +180,40 @@ async def test_setup_get(hass): assert state.attributes[ATTR_DEVICE_CLASS] == binary_sensor.DEVICE_CLASS_PLUG +@respx.mock +async def test_setup_get_template_headers_params(hass): + """Test setup with valid configuration.""" + respx.get("http://localhost").respond(status_code=200, json={}) + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.key }}", + "name": "foo", + "verify_ssl": "true", + "timeout": 30, + "headers": { + "Accept": CONTENT_TYPE_JSON, + "User-Agent": "Mozilla/{{ 3 + 2 }}.0", + }, + "params": { + "start": 0, + "end": "{{ 3 + 2 }}", + }, + } + }, + ) + await async_setup_component(hass, "homeassistant", {}) + + assert respx.calls.last.request.headers["Accept"] == CONTENT_TYPE_JSON + assert respx.calls.last.request.headers["User-Agent"] == "Mozilla/5.0" + assert respx.calls.last.request.url.query == b"start=0&end=5" + + @respx.mock async def test_setup_get_digest_auth(hass): """Test setup with valid configuration.""" @@ -363,11 +398,7 @@ async def test_reload(hass): assert hass.states.get("binary_sensor.mockrest") - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "rest/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "rest") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( "rest", @@ -399,7 +430,3 @@ async def test_setup_query_params(hass): ) await hass.async_block_till_done() assert len(hass.states.async_all("binary_sensor")) == 1 - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/components/rest/test_init.py b/tests/components/rest/test_init.py index ddd5356525d..988f88b348e 100644 --- a/tests/components/rest/test_init.py +++ b/tests/components/rest/test_init.py @@ -3,7 +3,6 @@ import asyncio from datetime import timedelta from http import HTTPStatus -from os import path from unittest.mock import patch import respx @@ -19,7 +18,7 @@ from homeassistant.const import ( from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, get_fixture_path @respx.mock @@ -220,11 +219,8 @@ async def test_reload(hass): assert hass.states.get("sensor.mockrest") - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "rest/configuration_top_level.yaml", - ) + yaml_path = get_fixture_path("configuration_top_level.yaml", "rest") + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( "rest", @@ -272,11 +268,8 @@ async def test_reload_and_remove_all(hass): assert hass.states.get("sensor.mockrest") - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "rest/configuration_empty.yaml", - ) + yaml_path = get_fixture_path("configuration_empty.yaml", "rest") + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( "rest", @@ -320,11 +313,7 @@ async def test_reload_fails_to_read_configuration(hass): assert len(hass.states.async_all()) == 1 - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "rest/configuration_invalid.notyaml", - ) + yaml_path = get_fixture_path("configuration_invalid.notyaml", "rest") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( "rest", @@ -337,10 +326,6 @@ async def test_reload_fails_to_read_configuration(hass): assert len(hass.states.async_all()) == 1 -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) - - @respx.mock async def test_multiple_rest_endpoints(hass): """Test multiple rest endpoints.""" diff --git a/tests/components/rest/test_notify.py b/tests/components/rest/test_notify.py index fb7b8a31238..31567ae63f0 100644 --- a/tests/components/rest/test_notify.py +++ b/tests/components/rest/test_notify.py @@ -1,5 +1,4 @@ """The tests for the rest.notify platform.""" -from os import path from unittest.mock import patch import respx @@ -10,6 +9,8 @@ from homeassistant.components.rest import DOMAIN from homeassistant.const import SERVICE_RELOAD from homeassistant.setup import async_setup_component +from tests.common import get_fixture_path + @respx.mock async def test_reload_notify(hass): @@ -33,11 +34,8 @@ async def test_reload_notify(hass): assert hass.services.has_service(notify.DOMAIN, DOMAIN) - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "rest/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "rest") + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( DOMAIN, @@ -49,7 +47,3 @@ async def test_reload_notify(hass): assert not hass.services.has_service(notify.DOMAIN, DOMAIN) assert hass.services.has_service(notify.DOMAIN, "rest_reloaded") - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 4ff8ca12dad..fb826eefd78 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -1,7 +1,6 @@ """The tests for the REST sensor platform.""" import asyncio from http import HTTPStatus -from os import path from unittest.mock import MagicMock, patch import httpx @@ -17,12 +16,15 @@ from homeassistant.const import ( CONTENT_TYPE_JSON, DATA_MEGABYTES, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, SERVICE_RELOAD, STATE_UNKNOWN, TEMP_CELSIUS, ) from homeassistant.setup import async_setup_component +from tests.common import get_fixture_path + async def test_setup_missing_config(hass): """Test setup with configuration missing required entries.""" @@ -217,6 +219,102 @@ async def test_setup_get(hass): assert state.attributes[sensor.ATTR_STATE_CLASS] == sensor.STATE_CLASS_MEASUREMENT +@respx.mock +async def test_setup_timestamp(hass, caplog): + """Test setup with valid configuration.""" + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, json={"key": "2021-11-11 11:39Z"} + ) + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.key }}", + "device_class": DEVICE_CLASS_TIMESTAMP, + "state_class": sensor.STATE_CLASS_MEASUREMENT, + } + }, + ) + await async_setup_component(hass, "homeassistant", {}) + + await hass.async_block_till_done() + assert len(hass.states.async_all("sensor")) == 1 + + state = hass.states.get("sensor.rest_sensor") + assert state.state == "2021-11-11T11:39:00+00:00" + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TIMESTAMP + assert "sensor.rest_sensor rendered invalid timestamp" not in caplog.text + assert "sensor.rest_sensor rendered timestamp without timezone" not in caplog.text + + # Bad response: Not a timestamp + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, json={"key": "invalid time stamp"} + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.rest_sensor"]}, + blocking=True, + ) + state = hass.states.get("sensor.rest_sensor") + assert state.state == "unknown" + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TIMESTAMP + assert "sensor.rest_sensor rendered invalid timestamp" in caplog.text + + # Bad response: No timezone + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, json={"key": "2021-10-11 11:39"} + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.rest_sensor"]}, + blocking=True, + ) + state = hass.states.get("sensor.rest_sensor") + assert state.state == "unknown" + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TIMESTAMP + assert "sensor.rest_sensor rendered timestamp without timezone" in caplog.text + + +@respx.mock +async def test_setup_get_templated_headers_params(hass): + """Test setup with valid configuration.""" + respx.get("http://localhost").respond(status_code=200, json={}) + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.key }}", + "name": "foo", + "verify_ssl": "true", + "timeout": 30, + "headers": { + "Accept": CONTENT_TYPE_JSON, + "User-Agent": "Mozilla/{{ 3 + 2 }}.0", + }, + "params": { + "start": 0, + "end": "{{ 3 + 2 }}", + }, + } + }, + ) + await async_setup_component(hass, "homeassistant", {}) + + assert respx.calls.last.request.headers["Accept"] == CONTENT_TYPE_JSON + assert respx.calls.last.request.headers["User-Agent"] == "Mozilla/5.0" + assert respx.calls.last.request.url.query == b"start=0&end=5" + + @respx.mock async def test_setup_get_digest_auth(hass): """Test setup with valid configuration.""" @@ -752,11 +850,7 @@ async def test_reload(hass): assert hass.states.get("sensor.mockrest") - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "rest/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "rest") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( "rest", @@ -768,7 +862,3 @@ async def test_reload(hass): assert hass.states.get("sensor.mockreset") is None assert hass.states.get("sensor.rollout") - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index 4370386dcff..1b724052b1e 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -27,7 +27,6 @@ DEVICE_CLASS = DEVICE_CLASS_SWITCH METHOD = "post" RESOURCE = "http://localhost/" STATE_RESOURCE = RESOURCE -HEADERS = {"Content-type": CONTENT_TYPE_JSON} AUTH = None PARAMS = None @@ -151,19 +150,51 @@ async def test_setup_with_state_resource(hass, aioclient_mock): assert_setup_component(1, SWITCH_DOMAIN) +async def test_setup_with_templated_headers_params(hass, aioclient_mock): + """Test setup with valid configuration.""" + aioclient_mock.get("http://localhost", status=HTTPStatus.OK) + assert await async_setup_component( + hass, + SWITCH_DOMAIN, + { + SWITCH_DOMAIN: { + CONF_PLATFORM: DOMAIN, + CONF_NAME: "foo", + CONF_RESOURCE: "http://localhost", + CONF_HEADERS: { + "Accept": CONTENT_TYPE_JSON, + "User-Agent": "Mozilla/{{ 3 + 2 }}.0", + }, + CONF_PARAMS: { + "start": 0, + "end": "{{ 3 + 2 }}", + }, + } + }, + ) + await hass.async_block_till_done() + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[-1][3].get("Accept") == CONTENT_TYPE_JSON + assert aioclient_mock.mock_calls[-1][3].get("User-Agent") == "Mozilla/5.0" + assert aioclient_mock.mock_calls[-1][1].query["start"] == "0" + assert aioclient_mock.mock_calls[-1][1].query["end"] == "5" + assert_setup_component(1, SWITCH_DOMAIN) + + """Tests for REST switch platform.""" def _setup_test_switch(hass): body_on = Template("on", hass) body_off = Template("off", hass) + headers = {"Content-type": Template(CONTENT_TYPE_JSON, hass)} switch = rest.RestSwitch( NAME, DEVICE_CLASS, RESOURCE, STATE_RESOURCE, METHOD, - HEADERS, + headers, PARAMS, AUTH, body_on, diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 4aa275fa3b4..10c9ca12022 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -352,7 +352,7 @@ async def test_options_add_device(hass): assert result["step_id"] == "set_device_options" result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={"fire_event": True, "signal_repetitions": 5} + result["flow_id"], user_input={"signal_repetitions": 5} ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -362,7 +362,6 @@ async def test_options_add_device(hass): assert entry.data["automatic_add"] assert entry.data["devices"]["0b1100cd0213c7f230010f71"] - assert entry.data["devices"]["0b1100cd0213c7f230010f71"]["fire_event"] assert entry.data["devices"]["0b1100cd0213c7f230010f71"]["signal_repetitions"] == 5 assert "delay_off" not in entry.data["devices"]["0b1100cd0213c7f230010f71"] @@ -442,7 +441,7 @@ async def test_options_add_remove_device(hass): result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"fire_event": True, "signal_repetitions": 5, "off_delay": "4"}, + user_input={"signal_repetitions": 5, "off_delay": "4"}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -452,7 +451,6 @@ async def test_options_add_remove_device(hass): assert entry.data["automatic_add"] assert entry.data["devices"]["0b1100cd0213c7f230010f71"] - assert entry.data["devices"]["0b1100cd0213c7f230010f71"]["fire_event"] assert entry.data["devices"]["0b1100cd0213c7f230010f71"]["signal_repetitions"] == 5 assert entry.data["devices"]["0b1100cd0213c7f230010f71"]["off_delay"] == 4 @@ -864,7 +862,6 @@ async def test_options_add_and_configure_device(hass): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "fire_event": False, "signal_repetitions": 5, "data_bits": 4, "off_delay": "abcdef", @@ -883,7 +880,6 @@ async def test_options_add_and_configure_device(hass): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "fire_event": False, "signal_repetitions": 5, "data_bits": 4, "command_on": "0xE", @@ -899,7 +895,6 @@ async def test_options_add_and_configure_device(hass): assert entry.data["automatic_add"] assert entry.data["devices"]["0913000022670e013970"] - assert not entry.data["devices"]["0913000022670e013970"]["fire_event"] assert entry.data["devices"]["0913000022670e013970"]["signal_repetitions"] == 5 assert entry.data["devices"]["0913000022670e013970"]["off_delay"] == 9 @@ -932,7 +927,6 @@ async def test_options_add_and_configure_device(hass): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "fire_event": True, "signal_repetitions": 5, "data_bits": 4, "command_on": "0xE", @@ -945,7 +939,6 @@ async def test_options_add_and_configure_device(hass): await hass.async_block_till_done() assert entry.data["devices"]["0913000022670e013970"] - assert entry.data["devices"]["0913000022670e013970"]["fire_event"] assert entry.data["devices"]["0913000022670e013970"]["signal_repetitions"] == 5 assert "delay_off" not in entry.data["devices"]["0913000022670e013970"] @@ -988,7 +981,6 @@ async def test_options_configure_rfy_cover_device(hass): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "fire_event": False, "venetian_blind_mode": "EU", }, ) @@ -1021,7 +1013,6 @@ async def test_options_configure_rfy_cover_device(hass): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "fire_event": False, "venetian_blind_mode": "EU", }, ) diff --git a/tests/components/rfxtrx/test_init.py b/tests/components/rfxtrx/test_init.py index 0c904896090..4ad5f9a342a 100644 --- a/tests/components/rfxtrx/test_init.py +++ b/tests/components/rfxtrx/test_init.py @@ -17,8 +17,8 @@ async def test_fire_event(hass, rfxtrx): device="/dev/serial/by-id/usb-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0", automatic_add=True, devices={ - "0b1100cd0213c7f210010f51": {"fire_event": True}, - "0716000100900970": {"fire_event": True}, + "0b1100cd0213c7f210010f51": {}, + "0716000100900970": {}, }, ) mock_entry = MockConfigEntry(domain="rfxtrx", unique_id=DOMAIN, data=entry_data) diff --git a/tests/components/ridwell/__init__.py b/tests/components/ridwell/__init__.py new file mode 100644 index 00000000000..7393c0b0364 --- /dev/null +++ b/tests/components/ridwell/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ridwell integration.""" diff --git a/tests/components/ridwell/test_config_flow.py b/tests/components/ridwell/test_config_flow.py new file mode 100644 index 00000000000..957ad31affb --- /dev/null +++ b/tests/components/ridwell/test_config_flow.py @@ -0,0 +1,127 @@ +"""Test the Ridwell config flow.""" +from unittest.mock import AsyncMock, patch + +from aioridwell.errors import InvalidCredentialsError, RidwellError +import pytest + +from homeassistant import config_entries +from homeassistant.components.ridwell.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="client") +def client_fixture(): + """Define a fixture for an aioridwell client.""" + return AsyncMock(return_value=None) + + +@pytest.fixture(name="client_login") +def client_login_fixture(client): + """Define a fixture for patching the aioridwell coroutine to get a client.""" + with patch( + "homeassistant.components.ridwell.config_flow.async_get_client" + ) as mock_client: + mock_client.side_effect = client + yield mock_client + + +async def test_duplicate_error(hass: HomeAssistant): + """Test that errors are shown when duplicate entries are added.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="user@email.com", + data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_show_form_user(hass: HomeAssistant) -> None: + """Test showing the form to input credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] is None + + +async def test_step_reauth(hass: HomeAssistant, client_login) -> None: + """Test a full reauth flow.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="user@email.com", + data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, + ).add_to_hass(hass) + + with patch( + "homeassistant.components.ridwell.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_REAUTH}, + data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert len(hass.config_entries.async_entries()) == 1 + + +async def test_step_user(hass: HomeAssistant, client_login) -> None: + """Test that the full user step succeeds.""" + with patch( + "homeassistant.components.ridwell.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + + +@pytest.mark.parametrize( + "client,error", + [ + (AsyncMock(side_effect=InvalidCredentialsError), "invalid_auth"), + (AsyncMock(side_effect=RidwellError), "unknown"), + ], +) +async def test_step_user_invalid_credentials( + hass: HomeAssistant, client_login, error +) -> None: + """Test that invalid credentials are handled correctly.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"]["base"] == error diff --git a/tests/components/ring/conftest.py b/tests/components/ring/conftest.py index cda662aab64..2b6edf86132 100644 --- a/tests/components/ring/conftest.py +++ b/tests/components/ring/conftest.py @@ -18,38 +18,39 @@ def requests_mock_fixture(): # Mocks the response for authenticating mock.post( - "https://oauth.ring.com/oauth/token", text=load_fixture("ring_oauth.json") + "https://oauth.ring.com/oauth/token", + text=load_fixture("oauth.json", "ring"), ) # Mocks the response for getting the login session mock.post( "https://api.ring.com/clients_api/session", - text=load_fixture("ring_session.json"), + text=load_fixture("session.json", "ring"), ) # Mocks the response for getting all the devices mock.get( "https://api.ring.com/clients_api/ring_devices", - text=load_fixture("ring_devices.json"), + text=load_fixture("devices.json", "ring"), ) mock.get( "https://api.ring.com/clients_api/dings/active", - text=load_fixture("ring_ding_active.json"), + text=load_fixture("ding_active.json", "ring"), ) # Mocks the response for getting the history of a device mock.get( re.compile( r"https:\/\/api\.ring\.com\/clients_api\/doorbots\/\d+\/history" ), - text=load_fixture("ring_doorbots.json"), + text=load_fixture("doorbots.json", "ring"), ) # Mocks the response for getting the health of a device mock.get( re.compile(r"https:\/\/api\.ring\.com\/clients_api\/doorbots\/\d+\/health"), - text=load_fixture("ring_doorboot_health_attrs.json"), + text=load_fixture("doorboot_health_attrs.json", "ring"), ) # Mocks the response for getting a chimes health mock.get( re.compile(r"https:\/\/api\.ring\.com\/clients_api\/chimes\/\d+\/health"), - text=load_fixture("ring_chime_health_attrs.json"), + text=load_fixture("chime_health_attrs.json", "ring"), ) yield mock diff --git a/tests/fixtures/ring_chime_health_attrs.json b/tests/components/ring/fixtures/chime_health_attrs.json similarity index 100% rename from tests/fixtures/ring_chime_health_attrs.json rename to tests/components/ring/fixtures/chime_health_attrs.json diff --git a/tests/fixtures/ring_devices.json b/tests/components/ring/fixtures/devices.json similarity index 100% rename from tests/fixtures/ring_devices.json rename to tests/components/ring/fixtures/devices.json diff --git a/tests/fixtures/ring_devices_updated.json b/tests/components/ring/fixtures/devices_updated.json similarity index 100% rename from tests/fixtures/ring_devices_updated.json rename to tests/components/ring/fixtures/devices_updated.json diff --git a/tests/fixtures/ring_ding_active.json b/tests/components/ring/fixtures/ding_active.json similarity index 100% rename from tests/fixtures/ring_ding_active.json rename to tests/components/ring/fixtures/ding_active.json diff --git a/tests/fixtures/ring_doorboot_health_attrs.json b/tests/components/ring/fixtures/doorboot_health_attrs.json similarity index 100% rename from tests/fixtures/ring_doorboot_health_attrs.json rename to tests/components/ring/fixtures/doorboot_health_attrs.json diff --git a/tests/fixtures/ring_doorbot_siren_on_response.json b/tests/components/ring/fixtures/doorbot_siren_on_response.json similarity index 100% rename from tests/fixtures/ring_doorbot_siren_on_response.json rename to tests/components/ring/fixtures/doorbot_siren_on_response.json diff --git a/tests/fixtures/ring_doorbots.json b/tests/components/ring/fixtures/doorbots.json similarity index 100% rename from tests/fixtures/ring_doorbots.json rename to tests/components/ring/fixtures/doorbots.json diff --git a/tests/fixtures/ring_oauth.json b/tests/components/ring/fixtures/oauth.json similarity index 100% rename from tests/fixtures/ring_oauth.json rename to tests/components/ring/fixtures/oauth.json diff --git a/tests/fixtures/ring_session.json b/tests/components/ring/fixtures/session.json similarity index 100% rename from tests/fixtures/ring_session.json rename to tests/components/ring/fixtures/session.json diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index c7a87ae880a..860102f9fc8 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -18,23 +18,23 @@ async def test_setup(hass, requests_mock): await async_setup_component(hass, ring.DOMAIN, {}) requests_mock.post( - "https://oauth.ring.com/oauth/token", text=load_fixture("ring_oauth.json") + "https://oauth.ring.com/oauth/token", text=load_fixture("oauth.json", "ring") ) requests_mock.post( "https://api.ring.com/clients_api/session", - text=load_fixture("ring_session.json"), + text=load_fixture("session.json", "ring"), ) requests_mock.get( "https://api.ring.com/clients_api/ring_devices", - text=load_fixture("ring_devices.json"), + text=load_fixture("devices.json", "ring"), ) requests_mock.get( "https://api.ring.com/clients_api/chimes/999999/health", - text=load_fixture("ring_chime_health_attrs.json"), + text=load_fixture("chime_health_attrs.json", "ring"), ) requests_mock.get( "https://api.ring.com/clients_api/doorbots/987652/health", - text=load_fixture("ring_doorboot_health_attrs.json"), + text=load_fixture("doorboot_health_attrs.json", "ring"), ) assert await ring.async_setup(hass, VALID_CONFIG) diff --git a/tests/components/ring/test_light.py b/tests/components/ring/test_light.py index 603c0bf3e84..1b8150364bc 100644 --- a/tests/components/ring/test_light.py +++ b/tests/components/ring/test_light.py @@ -44,7 +44,7 @@ async def test_light_can_be_turned_on(hass, requests_mock): # Mocks the response for turning a light on requests_mock.put( "https://api.ring.com/clients_api/doorbots/765432/floodlight_light_on", - text=load_fixture("ring_doorbot_siren_on_response.json"), + text=load_fixture("doorbot_siren_on_response.json", "ring"), ) state = hass.states.get("light.front_light") @@ -67,7 +67,7 @@ async def test_updates_work(hass, requests_mock): # Changes the return to indicate that the light is now on. requests_mock.get( "https://api.ring.com/clients_api/ring_devices", - text=load_fixture("ring_devices_updated.json"), + text=load_fixture("devices_updated.json", "ring"), ) await hass.services.async_call("ring", "update", {}, blocking=True) diff --git a/tests/components/ring/test_switch.py b/tests/components/ring/test_switch.py index ab81ed5c69a..ed4e9024292 100644 --- a/tests/components/ring/test_switch.py +++ b/tests/components/ring/test_switch.py @@ -45,7 +45,7 @@ async def test_siren_can_be_turned_on(hass, requests_mock): # Mocks the response for turning a siren on requests_mock.put( "https://api.ring.com/clients_api/doorbots/765432/siren_on", - text=load_fixture("ring_doorbot_siren_on_response.json"), + text=load_fixture("doorbot_siren_on_response.json", "ring"), ) state = hass.states.get("switch.front_siren") @@ -68,7 +68,7 @@ async def test_updates_work(hass, requests_mock): # Changes the return to indicate that the siren is now on. requests_mock.get( "https://api.ring.com/clients_api/ring_devices", - text=load_fixture("ring_devices_updated.json"), + text=load_fixture("devices_updated.json", "ring"), ) await hass.services.async_call("ring", "update", {}, blocking=True) diff --git a/tests/components/risco/test_sensor.py b/tests/components/risco/test_sensor.py index eb7ed990bd9..4286a7d09c9 100644 --- a/tests/components/risco/test_sensor.py +++ b/tests/components/risco/test_sensor.py @@ -147,7 +147,7 @@ def _check_state(hass, category, entity_id): event_index = CATEGORIES_TO_EVENTS[category] event = TEST_EVENTS[event_index] state = hass.states.get(entity_id) - assert state.state == event.time + assert state.state == dt.parse_datetime(event.time).isoformat() assert state.attributes["category_id"] == event.category_id assert state.attributes["category_name"] == event.category_name assert state.attributes["type_id"] == event.type_id diff --git a/tests/components/rituals_perfume_genie/test_binary_sensor.py b/tests/components/rituals_perfume_genie/test_binary_sensor.py index f2e499655ca..c0ba64f281c 100644 --- a/tests/components/rituals_perfume_genie/test_binary_sensor.py +++ b/tests/components/rituals_perfume_genie/test_binary_sensor.py @@ -1,9 +1,10 @@ """Tests for the Rituals Perfume Genie binary sensor platform.""" -from homeassistant.components.binary_sensor import DEVICE_CLASS_BATTERY_CHARGING +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.rituals_perfume_genie.binary_sensor import CHARGING_SUFFIX from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry +from homeassistant.helpers.entity import EntityCategory from .common import ( init_integration, @@ -23,8 +24,11 @@ async def test_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.genie_battery_charging") assert state assert state.state == STATE_ON - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_BATTERY_CHARGING + assert ( + state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.BATTERY_CHARGING + ) entry = registry.async_get("binary_sensor.genie_battery_charging") assert entry assert entry.unique_id == f"{hublot}{CHARGING_SUFFIX}" + assert entry.entity_category == EntityCategory.DIAGNOSTIC diff --git a/tests/components/rituals_perfume_genie/test_select.py b/tests/components/rituals_perfume_genie/test_select.py index fb159166fb7..883e00b8a59 100644 --- a/tests/components/rituals_perfume_genie/test_select.py +++ b/tests/components/rituals_perfume_genie/test_select.py @@ -13,6 +13,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry +from homeassistant.helpers.entity import EntityCategory from homeassistant.setup import async_setup_component from .common import init_integration, mock_config_entry, mock_diffuser @@ -36,6 +37,7 @@ async def test_select_entity(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == f"{diffuser.hublot}{ROOM_SIZE_SUFFIX}" assert entry.unit_of_measurement == AREA_SQUARE_METERS + assert entry.entity_category == EntityCategory.CONFIG async def test_select_option(hass: HomeAssistant) -> None: diff --git a/tests/components/rituals_perfume_genie/test_sensor.py b/tests/components/rituals_perfume_genie/test_sensor.py index 2c72d429a99..e7b8daec27f 100644 --- a/tests/components/rituals_perfume_genie/test_sensor.py +++ b/tests/components/rituals_perfume_genie/test_sensor.py @@ -4,17 +4,17 @@ from homeassistant.components.rituals_perfume_genie.sensor import ( FILL_SUFFIX, PERFUME_SUFFIX, WIFI_SUFFIX, + SensorDeviceClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_SIGNAL_STRENGTH, PERCENTAGE, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry +from homeassistant.helpers.entity import EntityCategory from .common import ( init_integration, @@ -53,22 +53,24 @@ async def test_sensors_diffuser_v1_battery_cartridge(hass: HomeAssistant) -> Non state = hass.states.get("sensor.genie_battery") assert state assert state.state == str(diffuser.battery_percentage) - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_BATTERY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.BATTERY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE entry = registry.async_get("sensor.genie_battery") assert entry assert entry.unique_id == f"{hublot}{BATTERY_SUFFIX}" + assert entry.entity_category == EntityCategory.DIAGNOSTIC state = hass.states.get("sensor.genie_wifi") assert state assert state.state == str(diffuser.wifi_percentage) - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_SIGNAL_STRENGTH + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.SIGNAL_STRENGTH assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE entry = registry.async_get("sensor.genie_wifi") assert entry assert entry.unique_id == f"{hublot}{WIFI_SUFFIX}" + assert entry.entity_category == EntityCategory.DIAGNOSTIC async def test_sensors_diffuser_v2_no_battery_no_cartridge(hass: HomeAssistant) -> None: diff --git a/tests/components/roku/__init__.py b/tests/components/roku/__init__.py index 0f508f3efc9..5ae81eb7b72 100644 --- a/tests/components/roku/__init__.py +++ b/tests/components/roku/__init__.py @@ -3,13 +3,10 @@ from http import HTTPStatus import re from socket import gaierror as SocketGIAError +from homeassistant.components import ssdp, zeroconf from homeassistant.components.roku.const import DOMAIN -from homeassistant.components.ssdp import ( - ATTR_SSDP_LOCATION, - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_SERIAL, -) -from homeassistant.const import CONF_HOST, CONF_ID, CONF_NAME +from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -23,21 +20,28 @@ SSDP_LOCATION = "http://192.168.1.160/" UPNP_FRIENDLY_NAME = "My Roku 3" UPNP_SERIAL = "1GU48T017973" -MOCK_SSDP_DISCOVERY_INFO = { - ATTR_SSDP_LOCATION: SSDP_LOCATION, - ATTR_UPNP_FRIENDLY_NAME: UPNP_FRIENDLY_NAME, - ATTR_UPNP_SERIAL: UPNP_SERIAL, -} +MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=SSDP_LOCATION, + upnp={ + ATTR_UPNP_FRIENDLY_NAME: UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL: UPNP_SERIAL, + }, +) HOMEKIT_HOST = "192.168.1.161" -MOCK_HOMEKIT_DISCOVERY_INFO = { - CONF_NAME: "onn._hap._tcp.local.", - CONF_HOST: HOMEKIT_HOST, - "properties": { - CONF_ID: "2d:97:da:ee:dc:99", +MOCK_HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( + host=HOMEKIT_HOST, + hostname="mock_hostname", + name="onn._hap._tcp.local.", + port=None, + properties={ + zeroconf.ATTR_PROPERTIES_ID: "2d:97:da:ee:dc:99", }, -} + type="mock_type", +) def mock_connection( diff --git a/tests/fixtures/roku/active-app-netflix.xml b/tests/components/roku/fixtures/active-app-netflix.xml similarity index 100% rename from tests/fixtures/roku/active-app-netflix.xml rename to tests/components/roku/fixtures/active-app-netflix.xml diff --git a/tests/fixtures/roku/active-app-pluto.xml b/tests/components/roku/fixtures/active-app-pluto.xml similarity index 100% rename from tests/fixtures/roku/active-app-pluto.xml rename to tests/components/roku/fixtures/active-app-pluto.xml diff --git a/tests/fixtures/roku/active-app-roku.xml b/tests/components/roku/fixtures/active-app-roku.xml similarity index 100% rename from tests/fixtures/roku/active-app-roku.xml rename to tests/components/roku/fixtures/active-app-roku.xml diff --git a/tests/fixtures/roku/active-app-screensaver.xml b/tests/components/roku/fixtures/active-app-screensaver.xml similarity index 100% rename from tests/fixtures/roku/active-app-screensaver.xml rename to tests/components/roku/fixtures/active-app-screensaver.xml diff --git a/tests/fixtures/roku/active-app-tvinput-dtv.xml b/tests/components/roku/fixtures/active-app-tvinput-dtv.xml similarity index 100% rename from tests/fixtures/roku/active-app-tvinput-dtv.xml rename to tests/components/roku/fixtures/active-app-tvinput-dtv.xml diff --git a/tests/fixtures/roku/apps-tv.xml b/tests/components/roku/fixtures/apps-tv.xml similarity index 100% rename from tests/fixtures/roku/apps-tv.xml rename to tests/components/roku/fixtures/apps-tv.xml diff --git a/tests/fixtures/roku/apps.xml b/tests/components/roku/fixtures/apps.xml similarity index 100% rename from tests/fixtures/roku/apps.xml rename to tests/components/roku/fixtures/apps.xml diff --git a/tests/fixtures/roku/media-player-close.xml b/tests/components/roku/fixtures/media-player-close.xml similarity index 100% rename from tests/fixtures/roku/media-player-close.xml rename to tests/components/roku/fixtures/media-player-close.xml diff --git a/tests/fixtures/roku/media-player-live.xml b/tests/components/roku/fixtures/media-player-live.xml similarity index 100% rename from tests/fixtures/roku/media-player-live.xml rename to tests/components/roku/fixtures/media-player-live.xml diff --git a/tests/fixtures/roku/media-player-pause.xml b/tests/components/roku/fixtures/media-player-pause.xml similarity index 100% rename from tests/fixtures/roku/media-player-pause.xml rename to tests/components/roku/fixtures/media-player-pause.xml diff --git a/tests/fixtures/roku/media-player-play.xml b/tests/components/roku/fixtures/media-player-play.xml similarity index 100% rename from tests/fixtures/roku/media-player-play.xml rename to tests/components/roku/fixtures/media-player-play.xml diff --git a/tests/fixtures/roku/roku3-device-info-power-off.xml b/tests/components/roku/fixtures/roku3-device-info-power-off.xml similarity index 100% rename from tests/fixtures/roku/roku3-device-info-power-off.xml rename to tests/components/roku/fixtures/roku3-device-info-power-off.xml diff --git a/tests/fixtures/roku/roku3-device-info.xml b/tests/components/roku/fixtures/roku3-device-info.xml similarity index 100% rename from tests/fixtures/roku/roku3-device-info.xml rename to tests/components/roku/fixtures/roku3-device-info.xml diff --git a/tests/fixtures/roku/rokutv-device-info-power-off.xml b/tests/components/roku/fixtures/rokutv-device-info-power-off.xml similarity index 100% rename from tests/fixtures/roku/rokutv-device-info-power-off.xml rename to tests/components/roku/fixtures/rokutv-device-info-power-off.xml diff --git a/tests/fixtures/roku/rokutv-device-info.xml b/tests/components/roku/fixtures/rokutv-device-info.xml similarity index 100% rename from tests/fixtures/roku/rokutv-device-info.xml rename to tests/components/roku/fixtures/rokutv-device-info.xml diff --git a/tests/fixtures/roku/rokutv-tv-active-channel.xml b/tests/components/roku/fixtures/rokutv-tv-active-channel.xml similarity index 100% rename from tests/fixtures/roku/rokutv-tv-active-channel.xml rename to tests/components/roku/fixtures/rokutv-tv-active-channel.xml diff --git a/tests/fixtures/roku/rokutv-tv-channels.xml b/tests/components/roku/fixtures/rokutv-tv-channels.xml similarity index 100% rename from tests/fixtures/roku/rokutv-tv-channels.xml rename to tests/components/roku/fixtures/rokutv-tv-channels.xml diff --git a/tests/components/roku/test_config_flow.py b/tests/components/roku/test_config_flow.py index 743d69167fe..8aa015d3e01 100644 --- a/tests/components/roku/test_config_flow.py +++ b/tests/components/roku/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Roku config flow.""" +import dataclasses from unittest.mock import patch from homeassistant.components.roku.const import DOMAIN @@ -47,7 +48,7 @@ async def test_duplicate_error( assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" - discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info ) @@ -104,15 +105,19 @@ async def test_form_cannot_connect( assert result["errors"] == {"base": "cannot_connect"} -async def test_form_unknown_error(hass: HomeAssistant) -> None: +async def test_form_unknown_error( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Test we handle unknown error.""" + mock_connection(aioclient_mock) + result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) user_input = {CONF_HOST: HOST} with patch( - "homeassistant.components.roku.config_flow.Roku.update", + "homeassistant.components.roku.config_flow.Roku._request", side_effect=Exception, ) as mock_validate_input: result = await hass.config_entries.flow.async_configure( @@ -136,7 +141,7 @@ async def test_homekit_cannot_connect( error=True, ) - discovery_info = MOCK_HOMEKIT_DISCOVERY_INFO.copy() + discovery_info = dataclasses.replace(MOCK_HOMEKIT_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_HOMEKIT}, @@ -151,9 +156,11 @@ async def test_homekit_unknown_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort homekit flow on unknown error.""" - discovery_info = MOCK_HOMEKIT_DISCOVERY_INFO.copy() + mock_connection(aioclient_mock) + + discovery_info = dataclasses.replace(MOCK_HOMEKIT_DISCOVERY_INFO) with patch( - "homeassistant.components.roku.config_flow.Roku.update", + "homeassistant.components.roku.config_flow.Roku._request", side_effect=Exception, ): result = await hass.config_entries.flow.async_init( @@ -172,7 +179,7 @@ async def test_homekit_discovery( """Test the homekit discovery flow.""" mock_connection(aioclient_mock, device="rokutv", host=HOMEKIT_HOST) - discovery_info = MOCK_HOMEKIT_DISCOVERY_INFO.copy() + discovery_info = dataclasses.replace(MOCK_HOMEKIT_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_HOMEKIT}, data=discovery_info ) @@ -200,7 +207,7 @@ async def test_homekit_discovery( assert len(mock_setup_entry.mock_calls) == 1 # test abort on existing host - discovery_info = MOCK_HOMEKIT_DISCOVERY_INFO.copy() + discovery_info = dataclasses.replace(MOCK_HOMEKIT_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_HOMEKIT}, data=discovery_info ) @@ -215,7 +222,7 @@ async def test_ssdp_cannot_connect( """Test we abort SSDP flow on connection error.""" mock_connection(aioclient_mock, error=True) - discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, @@ -230,9 +237,11 @@ async def test_ssdp_unknown_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort SSDP flow on unknown error.""" - discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() + mock_connection(aioclient_mock) + + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) with patch( - "homeassistant.components.roku.config_flow.Roku.update", + "homeassistant.components.roku.config_flow.Roku._request", side_effect=Exception, ): result = await hass.config_entries.flow.async_init( @@ -251,7 +260,7 @@ async def test_ssdp_discovery( """Test the SSDP discovery flow.""" mock_connection(aioclient_mock) - discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info ) diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index eb0d0028417..9d97e467c68 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -89,7 +89,7 @@ async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - assert hass.states.get(MAIN_ENTITY_ID) assert main - assert main.device_class == DEVICE_CLASS_RECEIVER + assert main.original_device_class == DEVICE_CLASS_RECEIVER assert main.unique_id == UPNP_SERIAL @@ -121,7 +121,7 @@ async def test_tv_setup( assert hass.states.get(TV_ENTITY_ID) assert tv - assert tv.device_class == DEVICE_CLASS_TV + assert tv.original_device_class == DEVICE_CLASS_TV assert tv.unique_id == TV_SERIAL diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index 54554af6ecb..de66a1ae3b7 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -5,7 +5,7 @@ import pytest from roombapy import RoombaConnectionError, RoombaInfo from homeassistant import config_entries, data_entry_flow -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp from homeassistant.components.roomba import config_flow from homeassistant.components.roomba.const import CONF_BLID, CONF_CONTINUOUS, DOMAIN from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_PASSWORD @@ -16,30 +16,30 @@ MOCK_IP = "1.2.3.4" VALID_CONFIG = {CONF_HOST: MOCK_IP, CONF_BLID: "BLID", CONF_PASSWORD: "password"} DHCP_DISCOVERY_DEVICES = [ - { - IP_ADDRESS: MOCK_IP, - MAC_ADDRESS: "50:14:79:DD:EE:FF", - HOSTNAME: "irobot-blid", - }, - { - IP_ADDRESS: MOCK_IP, - MAC_ADDRESS: "80:A5:89:DD:EE:FF", - HOSTNAME: "roomba-blid", - }, + dhcp.DhcpServiceInfo( + ip=MOCK_IP, + macaddress="50:14:79:DD:EE:FF", + hostname="irobot-blid", + ), + dhcp.DhcpServiceInfo( + ip=MOCK_IP, + macaddress="80:A5:89:DD:EE:FF", + hostname="roomba-blid", + ), ] DHCP_DISCOVERY_DEVICES_WITHOUT_MATCHING_IP = [ - { - IP_ADDRESS: "4.4.4.4", - MAC_ADDRESS: "50:14:79:DD:EE:FF", - HOSTNAME: "irobot-blid", - }, - { - IP_ADDRESS: "5.5.5.5", - MAC_ADDRESS: "80:A5:89:DD:EE:FF", - HOSTNAME: "roomba-blid", - }, + dhcp.DhcpServiceInfo( + ip="4.4.4.4", + macaddress="50:14:79:DD:EE:FF", + hostname="irobot-blid", + ), + dhcp.DhcpServiceInfo( + ip="5.5.5.5", + macaddress="80:A5:89:DD:EE:FF", + hostname="roomba-blid", + ), ] @@ -815,11 +815,11 @@ async def test_dhcp_discovery_with_ignored(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - IP_ADDRESS: MOCK_IP, - MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", - HOSTNAME: "irobot-blid", - }, + data=dhcp.DhcpServiceInfo( + ip=MOCK_IP, + macaddress="AA:BB:CC:DD:EE:FF", + hostname="irobot-blid", + ), ) await hass.async_block_till_done() @@ -838,11 +838,11 @@ async def test_dhcp_discovery_already_configured_host(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - IP_ADDRESS: MOCK_IP, - MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", - HOSTNAME: "irobot-blid", - }, + data=dhcp.DhcpServiceInfo( + ip=MOCK_IP, + macaddress="AA:BB:CC:DD:EE:FF", + hostname="irobot-blid", + ), ) await hass.async_block_till_done() @@ -864,11 +864,11 @@ async def test_dhcp_discovery_already_configured_blid(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - IP_ADDRESS: MOCK_IP, - MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", - HOSTNAME: "irobot-blid", - }, + data=dhcp.DhcpServiceInfo( + ip=MOCK_IP, + macaddress="AA:BB:CC:DD:EE:FF", + hostname="irobot-blid", + ), ) await hass.async_block_till_done() @@ -890,11 +890,11 @@ async def test_dhcp_discovery_not_irobot(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - IP_ADDRESS: MOCK_IP, - MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", - HOSTNAME: "Notirobot-blid", - }, + data=dhcp.DhcpServiceInfo( + ip=MOCK_IP, + macaddress="AA:BB:CC:DD:EE:FF", + hostname="Notirobot-blid", + ), ) await hass.async_block_till_done() @@ -911,11 +911,11 @@ async def test_dhcp_discovery_partial_hostname(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - IP_ADDRESS: MOCK_IP, - MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", - HOSTNAME: "irobot-blid", - }, + data=dhcp.DhcpServiceInfo( + ip=MOCK_IP, + macaddress="AA:BB:CC:DD:EE:FF", + hostname="irobot-blid", + ), ) await hass.async_block_till_done() @@ -928,11 +928,11 @@ async def test_dhcp_discovery_partial_hostname(hass): result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - IP_ADDRESS: MOCK_IP, - MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", - HOSTNAME: "irobot-blidthatislonger", - }, + data=dhcp.DhcpServiceInfo( + ip=MOCK_IP, + macaddress="AA:BB:CC:DD:EE:FF", + hostname="irobot-blidthatislonger", + ), ) await hass.async_block_till_done() @@ -949,11 +949,11 @@ async def test_dhcp_discovery_partial_hostname(hass): result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - IP_ADDRESS: MOCK_IP, - MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", - HOSTNAME: "irobot-bl", - }, + data=dhcp.DhcpServiceInfo( + ip=MOCK_IP, + macaddress="AA:BB:CC:DD:EE:FF", + hostname="irobot-bl", + ), ) await hass.async_block_till_done() diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index d9c96982aa1..c2a258b2afa 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -7,9 +7,8 @@ from samsungtvws.exceptions import ConnectionFailure, HttpApiError from websocket import WebSocketException, WebSocketProtocolException from homeassistant import config_entries -from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.components.samsungtv.const import ( - ATTR_PROPERTIES, CONF_MANUFACTURER, CONF_MODEL, DEFAULT_MANUFACTURER, @@ -25,7 +24,6 @@ from homeassistant.components.samsungtv.const import ( TIMEOUT_WEBSOCKET, ) from homeassistant.components.ssdp import ( - ATTR_SSDP_LOCATION, ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_MANUFACTURER, ATTR_UPNP_MODEL_NAME, @@ -64,39 +62,56 @@ MOCK_IMPORT_WSDATA = { CONF_PORT: 8002, } MOCK_USER_DATA = {CONF_HOST: "fake_host", CONF_NAME: "fake_name"} -MOCK_SSDP_DATA = { - ATTR_SSDP_LOCATION: "https://fake_host:12345/test", - ATTR_UPNP_FRIENDLY_NAME: "[TV] fake_name", - ATTR_UPNP_MANUFACTURER: "Samsung fake_manufacturer", - ATTR_UPNP_MODEL_NAME: "fake_model", - ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", -} -MOCK_SSDP_DATA_NOPREFIX = { - ATTR_SSDP_LOCATION: "http://fake2_host:12345/test", - ATTR_UPNP_FRIENDLY_NAME: "fake2_name", - ATTR_UPNP_MANUFACTURER: "Samsung fake2_manufacturer", - ATTR_UPNP_MODEL_NAME: "fake2_model", - ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172df", -} -MOCK_SSDP_DATA_WRONGMODEL = { - ATTR_SSDP_LOCATION: "http://fake2_host:12345/test", - ATTR_UPNP_FRIENDLY_NAME: "fake2_name", - ATTR_UPNP_MANUFACTURER: "fake2_manufacturer", - ATTR_UPNP_MODEL_NAME: "HW-Qfake", - ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172df", -} -MOCK_DHCP_DATA = {IP_ADDRESS: "fake_host", MAC_ADDRESS: "aa:bb:cc:dd:ee:ff"} +MOCK_SSDP_DATA = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="https://fake_host:12345/test", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: "[TV] fake_name", + ATTR_UPNP_MANUFACTURER: "Samsung fake_manufacturer", + ATTR_UPNP_MODEL_NAME: "fake_model", + ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", + }, +) +MOCK_SSDP_DATA_NOPREFIX = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://fake2_host:12345/test", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: "fake2_name", + ATTR_UPNP_MANUFACTURER: "Samsung fake2_manufacturer", + ATTR_UPNP_MODEL_NAME: "fake2_model", + ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172df", + }, +) +MOCK_SSDP_DATA_WRONGMODEL = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://fake2_host:12345/test", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: "fake2_name", + ATTR_UPNP_MANUFACTURER: "fake2_manufacturer", + ATTR_UPNP_MODEL_NAME: "HW-Qfake", + ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172df", + }, +) +MOCK_DHCP_DATA = dhcp.DhcpServiceInfo( + ip="fake_host", macaddress="aa:bb:cc:dd:ee:ff", hostname="fake_hostname" +) EXISTING_IP = "192.168.40.221" -MOCK_ZEROCONF_DATA = { - CONF_HOST: "fake_host", - CONF_PORT: 1234, - ATTR_PROPERTIES: { +MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( + host="fake_host", + hostname="mock_hostname", + name="mock_name", + port=1234, + properties={ "deviceid": "aa:bb:cc:dd:ee:ff", "manufacturer": "fake_manufacturer", "model": "fake_model", "serialNumber": "fake_serial", }, -} + type="mock_type", +) MOCK_OLD_ENTRY = { CONF_HOST: "fake_host", CONF_ID: "0d1cef00-00dc-1000-9c80-4844f7b172de_old", @@ -125,6 +140,14 @@ MOCK_DEVICE_INFO = { }, "id": "123", } +MOCK_DEVICE_INFO_2 = { + "device": { + "type": "Samsung SmartTV", + "name": "fake2_name", + "modelName": "fake2_model", + }, + "id": "345", +} AUTODETECT_LEGACY = { "name": "HomeAssistant", @@ -209,7 +232,9 @@ async def test_user_websocket(hass: HomeAssistant, remotews: Mock): assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -async def test_user_legacy_missing_auth(hass: HomeAssistant, remote: Mock): +async def test_user_legacy_missing_auth( + hass: HomeAssistant, remote: Mock, remotews: Mock +): """Test starting a flow by user with authentication.""" with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -286,60 +311,72 @@ async def test_user_not_successful_2(hass: HomeAssistant, remotews: Mock): assert result["reason"] == RESULT_CANNOT_CONNECT -async def test_ssdp(hass: HomeAssistant, remote: Mock): +async def test_ssdp(hass: HomeAssistant, remote: Mock, no_mac_address: Mock): """Test starting a flow from discovery.""" - # confirm to add the entry - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA - ) - assert result["type"] == "form" - assert result["step_id"] == "confirm" - - # entry was added - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input="whatever" - ) - assert result["type"] == "create_entry" - assert result["title"] == "fake_model" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake_model" - assert result["data"][CONF_MANUFACTURER] == "Samsung fake_manufacturer" - assert result["data"][CONF_MODEL] == "fake_model" - assert result["result"].unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" - - -async def test_ssdp_noprefix(hass: HomeAssistant, remote: Mock): - """Test starting a flow from discovery without prefixes.""" - - # confirm to add the entry - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_NOPREFIX, - ) - assert result["type"] == "form" - assert result["step_id"] == "confirm" - + no_mac_address.return_value = "aa:bb:cc:dd:ee:ff" with patch( - "homeassistant.components.samsungtv.bridge.Remote.__enter__", - return_value=True, + "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.device_info", + return_value=MOCK_DEVICE_INFO, ): + # confirm to add the entry + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA + ) + assert result["type"] == "form" + assert result["step_id"] == "confirm" # entry was added result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input="whatever" ) assert result["type"] == "create_entry" - assert result["title"] == "fake2_model" - assert result["data"][CONF_HOST] == "fake2_host" - assert result["data"][CONF_NAME] == "fake2_model" - assert result["data"][CONF_MANUFACTURER] == "Samsung fake2_manufacturer" - assert result["data"][CONF_MODEL] == "fake2_model" - assert result["result"].unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172df" + assert result["title"] == "fake_name (fake_model)" + assert result["data"][CONF_HOST] == "fake_host" + assert result["data"][CONF_NAME] == "fake_name" + assert result["data"][CONF_MANUFACTURER] == "Samsung fake_manufacturer" + assert result["data"][CONF_MODEL] == "fake_model" + assert result["result"].unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" -async def test_ssdp_legacy_missing_auth(hass: HomeAssistant, remote: Mock): +async def test_ssdp_noprefix(hass: HomeAssistant, remote: Mock, no_mac_address: Mock): + """Test starting a flow from discovery without prefixes.""" + + no_mac_address.return_value = "aa:bb:cc:dd:ee:ff" + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.device_info", + return_value=MOCK_DEVICE_INFO_2, + ): + # confirm to add the entry + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA_NOPREFIX, + ) + assert result["type"] == "form" + assert result["step_id"] == "confirm" + + with patch( + "homeassistant.components.samsungtv.bridge.Remote.__enter__", + return_value=True, + ): + + # entry was added + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input="whatever" + ) + assert result["type"] == "create_entry" + assert result["title"] == "fake2_name (fake2_model)" + assert result["data"][CONF_HOST] == "fake2_host" + assert result["data"][CONF_NAME] == "fake2_name" + assert result["data"][CONF_MANUFACTURER] == "Samsung fake2_manufacturer" + assert result["data"][CONF_MODEL] == "fake2_model" + assert result["result"].unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172df" + + +async def test_ssdp_legacy_missing_auth( + hass: HomeAssistant, remote: Mock, remotews: Mock +): """Test starting a flow from discovery with authentication.""" with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -366,7 +403,9 @@ async def test_ssdp_legacy_missing_auth(hass: HomeAssistant, remote: Mock): assert result["reason"] == RESULT_AUTH_MISSING -async def test_ssdp_legacy_not_supported(hass: HomeAssistant, remote: Mock): +async def test_ssdp_legacy_not_supported( + hass: HomeAssistant, remote: Mock, remotews: Mock +): """Test starting a flow from discovery for not supported device.""" # confirm to add the entry @@ -503,50 +542,68 @@ async def test_ssdp_not_successful_2( assert result["reason"] == RESULT_CANNOT_CONNECT -async def test_ssdp_already_in_progress(hass: HomeAssistant, remote: Mock): +async def test_ssdp_already_in_progress( + hass: HomeAssistant, remote: Mock, no_mac_address: Mock +): """Test starting a flow from discovery twice.""" - # confirm to add the entry - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA - ) - assert result["type"] == "form" - assert result["step_id"] == "confirm" + no_mac_address.return_value = "aa:bb:cc:dd:ee:ff" + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.device_info", + return_value=MOCK_DEVICE_INFO, + ): - # failed as already in progress - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA - ) - assert result["type"] == "abort" - assert result["reason"] == RESULT_ALREADY_IN_PROGRESS + # confirm to add the entry + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA + ) + assert result["type"] == "form" + assert result["step_id"] == "confirm" + + # failed as already in progress + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA + ) + assert result["type"] == "abort" + assert result["reason"] == RESULT_ALREADY_IN_PROGRESS -async def test_ssdp_already_configured(hass: HomeAssistant, remote: Mock): +async def test_ssdp_already_configured( + hass: HomeAssistant, remote: Mock, no_mac_address: Mock +): """Test starting a flow from discovery when already configured.""" - # entry was added - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=MOCK_USER_DATA - ) - assert result["type"] == "create_entry" - entry = result["result"] - assert entry.data[CONF_MANUFACTURER] == DEFAULT_MANUFACTURER - assert entry.data[CONF_MODEL] is None - assert entry.unique_id is None + no_mac_address.return_value = "aa:bb:cc:dd:ee:ff" + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.device_info", + return_value=MOCK_DEVICE_INFO, + ): - # failed as already configured - result2 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA - ) - assert result2["type"] == "abort" - assert result2["reason"] == RESULT_ALREADY_CONFIGURED + # entry was added + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=MOCK_USER_DATA + ) + assert result["type"] == "create_entry" + entry = result["result"] + assert entry.data[CONF_MANUFACTURER] == DEFAULT_MANUFACTURER + assert entry.data[CONF_MODEL] is None + assert entry.unique_id is None - # check updated device info - assert entry.unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" + # failed as already configured + result2 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA + ) + assert result2["type"] == "abort" + assert result2["reason"] == RESULT_ALREADY_CONFIGURED + + # check updated device info + assert entry.unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" -async def test_import_legacy(hass: HomeAssistant, remote: Mock): +async def test_import_legacy(hass: HomeAssistant, remote: Mock, no_mac_address: Mock): """Test importing from yaml with hostname.""" + + no_mac_address.return_value = "aa:bb:cc:dd:ee:ff" with patch( "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", return_value="fake_host", @@ -571,7 +628,10 @@ async def test_import_legacy(hass: HomeAssistant, remote: Mock): async def test_import_legacy_without_name( - hass: HomeAssistant, remote: Mock, no_mac_address: Mock + hass: HomeAssistant, + remote: Mock, + remotews_no_device_info: Mock, + no_mac_address: Mock, ): """Test importing from yaml without a name.""" with patch( @@ -596,7 +656,7 @@ async def test_import_legacy_without_name( assert entries[0].data[CONF_PORT] == LEGACY_PORT -async def test_import_websocket(hass: HomeAssistant): +async def test_import_websocket(hass: HomeAssistant, remotews: Mock): """Test importing from yaml with hostname.""" with patch( "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", @@ -930,7 +990,7 @@ async def test_autodetect_none(hass: HomeAssistant, remote: Mock, remotews: Mock ] -async def test_update_old_entry(hass: HomeAssistant, remote: Mock): +async def test_update_old_entry(hass: HomeAssistant, remote: Mock, remotews: Mock): """Test update of old entry.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: remote().rest_device_info.return_value = { @@ -1097,7 +1157,9 @@ async def test_update_legacy_missing_mac_from_dhcp(hass, remote: Mock): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={IP_ADDRESS: EXISTING_IP, MAC_ADDRESS: "aa:bb:cc:dd:ee:ff"}, + data=dhcp.DhcpServiceInfo( + ip=EXISTING_IP, macaddress="aa:bb:cc:dd:ee:ff", hostname="fake_hostname" + ), ) await hass.async_block_till_done() assert len(mock_setup.mock_calls) == 1 @@ -1131,7 +1193,9 @@ async def test_update_legacy_missing_mac_from_dhcp_no_unique_id(hass, remote: Mo result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={IP_ADDRESS: EXISTING_IP, MAC_ADDRESS: "aa:bb:cc:dd:ee:ff"}, + data=dhcp.DhcpServiceInfo( + ip=EXISTING_IP, macaddress="aa:bb:cc:dd:ee:ff", hostname="fake_hostname" + ), ) await hass.async_block_till_done() assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 500c39d677a..bd3e2a51256 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -1,5 +1,5 @@ """Tests for the Samsung TV Integration.""" -from unittest.mock import Mock, call, patch +from unittest.mock import Mock, patch from homeassistant.components.media_player.const import DOMAIN, SUPPORT_TURN_ON from homeassistant.components.samsungtv.const import ( @@ -53,15 +53,15 @@ REMOTE_CALL = { } -async def test_setup(hass: HomeAssistant, remote: Mock, no_mac_address: Mock): +async def test_setup(hass: HomeAssistant, remotews: Mock, no_mac_address: Mock): """Test Samsung TV integration is setup.""" - with patch("homeassistant.components.samsungtv.bridge.Remote") as remote, patch( + with patch( "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", return_value="fake_host", ): - with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() + + await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) # test name and turn_on @@ -76,7 +76,6 @@ async def test_setup(hass: HomeAssistant, remote: Mock, no_mac_address: Mock): assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) - assert remote.call_args == call(REMOTE_CALL) async def test_setup_from_yaml_without_port_device_offline(hass: HomeAssistant): @@ -86,6 +85,9 @@ async def test_setup_from_yaml_without_port_device_offline(hass: HomeAssistant): ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVWS.open", side_effect=OSError, + ), patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.device_info", + return_value=None, ), patch( "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", return_value="fake_host", @@ -130,7 +132,7 @@ async def test_setup_duplicate_config(hass: HomeAssistant, remote: Mock, caplog) async def test_setup_duplicate_entries( - hass: HomeAssistant, remote: Mock, no_mac_address: Mock, caplog + hass: HomeAssistant, remote: Mock, remotews: Mock, no_mac_address: Mock, caplog ): """Test duplicate setup of platform.""" await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) diff --git a/tests/components/screenlogic/test_config_flow.py b/tests/components/screenlogic/test_config_flow.py index d1333cb7514..1bb441f145c 100644 --- a/tests/components/screenlogic/test_config_flow.py +++ b/tests/components/screenlogic/test_config_flow.py @@ -11,7 +11,7 @@ from screenlogicpy.const import ( ) from homeassistant import config_entries -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS +from homeassistant.components import dhcp from homeassistant.components.screenlogic.config_flow import ( GATEWAY_MANUAL_ENTRY, GATEWAY_SELECT_KEY, @@ -50,8 +50,6 @@ async def test_flow_discovery(hass): assert result["step_id"] == "gateway_select" with patch( - "homeassistant.components.screenlogic.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.screenlogic.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -66,7 +64,6 @@ async def test_flow_discovery(hass): CONF_IP_ADDRESS: "1.1.1.1", CONF_PORT: 80, } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -102,15 +99,10 @@ async def test_flow_discover_error(hass): assert result["step_id"] == "gateway_entry" with patch( - "homeassistant.components.screenlogic.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.screenlogic.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( - "homeassistant.components.screenlogic.config_flow.login.create_socket", - return_value=True, - ), patch( - "homeassistant.components.screenlogic.config_flow.login.gateway_connect", + "homeassistant.components.screenlogic.config_flow.login.async_get_mac_address", return_value="00-C0-33-01-01-01", ): result3 = await hass.config_entries.flow.async_configure( @@ -128,7 +120,6 @@ async def test_flow_discover_error(hass): CONF_IP_ADDRESS: "1.1.1.1", CONF_PORT: 80, } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -138,25 +129,21 @@ async def test_dhcp(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - HOSTNAME: "Pentair: 01-01-01", - IP_ADDRESS: "1.1.1.1", - }, + data=dhcp.DhcpServiceInfo( + hostname="Pentair: 01-01-01", + ip="1.1.1.1", + macaddress="AA:BB:CC:DD:EE:FF", + ), ) assert result["type"] == "form" assert result["step_id"] == "gateway_entry" with patch( - "homeassistant.components.screenlogic.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.screenlogic.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( - "homeassistant.components.screenlogic.config_flow.login.create_socket", - return_value=True, - ), patch( - "homeassistant.components.screenlogic.config_flow.login.gateway_connect", + "homeassistant.components.screenlogic.config_flow.login.async_get_mac_address", return_value="00-C0-33-01-01-01", ): result3 = await hass.config_entries.flow.async_configure( @@ -174,7 +161,6 @@ async def test_dhcp(hass): CONF_IP_ADDRESS: "1.1.1.1", CONF_PORT: 80, } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -209,15 +195,10 @@ async def test_form_manual_entry(hass): assert result2["step_id"] == "gateway_entry" with patch( - "homeassistant.components.screenlogic.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.screenlogic.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( - "homeassistant.components.screenlogic.config_flow.login.create_socket", - return_value=True, - ), patch( - "homeassistant.components.screenlogic.config_flow.login.gateway_connect", + "homeassistant.components.screenlogic.config_flow.login.async_get_mac_address", return_value="00-C0-33-01-01-01", ): result3 = await hass.config_entries.flow.async_configure( @@ -235,7 +216,6 @@ async def test_form_manual_entry(hass): CONF_IP_ADDRESS: "1.1.1.1", CONF_PORT: 80, } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -250,8 +230,8 @@ async def test_form_cannot_connect(hass): ) with patch( - "homeassistant.components.screenlogic.config_flow.login.create_socket", - return_value=None, + "homeassistant.components.screenlogic.config_flow.login.async_get_mac_address", + side_effect=ScreenLogicError("Failed to connect to host at 1.1.1.1:80"), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -271,8 +251,6 @@ async def test_option_flow(hass): entry.add_to_hass(hass) with patch( - "homeassistant.components.screenlogic.async_setup", return_value=True - ), patch( "homeassistant.components.screenlogic.async_setup_entry", return_value=True, ): @@ -298,8 +276,6 @@ async def test_option_flow_defaults(hass): entry.add_to_hass(hass) with patch( - "homeassistant.components.screenlogic.async_setup", return_value=True - ), patch( "homeassistant.components.screenlogic.async_setup_entry", return_value=True, ): @@ -326,8 +302,6 @@ async def test_option_flow_input_floor(hass): entry.add_to_hass(hass) with patch( - "homeassistant.components.screenlogic.async_setup", return_value=True - ), patch( "homeassistant.components.screenlogic.async_setup_entry", return_value=True, ): diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index daf452cf715..5742f7b47c4 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -100,7 +100,7 @@ async def test_get_conditions_no_state(hass, device_reg, entity_reg): "test", f"5678_{device_class}", device_id=device_entry.id, - device_class=device_class, + original_device_class=device_class, unit_of_measurement=UNITS_OF_MEASUREMENT.get(device_class), ).entity_id @@ -155,7 +155,7 @@ async def test_get_condition_capabilities( "test", platform.ENTITIES["battery"].unique_id, device_id=device_entry.id, - device_class=device_class_reg, + original_device_class=device_class_reg, unit_of_measurement=unit_reg, ).entity_id if set_state: diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 5ef99b6c669..b8b3ee46a43 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -86,7 +86,7 @@ async def test_get_triggers(hass, device_reg, entity_reg, enable_custom_integrat if device_class != "none" ] triggers = await async_get_device_automations(hass, "trigger", device_entry.id) - assert len(triggers) == 23 + assert len(triggers) == 24 assert triggers == expected_triggers @@ -122,7 +122,7 @@ async def test_get_trigger_capabilities( "test", platform.ENTITIES["battery"].unique_id, device_id=device_entry.id, - device_class=device_class_reg, + original_device_class=device_class_reg, unit_of_measurement=unit_reg, ).entity_id if set_state: diff --git a/tests/components/sensor/test_helpers.py b/tests/components/sensor/test_helpers.py new file mode 100644 index 00000000000..d43443b85ba --- /dev/null +++ b/tests/components/sensor/test_helpers.py @@ -0,0 +1,38 @@ +"""The test for sensor helpers.""" +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sensor.helpers import async_parse_date_datetime + + +def test_async_parse_datetime(caplog): + """Test async_parse_date_datetime.""" + entity_id = "sensor.timestamp" + device_class = SensorDeviceClass.TIMESTAMP + assert ( + async_parse_date_datetime( + "2021-12-12 12:12Z", entity_id, device_class + ).isoformat() + == "2021-12-12T12:12:00+00:00" + ) + assert not caplog.text + + # No timezone + assert ( + async_parse_date_datetime("2021-12-12 12:12", entity_id, device_class) is None + ) + assert "sensor.timestamp rendered timestamp without timezone" in caplog.text + + # Invalid timestamp + assert async_parse_date_datetime("12 past 12", entity_id, device_class) is None + assert "sensor.timestamp rendered invalid timestamp: 12 past 12" in caplog.text + + device_class = SensorDeviceClass.DATE + caplog.clear() + assert ( + async_parse_date_datetime("2021-12-12", entity_id, device_class).isoformat() + == "2021-12-12" + ) + assert not caplog.text + + # Invalid date + assert async_parse_date_datetime("December 12th", entity_id, device_class) is None + assert "sensor.timestamp rendered invalid date December 12th" in caplog.text diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 7859d133c29..d5deee41679 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -1,8 +1,60 @@ -"""The test for sensor device automation.""" +"""The test for sensor entity.""" +from datetime import date, datetime, timezone + +import pytest +from pytest import approx + from homeassistant.components.sensor import SensorEntityDescription -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_DATE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, + STATE_UNKNOWN, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) 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 + + +@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), + (METRIC_SYSTEM, TEMP_FAHRENHEIT, TEMP_CELSIUS, 100, 38), + (METRIC_SYSTEM, TEMP_CELSIUS, TEMP_CELSIUS, 38, 38), + ], +) +async def test_temperature_conversion( + hass, + enable_custom_integrations, + unit_system, + native_unit, + state_unit, + native_value, + state_value, +): + """Test temperature conversion.""" + hass.config.units = unit_system + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + native_value=str(native_value), + native_unit_of_measurement=native_unit, + device_class=DEVICE_CLASS_TEMPERATURE, + ) + + 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(state_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == state_unit async def test_deprecated_temperature_conversion( @@ -60,3 +112,138 @@ async def test_deprecated_unit_of_measurement(hass, caplog, enable_custom_integr "tests.components.sensor.test_init is setting 'unit_of_measurement' on an " "instance of SensorEntityDescription" ) in caplog.text + + +async def test_datetime_conversion(hass, caplog, enable_custom_integrations): + """Test conversion of datetime.""" + test_timestamp = datetime(2017, 12, 19, 18, 29, 42, tzinfo=timezone.utc) + test_local_timestamp = test_timestamp.astimezone( + dt_util.get_time_zone("Europe/Amsterdam") + ) + test_date = date(2017, 12, 19) + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", native_value=test_timestamp, device_class=DEVICE_CLASS_TIMESTAMP + ) + platform.ENTITIES["1"] = platform.MockSensor( + name="Test", native_value=test_date, device_class=DEVICE_CLASS_DATE + ) + platform.ENTITIES["2"] = platform.MockSensor( + name="Test", native_value=None, device_class=DEVICE_CLASS_TIMESTAMP + ) + platform.ENTITIES["3"] = platform.MockSensor( + name="Test", native_value=None, device_class=DEVICE_CLASS_DATE + ) + platform.ENTITIES["4"] = platform.MockSensor( + name="Test", + native_value=test_local_timestamp, + device_class=DEVICE_CLASS_TIMESTAMP, + ) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(platform.ENTITIES["0"].entity_id) + assert state.state == test_timestamp.isoformat() + + state = hass.states.get(platform.ENTITIES["1"].entity_id) + assert state.state == test_date.isoformat() + + state = hass.states.get(platform.ENTITIES["2"].entity_id) + assert state.state == STATE_UNKNOWN + + state = hass.states.get(platform.ENTITIES["3"].entity_id) + assert state.state == STATE_UNKNOWN + + state = hass.states.get(platform.ENTITIES["4"].entity_id) + assert state.state == test_timestamp.isoformat() + + +@pytest.mark.parametrize( + "device_class,native_value,state_value", + [ + (DEVICE_CLASS_DATE, "2021-11-09", "2021-11-09"), + ( + DEVICE_CLASS_DATE, + "2021-01-09T12:00:00+00:00", + "2021-01-09", + ), + ( + DEVICE_CLASS_DATE, + "2021-01-09T00:00:00+01:00", + "2021-01-08", + ), + ( + DEVICE_CLASS_TIMESTAMP, + "2021-01-09T12:00:00+00:00", + "2021-01-09T12:00:00+00:00", + ), + ( + DEVICE_CLASS_TIMESTAMP, + "2021-01-09 12:00:00+00:00", + "2021-01-09T12:00:00+00:00", + ), + ( + DEVICE_CLASS_TIMESTAMP, + "2021-01-09T12:00:00+04:00", + "2021-01-09T08:00:00+00:00", + ), + ( + DEVICE_CLASS_TIMESTAMP, + "2021-01-09 12:00:00+01:00", + "2021-01-09T11:00:00+00:00", + ), + ( + DEVICE_CLASS_TIMESTAMP, + "2021-01-09 12:00:00", + "2021-01-09T12:00:00", + ), + ( + DEVICE_CLASS_TIMESTAMP, + "2021-01-09T12:00:00", + "2021-01-09T12:00:00", + ), + ], +) +async def test_deprecated_datetime_str( + hass, caplog, enable_custom_integrations, device_class, native_value, state_value +): + """Test warning on deprecated str for a date(time) value.""" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", native_value=native_value, device_class=device_class + ) + + 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 state.state == state_value + assert ( + "is providing a string for its state, while the device class is " + f"'{device_class}', this is not valid and will be unsupported " + "from Home Assistant 2022.2." + ) in caplog.text + + +async def test_reject_timezoneless_datetime_str( + hass, caplog, enable_custom_integrations +): + """Test rejection of timezone-less datetime objects as timestamp.""" + test_timestamp = datetime(2017, 12, 19, 18, 29, 42, tzinfo=None) + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", native_value=test_timestamp, device_class=DEVICE_CLASS_TIMESTAMP + ) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + assert ( + "Invalid datetime: sensor.test provides state '2017-12-19 18:29:42', " + "which is missing timezone information" + ) in caplog.text diff --git a/tests/components/seventeentrack/test_sensor.py b/tests/components/seventeentrack/test_sensor.py index cef92496561..4e22822150b 100644 --- a/tests/components/seventeentrack/test_sensor.py +++ b/tests/components/seventeentrack/test_sensor.py @@ -172,14 +172,14 @@ async def test_invalid_config(hass): async def test_add_package(hass): """Ensure package is added correctly when user add a new package.""" package = Package( - "456", - 206, - "friendly name 1", - "info text 1", - "location 1", - "2020-08-10 10:32", - 206, - 2, + tracking_number="456", + destination_country=206, + friendly_name="friendly name 1", + info_text="info text 1", + location="location 1", + timestamp="2020-08-10 10:32", + origin_country=206, + package_type=2, ) ProfileMock.package_list = [package] @@ -188,14 +188,14 @@ async def test_add_package(hass): assert len(hass.states.async_entity_ids()) == 1 package2 = Package( - "789", - 206, - "friendly name 2", - "info text 2", - "location 2", - "2020-08-10 14:25", - 206, - 2, + tracking_number="789", + destination_country=206, + friendly_name="friendly name 2", + info_text="info text 2", + location="location 2", + timestamp="2020-08-10 14:25", + origin_country=206, + package_type=2, ) ProfileMock.package_list = [package, package2] @@ -208,24 +208,24 @@ async def test_add_package(hass): async def test_remove_package(hass): """Ensure entity is not there anymore if package is not there.""" package1 = Package( - "456", - 206, - "friendly name 1", - "info text 1", - "location 1", - "2020-08-10 10:32", - 206, - 2, + tracking_number="456", + destination_country=206, + friendly_name="friendly name 1", + info_text="info text 1", + location="location 1", + timestamp="2020-08-10 10:32", + origin_country=206, + package_type=2, ) package2 = Package( - "789", - 206, - "friendly name 2", - "info text 2", - "location 2", - "2020-08-10 14:25", - 206, - 2, + tracking_number="789", + destination_country=206, + friendly_name="friendly name 2", + info_text="info text 2", + location="location 2", + timestamp="2020-08-10 14:25", + origin_country=206, + package_type=2, ) ProfileMock.package_list = [package1, package2] @@ -248,14 +248,14 @@ async def test_remove_package(hass): async def test_friendly_name_changed(hass): """Test friendly name change.""" package = Package( - "456", - 206, - "friendly name 1", - "info text 1", - "location 1", - "2020-08-10 10:32", - 206, - 2, + tracking_number="456", + destination_country=206, + friendly_name="friendly name 1", + info_text="info text 1", + location="location 1", + timestamp="2020-08-10 10:32", + origin_country=206, + package_type=2, ) ProfileMock.package_list = [package] @@ -265,14 +265,14 @@ async def test_friendly_name_changed(hass): assert len(hass.states.async_entity_ids()) == 1 package = Package( - "456", - 206, - "friendly name 2", - "info text 1", - "location 1", - "2020-08-10 10:32", - 206, - 2, + tracking_number="456", + destination_country=206, + friendly_name="friendly name 2", + info_text="info text 1", + location="location 1", + timestamp="2020-08-10 10:32", + origin_country=206, + package_type=2, ) ProfileMock.package_list = [package] @@ -289,15 +289,15 @@ async def test_friendly_name_changed(hass): async def test_delivered_not_shown(hass): """Ensure delivered packages are not shown.""" package = Package( - "456", - 206, - "friendly name 1", - "info text 1", - "location 1", - "2020-08-10 10:32", - 206, - 2, - 40, + tracking_number="456", + destination_country=206, + friendly_name="friendly name 1", + info_text="info text 1", + location="location 1", + timestamp="2020-08-10 10:32", + origin_country=206, + package_type=2, + status=40, ) ProfileMock.package_list = [package] @@ -312,15 +312,15 @@ async def test_delivered_not_shown(hass): async def test_delivered_shown(hass): """Ensure delivered packages are show when user choose to show them.""" package = Package( - "456", - 206, - "friendly name 1", - "info text 1", - "location 1", - "2020-08-10 10:32", - 206, - 2, - 40, + tracking_number="456", + destination_country=206, + friendly_name="friendly name 1", + info_text="info text 1", + location="location 1", + timestamp="2020-08-10 10:32", + origin_country=206, + package_type=2, + status=40, ) ProfileMock.package_list = [package] @@ -335,14 +335,14 @@ async def test_delivered_shown(hass): async def test_becomes_delivered_not_shown_notification(hass): """Ensure notification is triggered when package becomes delivered.""" package = Package( - "456", - 206, - "friendly name 1", - "info text 1", - "location 1", - "2020-08-10 10:32", - 206, - 2, + tracking_number="456", + destination_country=206, + friendly_name="friendly name 1", + info_text="info text 1", + location="location 1", + timestamp="2020-08-10 10:32", + origin_country=206, + package_type=2, ) ProfileMock.package_list = [package] @@ -352,15 +352,15 @@ async def test_becomes_delivered_not_shown_notification(hass): assert len(hass.states.async_entity_ids()) == 1 package_delivered = Package( - "456", - 206, - "friendly name 1", - "info text 1", - "location 1", - "2020-08-10 10:32", - 206, - 2, - 40, + tracking_number="456", + destination_country=206, + friendly_name="friendly name 1", + info_text="info text 1", + location="location 1", + timestamp="2020-08-10 10:32", + origin_country=206, + package_type=2, + status=40, ) ProfileMock.package_list = [package_delivered] @@ -391,14 +391,14 @@ async def test_summary_correctly_updated(hass): async def test_utc_timestamp(hass): """Ensure package timestamp is converted correctly from HA-defined time zone to UTC.""" package = Package( - "456", - 206, - "friendly name 1", - "info text 1", - "location 1", - "2020-08-10 10:32", - 206, - 2, + tracking_number="456", + destination_country=206, + friendly_name="friendly name 1", + info_text="info text 1", + location="location 1", + timestamp="2020-08-10 10:32", + origin_country=206, + package_type=2, tz="Asia/Jakarta", ) ProfileMock.package_list = [package] diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index a0d4a27bbc4..5391f3f74fe 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -58,9 +58,9 @@ MOCK_BLOCKS = [ MOCK_CONFIG = { "input:0": {"id": 0, "type": "button"}, "switch:0": {"name": "test switch_0"}, - "sys": {"ui_data": {}}, - "wifi": { - "ap": {"ssid": "Test name"}, + "sys": { + "ui_data": {}, + "device": {"name": "Test name"}, }, } @@ -71,8 +71,25 @@ MOCK_SHELLY = { "num_outputs": 2, } -MOCK_STATUS = { +MOCK_STATUS_COAP = { + "update": { + "status": "pending", + "has_update": True, + "beta_version": "some_beta_version", + "new_version": "some_new_version", + "old_version": "some_old_version", + }, +} + + +MOCK_STATUS_RPC = { "switch:0": {"output": True}, + "sys": { + "available_updates": { + "beta": {"version": "some_beta_version"}, + "stable": {"version": "some_beta_version"}, + } + }, } @@ -117,8 +134,11 @@ async def coap_wrapper(hass): blocks=MOCK_BLOCKS, settings=MOCK_SETTINGS, shelly=MOCK_SHELLY, + status=MOCK_STATUS_COAP, firmware_version="some fw string", update=AsyncMock(), + trigger_ota_update=AsyncMock(), + trigger_reboot=AsyncMock(), initialized=True, ) @@ -150,9 +170,11 @@ async def rpc_wrapper(hass): config=MOCK_CONFIG, event={}, shelly=MOCK_SHELLY, - status=MOCK_STATUS, + status=MOCK_STATUS_RPC, firmware_version="some fw string", update=AsyncMock(), + trigger_ota_update=AsyncMock(), + trigger_reboot=AsyncMock(), initialized=True, shutdown=AsyncMock(), ) diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py new file mode 100644 index 00000000000..442e6ef248f --- /dev/null +++ b/tests/components/shelly/test_button.py @@ -0,0 +1,136 @@ +"""Tests for Shelly button platform.""" +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.button.const import 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 + + +async def test_block_button(hass: HomeAssistant, coap_wrapper): + """Test block device OTA button.""" + assert coap_wrapper + + entity_registry = async_get(hass) + entity_registry.async_get_or_create( + BUTTON_DOMAIN, + DOMAIN, + "test_name_ota_update_beta", + suggested_object_id="test_name_ota_update_beta", + 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() + + # stable channel button + state = hass.states.get("button.test_name_ota_update") + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_name_ota_update"}, + blocking=True, + ) + await hass.async_block_till_done() + assert coap_wrapper.device.trigger_ota_update.call_count == 1 + coap_wrapper.device.trigger_ota_update.assert_called_with(beta=False) + + # beta channel button + state = hass.states.get("button.test_name_ota_update_beta") + + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_name_ota_update_beta"}, + blocking=True, + ) + await hass.async_block_till_done() + assert coap_wrapper.device.trigger_ota_update.call_count == 2 + coap_wrapper.device.trigger_ota_update.assert_called_with(beta=True) + + # reboot button + state = hass.states.get("button.test_name_reboot") + + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_name_reboot"}, + blocking=True, + ) + await hass.async_block_till_done() + assert coap_wrapper.device.trigger_reboot.call_count == 1 + + +async def test_rpc_button(hass: HomeAssistant, rpc_wrapper): + """Test rpc device OTA button.""" + assert rpc_wrapper + + entity_registry = async_get(hass) + entity_registry.async_get_or_create( + BUTTON_DOMAIN, + DOMAIN, + "test_name_ota_update_beta", + suggested_object_id="test_name_ota_update_beta", + 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() + + # stable channel button + state = hass.states.get("button.test_name_ota_update") + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_name_ota_update"}, + blocking=True, + ) + await hass.async_block_till_done() + assert rpc_wrapper.device.trigger_ota_update.call_count == 1 + rpc_wrapper.device.trigger_ota_update.assert_called_with(beta=False) + + # beta channel button + state = hass.states.get("button.test_name_ota_update_beta") + + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_name_ota_update_beta"}, + blocking=True, + ) + await hass.async_block_till_done() + assert rpc_wrapper.device.trigger_ota_update.call_count == 2 + rpc_wrapper.device.trigger_ota_update.assert_called_with(beta=True) + + # reboot button + state = hass.states.get("button.test_name_reboot") + + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_name_reboot"}, + blocking=True, + ) + await hass.async_block_till_done() + assert rpc_wrapper.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 dc6921b9735..12690a35faa 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -8,6 +8,7 @@ import aioshelly import pytest from homeassistant import config_entries, data_entry_flow +from homeassistant.components import zeroconf from homeassistant.components.shelly.const import DOMAIN from tests.common import MockConfigEntry @@ -16,13 +17,18 @@ MOCK_SETTINGS = { "name": "Test name", "device": {"mac": "test-mac", "hostname": "test-host", "type": "SHSW-1"}, } -DISCOVERY_INFO = { - "host": "1.1.1.1", - "name": "shelly1pm-12345", - "properties": {"id": "shelly1pm-12345"}, -} +DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + hostname="mock_hostname", + name="shelly1pm-12345", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "shelly1pm-12345"}, + type="mock_type", +) MOCK_CONFIG = { - "wifi": {"ap": {"ssid": "Test name"}}, + "sys": { + "device": {"name": "Test name"}, + }, } diff --git a/tests/components/signal_messenger/conftest.py b/tests/components/signal_messenger/conftest.py new file mode 100644 index 00000000000..bc7aa7f84a1 --- /dev/null +++ b/tests/components/signal_messenger/conftest.py @@ -0,0 +1,39 @@ +"""Signal notification test helpers.""" +from http import HTTPStatus + +from pysignalclirestapi import SignalCliRestApi +import pytest + +from homeassistant.components.signal_messenger.notify import SignalNotificationService + + +@pytest.fixture +def signal_notification_service(): + """Set up signal notification service.""" + recipients = ["+435565656565"] + number = "+43443434343" + client = SignalCliRestApi("http://127.0.0.1:8080", number) + return SignalNotificationService(recipients, client) + + +SIGNAL_SEND_PATH_SUFIX = "/v2/send" +MESSAGE = "Testing Signal Messenger platform :)" +NUMBER_FROM = "+43443434343" +NUMBERS_TO = ["+435565656565"] + + +@pytest.fixture +def signal_requests_mock(requests_mock): + """Prepare signal service mock.""" + requests_mock.register_uri( + "POST", + "http://127.0.0.1:8080" + SIGNAL_SEND_PATH_SUFIX, + status_code=HTTPStatus.CREATED, + ) + requests_mock.register_uri( + "GET", + "http://127.0.0.1:8080/v1/about", + status_code=HTTPStatus.OK, + json={"versions": ["v1", "v2"]}, + ) + return requests_mock diff --git a/tests/components/signal_messenger/test_notify.py b/tests/components/signal_messenger/test_notify.py index 61d13b8c60b..1feefa28513 100644 --- a/tests/components/signal_messenger/test_notify.py +++ b/tests/components/signal_messenger/test_notify.py @@ -1,29 +1,31 @@ """The tests for the signal_messenger platform.""" -from http import HTTPStatus +import json +import logging import os import tempfile -import unittest from unittest.mock import patch -from pysignalclirestapi import SignalCliRestApi -import requests_mock - -import homeassistant.components.signal_messenger.notify as signalmessenger from homeassistant.setup import async_setup_component +from tests.components.signal_messenger.conftest import ( + MESSAGE, + NUMBER_FROM, + NUMBERS_TO, + SIGNAL_SEND_PATH_SUFIX, +) + BASE_COMPONENT = "notify" async def test_signal_messenger_init(hass): """Test that service loads successfully.""" - config = { BASE_COMPONENT: { "name": "test", "platform": "signal_messenger", "url": "http://127.0.0.1:8080", - "number": "+43443434343", - "recipients": ["+435565656565"], + "number": NUMBER_FROM, + "recipients": NUMBERS_TO, } } @@ -31,96 +33,71 @@ async def test_signal_messenger_init(hass): assert await async_setup_component(hass, BASE_COMPONENT, config) await hass.async_block_till_done() - # Test that service loads successfully assert hass.services.has_service(BASE_COMPONENT, "test") -class TestSignalMesssenger(unittest.TestCase): - """Test the signal_messenger notify.""" +def test_send_message(signal_notification_service, signal_requests_mock, caplog): + """Test send message.""" + with caplog.at_level( + logging.DEBUG, logger="homeassistant.components.signal_messenger.notify" + ): + signal_notification_service.send_message(MESSAGE) + assert "Sending signal message" in caplog.text + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 2 + assert_sending_requests(signal_requests_mock) - def setUp(self): - """Set up things to be run when tests are started.""" - recipients = ["+435565656565"] - number = "+43443434343" - client = SignalCliRestApi("http://127.0.0.1:8080", number) - self._signalmessenger = signalmessenger.SignalNotificationService( - recipients, client - ) - @requests_mock.Mocker() - def test_send_message(self, mock): - """Test send message.""" - message = "Testing Signal Messenger platform :)" - mock.register_uri( - "POST", - "http://127.0.0.1:8080/v2/send", - status_code=HTTPStatus.CREATED, - ) - mock.register_uri( - "GET", - "http://127.0.0.1:8080/v1/about", - status_code=HTTPStatus.OK, - json={"versions": ["v1", "v2"]}, - ) - with self.assertLogs( - "homeassistant.components.signal_messenger.notify", level="DEBUG" - ) as context: - self._signalmessenger.send_message(message) - self.assertIn("Sending signal message", context.output[0]) - self.assertTrue(mock.called) - self.assertEqual(mock.call_count, 2) +def test_send_message_should_show_deprecation_warning( + signal_notification_service, signal_requests_mock, caplog +): + """Test send message should show deprecation warning.""" + with caplog.at_level( + logging.WARNING, logger="homeassistant.components.signal_messenger.notify" + ): + send_message_with_attachment(signal_notification_service, True) - @requests_mock.Mocker() - def test_send_message_should_show_deprecation_warning(self, mock): - """Test send message.""" - message = "Testing Signal Messenger platform with attachment :)" - mock.register_uri( - "POST", - "http://127.0.0.1:8080/v2/send", - status_code=HTTPStatus.CREATED, - ) - mock.register_uri( - "GET", - "http://127.0.0.1:8080/v1/about", - status_code=HTTPStatus.OK, - json={"versions": ["v1", "v2"]}, - ) - with self.assertLogs( - "homeassistant.components.signal_messenger.notify", level="WARNING" - ) as context, tempfile.NamedTemporaryFile( - suffix=".png", prefix=os.path.basename(__file__) - ) as tf: - data = {"data": {"attachment": tf.name}} - self._signalmessenger.send_message(message, **data) - self.assertIn( - "The 'attachment' option is deprecated, please replace it with 'attachments'. This option will become invalid in version 0.108", - context.output[0], - ) - self.assertTrue(mock.called) - self.assertEqual(mock.call_count, 2) + assert ( + "The 'attachment' option is deprecated, please replace it with 'attachments'. This option will become invalid in version 0.108" + in caplog.text + ) + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 2 + assert_sending_requests(signal_requests_mock, 1) - @requests_mock.Mocker() - def test_send_message_with_attachment(self, mock): - """Test send message.""" - message = "Testing Signal Messenger platform :)" - mock.register_uri( - "POST", - "http://127.0.0.1:8080/v2/send", - status_code=HTTPStatus.CREATED, - ) - mock.register_uri( - "GET", - "http://127.0.0.1:8080/v1/about", - status_code=HTTPStatus.OK, - json={"versions": ["v1", "v2"]}, - ) - with self.assertLogs( - "homeassistant.components.signal_messenger.notify", level="DEBUG" - ) as context, tempfile.NamedTemporaryFile( - suffix=".png", prefix=os.path.basename(__file__) - ) as tf: - data = {"data": {"attachments": [tf.name]}} - self._signalmessenger.send_message(message, **data) - self.assertIn("Sending signal message", context.output[0]) - self.assertTrue(mock.called) - self.assertEqual(mock.call_count, 2) + +def test_send_message_with_attachment( + signal_notification_service, signal_requests_mock, caplog +): + """Test send message with attachment.""" + with caplog.at_level( + logging.DEBUG, logger="homeassistant.components.signal_messenger.notify" + ): + send_message_with_attachment(signal_notification_service, False) + + assert "Sending signal message" in caplog.text + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 2 + assert_sending_requests(signal_requests_mock, 1) + + +def send_message_with_attachment(signal_notification_service, deprecated=False): + """Send message with attachment.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".png", prefix=os.path.basename(__file__) + ) as tf: + tf.write("attachment_data") + data = {"attachment": tf.name} if deprecated else {"attachments": [tf.name]} + signal_notification_service.send_message(MESSAGE, **{"data": data}) + + +def assert_sending_requests(signal_requests_mock, attachments_num=0): + """Assert message was send with correct parameters.""" + send_request = signal_requests_mock.request_history[-1] + assert send_request.path == SIGNAL_SEND_PATH_SUFIX + + body_request = json.loads(send_request.text) + assert body_request["message"] == MESSAGE + assert body_request["number"] == NUMBER_FROM + assert body_request["recipients"] == NUMBERS_TO + assert len(body_request["base64_attachments"]) == attachments_num diff --git a/tests/components/simplisafe/test_config_flow.py b/tests/components/simplisafe/test_config_flow.py index 4546b7d3383..0597ad377cf 100644 --- a/tests/components/simplisafe/test_config_flow.py +++ b/tests/components/simplisafe/test_config_flow.py @@ -53,9 +53,6 @@ async def test_duplicate_error(hass, mock_async_from_auth): assert result["step_id"] == "user" assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_AUTH_CODE: "code123"} ) @@ -73,9 +70,6 @@ async def test_invalid_credentials(hass, mock_async_from_auth): assert result["step_id"] == "user" assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_AUTH_CODE: "code123"} ) @@ -131,9 +125,6 @@ async def test_step_reauth_old_format(hass, mock_async_from_auth): with patch( "homeassistant.components.simplisafe.async_setup_entry", return_value=True ), patch("homeassistant.config_entries.ConfigEntries.async_reload"): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_AUTH_CODE: "code123"} ) @@ -166,9 +157,6 @@ async def test_step_reauth_new_format(hass, mock_async_from_auth): with patch( "homeassistant.components.simplisafe.async_setup_entry", return_value=True ), patch("homeassistant.config_entries.ConfigEntries.async_reload"): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_AUTH_CODE: "code123"} ) @@ -180,6 +168,42 @@ async def test_step_reauth_new_format(hass, mock_async_from_auth): assert config_entry.data == {CONF_USER_ID: "12345", CONF_TOKEN: "token123"} +async def test_step_reauth_wrong_account(hass, api, mock_async_from_auth): + """Test the re-auth step returning a different account from this one.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="12345", + data={ + CONF_USER_ID: "12345", + CONF_TOKEN: "token123", + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data={CONF_USER_ID: "12345", CONF_TOKEN: "token123"}, + ) + assert result["step_id"] == "user" + + # Simulate the next auth call returning a different user ID than the one we've + # identified as this entry's unique ID: + api.user_id = "67890" + + with patch( + "homeassistant.components.simplisafe.async_setup_entry", return_value=True + ), patch("homeassistant.config_entries.ConfigEntries.async_reload"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_AUTH_CODE: "code123"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "wrong_account" + + assert len(hass.config_entries.async_entries()) == 1 + [config_entry] = hass.config_entries.async_entries(DOMAIN) + assert config_entry.unique_id == "12345" + + async def test_step_user(hass, mock_async_from_auth): """Test the user step.""" result = await hass.config_entries.flow.async_init( @@ -190,9 +214,6 @@ async def test_step_user(hass, mock_async_from_auth): with patch( "homeassistant.components.simplisafe.async_setup_entry", return_value=True ), patch("homeassistant.config_entries.ConfigEntries.async_reload"): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_AUTH_CODE: "code123"} ) @@ -213,9 +234,6 @@ async def test_unknown_error(hass, mock_async_from_auth): assert result["step_id"] == "user" assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_AUTH_CODE: "code123"} ) diff --git a/tests/components/smappee/test_config_flow.py b/tests/components/smappee/test_config_flow.py index d8efe8b2903..f6f988a3caa 100644 --- a/tests/components/smappee/test_config_flow.py +++ b/tests/components/smappee/test_config_flow.py @@ -3,8 +3,8 @@ from http import HTTPStatus from unittest.mock import patch from homeassistant import data_entry_flow, setup +from homeassistant.components import zeroconf from homeassistant.components.smappee.const import ( - CONF_HOSTNAME, CONF_SERIALNUMBER, DOMAIN, ENV_CLOUD, @@ -55,14 +55,14 @@ async def test_show_zeroconf_connection_error_form(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={ - "host": "1.2.3.4", - "port": 22, - CONF_HOSTNAME: "Smappee1006000212.local.", - "type": "_ssh._tcp.local.", - "name": "Smappee1006000212._ssh._tcp.local.", - "properties": {"_raw": {}}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="1.2.3.4", + port=22, + hostname="Smappee1006000212.local.", + type="_ssh._tcp.local.", + name="Smappee1006000212._ssh._tcp.local.", + properties={"_raw": {}}, + ), ) assert result["description_placeholders"] == {CONF_SERIALNUMBER: "1006000212"} @@ -84,14 +84,14 @@ async def test_show_zeroconf_connection_error_form_next_generation(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={ - "host": "1.2.3.4", - "port": 22, - CONF_HOSTNAME: "Smappee5001000212.local.", - "type": "_ssh._tcp.local.", - "name": "Smappee5001000212._ssh._tcp.local.", - "properties": {"_raw": {}}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="1.2.3.4", + port=22, + hostname="Smappee5001000212.local.", + type="_ssh._tcp.local.", + name="Smappee5001000212._ssh._tcp.local.", + properties={"_raw": {}}, + ), ) assert result["description_placeholders"] == {CONF_SERIALNUMBER: "5001000212"} @@ -166,14 +166,14 @@ async def test_zeroconf_wrong_mdns(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={ - "host": "1.2.3.4", - "port": 22, - CONF_HOSTNAME: "example.local.", - "type": "_ssh._tcp.local.", - "name": "example._ssh._tcp.local.", - "properties": {"_raw": {}}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="1.2.3.4", + port=22, + hostname="example.local.", + type="_ssh._tcp.local.", + name="example._ssh._tcp.local.", + properties={"_raw": {}}, + ), ) assert result["reason"] == "invalid_mdns" @@ -276,14 +276,14 @@ async def test_zeroconf_device_exists_abort(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={ - "host": "1.2.3.4", - "port": 22, - CONF_HOSTNAME: "Smappee1006000212.local.", - "type": "_ssh._tcp.local.", - "name": "Smappee1006000212._ssh._tcp.local.", - "properties": {"_raw": {}}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="1.2.3.4", + port=22, + hostname="Smappee1006000212.local.", + type="_ssh._tcp.local.", + name="Smappee1006000212._ssh._tcp.local.", + properties={"_raw": {}}, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" @@ -325,14 +325,14 @@ async def test_zeroconf_abort_if_cloud_device_exists(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={ - "host": "1.2.3.4", - "port": 22, - CONF_HOSTNAME: "Smappee1006000212.local.", - "type": "_ssh._tcp.local.", - "name": "Smappee1006000212._ssh._tcp.local.", - "properties": {"_raw": {}}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="1.2.3.4", + port=22, + hostname="Smappee1006000212.local.", + type="_ssh._tcp.local.", + name="Smappee1006000212._ssh._tcp.local.", + properties={"_raw": {}}, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured_device" @@ -344,14 +344,14 @@ async def test_zeroconf_confirm_abort_if_cloud_device_exists(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={ - "host": "1.2.3.4", - "port": 22, - CONF_HOSTNAME: "Smappee1006000212.local.", - "type": "_ssh._tcp.local.", - "name": "Smappee1006000212._ssh._tcp.local.", - "properties": {"_raw": {}}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="1.2.3.4", + port=22, + hostname="Smappee1006000212.local.", + type="_ssh._tcp.local.", + name="Smappee1006000212._ssh._tcp.local.", + properties={"_raw": {}}, + ), ) config_entry = MockConfigEntry( @@ -463,14 +463,14 @@ async def test_full_zeroconf_flow(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={ - "host": "1.2.3.4", - "port": 22, - CONF_HOSTNAME: "Smappee1006000212.local.", - "type": "_ssh._tcp.local.", - "name": "Smappee1006000212._ssh._tcp.local.", - "properties": {"_raw": {}}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="1.2.3.4", + port=22, + hostname="Smappee1006000212.local.", + type="_ssh._tcp.local.", + name="Smappee1006000212._ssh._tcp.local.", + properties={"_raw": {}}, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "zeroconf_confirm" @@ -538,14 +538,14 @@ async def test_full_zeroconf_flow_next_generation(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={ - "host": "1.2.3.4", - "port": 22, - CONF_HOSTNAME: "Smappee5001000212.local.", - "type": "_ssh._tcp.local.", - "name": "Smappee5001000212._ssh._tcp.local.", - "properties": {"_raw": {}}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="1.2.3.4", + port=22, + hostname="Smappee5001000212.local.", + type="_ssh._tcp.local.", + name="Smappee5001000212._ssh._tcp.local.", + properties={"_raw": {}}, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "zeroconf_confirm" diff --git a/tests/components/smart_meter_texas/conftest.py b/tests/components/smart_meter_texas/conftest.py index 0d474503582..b5193b59d08 100644 --- a/tests/components/smart_meter_texas/conftest.py +++ b/tests/components/smart_meter_texas/conftest.py @@ -2,7 +2,6 @@ import asyncio from http import HTTPStatus import json -from pathlib import Path import pytest from smart_meter_texas.const import ( @@ -29,7 +28,7 @@ TEST_ENTITY_ID = "sensor.electric_meter_123456789" def load_smt_fixture(name): """Return a dict of the json fixture.""" - json_fixture = load_fixture(Path() / DOMAIN / f"{name}.json") + json_fixture = load_fixture(f"{name}.json", DOMAIN) return json.loads(json_fixture) diff --git a/tests/fixtures/smart_meter_texas/latestodrread.json b/tests/components/smart_meter_texas/fixtures/latestodrread.json similarity index 100% rename from tests/fixtures/smart_meter_texas/latestodrread.json rename to tests/components/smart_meter_texas/fixtures/latestodrread.json diff --git a/tests/fixtures/smart_meter_texas/meter.json b/tests/components/smart_meter_texas/fixtures/meter.json similarity index 100% rename from tests/fixtures/smart_meter_texas/meter.json rename to tests/components/smart_meter_texas/fixtures/meter.json diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index efc34424ae0..7f4748bc215 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -65,6 +65,8 @@ async def test_entity_and_device_attributes(hass, device_factory): assert entry.unique_id == f"{device.device_id}.{Attribute.motion}" entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry + assert entry.configuration_url == "https://account.smartthings.com" + assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label assert entry.model == device.device_type_name assert entry.manufacturer == "Unavailable" diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index dc8f2acc9fa..17443b72029 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -579,6 +579,8 @@ async def test_entity_and_device_attributes(hass, thermostat): entry = device_registry.async_get_device({(DOMAIN, thermostat.device_id)}) assert entry + assert entry.configuration_url == "https://account.smartthings.com" + assert entry.identifiers == {(DOMAIN, thermostat.device_id)} assert entry.name == thermostat.label assert entry.model == thermostat.device_type_name assert entry.manufacturer == "Unavailable" diff --git a/tests/components/smartthings/test_cover.py b/tests/components/smartthings/test_cover.py index aad7a4b037e..4111d21b25b 100644 --- a/tests/components/smartthings/test_cover.py +++ b/tests/components/smartthings/test_cover.py @@ -44,6 +44,8 @@ async def test_entity_and_device_attributes(hass, device_factory): entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry + assert entry.configuration_url == "https://account.smartthings.com" + assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label assert entry.model == device.device_type_name assert entry.manufacturer == "Unavailable" diff --git a/tests/components/smartthings/test_fan.py b/tests/components/smartthings/test_fan.py index 2a66fc646c7..16b0360f9eb 100644 --- a/tests/components/smartthings/test_fan.py +++ b/tests/components/smartthings/test_fan.py @@ -70,6 +70,8 @@ async def test_entity_and_device_attributes(hass, device_factory): entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry + assert entry.configuration_url == "https://account.smartthings.com" + assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label assert entry.model == device.device_type_name assert entry.manufacturer == "Unavailable" diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index 81062adf934..166b0606b66 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -123,6 +123,8 @@ async def test_entity_and_device_attributes(hass, device_factory): entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry + assert entry.configuration_url == "https://account.smartthings.com" + assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label assert entry.model == device.device_type_name assert entry.manufacturer == "Unavailable" diff --git a/tests/components/smartthings/test_lock.py b/tests/components/smartthings/test_lock.py index 86c8d534a71..f1ab6640058 100644 --- a/tests/components/smartthings/test_lock.py +++ b/tests/components/smartthings/test_lock.py @@ -32,6 +32,8 @@ async def test_entity_and_device_attributes(hass, device_factory): entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry + assert entry.configuration_url == "https://account.smartthings.com" + assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label assert entry.model == device.device_type_name assert entry.manufacturer == "Unavailable" diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 049666baf99..0e22a1facba 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -98,6 +98,8 @@ async def test_entity_and_device_attributes(hass, device_factory): assert entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry + assert entry.configuration_url == "https://account.smartthings.com" + assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label assert entry.model == device.device_type_name assert entry.manufacturer == "Unavailable" @@ -125,6 +127,8 @@ async def test_energy_sensors_for_switch_device(hass, device_factory): assert entry.entity_category is None entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry + assert entry.configuration_url == "https://account.smartthings.com" + assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label assert entry.model == device.device_type_name assert entry.manufacturer == "Unavailable" @@ -138,6 +142,8 @@ async def test_energy_sensors_for_switch_device(hass, device_factory): assert entry.entity_category is None entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry + assert entry.configuration_url == "https://account.smartthings.com" + assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label assert entry.model == device.device_type_name assert entry.manufacturer == "Unavailable" @@ -175,6 +181,8 @@ async def test_power_consumption_sensor(hass, device_factory): assert entry.unique_id == f"{device.device_id}.energy_meter" entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry + assert entry.configuration_url == "https://account.smartthings.com" + assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label assert entry.model == device.device_type_name assert entry.manufacturer == "Unavailable" @@ -187,6 +195,8 @@ async def test_power_consumption_sensor(hass, device_factory): assert entry.unique_id == f"{device.device_id}.power_meter" entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry + assert entry.configuration_url == "https://account.smartthings.com" + assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label assert entry.model == device.device_type_name assert entry.manufacturer == "Unavailable" @@ -209,6 +219,8 @@ async def test_power_consumption_sensor(hass, device_factory): assert entry.unique_id == f"{device.device_id}.energy_meter" entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry + assert entry.configuration_url == "https://account.smartthings.com" + assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label assert entry.model == device.device_type_name assert entry.manufacturer == "Unavailable" diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index c884d601baf..77c7f4d2c7e 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -31,6 +31,8 @@ async def test_entity_and_device_attributes(hass, device_factory): entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry + assert entry.configuration_url == "https://account.smartthings.com" + assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label assert entry.model == device.device_type_name assert entry.manufacturer == "Unavailable" diff --git a/tests/fixtures/smtp/configuration.yaml b/tests/components/smtp/fixtures/configuration.yaml similarity index 100% rename from tests/fixtures/smtp/configuration.yaml rename to tests/components/smtp/fixtures/configuration.yaml diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 5af1e5fcdbc..38f48c169ac 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -1,5 +1,4 @@ """The tests for the notify smtp platform.""" -from os import path import re from unittest.mock import patch @@ -12,6 +11,8 @@ from homeassistant.components.smtp.notify import MailNotificationService from homeassistant.const import SERVICE_RELOAD from homeassistant.setup import async_setup_component +from tests.common import get_fixture_path + class MockSMTP(MailNotificationService): """Test SMTP object that doesn't need a working server.""" @@ -45,11 +46,7 @@ async def test_reload_notify(hass): assert hass.services.has_service(notify.DOMAIN, DOMAIN) - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "smtp/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "smtp") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path), patch( "homeassistant.components.smtp.notify.MailNotificationService.connection_is_valid" ): @@ -65,10 +62,6 @@ async def test_reload_notify(hass): assert hass.services.has_service(notify.DOMAIN, "smtp_reloaded") -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) - - @pytest.fixture def message(): """Return MockSMTP object with test data.""" diff --git a/tests/components/snips/test_init.py b/tests/components/snips/test_init.py index 82811b61925..14e58d54ebe 100644 --- a/tests/components/snips/test_init.py +++ b/tests/components/snips/test_init.py @@ -6,7 +6,6 @@ import pytest import voluptuous as vol from homeassistant.bootstrap import async_setup_component -from homeassistant.components.mqtt import MQTT_PUBLISH_SCHEMA import homeassistant.components.snips as snips from homeassistant.helpers.intent import ServiceIntentHandler, async_register @@ -47,38 +46,36 @@ async def test_snips_bad_config(hass, mqtt_mock): async def test_snips_config_feedback_on(hass, mqtt_mock): """Test Snips Config.""" - calls = async_mock_service(hass, "mqtt", "publish", MQTT_PUBLISH_SCHEMA) result = await async_setup_component( hass, "snips", {"snips": {"feedback_sounds": True}} ) assert result await hass.async_block_till_done() - assert len(calls) == 2 - topic = calls[0].data["topic"] + assert mqtt_mock.async_publish.call_count == 2 + topic = mqtt_mock.async_publish.call_args_list[0][0][0] assert topic == "hermes/feedback/sound/toggleOn" - topic = calls[1].data["topic"] + topic = mqtt_mock.async_publish.call_args_list[1][0][0] assert topic == "hermes/feedback/sound/toggleOn" - assert calls[1].data["qos"] == 1 - assert calls[1].data["retain"] + assert mqtt_mock.async_publish.call_args_list[1][0][2] == 1 + assert mqtt_mock.async_publish.call_args_list[1][0][3] async def test_snips_config_feedback_off(hass, mqtt_mock): """Test Snips Config.""" - calls = async_mock_service(hass, "mqtt", "publish", MQTT_PUBLISH_SCHEMA) result = await async_setup_component( hass, "snips", {"snips": {"feedback_sounds": False}} ) assert result await hass.async_block_till_done() - assert len(calls) == 2 - topic = calls[0].data["topic"] + assert mqtt_mock.async_publish.call_count == 2 + topic = mqtt_mock.async_publish.call_args_list[0][0][0] assert topic == "hermes/feedback/sound/toggleOn" - topic = calls[1].data["topic"] + topic = mqtt_mock.async_publish.call_args_list[1][0][0] assert topic == "hermes/feedback/sound/toggleOff" - assert calls[1].data["qos"] == 0 - assert not calls[1].data["retain"] + assert mqtt_mock.async_publish.call_args_list[1][0][2] == 0 + assert not mqtt_mock.async_publish.call_args_list[1][0][3] async def test_snips_config_no_feedback(hass, mqtt_mock): @@ -232,7 +229,6 @@ async def test_snips_intent_with_duration(hass, mqtt_mock): async def test_intent_speech_response(hass, mqtt_mock): """Test intent speech response via Snips.""" - calls = async_mock_service(hass, "mqtt", "publish", MQTT_PUBLISH_SCHEMA) result = await async_setup_component(hass, "snips", {"snips": {}}) assert result result = await async_setup_component( @@ -261,9 +257,9 @@ async def test_intent_speech_response(hass, mqtt_mock): async_fire_mqtt_message(hass, "hermes/intent/spokenIntent", payload) await hass.async_block_till_done() - assert len(calls) == 1 - payload = json.loads(calls[0].data["payload"]) - topic = calls[0].data["topic"] + assert mqtt_mock.async_publish.call_count == 1 + payload = json.loads(mqtt_mock.async_publish.call_args[0][1]) + topic = mqtt_mock.async_publish.call_args[0][0] assert payload["sessionId"] == "abcdef0123456789" assert payload["text"] == "I am speaking to you" assert topic == "hermes/dialogueManager/endSession" diff --git a/tests/components/somfy_mylink/test_config_flow.py b/tests/components/somfy_mylink/test_config_flow.py index 38cb2a52dfd..d98e7429d8b 100644 --- a/tests/components/somfy_mylink/test_config_flow.py +++ b/tests/components/somfy_mylink/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from homeassistant import config_entries, data_entry_flow -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp from homeassistant.components.somfy_mylink.const import ( CONF_REVERSED_TARGET_IDS, CONF_SYSTEM_ID, @@ -252,11 +252,11 @@ async def test_form_user_already_configured_from_dhcp(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - IP_ADDRESS: "1.1.1.1", - MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", - HOSTNAME: "somfy_eeff", - }, + data=dhcp.DhcpServiceInfo( + ip="1.1.1.1", + macaddress="AA:BB:CC:DD:EE:FF", + hostname="somfy_eeff", + ), ) await hass.async_block_till_done() @@ -276,11 +276,11 @@ async def test_already_configured_with_ignored(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - IP_ADDRESS: "1.1.1.1", - MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", - HOSTNAME: "somfy_eeff", - }, + data=dhcp.DhcpServiceInfo( + ip="1.1.1.1", + macaddress="AA:BB:CC:DD:EE:FF", + hostname="somfy_eeff", + ), ) assert result["type"] == "form" @@ -291,11 +291,11 @@ async def test_dhcp_discovery(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - IP_ADDRESS: "1.1.1.1", - MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", - HOSTNAME: "somfy_eeff", - }, + data=dhcp.DhcpServiceInfo( + ip="1.1.1.1", + macaddress="AA:BB:CC:DD:EE:FF", + hostname="somfy_eeff", + ), ) assert result["type"] == "form" assert result["errors"] == {} diff --git a/tests/fixtures/sonarr/calendar.json b/tests/components/sonarr/fixtures/calendar.json similarity index 100% rename from tests/fixtures/sonarr/calendar.json rename to tests/components/sonarr/fixtures/calendar.json diff --git a/tests/fixtures/sonarr/command.json b/tests/components/sonarr/fixtures/command.json similarity index 100% rename from tests/fixtures/sonarr/command.json rename to tests/components/sonarr/fixtures/command.json diff --git a/tests/fixtures/sonarr/diskspace.json b/tests/components/sonarr/fixtures/diskspace.json similarity index 100% rename from tests/fixtures/sonarr/diskspace.json rename to tests/components/sonarr/fixtures/diskspace.json diff --git a/tests/fixtures/sonarr/queue.json b/tests/components/sonarr/fixtures/queue.json similarity index 100% rename from tests/fixtures/sonarr/queue.json rename to tests/components/sonarr/fixtures/queue.json diff --git a/tests/fixtures/sonarr/series.json b/tests/components/sonarr/fixtures/series.json similarity index 100% rename from tests/fixtures/sonarr/series.json rename to tests/components/sonarr/fixtures/series.json diff --git a/tests/fixtures/sonarr/system-status.json b/tests/components/sonarr/fixtures/system-status.json similarity index 100% rename from tests/fixtures/sonarr/system-status.json rename to tests/components/sonarr/fixtures/system-status.json diff --git a/tests/fixtures/sonarr/wanted-missing.json b/tests/components/sonarr/fixtures/wanted-missing.json similarity index 100% rename from tests/fixtures/sonarr/wanted-missing.json rename to tests/components/sonarr/fixtures/wanted-missing.json diff --git a/tests/components/songpal/test_config_flow.py b/tests/components/songpal/test_config_flow.py index a1751bca676..4d58639a1d0 100644 --- a/tests/components/songpal/test_config_flow.py +++ b/tests/components/songpal/test_config_flow.py @@ -1,5 +1,6 @@ """Test the songpal config flow.""" import copy +import dataclasses from unittest.mock import patch from homeassistant.components import ssdp @@ -26,17 +27,21 @@ from tests.common import MockConfigEntry UDN = "uuid:1234" -SSDP_DATA = { - ssdp.ATTR_UPNP_UDN: UDN, - ssdp.ATTR_UPNP_FRIENDLY_NAME: FRIENDLY_NAME, - ssdp.ATTR_SSDP_LOCATION: f"http://{HOST}:52323/dmr.xml", - "X_ScalarWebAPI_DeviceInfo": { - "X_ScalarWebAPI_BaseURL": ENDPOINT, - "X_ScalarWebAPI_ServiceList": { - "X_ScalarWebAPI_ServiceType": ["guide", "system", "audio", "avContent"], +SSDP_DATA = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=f"http://{HOST}:52323/dmr.xml", + upnp={ + ssdp.ATTR_UPNP_UDN: UDN, + ssdp.ATTR_UPNP_FRIENDLY_NAME: FRIENDLY_NAME, + "X_ScalarWebAPI_DeviceInfo": { + "X_ScalarWebAPI_BaseURL": ENDPOINT, + "X_ScalarWebAPI_ServiceList": { + "X_ScalarWebAPI_ServiceType": ["guide", "system", "audio", "avContent"], + }, }, }, -} +) def _flow_next(hass, flow_id): @@ -150,8 +155,9 @@ def _create_mock_config_entry(hass): async def test_ssdp_bravia(hass): """Test discovering a bravia TV.""" - ssdp_data = copy.deepcopy(SSDP_DATA) - ssdp_data["X_ScalarWebAPI_DeviceInfo"]["X_ScalarWebAPI_ServiceList"][ + ssdp_data = dataclasses.replace(SSDP_DATA) + ssdp_data.upnp = copy.deepcopy(ssdp_data.upnp) + ssdp_data.upnp["X_ScalarWebAPI_DeviceInfo"]["X_ScalarWebAPI_ServiceList"][ "X_ScalarWebAPI_ServiceType" ].append("videoScreen") result = await hass.config_entries.flow.async_init( diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 8a3a6571faa..f7f8d67589f 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -70,6 +70,11 @@ def soco_fixture(music_library, speaker_info, battery_info, alarm_clock): mock_soco.night_mode = True mock_soco.dialog_mode = True mock_soco.volume = 19 + mock_soco.bass = 1 + mock_soco.treble = -1 + mock_soco.sub_enabled = False + mock_soco.surround_enabled = True + mock_soco.soundbar_audio_input_format = "Dolby 5.1" mock_soco.get_battery_info.return_value = battery_info mock_soco.all_zones = [mock_soco] yield mock_soco @@ -92,10 +97,14 @@ def discover_fixture(soco): async def do_callback(hass, callback, *args, **kwargs): await callback( - { - ssdp.ATTR_UPNP_UDN: f"uuid:{soco.uid}", - ssdp.ATTR_SSDP_LOCATION: f"http://{soco.ip_address}/", - }, + ssdp.SsdpServiceInfo( + ssdp_location=f"http://{soco.ip_address}/", + ssdp_st="urn:schemas-upnp-org:device:ZonePlayer:1", + ssdp_usn=f"uuid:{soco.uid}_MR::urn:schemas-upnp-org:service:GroupRenderingControl:1", + upnp={ + ssdp.ATTR_UPNP_UDN: f"uuid:{soco.uid}", + }, + ), ssdp.SsdpChange.ALIVE, ) return MagicMock() diff --git a/tests/components/sonos/test_config_flow.py b/tests/components/sonos/test_config_flow.py index 7d6fd02f51d..9677ee73759 100644 --- a/tests/components/sonos/test_config_flow.py +++ b/tests/components/sonos/test_config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations from unittest.mock import MagicMock, patch from homeassistant import config_entries, core +from homeassistant.components import zeroconf from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER, DOMAIN @@ -43,12 +44,14 @@ async def test_zeroconf_form(hass: core.HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "host": "192.168.4.2", - "name": "Sonos-aaa@Living Room._sonos._tcp.local.", - "hostname": "Sonos-aaa", - "properties": {"bootseq": "1234"}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.4.2", + hostname="Sonos-aaa", + name="Sonos-aaa@Living Room._sonos._tcp.local.", + port=None, + properties={"bootseq": "1234"}, + type="mock_type", + ), ) assert result["type"] == "form" assert result["errors"] is None @@ -82,13 +85,13 @@ async def test_zeroconf_sonos_v1(hass: core.HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "host": "192.168.1.107", - "port": 1443, - "hostname": "sonos5CAAFDE47AC8.local.", - "type": "_sonos._tcp.local.", - "name": "Sonos-5CAAFDE47AC8._sonos._tcp.local.", - "properties": { + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.107", + port=1443, + hostname="sonos5CAAFDE47AC8.local.", + type="_sonos._tcp.local.", + name="Sonos-5CAAFDE47AC8._sonos._tcp.local.", + properties={ "_raw": { "info": b"/api/v1/players/RINCON_5CAAFDE47AC801400/info", "vers": b"1", @@ -98,7 +101,7 @@ async def test_zeroconf_sonos_v1(hass: core.HomeAssistant): "vers": "1", "protovers": "1.18.9", }, - }, + ), ) assert result["type"] == "form" assert result["errors"] is None @@ -132,11 +135,14 @@ async def test_zeroconf_form_not_sonos(hass: core.HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - "host": "192.168.4.2", - "hostname": "not-aaa", - "properties": {"bootseq": "1234"}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.4.2", + hostname="not-aaa", + name="mock_name", + port=None, + properties={"bootseq": "1234"}, + type="mock_type", + ), ) assert result["type"] == "abort" assert result["reason"] == "not_sonos_device" diff --git a/tests/components/sonos/test_sensor.py b/tests/components/sonos/test_sensor.py index 18cd87ca9be..a45d587cc08 100644 --- a/tests/components/sonos/test_sensor.py +++ b/tests/components/sonos/test_sensor.py @@ -123,3 +123,14 @@ async def test_device_payload_without_battery_and_ignored_keys( await hass.async_block_till_done() assert ignored_payload not in caplog.text + + +async def test_audio_input_sensor(hass, config_entry, config, soco): + """Test sonos device with battery state.""" + await setup_platform(hass, config_entry, config) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + audio_input_sensor = entity_registry.entities["sensor.zone_a_audio_input_format"] + audio_input_state = hass.states.get(audio_input_sensor.entity_id) + assert audio_input_state.state == "Dolby 5.1" diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index c2f997dbcb6..9c25e2b8cc7 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -14,7 +14,7 @@ from homeassistant.components.sonos.switch import ( ATTR_VOLUME, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY -from homeassistant.const import ATTR_TIME, STATE_ON +from homeassistant.const import ATTR_TIME, STATE_OFF, STATE_ON from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry from homeassistant.setup import async_setup_component from homeassistant.util import dt @@ -42,6 +42,8 @@ async def test_entity_registry(hass, config_entry, config): assert "switch.sonos_zone_a_status_light" in entity_registry.entities assert "switch.sonos_zone_a_night_sound" in entity_registry.entities assert "switch.sonos_zone_a_speech_enhancement" in entity_registry.entities + assert "switch.sonos_zone_a_subwoofer_enabled" in entity_registry.entities + assert "switch.sonos_zone_a_surround_enabled" in entity_registry.entities assert "switch.sonos_zone_a_touch_controls" in entity_registry.entities @@ -83,6 +85,14 @@ async def test_switch_attributes(hass, config_entry, config, soco): touch_controls = entity_registry.entities["switch.sonos_zone_a_touch_controls"] assert hass.states.get(touch_controls.entity_id) is None + sub_switch = entity_registry.entities["switch.sonos_zone_a_subwoofer_enabled"] + sub_switch_state = hass.states.get(sub_switch.entity_id) + assert sub_switch_state.state == STATE_OFF + + surround_switch = entity_registry.entities["switch.sonos_zone_a_surround_enabled"] + surround_switch_state = hass.states.get(surround_switch.entity_id) + assert surround_switch_state.state == STATE_ON + # Enable disabled switches for entity in (status_light, touch_controls): entity_registry.async_update_entity( diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py index 8ff18882e8b..fb0279f9112 100644 --- a/tests/components/spotify/test_config_flow.py +++ b/tests/components/spotify/test_config_flow.py @@ -5,6 +5,7 @@ from unittest.mock import patch from spotipy import SpotifyException from homeassistant import data_entry_flow, setup +from homeassistant.components import zeroconf from homeassistant.components.spotify.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET @@ -12,6 +13,15 @@ from homeassistant.helpers import config_entry_oauth2_flow from tests.common import MockConfigEntry +BLANK_ZEROCONF_INFO = zeroconf.ZeroconfServiceInfo( + host="1.2.3.4", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={}, + type="mock_type", +) + async def test_abort_if_no_configuration(hass): """Check flow aborts when no configuration is present.""" @@ -23,7 +33,7 @@ async def test_abort_if_no_configuration(hass): assert result["reason"] == "missing_configuration" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_ZEROCONF} + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=BLANK_ZEROCONF_INFO ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -35,7 +45,7 @@ async def test_zeroconf_abort_if_existing_entry(hass): MockConfigEntry(domain=DOMAIN).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_ZEROCONF} + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=BLANK_ZEROCONF_INFO ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index 7f07576427b..70f5f1233d7 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -1,11 +1,12 @@ """Test the Logitech Squeezebox config flow.""" + from http import HTTPStatus from unittest.mock import patch from pysqueezebox import Server from homeassistant import config_entries -from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from homeassistant.components import dhcp from homeassistant.components.squeezebox.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.data_entry_flow import ( @@ -200,11 +201,11 @@ async def test_dhcp_discovery(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - IP_ADDRESS: "1.1.1.1", - MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", - HOSTNAME: "any", - }, + data=dhcp.DhcpServiceInfo( + ip="1.1.1.1", + macaddress="AA:BB:CC:DD:EE:FF", + hostname="any", + ), ) assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "edit" @@ -215,15 +216,15 @@ async def test_dhcp_discovery_no_server_found(hass): with patch( "homeassistant.components.squeezebox.config_flow.async_discover", mock_failed_discover, - ): + ), patch("homeassistant.components.squeezebox.config_flow.TIMEOUT", 0.1): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - IP_ADDRESS: "1.1.1.1", - MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", - HOSTNAME: "any", - }, + data=dhcp.DhcpServiceInfo( + ip="1.1.1.1", + macaddress="AA:BB:CC:DD:EE:FF", + hostname="any", + ), ) assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "user" @@ -238,11 +239,11 @@ async def test_dhcp_discovery_existing_player(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={ - IP_ADDRESS: "1.1.1.1", - MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", - HOSTNAME: "any", - }, + data=dhcp.DhcpServiceInfo( + ip="1.1.1.1", + macaddress="AA:BB:CC:DD:EE:FF", + hostname="any", + ), ) assert result["type"] == RESULT_TYPE_ABORT diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index c9098726ab4..9e4d7424eb9 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -1,4 +1,6 @@ """Test the SSDP integration.""" +# pylint: disable=protected-access + from datetime import datetime, timedelta from ipaddress import IPv4Address, IPv6Address from unittest.mock import ANY, AsyncMock, patch @@ -62,18 +64,28 @@ async def test_ssdp_flow_dispatched_on_st(mock_get_ssdp, hass, caplog, mock_flow assert mock_flow_init.mock_calls[0][2]["context"] == { "source": config_entries.SOURCE_SSDP } - assert mock_flow_init.mock_calls[0][2]["data"] == { - ssdp.ATTR_SSDP_ST: "mock-st", - ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", - ssdp.ATTR_SSDP_USN: "uuid:mock-udn::mock-st", - ssdp.ATTR_SSDP_SERVER: "mock-server", - ssdp.ATTR_SSDP_EXT: "", - ssdp.ATTR_UPNP_UDN: "uuid:mock-udn", - ssdp.ATTR_SSDP_UDN: ANY, - "_timestamp": ANY, - ssdp.ATTR_HA_MATCHING_DOMAINS: {"mock-domain"}, - } + mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + assert mock_call_data.ssdp_st == "mock-st" + assert mock_call_data.ssdp_location == "http://1.1.1.1" + assert mock_call_data.ssdp_usn == "uuid:mock-udn::mock-st" + assert mock_call_data.ssdp_server == "mock-server" + assert mock_call_data.ssdp_ext == "" + assert mock_call_data.ssdp_udn == ANY + assert mock_call_data.ssdp_headers["_timestamp"] == ANY + assert mock_call_data.x_homeassistant_matching_domains == {"mock-domain"} + assert mock_call_data.upnp == {ssdp.ATTR_UPNP_UDN: "uuid:mock-udn"} assert "Failed to fetch ssdp data" not in caplog.text + # Compatibility with old dict access (to be removed after 2022.6) + assert mock_call_data[ssdp.ATTR_SSDP_ST] == "mock-st" + assert mock_call_data[ssdp.ATTR_SSDP_LOCATION] == "http://1.1.1.1" + assert mock_call_data[ssdp.ATTR_SSDP_USN] == "uuid:mock-udn::mock-st" + assert mock_call_data[ssdp.ATTR_SSDP_SERVER] == "mock-server" + assert mock_call_data[ssdp.ATTR_SSDP_EXT] == "" + assert mock_call_data[ssdp.ATTR_UPNP_UDN] == "uuid:mock-udn" + assert mock_call_data[ssdp.ATTR_SSDP_UDN] == ANY + assert mock_call_data["_timestamp"] == ANY + assert mock_call_data[ssdp.ATTR_HA_MATCHING_DOMAINS] == {"mock-domain"} + # End compatibility checks @pytest.mark.usefixtures("mock_get_source_ip") @@ -347,19 +359,31 @@ async def test_discovery_from_advertisement_sets_ssdp_st( await hass.async_block_till_done() discovery_info = await ssdp.async_get_discovery_info_by_udn(hass, "uuid:mock-udn") - assert discovery_info == [ - { - ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", - ssdp.ATTR_SSDP_NT: "mock-st", - ssdp.ATTR_SSDP_ST: "mock-st", # Set by ssdp component, not in original advertisement. - ssdp.ATTR_SSDP_USN: "uuid:mock-udn::mock-st", - ssdp.ATTR_UPNP_UDN: "uuid:mock-udn", - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_SSDP_UDN: ANY, - "nts": "ssdp:alive", - "_timestamp": ANY, - } - ] + discovery_info = discovery_info[0] + assert discovery_info.ssdp_location == "http://1.1.1.1" + assert discovery_info.ssdp_nt == "mock-st" + # Set by ssdp component, not in original advertisement. + assert discovery_info.ssdp_st == "mock-st" + assert discovery_info.ssdp_usn == "uuid:mock-udn::mock-st" + assert discovery_info.ssdp_udn == ANY + assert discovery_info.ssdp_headers["nts"] == "ssdp:alive" + assert discovery_info.ssdp_headers["_timestamp"] == ANY + assert discovery_info.upnp == { + ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", + ssdp.ATTR_UPNP_UDN: "uuid:mock-udn", + } + # Compatibility with old dict access (to be removed after 2022.6) + assert discovery_info[ssdp.ATTR_SSDP_LOCATION] == "http://1.1.1.1" + assert discovery_info[ssdp.ATTR_SSDP_NT] == "mock-st" + # Set by ssdp component, not in original advertisement. + assert discovery_info[ssdp.ATTR_SSDP_ST] == "mock-st" + assert discovery_info[ssdp.ATTR_SSDP_USN] == "uuid:mock-udn::mock-st" + assert discovery_info[ssdp.ATTR_UPNP_UDN] == "uuid:mock-udn" + assert discovery_info[ssdp.ATTR_UPNP_DEVICE_TYPE] == "Paulus" + assert discovery_info[ssdp.ATTR_SSDP_UDN] == ANY + assert discovery_info["nts"] == "ssdp:alive" + assert discovery_info["_timestamp"] == ANY + # End compatibility checks @patch( # XXX TODO: Isn't this duplicate with mock_get_source_ip? @@ -452,29 +476,48 @@ async def test_scan_with_registered_callback( assert async_integration_match_all_not_present_callback1.call_count == 0 assert async_match_any_callback1.call_count == 1 assert async_not_matching_integration_callback1.call_count == 0 - assert async_integration_callback.call_args[0] == ( - { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_SSDP_EXT: "", - ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", - ssdp.ATTR_SSDP_SERVER: "mock-server", - ssdp.ATTR_SSDP_ST: "mock-st", - ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::mock-st", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", - "x-rincon-bootseq": "55", - ssdp.ATTR_SSDP_UDN: ANY, - "_timestamp": ANY, - ssdp.ATTR_HA_MATCHING_DOMAINS: set(), - }, - ssdp.SsdpChange.ALIVE, + assert async_integration_callback.call_args[0][1] == ssdp.SsdpChange.ALIVE + mock_call_data: ssdp.SsdpServiceInfo = async_integration_callback.call_args[0][0] + assert mock_call_data.ssdp_ext == "" + assert mock_call_data.ssdp_location == "http://1.1.1.1" + assert mock_call_data.ssdp_server == "mock-server" + assert mock_call_data.ssdp_st == "mock-st" + assert ( + mock_call_data.ssdp_usn == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::mock-st" ) + assert mock_call_data.ssdp_headers["x-rincon-bootseq"] == "55" + assert mock_call_data.ssdp_udn == ANY + assert mock_call_data.ssdp_headers["_timestamp"] == ANY + assert mock_call_data.x_homeassistant_matching_domains == set() + assert mock_call_data.upnp == { + ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", + ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + } + # Compatibility with old dict access (to be removed after 2022.6) + assert mock_call_data[ssdp.ATTR_UPNP_DEVICE_TYPE] == "Paulus" + assert mock_call_data[ssdp.ATTR_SSDP_EXT] == "" + assert mock_call_data[ssdp.ATTR_SSDP_LOCATION] == "http://1.1.1.1" + assert mock_call_data[ssdp.ATTR_SSDP_SERVER] == "mock-server" + assert mock_call_data[ssdp.ATTR_SSDP_ST] == "mock-st" + assert ( + mock_call_data[ssdp.ATTR_SSDP_USN] + == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::mock-st" + ) + assert ( + mock_call_data[ssdp.ATTR_UPNP_UDN] + == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL" + ) + assert mock_call_data["x-rincon-bootseq"] == "55" + assert mock_call_data[ssdp.ATTR_SSDP_UDN] == ANY + assert mock_call_data["_timestamp"] == ANY + assert mock_call_data[ssdp.ATTR_HA_MATCHING_DOMAINS] == set() + # End of compatibility checks assert "Failed to callback info" in caplog.text async_integration_callback_from_cache = AsyncMock() await ssdp.async_register_callback( hass, async_integration_callback_from_cache, {"st": "mock-st"} ) - assert async_integration_callback_from_cache.call_count == 1 @@ -510,51 +553,109 @@ async def test_getting_existing_headers( await ssdp_listener._on_search(mock_ssdp_search_response) discovery_info_by_st = await ssdp.async_get_discovery_info_by_st(hass, "mock-st") - assert discovery_info_by_st == [ - { - ssdp.ATTR_SSDP_EXT: "", - ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", - ssdp.ATTR_SSDP_SERVER: "mock-server", - ssdp.ATTR_SSDP_ST: "mock-st", - ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_SSDP_UDN: ANY, - "_timestamp": ANY, - } - ] + discovery_info_by_st = discovery_info_by_st[0] + assert discovery_info_by_st.ssdp_ext == "" + assert discovery_info_by_st.ssdp_location == "http://1.1.1.1" + assert discovery_info_by_st.ssdp_server == "mock-server" + assert discovery_info_by_st.ssdp_st == "mock-st" + assert ( + discovery_info_by_st.ssdp_usn + == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3" + ) + assert discovery_info_by_st.ssdp_udn == ANY + assert discovery_info_by_st.ssdp_headers["_timestamp"] == ANY + assert discovery_info_by_st.upnp == { + ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", + ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + } + # Compatibility with old dict access (to be removed after 2022.6) + assert discovery_info_by_st[ssdp.ATTR_SSDP_EXT] == "" + assert discovery_info_by_st[ssdp.ATTR_SSDP_LOCATION] == "http://1.1.1.1" + assert discovery_info_by_st[ssdp.ATTR_SSDP_SERVER] == "mock-server" + assert discovery_info_by_st[ssdp.ATTR_SSDP_ST] == "mock-st" + assert ( + discovery_info_by_st[ssdp.ATTR_SSDP_USN] + == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3" + ) + assert ( + discovery_info_by_st[ssdp.ATTR_UPNP_UDN] + == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL" + ) + assert discovery_info_by_st[ssdp.ATTR_UPNP_DEVICE_TYPE] == "Paulus" + assert discovery_info_by_st[ssdp.ATTR_SSDP_UDN] == ANY + assert discovery_info_by_st["_timestamp"] == ANY + # End of compatibility checks discovery_info_by_udn = await ssdp.async_get_discovery_info_by_udn( hass, "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL" ) - assert discovery_info_by_udn == [ - { - ssdp.ATTR_SSDP_EXT: "", - ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", - ssdp.ATTR_SSDP_SERVER: "mock-server", - ssdp.ATTR_SSDP_ST: "mock-st", - ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_SSDP_UDN: ANY, - "_timestamp": ANY, - } - ] + discovery_info_by_udn = discovery_info_by_udn[0] + assert discovery_info_by_udn.ssdp_ext == "" + assert discovery_info_by_udn.ssdp_location == "http://1.1.1.1" + assert discovery_info_by_udn.ssdp_server == "mock-server" + assert discovery_info_by_udn.ssdp_st == "mock-st" + assert ( + discovery_info_by_udn.ssdp_usn + == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3" + ) + assert discovery_info_by_udn.ssdp_udn == ANY + assert discovery_info_by_udn.ssdp_headers["_timestamp"] == ANY + assert discovery_info_by_udn.upnp == { + ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", + ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + } + # Compatibility with old dict access (to be removed after 2022.6) + assert discovery_info_by_udn[ssdp.ATTR_SSDP_EXT] == "" + assert discovery_info_by_udn[ssdp.ATTR_SSDP_LOCATION] == "http://1.1.1.1" + assert discovery_info_by_udn[ssdp.ATTR_SSDP_SERVER] == "mock-server" + assert discovery_info_by_udn[ssdp.ATTR_SSDP_ST] == "mock-st" + assert ( + discovery_info_by_udn[ssdp.ATTR_SSDP_USN] + == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3" + ) + assert ( + discovery_info_by_udn[ssdp.ATTR_UPNP_UDN] + == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL" + ) + assert discovery_info_by_udn[ssdp.ATTR_UPNP_DEVICE_TYPE] == "Paulus" + assert discovery_info_by_udn[ssdp.ATTR_SSDP_UDN] == ANY + assert discovery_info_by_udn["_timestamp"] == ANY + # End of compatibility checks discovery_info_by_udn_st = await ssdp.async_get_discovery_info_by_udn_st( hass, "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", "mock-st" ) - assert discovery_info_by_udn_st == { - ssdp.ATTR_SSDP_EXT: "", - ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1", - ssdp.ATTR_SSDP_SERVER: "mock-server", - ssdp.ATTR_SSDP_ST: "mock-st", - ssdp.ATTR_SSDP_USN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + assert discovery_info_by_udn_st.ssdp_ext == "" + assert discovery_info_by_udn_st.ssdp_location == "http://1.1.1.1" + assert discovery_info_by_udn_st.ssdp_server == "mock-server" + assert discovery_info_by_udn_st.ssdp_st == "mock-st" + assert ( + discovery_info_by_udn_st.ssdp_usn + == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3" + ) + assert discovery_info_by_udn_st.ssdp_udn == ANY + assert discovery_info_by_udn_st.ssdp_headers["_timestamp"] == ANY + assert discovery_info_by_udn_st.upnp == { ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_SSDP_UDN: ANY, - "_timestamp": ANY, + ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } + # Compatibility with old dict access (to be removed after 2022.6) + assert discovery_info_by_udn_st[ssdp.ATTR_SSDP_EXT] == "" + assert discovery_info_by_udn_st[ssdp.ATTR_SSDP_LOCATION] == "http://1.1.1.1" + assert discovery_info_by_udn_st[ssdp.ATTR_SSDP_SERVER] == "mock-server" + assert discovery_info_by_udn_st[ssdp.ATTR_SSDP_ST] == "mock-st" + assert ( + discovery_info_by_udn_st[ssdp.ATTR_SSDP_USN] + == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3" + ) + assert ( + discovery_info_by_udn_st[ssdp.ATTR_UPNP_UDN] + == "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL" + ) + assert discovery_info_by_udn_st[ssdp.ATTR_UPNP_DEVICE_TYPE] == "Paulus" + assert discovery_info_by_udn_st[ssdp.ATTR_SSDP_UDN] == ANY + assert discovery_info_by_udn_st["_timestamp"] == ANY + # End of compatibility checks assert ( await ssdp.async_get_discovery_info_by_udn_st(hass, "wrong", "mock-st") is None diff --git a/tests/fixtures/statistics/configuration.yaml b/tests/components/statistics/fixtures/configuration.yaml similarity index 71% rename from tests/fixtures/statistics/configuration.yaml rename to tests/components/statistics/fixtures/configuration.yaml index a6ce34377a0..4708910b53e 100644 --- a/tests/fixtures/statistics/configuration.yaml +++ b/tests/components/statistics/fixtures/configuration.yaml @@ -2,3 +2,4 @@ sensor: - platform: statistics entity_id: sensor.cpu name: cputest + state_characteristic: mean diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 6cb53c9b93f..658d6a089e7 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -1,6 +1,5 @@ """The test for the statistics sensor platform.""" from datetime import datetime, timedelta -from os import path import statistics import unittest from unittest.mock import patch @@ -9,10 +8,12 @@ import pytest from homeassistant import config as hass_config from homeassistant.components import recorder +from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT from homeassistant.components.statistics.sensor import DOMAIN, StatisticsSensor from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, SERVICE_RELOAD, + STATE_UNAVAILABLE, STATE_UNKNOWN, TEMP_CELSIUS, ) @@ -21,6 +22,7 @@ from homeassistant.util import dt as dt_util from tests.common import ( fire_time_changed, + get_fixture_path, get_test_home_assistant, init_recorder_component, ) @@ -39,61 +41,27 @@ class TestStatisticsSensor(unittest.TestCase): def setup_method(self, method): """Set up things to be run when tests are started.""" self.hass = get_test_home_assistant() + self.values_binary = ["on", "off", "on", "off", "on", "off", "on", "off", "on"] + self.mean_binary = round( + 100 / len(self.values_binary) * self.values_binary.count("on"), 2 + ) self.values = [17, 20, 15.2, 5, 3.8, 9.2, 6.7, 14, 6] - self.count = len(self.values) - self.min = min(self.values) - self.max = max(self.values) - self.total = sum(self.values) self.mean = round(sum(self.values) / len(self.values), 2) - self.median = round(statistics.median(self.values), 2) - self.deviation = round(statistics.stdev(self.values), 2) - self.variance = round(statistics.variance(self.values), 2) - self.quantiles = [ - round(quantile, 2) for quantile in statistics.quantiles(self.values) - ] - self.change = round(self.values[-1] - self.values[0], 2) - self.average_change = round(self.change / (len(self.values) - 1), 2) - self.change_rate = round(self.change / (60 * (self.count - 1)), 2) self.addCleanup(self.hass.stop) - def test_binary_sensor_source(self): - """Test if source is a sensor.""" - values = ["on", "off", "on", "off", "on", "off", "on"] + def test_sensor_defaults_numeric(self): + """Test the general behavior of the sensor, with numeric source sensor.""" assert setup_component( self.hass, "sensor", { - "sensor": { - "platform": "statistics", - "name": "test", - "entity_id": "binary_sensor.test_monitored", - } - }, - ) - - self.hass.block_till_done() - self.hass.start() - self.hass.block_till_done() - - for value in values: - self.hass.states.set("binary_sensor.test_monitored", value) - self.hass.block_till_done() - - state = self.hass.states.get("sensor.test") - - assert str(len(values)) == state.state - - def test_sensor_source(self): - """Test if source is a sensor.""" - assert setup_component( - self.hass, - "sensor", - { - "sensor": { - "platform": "statistics", - "name": "test", - "entity_id": "sensor.test_monitored", - } + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "sensor.test_monitored", + }, + ] }, ) @@ -103,50 +71,170 @@ class TestStatisticsSensor(unittest.TestCase): for value in self.values: self.hass.states.set( - "sensor.test_monitored", value, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS} + "sensor.test_monitored", + value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, ) self.hass.block_till_done() state = self.hass.states.get("sensor.test") - - assert str(self.mean) == state.state - assert self.min == state.attributes.get("min_value") - assert self.max == state.attributes.get("max_value") - assert self.variance == state.attributes.get("variance") - assert self.median == state.attributes.get("median") - assert self.deviation == state.attributes.get("standard_deviation") - assert self.quantiles == state.attributes.get("quantiles") - assert self.mean == state.attributes.get("mean") - assert self.count == state.attributes.get("count") - assert self.total == state.attributes.get("total") + assert state.state == str(self.mean) assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - assert self.change == state.attributes.get("change") - assert self.average_change == state.attributes.get("average_change") + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) + assert state.attributes.get("source_value_valid") is True + assert "age_coverage_ratio" not in state.attributes - # Source sensor is unavailable, unit and state should not change - self.hass.states.set("sensor.test_monitored", "unavailable", {}) + # Source sensor turns unavailable, then available with valid value, + # statistics sensor should follow + state = self.hass.states.get("sensor.test") + self.hass.states.set( + "sensor.test_monitored", + STATE_UNAVAILABLE, + ) self.hass.block_till_done() new_state = self.hass.states.get("sensor.test") - assert state == new_state + assert new_state.state == STATE_UNAVAILABLE + assert new_state.attributes.get("source_value_valid") is None + self.hass.states.set( + "sensor.test_monitored", + 0, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + ) + self.hass.block_till_done() + new_state = self.hass.states.get("sensor.test") + new_mean = round(sum(self.values) / (len(self.values) + 1), 2) + assert new_state.state == str(new_mean) + assert new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + assert new_state.attributes.get("buffer_usage_ratio") == round(10 / 20, 2) + assert new_state.attributes.get("source_value_valid") is True - # Source sensor has a non float state, unit and state should not change + # Source sensor has a nonnumerical state, unit and state should not change + state = self.hass.states.get("sensor.test") self.hass.states.set("sensor.test_monitored", "beer", {}) self.hass.block_till_done() new_state = self.hass.states.get("sensor.test") - assert state == new_state + assert new_state.state == str(new_mean) + assert new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + assert new_state.attributes.get("source_value_valid") is False - def test_sampling_size(self): + # Source sensor has the STATE_UNKNOWN state, unit and state should not change + state = self.hass.states.get("sensor.test") + self.hass.states.set("sensor.test_monitored", STATE_UNKNOWN, {}) + self.hass.block_till_done() + new_state = self.hass.states.get("sensor.test") + assert new_state.state == str(new_mean) + assert new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + assert new_state.attributes.get("source_value_valid") is False + + # Source sensor is removed, unit and state should not change + # This is equal to a None value being published + self.hass.states.remove("sensor.test_monitored") + self.hass.block_till_done() + new_state = self.hass.states.get("sensor.test") + assert new_state.state == str(new_mean) + assert new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + assert new_state.attributes.get("source_value_valid") is False + + def test_sensor_defaults_binary(self): + """Test the general behavior of the sensor, with binary source sensor.""" + assert setup_component( + self.hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "binary_sensor.test_monitored", + }, + ] + }, + ) + + self.hass.block_till_done() + self.hass.start() + self.hass.block_till_done() + for value in self.values_binary: + self.hass.states.set( + "binary_sensor.test_monitored", + value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + ) + self.hass.block_till_done() + + state = self.hass.states.get("sensor.test") + assert state.state == str(len(self.values_binary)) + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) + assert state.attributes.get("source_value_valid") is True + assert "age_coverage_ratio" not in state.attributes + + def test_sensor_source_with_force_update(self): + """Test the behavior of the sensor when the source sensor force-updates with same value.""" + repeating_values = [18, 0, 0, 0, 0, 0, 0, 0, 9] + assert setup_component( + self.hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test_normal", + "entity_id": "sensor.test_monitored_normal", + "state_characteristic": "mean", + }, + { + "platform": "statistics", + "name": "test_force", + "entity_id": "sensor.test_monitored_force", + "state_characteristic": "mean", + }, + ] + }, + ) + + self.hass.block_till_done() + self.hass.start() + self.hass.block_till_done() + + for value in repeating_values: + self.hass.states.set( + "sensor.test_monitored_normal", + value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + ) + self.hass.states.set( + "sensor.test_monitored_force", + value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + force_update=True, + ) + self.hass.block_till_done() + + state_normal = self.hass.states.get("sensor.test_normal") + state_force = self.hass.states.get("sensor.test_force") + assert state_normal.state == str(round(sum(repeating_values) / 3, 2)) + assert state_force.state == str(round(sum(repeating_values) / 9, 2)) + assert state_normal.attributes.get("buffer_usage_ratio") == round(3 / 20, 2) + assert state_force.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) + + def test_sampling_size_non_default(self): """Test rotation.""" assert setup_component( self.hass, "sensor", { - "sensor": { - "platform": "statistics", - "name": "test", - "entity_id": "sensor.test_monitored", - "sampling_size": 5, - } + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "sampling_size": 5, + }, + ] }, ) @@ -156,14 +244,16 @@ class TestStatisticsSensor(unittest.TestCase): for value in self.values: self.hass.states.set( - "sensor.test_monitored", value, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS} + "sensor.test_monitored", + value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, ) self.hass.block_till_done() state = self.hass.states.get("sensor.test") - - assert state.attributes.get("min_value") == 3.8 - assert state.attributes.get("max_value") == 14 + new_mean = round(sum(self.values[-5:]) / len(self.values[-5:]), 2) + assert state.state == str(new_mean) + assert state.attributes.get("buffer_usage_ratio") == round(5 / 5, 2) def test_sampling_size_1(self): """Test validity of stats requiring only one sample.""" @@ -171,12 +261,15 @@ class TestStatisticsSensor(unittest.TestCase): self.hass, "sensor", { - "sensor": { - "platform": "statistics", - "name": "test", - "entity_id": "sensor.test_monitored", - "sampling_size": 1, - } + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "sampling_size": 1, + }, + ] }, ) @@ -186,28 +279,19 @@ class TestStatisticsSensor(unittest.TestCase): for value in self.values[-3:]: # just the last 3 will do self.hass.states.set( - "sensor.test_monitored", value, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS} + "sensor.test_monitored", + value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, ) self.hass.block_till_done() state = self.hass.states.get("sensor.test") + new_mean = float(self.values[-1]) + assert state.state == str(new_mean) + assert state.attributes.get("buffer_usage_ratio") == round(1 / 1, 2) - # require only one data point - assert self.values[-1] == state.attributes.get("min_value") - assert self.values[-1] == state.attributes.get("max_value") - assert self.values[-1] == state.attributes.get("mean") - assert self.values[-1] == state.attributes.get("median") - assert self.values[-1] == state.attributes.get("total") - assert state.attributes.get("change") == 0 - assert state.attributes.get("average_change") == 0 - - # require at least two data points - assert state.attributes.get("variance") == STATE_UNKNOWN - assert state.attributes.get("standard_deviation") == STATE_UNKNOWN - assert state.attributes.get("quantiles") == STATE_UNKNOWN - - def test_max_age(self): - """Test value deprecation.""" + def test_age_limit_expiry(self): + """Test that values are removed after certain age.""" now = dt_util.utcnow() mock_data = { "return_time": datetime(now.year + 1, 8, 2, 12, 23, tzinfo=dt_util.UTC) @@ -223,12 +307,15 @@ class TestStatisticsSensor(unittest.TestCase): self.hass, "sensor", { - "sensor": { - "platform": "statistics", - "name": "test", - "entity_id": "sensor.test_monitored", - "max_age": {"minutes": 3}, - } + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "max_age": {"minutes": 4}, + }, + ] }, ) @@ -243,72 +330,227 @@ class TestStatisticsSensor(unittest.TestCase): {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, ) self.hass.block_till_done() - # insert the next value one minute later mock_data["return_time"] += timedelta(minutes=1) - state = self.hass.states.get("sensor.test") - - assert state.attributes.get("min_value") == 6 - assert state.attributes.get("max_value") == 14 - - def test_max_age_without_sensor_change(self): - """Test value deprecation.""" - now = dt_util.utcnow() - mock_data = { - "return_time": datetime(now.year + 1, 8, 2, 12, 23, tzinfo=dt_util.UTC) - } - - def mock_now(): - return mock_data["return_time"] - - with patch( - "homeassistant.components.statistics.sensor.dt_util.utcnow", new=mock_now - ): - assert setup_component( - self.hass, - "sensor", - { - "sensor": { - "platform": "statistics", - "name": "test", - "entity_id": "sensor.test_monitored", - "max_age": {"minutes": 3}, - } - }, - ) - - self.hass.block_till_done() - self.hass.start() - self.hass.block_till_done() - - for value in self.values: - self.hass.states.set( - "sensor.test_monitored", - value, - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, - ) - self.hass.block_till_done() - # insert the next value 30 seconds later - mock_data["return_time"] += timedelta(seconds=30) + # After adding all values, we should only see 5 values in memory state = self.hass.states.get("sensor.test") + new_mean = round(sum(self.values[-5:]) / len(self.values[-5:]), 2) + assert state.state == str(new_mean) + assert state.attributes.get("buffer_usage_ratio") == round(5 / 20, 2) + assert state.attributes.get("age_coverage_ratio") == 1.0 - assert state.attributes.get("min_value") == 3.8 - assert state.attributes.get("max_value") == 15.2 + # Values expire over time. Only two are left - # wait for 3 minutes (max_age). - mock_data["return_time"] += timedelta(minutes=3) + mock_data["return_time"] += timedelta(minutes=2) fire_time_changed(self.hass, mock_data["return_time"]) self.hass.block_till_done() state = self.hass.states.get("sensor.test") + new_mean = round(sum(self.values[-2:]) / len(self.values[-2:]), 2) + assert state.state == str(new_mean) + assert state.attributes.get("buffer_usage_ratio") == round(2 / 20, 2) + assert state.attributes.get("age_coverage_ratio") == 1 / 4 - assert state.attributes.get("min_value") == STATE_UNKNOWN - assert state.attributes.get("max_value") == STATE_UNKNOWN - assert state.attributes.get("count") == 0 + # Values expire over time. Only one is left - def test_change_rate(self): - """Test min_age/max_age and change_rate.""" + mock_data["return_time"] += timedelta(minutes=1) + fire_time_changed(self.hass, mock_data["return_time"]) + self.hass.block_till_done() + + state = self.hass.states.get("sensor.test") + new_mean = float(self.values[-1]) + assert state.state == str(new_mean) + assert state.attributes.get("buffer_usage_ratio") == round(1 / 20, 2) + assert state.attributes.get("age_coverage_ratio") == 0 + + # Values expire over time. Memory is empty + + mock_data["return_time"] += timedelta(minutes=1) + fire_time_changed(self.hass, mock_data["return_time"]) + self.hass.block_till_done() + + state = self.hass.states.get("sensor.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get("buffer_usage_ratio") == round(0 / 20, 2) + assert state.attributes.get("age_coverage_ratio") is None + + def test_precision_0(self): + """Test correct result with precision=0 as integer.""" + assert setup_component( + self.hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "precision": 0, + }, + ] + }, + ) + + self.hass.block_till_done() + self.hass.start() + self.hass.block_till_done() + + for value in self.values: + self.hass.states.set( + "sensor.test_monitored", + value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + ) + self.hass.block_till_done() + + state = self.hass.states.get("sensor.test") + assert state.state == str(int(round(self.mean))) + + def test_precision_1(self): + """Test correct result with precision=1 rounded to one decimal.""" + assert setup_component( + self.hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "precision": 1, + }, + ] + }, + ) + + self.hass.block_till_done() + self.hass.start() + self.hass.block_till_done() + + for value in self.values: + self.hass.states.set( + "sensor.test_monitored", + value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + ) + self.hass.block_till_done() + + state = self.hass.states.get("sensor.test") + assert state.state == str(round(sum(self.values) / len(self.values), 1)) + + def test_state_class(self): + """Test state class, which depends on the characteristic configured.""" + assert setup_component( + self.hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test_normal", + "entity_id": "sensor.test_monitored", + "state_characteristic": "count", + }, + { + "platform": "statistics", + "name": "test_nan", + "entity_id": "sensor.test_monitored", + "state_characteristic": "datetime_oldest", + }, + ] + }, + ) + + self.hass.block_till_done() + self.hass.start() + self.hass.block_till_done() + + for value in self.values: + self.hass.states.set( + "sensor.test_monitored", + value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + ) + self.hass.block_till_done() + + state = self.hass.states.get("sensor.test_normal") + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + state = self.hass.states.get("sensor.test_nan") + assert state.attributes.get(ATTR_STATE_CLASS) is None + + def test_unitless_source_sensor(self): + """Statistics for a unitless source sensor should never have a unit.""" + assert setup_component( + self.hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test_unitless_1", + "entity_id": "sensor.test_monitored_unitless", + "state_characteristic": "count", + }, + { + "platform": "statistics", + "name": "test_unitless_2", + "entity_id": "sensor.test_monitored_unitless", + "state_characteristic": "mean", + }, + { + "platform": "statistics", + "name": "test_unitless_3", + "entity_id": "sensor.test_monitored_unitless", + "state_characteristic": "change_second", + }, + { + "platform": "statistics", + "name": "test_unitless_4", + "entity_id": "binary_sensor.test_monitored_unitless", + }, + { + "platform": "statistics", + "name": "test_unitless_5", + "entity_id": "binary_sensor.test_monitored_unitless", + "state_characteristic": "mean", + }, + ] + }, + ) + + self.hass.block_till_done() + self.hass.start() + self.hass.block_till_done() + + for value in self.values: + self.hass.states.set( + "sensor.test_monitored_unitless", + value, + ) + self.hass.block_till_done() + for value in self.values_binary: + self.hass.states.set( + "binary_sensor.test_monitored_unitless", + value, + ) + self.hass.block_till_done() + + state = self.hass.states.get("sensor.test_unitless_1") + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + state = self.hass.states.get("sensor.test_unitless_2") + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + state = self.hass.states.get("sensor.test_unitless_3") + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + state = self.hass.states.get("sensor.test_unitless_4") + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + state = self.hass.states.get("sensor.test_unitless_5") + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "%" + + def test_state_characteristics(self): + """Test configured state characteristic for value and unit.""" now = dt_util.utcnow() mock_data = { "return_time": datetime(now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) @@ -317,44 +559,387 @@ class TestStatisticsSensor(unittest.TestCase): def mock_now(): return mock_data["return_time"] + value_spacing_minutes = 1 + + characteristics = ( + { + "source_sensor_domain": "sensor", + "name": "average_linear", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": 10.68, + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "average_step", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": 11.36, + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "average_timeless", + "value_0": STATE_UNKNOWN, + "value_1": float(self.values[0]), + "value_9": float(self.mean), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "change", + "value_0": STATE_UNKNOWN, + "value_1": float(0), + "value_9": float(round(self.values[-1] - self.values[0], 2)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "change_sample", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float( + round( + (self.values[-1] - self.values[0]) / (len(self.values) - 1), 2 + ) + ), + "unit": "°C/sample", + }, + { + "source_sensor_domain": "sensor", + "name": "change_second", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float( + round( + (self.values[-1] - self.values[0]) + / (60 * (len(self.values) - 1)), + 2, + ) + ), + "unit": "°C/s", + }, + { + "source_sensor_domain": "sensor", + "name": "count", + "value_0": 0, + "value_1": 1, + "value_9": len(self.values), + "unit": None, + }, + { + "source_sensor_domain": "sensor", + "name": "datetime_newest", + "value_0": STATE_UNKNOWN, + "value_1": datetime( + now.year + 1, + 8, + 2, + 12, + 23 + len(self.values) + 10, + 42, + tzinfo=dt_util.UTC, + ), + "value_9": datetime( + now.year + 1, + 8, + 2, + 12, + 23 + len(self.values) - 1, + 42, + tzinfo=dt_util.UTC, + ), + "unit": None, + }, + { + "source_sensor_domain": "sensor", + "name": "datetime_oldest", + "value_0": STATE_UNKNOWN, + "value_1": datetime( + now.year + 1, + 8, + 2, + 12, + 23 + len(self.values) + 10, + 42, + tzinfo=dt_util.UTC, + ), + "value_9": datetime(now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC), + "unit": None, + }, + { + "source_sensor_domain": "sensor", + "name": "distance_95_percent_of_values", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float(round(2 * 1.96 * statistics.stdev(self.values), 2)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "distance_99_percent_of_values", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float(round(2 * 2.58 * statistics.stdev(self.values), 2)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "distance_absolute", + "value_0": STATE_UNKNOWN, + "value_1": float(0), + "value_9": float(max(self.values) - min(self.values)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "mean", + "value_0": STATE_UNKNOWN, + "value_1": float(self.values[0]), + "value_9": float(self.mean), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "median", + "value_0": STATE_UNKNOWN, + "value_1": float(self.values[0]), + "value_9": float(round(statistics.median(self.values), 2)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "noisiness", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float( + round(sum([3, 4.8, 10.2, 1.2, 5.4, 2.5, 7.3, 8]) / 8, 2) + ), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "quantiles", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": [ + round(quantile, 2) for quantile in statistics.quantiles(self.values) + ], + "unit": None, + }, + { + "source_sensor_domain": "sensor", + "name": "standard_deviation", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float(round(statistics.stdev(self.values), 2)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "total", + "value_0": STATE_UNKNOWN, + "value_1": float(self.values[0]), + "value_9": float(sum(self.values)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "value_max", + "value_0": STATE_UNKNOWN, + "value_1": float(self.values[0]), + "value_9": float(max(self.values)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "value_min", + "value_0": STATE_UNKNOWN, + "value_1": float(self.values[0]), + "value_9": float(min(self.values)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "variance", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float(round(statistics.variance(self.values), 2)), + "unit": "°C²", + }, + { + "source_sensor_domain": "binary_sensor", + "name": "average_step", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": 50.0, + "unit": "%", + }, + { + "source_sensor_domain": "binary_sensor", + "name": "average_timeless", + "value_0": STATE_UNKNOWN, + "value_1": 100.0, + "value_9": float(self.mean_binary), + "unit": "%", + }, + { + "source_sensor_domain": "binary_sensor", + "name": "count", + "value_0": 0, + "value_1": 1, + "value_9": len(self.values_binary), + "unit": None, + }, + { + "source_sensor_domain": "binary_sensor", + "name": "mean", + "value_0": STATE_UNKNOWN, + "value_1": 100.0, + "value_9": float(self.mean_binary), + "unit": "%", + }, + ) + sensors_config = [] + for characteristic in characteristics: + sensors_config.append( + { + "platform": "statistics", + "name": f"test_{characteristic['source_sensor_domain']}_{characteristic['name']}", + "entity_id": f"{characteristic['source_sensor_domain']}.test_monitored", + "state_characteristic": characteristic["name"], + "max_age": {"minutes": 10}, + } + ) + with patch( "homeassistant.components.statistics.sensor.dt_util.utcnow", new=mock_now ): assert setup_component( self.hass, "sensor", - { - "sensor": { - "platform": "statistics", - "name": "test", - "entity_id": "sensor.test_monitored", - } - }, + {"sensor": sensors_config}, ) self.hass.block_till_done() self.hass.start() self.hass.block_till_done() - for value in self.values: + # With all values in buffer + + for i in range(len(self.values)): self.hass.states.set( "sensor.test_monitored", - value, + self.values[i], + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + ) + self.hass.states.set( + "binary_sensor.test_monitored", + self.values_binary[i], {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, ) self.hass.block_till_done() - # insert the next value one minute later - mock_data["return_time"] += timedelta(minutes=1) + mock_data["return_time"] += timedelta(minutes=value_spacing_minutes) - state = self.hass.states.get("sensor.test") + for characteristic in characteristics: + state = self.hass.states.get( + f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}" + ) + assert state.state == str(characteristic["value_9"]), ( + f"value mismatch for characteristic " + f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " + f"(buffer filled) - " + f"assert {state.state} == {str(characteristic['value_9'])}" + ) + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == characteristic["unit"] + ), f"unit mismatch for characteristic '{characteristic['name']}'" - assert datetime( - now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC - ) == state.attributes.get("min_age") - assert datetime( - now.year + 1, 8, 2, 12, 23 + self.count - 1, 42, tzinfo=dt_util.UTC - ) == state.attributes.get("max_age") - assert self.change_rate == state.attributes.get("change_rate") + # With empty buffer + + mock_data["return_time"] += timedelta(minutes=10) + fire_time_changed(self.hass, mock_data["return_time"]) + self.hass.block_till_done() + + for characteristic in characteristics: + state = self.hass.states.get( + f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}" + ) + assert state.state == str(characteristic["value_0"]), ( + f"value mismatch for characteristic " + f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " + f"(buffer empty) - " + f"assert {state.state} == {str(characteristic['value_0'])}" + ) + + # With single value in buffer + + self.hass.states.set( + "sensor.test_monitored", + self.values[0], + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + ) + self.hass.states.set( + "binary_sensor.test_monitored", + self.values_binary[0], + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + force_update=True, + ) + mock_data["return_time"] += timedelta(minutes=1) + fire_time_changed(self.hass, mock_data["return_time"]) + self.hass.block_till_done() + + for characteristic in characteristics: + state = self.hass.states.get( + f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}" + ) + assert state.state == str(characteristic["value_1"]), ( + f"value mismatch for characteristic " + f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " + f"(one stored value) - " + f"assert {state.state} == {str(characteristic['value_1'])}" + ) + + def test_invalid_state_characteristic(self): + """Test the detection of wrong state_characteristics selected.""" + assert setup_component( + self.hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test_numeric", + "entity_id": "sensor.test_monitored", + "state_characteristic": "invalid", + }, + { + "platform": "statistics", + "name": "test_binary", + "entity_id": "binary_sensor.test_monitored", + "state_characteristic": "variance", + }, + ] + }, + ) + + self.hass.block_till_done() + self.hass.start() + self.hass.block_till_done() + + self.hass.states.set( + "sensor.test_monitored", + self.values[0], + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + ) + self.hass.block_till_done() + + state = self.hass.states.get("sensor.test_numeric") + assert state is None + state = self.hass.states.get("sensor.test_binary") + assert state is None def test_initialize_from_database(self): """Test initializing the statistics from the database.""" @@ -365,7 +950,9 @@ class TestStatisticsSensor(unittest.TestCase): # store some values for value in self.values: self.hass.states.set( - "sensor.test_monitored", value, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS} + "sensor.test_monitored", + value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, ) self.hass.block_till_done() # wait for the recorder to really store the data @@ -376,12 +963,15 @@ class TestStatisticsSensor(unittest.TestCase): self.hass, "sensor", { - "sensor": { - "platform": "statistics", - "name": "test", - "entity_id": "sensor.test_monitored", - "sampling_size": 100, - } + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "sampling_size": 100, + }, + ] }, ) @@ -409,13 +999,6 @@ class TestStatisticsSensor(unittest.TestCase): def mock_purge(self): return - # Set maximum age to 3 hours. - max_age = 3 - # Determine what our minimum age should be based on test values. - expected_min_age = mock_data["return_time"] + timedelta( - hours=len(self.values) - max_age - ) - # enable the recorder init_recorder_component(self.hass) self.hass.block_till_done() @@ -443,13 +1026,16 @@ class TestStatisticsSensor(unittest.TestCase): self.hass, "sensor", { - "sensor": { - "platform": "statistics", - "name": "test", - "entity_id": "sensor.test_monitored", - "sampling_size": 100, - "max_age": {"hours": max_age}, - } + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "sensor.test_monitored", + "sampling_size": 100, + "state_characteristic": "datetime_newest", + "max_age": {"hours": 3}, + }, + ] }, ) self.hass.block_till_done() @@ -461,12 +1047,12 @@ class TestStatisticsSensor(unittest.TestCase): # check if the result is as in test_sensor_source() state = self.hass.states.get("sensor.test") - assert expected_min_age == state.attributes.get("min_age") + assert state.attributes.get("age_coverage_ratio") == round(2 / 3, 2) # The max_age timestamp should be 1 hour before what we have right # now in mock_data['return_time']. - assert mock_data["return_time"] == state.attributes.get("max_age") + timedelta( - hours=1 - ) + assert mock_data["return_time"] == datetime.strptime( + state.state, "%Y-%m-%d %H:%M:%S%z" + ) + timedelta(hours=1) async def test_reload(hass): @@ -480,12 +1066,15 @@ async def test_reload(hass): hass, "sensor", { - "sensor": { - "platform": "statistics", - "name": "test", - "entity_id": "sensor.test_monitored", - "sampling_size": 100, - } + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "sampling_size": 100, + }, + ] }, ) await hass.async_block_till_done() @@ -496,11 +1085,7 @@ async def test_reload(hass): assert hass.states.get("sensor.test") - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "statistics/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "statistics") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( DOMAIN, @@ -514,7 +1099,3 @@ async def test_reload(hass): assert hass.states.get("sensor.test") is None assert hass.states.get("sensor.cputest") - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/components/stream/conftest.py b/tests/components/stream/conftest.py index b5d68ffaba5..10328a8f87b 100644 --- a/tests/components/stream/conftest.py +++ b/tests/components/stream/conftest.py @@ -23,7 +23,7 @@ import async_timeout import pytest from homeassistant.components.stream.core import Segment, StreamOutput -from homeassistant.components.stream.worker import SegmentBuffer +from homeassistant.components.stream.worker import StreamState TEST_TIMEOUT = 7.0 # Lower than 9s home assistant timeout @@ -34,7 +34,7 @@ class WorkerSync: def __init__(self): """Initialize WorkerSync.""" self._event = None - self._original = SegmentBuffer.discontinuity + self._original = StreamState.discontinuity def pause(self): """Pause the worker before it finalizes the stream.""" @@ -45,7 +45,7 @@ class WorkerSync: logging.debug("waking blocked worker") self._event.set() - def blocking_discontinuity(self, stream: SegmentBuffer): + def blocking_discontinuity(self, stream_state: StreamState): """Intercept call to pause stream worker.""" # Worker is ending the stream, which clears all output buffers. # Block the worker thread until the test has a chance to verify @@ -55,7 +55,7 @@ class WorkerSync: self._event.wait() # Forward to actual implementation - self._original(stream) + self._original(stream_state) @pytest.fixture() @@ -63,7 +63,7 @@ def stream_worker_sync(hass): """Patch StreamOutput to allow test to synchronize worker stream end.""" sync = WorkerSync() with patch( - "homeassistant.components.stream.worker.SegmentBuffer.discontinuity", + "homeassistant.components.stream.worker.StreamState.discontinuity", side_effect=sync.blocking_discontinuity, autospec=True, ): @@ -95,13 +95,13 @@ class SaveRecordWorkerSync: async def get_segments(self): """Return the recorded video segments.""" - with async_timeout.timeout(TEST_TIMEOUT): + async with async_timeout.timeout(TEST_TIMEOUT): await self._save_event.wait() return self._segments async def join(self): """Verify save worker was invoked and block on shutdown.""" - with async_timeout.timeout(TEST_TIMEOUT): + async with async_timeout.timeout(TEST_TIMEOUT): await self._save_event.wait() self._save_thread.join(timeout=TEST_TIMEOUT) assert not self._save_thread.is_alive() diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index 3bff13a936b..9c529d7abe5 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -135,6 +135,7 @@ async def test_hls_stream(hass, hls_stream, stream_worker_sync): # Request stream stream.add_provider(HLS_PROVIDER) + assert stream.available stream.start() hls_client = await hls_stream(stream) @@ -161,6 +162,9 @@ async def test_hls_stream(hass, hls_stream, stream_worker_sync): stream_worker_sync.resume() + # The stream worker reported end of stream and exited + assert not stream.available + # Stop stream, if it hasn't quit already stream.stop() @@ -181,6 +185,7 @@ async def test_stream_timeout(hass, hass_client, stream_worker_sync): # Request stream stream.add_provider(HLS_PROVIDER) + assert stream.available stream.start() url = stream.endpoint_url(HLS_PROVIDER) @@ -267,6 +272,7 @@ async def test_stream_keepalive(hass): stream._thread.join() stream._thread = None assert av_open.call_count == 2 + assert not stream.available # Stop stream, if it hasn't quit already stream.stop() diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 97fe4bd0d37..3e9ea157934 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -37,7 +37,12 @@ from homeassistant.components.stream.const import ( TARGET_SEGMENT_DURATION_NON_LL_HLS, ) from homeassistant.components.stream.core import StreamSettings -from homeassistant.components.stream.worker import SegmentBuffer, stream_worker +from homeassistant.components.stream.worker import ( + StreamEndedError, + StreamState, + StreamWorkerError, + stream_worker, +) from homeassistant.setup import async_setup_component from tests.components.stream.common import generate_h264_video, generate_h265_video @@ -250,6 +255,12 @@ class MockPyAv: return self.container +def run_worker(hass, stream, stream_source): + """Run the stream worker under test.""" + stream_state = StreamState(hass, stream.outputs) + stream_worker(stream_source, {}, stream_state, threading.Event()) + + async def async_decode_stream(hass, packets, py_av=None): """Start a stream worker that decodes incoming stream packets into output segments.""" stream = Stream(hass, STREAM_SOURCE, {}) @@ -263,9 +274,15 @@ async def async_decode_stream(hass, packets, py_av=None): "homeassistant.components.stream.core.StreamOutput.put", side_effect=py_av.capture_buffer.capture_output_segment, ): - segment_buffer = SegmentBuffer(hass, stream.outputs) - stream_worker(STREAM_SOURCE, {}, segment_buffer, threading.Event()) - await hass.async_block_till_done() + try: + run_worker(hass, stream, STREAM_SOURCE) + except StreamEndedError: + # Tests only use a limited number of packets, then the worker exits as expected. In + # production, stream ending would be unexpected. + pass + finally: + # Wait for all packets to be flushed even when exceptions are thrown + await hass.async_block_till_done() return py_av.capture_buffer @@ -274,10 +291,9 @@ async def test_stream_open_fails(hass): """Test failure on stream open.""" stream = Stream(hass, STREAM_SOURCE, {}) stream.add_provider(HLS_PROVIDER) - with patch("av.open") as av_open: + with patch("av.open") as av_open, pytest.raises(StreamWorkerError): av_open.side_effect = av.error.InvalidDataError(-2, "error") - segment_buffer = SegmentBuffer(hass, stream.outputs) - stream_worker(STREAM_SOURCE, {}, segment_buffer, threading.Event()) + run_worker(hass, stream, STREAM_SOURCE) await hass.async_block_till_done() av_open.assert_called_once() @@ -371,7 +387,10 @@ async def test_packet_overflow(hass): # Packet is so far out of order, exceeds max gap and looks like overflow packets[OUT_OF_ORDER_PACKET_INDEX].dts = -9000000 - decoded_stream = await async_decode_stream(hass, packets) + py_av = MockPyAv() + with pytest.raises(StreamWorkerError, match=r"Timestamp overflow detected"): + await async_decode_stream(hass, packets, py_av=py_av) + decoded_stream = py_av.capture_buffer segments = decoded_stream.segments complete_segments = decoded_stream.complete_segments # Check number of segments @@ -425,7 +444,10 @@ async def test_too_many_initial_bad_packets_fails(hass): for i in range(0, num_bad_packets): packets[i].dts = None - decoded_stream = await async_decode_stream(hass, packets) + py_av = MockPyAv() + with pytest.raises(StreamWorkerError, match=r"No dts"): + await async_decode_stream(hass, packets, py_av=py_av) + decoded_stream = py_av.capture_buffer segments = decoded_stream.segments assert len(segments) == 0 assert len(decoded_stream.video_packets) == 0 @@ -466,7 +488,10 @@ async def test_too_many_bad_packets(hass): for i in range(bad_packet_start, bad_packet_start + num_bad_packets): packets[i].dts = None - decoded_stream = await async_decode_stream(hass, packets) + py_av = MockPyAv() + with pytest.raises(StreamWorkerError, match=r"No dts"): + await async_decode_stream(hass, packets, py_av=py_av) + decoded_stream = py_av.capture_buffer complete_segments = decoded_stream.complete_segments assert len(complete_segments) == int((bad_packet_start - 1) * SEGMENTS_PER_PACKET) assert len(decoded_stream.video_packets) == bad_packet_start @@ -477,9 +502,11 @@ async def test_no_video_stream(hass): """Test no video stream in the container means no resulting output.""" py_av = MockPyAv(video=False) - decoded_stream = await async_decode_stream( - hass, PacketSequence(TEST_SEQUENCE_LENGTH), py_av=py_av - ) + with pytest.raises(StreamWorkerError, match=r"Stream has no video"): + await async_decode_stream( + hass, PacketSequence(TEST_SEQUENCE_LENGTH), py_av=py_av + ) + decoded_stream = py_av.capture_buffer # Note: This failure scenario does not output an end of stream segments = decoded_stream.segments assert len(segments) == 0 @@ -616,6 +643,9 @@ async def test_stream_stopped_while_decoding(hass): worker_wake.set() stream.stop() + # Stream is still considered available when the worker was still active and asked to stop + assert stream.available + async def test_update_stream_source(hass): """Tests that the worker is re-invoked when the stream source is updated.""" @@ -646,6 +676,7 @@ async def test_update_stream_source(hass): stream.start() assert worker_open.wait(TIMEOUT) assert last_stream_source == STREAM_SOURCE + assert stream.available # Update the stream source, then the test wakes up the worker and assert # that it re-opens the new stream (the test again waits on thread_started) @@ -655,6 +686,7 @@ async def test_update_stream_source(hass): assert worker_open.wait(TIMEOUT) assert last_stream_source == STREAM_SOURCE + "-updated-source" worker_wake.set() + assert stream.available # Cleanup stream.stop() @@ -664,15 +696,13 @@ async def test_worker_log(hass, caplog): """Test that the worker logs the url without username and password.""" stream = Stream(hass, "https://abcd:efgh@foo.bar", {}) stream.add_provider(HLS_PROVIDER) - with patch("av.open") as av_open: + + with patch("av.open") as av_open, pytest.raises(StreamWorkerError) as err: av_open.side_effect = av.error.InvalidDataError(-2, "error") - segment_buffer = SegmentBuffer(hass, stream.outputs) - stream_worker( - "https://abcd:efgh@foo.bar", {}, segment_buffer, threading.Event() - ) + run_worker(hass, stream, "https://abcd:efgh@foo.bar") await hass.async_block_till_done() + assert str(err.value) == "Error opening stream https://****:****@foo.bar" assert "https://abcd:efgh@foo.bar" not in caplog.text - assert "https://****:****@foo.bar" in caplog.text async def test_durations(hass, record_worker_sync): diff --git a/tests/components/switchbot/conftest.py b/tests/components/switchbot/conftest.py index 52e5fd4fa15..2fdea69de16 100644 --- a/tests/components/switchbot/conftest.py +++ b/tests/components/switchbot/conftest.py @@ -14,45 +14,42 @@ class MocGetSwitchbotDevices: self._all_services_data = { "e78943999999": { "mac_address": "e7:89:43:99:99:99", - "Flags": "06", - "Manufacturer": "5900e78943d9fe7c", - "Complete 128b Services": "cba20d00-224d-11e6-9fb8-0002a5d5c51b", + "isEncrypted": False, + "model": "H", "data": { "switchMode": "true", "isOn": "true", "battery": 91, "rssi": -71, }, - "model": "H", "modelName": "WoHand", }, "e78943909090": { "mac_address": "e7:89:43:90:90:90", - "Flags": "06", - "Manufacturer": "5900e78943d9fe7c", - "Complete 128b Services": "cba20d00-224d-11e6-9fb8-0002a5d5c51b", + "isEncrypted": False, + "model": "c", "data": { "calibration": True, "battery": 74, + "inMotion": False, "position": 100, "lightLevel": 2, + "deviceChain": 1, "rssi": -73, }, - "model": "c", "modelName": "WoCurtain", }, "ffffff19ffff": { "mac_address": "ff:ff:ff:19:ff:ff", - "Flags": "06", - "Manufacturer": "5900ffffff19ffff", - "Complete 128b Services": "cba20d00-224d-11e6-9fb8-0002a5d5c51b", + "isEncrypted": False, + "model": "m", + "rawAdvData": "000d6d00", }, } self._curtain_all_services_data = { "mac_address": "e7:89:43:90:90:90", - "Flags": "06", - "Manufacturer": "5900e78943d9fe7c", - "Complete 128b Services": "cba20d00-224d-11e6-9fb8-0002a5d5c51b", + "isEncrypted": False, + "model": "c", "data": { "calibration": True, "battery": 74, @@ -60,21 +57,18 @@ class MocGetSwitchbotDevices: "lightLevel": 2, "rssi": -73, }, - "model": "c", "modelName": "WoCurtain", } self._unsupported_device = { "mac_address": "test", - "Flags": "06", - "Manufacturer": "5900e78943d9fe7c", - "Complete 128b Services": "cba20d00-224d-11e6-9fb8-0002a5d5c51b", + "isEncrypted": False, + "model": "HoN", "data": { "switchMode": "true", "isOn": "true", "battery": 91, "rssi": -71, }, - "model": "HoN", "modelName": "WoOther", } diff --git a/tests/components/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py index 8ac91770741..7a91ff7a735 100644 --- a/tests/components/syncthru/test_config_flow.py +++ b/tests/components/syncthru/test_config_flow.py @@ -130,14 +130,18 @@ async def test_ssdp(hass, aioclient_mock): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: "http://192.168.1.2:5200/Printer.xml", - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:Printer:1", - ssdp.ATTR_UPNP_MANUFACTURER: "Samsung Electronics", - ssdp.ATTR_UPNP_PRESENTATION_URL: url, - ssdp.ATTR_UPNP_SERIAL: "00000000", - ssdp.ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.2:5200/Printer.xml", + upnp={ + ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:Printer:1", + ssdp.ATTR_UPNP_MANUFACTURER: "Samsung Electronics", + ssdp.ATTR_UPNP_PRESENTATION_URL: url, + ssdp.ATTR_UPNP_SERIAL: "00000000", + ssdp.ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + }, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 435ed3bdb4b..3907f9c42ec 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -387,11 +387,15 @@ async def test_form_ssdp(hass: HomeAssistant, service: MagicMock): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: "http://192.168.1.5:5000", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX99", # MAC address, but SSDP does not have `-` - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.5:5000", + upnp={ + ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ssdp.ATTR_UPNP_SERIAL: "001132XXXX99", # MAC address, but SSDP does not have `-` + }, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "link" @@ -434,11 +438,15 @@ async def test_reconfig_ssdp(hass: HomeAssistant, service: MagicMock): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: "http://192.168.1.5:5000", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.5:5000", + upnp={ + ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + }, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "reconfigure_successful" @@ -462,11 +470,15 @@ async def test_skip_reconfig_ssdp(hass: HomeAssistant, service: MagicMock): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: "http://192.168.1.5:5000", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.5:5000", + upnp={ + ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + }, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" @@ -490,11 +502,15 @@ async def test_existing_ssdp(hass: HomeAssistant, service: MagicMock): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: "http://192.168.1.5:5000", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.5:5000", + upnp={ + ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + }, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/system_bridge/test_config_flow.py b/tests/components/system_bridge/test_config_flow.py index 1b46bb45a6d..ecc36641e6c 100644 --- a/tests/components/system_bridge/test_config_flow.py +++ b/tests/components/system_bridge/test_config_flow.py @@ -5,6 +5,7 @@ from aiohttp.client_exceptions import ClientConnectionError from systembridge.exceptions import BridgeAuthenticationException from homeassistant import config_entries, data_entry_flow +from homeassistant.components import zeroconf from homeassistant.components.system_bridge.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT @@ -27,13 +28,13 @@ FIXTURE_ZEROCONF_INPUT = { CONF_PORT: "9170", } -FIXTURE_ZEROCONF = { - CONF_HOST: "1.1.1.1", - CONF_PORT: 9170, - "hostname": "test-bridge.local.", - "type": "_system-bridge._udp.local.", - "name": "System Bridge - test-bridge._system-bridge._udp.local.", - "properties": { +FIXTURE_ZEROCONF = zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + port=9170, + hostname="test-bridge.local.", + type="_system-bridge._udp.local.", + name="System Bridge - test-bridge._system-bridge._udp.local.", + properties={ "address": "http://test-bridge:9170", "fqdn": "test-bridge", "host": "test-bridge", @@ -42,18 +43,18 @@ FIXTURE_ZEROCONF = { "port": "9170", "uuid": FIXTURE_UUID, }, -} +) -FIXTURE_ZEROCONF_BAD = { - CONF_HOST: "1.1.1.1", - CONF_PORT: 9170, - "hostname": "test-bridge.local.", - "type": "_system-bridge._udp.local.", - "name": "System Bridge - test-bridge._system-bridge._udp.local.", - "properties": { +FIXTURE_ZEROCONF_BAD = zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + port=9170, + hostname="test-bridge.local.", + type="_system-bridge._udp.local.", + name="System Bridge - test-bridge._system-bridge._udp.local.", + properties={ "something": "bad", }, -} +) FIXTURE_INFORMATION = { @@ -80,9 +81,7 @@ FIXTURE_BASE_URL = ( f"http://{FIXTURE_USER_INPUT[CONF_HOST]}:{FIXTURE_USER_INPUT[CONF_PORT]}" ) -FIXTURE_ZEROCONF_BASE_URL = ( - f"http://{FIXTURE_ZEROCONF[CONF_HOST]}:{FIXTURE_ZEROCONF[CONF_PORT]}" -) +FIXTURE_ZEROCONF_BASE_URL = f"http://{FIXTURE_ZEROCONF.host}:{FIXTURE_ZEROCONF.port}" async def test_user_flow( diff --git a/tests/fixtures/tado/ac_issue_32294.heat_mode.json b/tests/components/tado/fixtures/ac_issue_32294.heat_mode.json similarity index 100% rename from tests/fixtures/tado/ac_issue_32294.heat_mode.json rename to tests/components/tado/fixtures/ac_issue_32294.heat_mode.json diff --git a/tests/fixtures/tado/device_temp_offset.json b/tests/components/tado/fixtures/device_temp_offset.json similarity index 100% rename from tests/fixtures/tado/device_temp_offset.json rename to tests/components/tado/fixtures/device_temp_offset.json diff --git a/tests/fixtures/tado/device_wr1.json b/tests/components/tado/fixtures/device_wr1.json similarity index 100% rename from tests/fixtures/tado/device_wr1.json rename to tests/components/tado/fixtures/device_wr1.json diff --git a/tests/fixtures/tado/devices.json b/tests/components/tado/fixtures/devices.json similarity index 100% rename from tests/fixtures/tado/devices.json rename to tests/components/tado/fixtures/devices.json diff --git a/tests/fixtures/tado/hvac_action_heat.json b/tests/components/tado/fixtures/hvac_action_heat.json similarity index 100% rename from tests/fixtures/tado/hvac_action_heat.json rename to tests/components/tado/fixtures/hvac_action_heat.json diff --git a/tests/fixtures/tado/me.json b/tests/components/tado/fixtures/me.json similarity index 100% rename from tests/fixtures/tado/me.json rename to tests/components/tado/fixtures/me.json diff --git a/tests/fixtures/tado/smartac3.auto_mode.json b/tests/components/tado/fixtures/smartac3.auto_mode.json similarity index 100% rename from tests/fixtures/tado/smartac3.auto_mode.json rename to tests/components/tado/fixtures/smartac3.auto_mode.json diff --git a/tests/fixtures/tado/smartac3.cool_mode.json b/tests/components/tado/fixtures/smartac3.cool_mode.json similarity index 100% rename from tests/fixtures/tado/smartac3.cool_mode.json rename to tests/components/tado/fixtures/smartac3.cool_mode.json diff --git a/tests/fixtures/tado/smartac3.dry_mode.json b/tests/components/tado/fixtures/smartac3.dry_mode.json similarity index 100% rename from tests/fixtures/tado/smartac3.dry_mode.json rename to tests/components/tado/fixtures/smartac3.dry_mode.json diff --git a/tests/fixtures/tado/smartac3.fan_mode.json b/tests/components/tado/fixtures/smartac3.fan_mode.json similarity index 100% rename from tests/fixtures/tado/smartac3.fan_mode.json rename to tests/components/tado/fixtures/smartac3.fan_mode.json diff --git a/tests/fixtures/tado/smartac3.heat_mode.json b/tests/components/tado/fixtures/smartac3.heat_mode.json similarity index 100% rename from tests/fixtures/tado/smartac3.heat_mode.json rename to tests/components/tado/fixtures/smartac3.heat_mode.json diff --git a/tests/fixtures/tado/smartac3.hvac_off.json b/tests/components/tado/fixtures/smartac3.hvac_off.json similarity index 100% rename from tests/fixtures/tado/smartac3.hvac_off.json rename to tests/components/tado/fixtures/smartac3.hvac_off.json diff --git a/tests/fixtures/tado/smartac3.manual_off.json b/tests/components/tado/fixtures/smartac3.manual_off.json similarity index 100% rename from tests/fixtures/tado/smartac3.manual_off.json rename to tests/components/tado/fixtures/smartac3.manual_off.json diff --git a/tests/fixtures/tado/smartac3.offline.json b/tests/components/tado/fixtures/smartac3.offline.json similarity index 100% rename from tests/fixtures/tado/smartac3.offline.json rename to tests/components/tado/fixtures/smartac3.offline.json diff --git a/tests/fixtures/tado/smartac3.smart_mode.json b/tests/components/tado/fixtures/smartac3.smart_mode.json similarity index 100% rename from tests/fixtures/tado/smartac3.smart_mode.json rename to tests/components/tado/fixtures/smartac3.smart_mode.json diff --git a/tests/fixtures/tado/smartac3.turning_off.json b/tests/components/tado/fixtures/smartac3.turning_off.json similarity index 100% rename from tests/fixtures/tado/smartac3.turning_off.json rename to tests/components/tado/fixtures/smartac3.turning_off.json diff --git a/tests/fixtures/tado/smartac3.with_swing.json b/tests/components/tado/fixtures/smartac3.with_swing.json similarity index 100% rename from tests/fixtures/tado/smartac3.with_swing.json rename to tests/components/tado/fixtures/smartac3.with_swing.json diff --git a/tests/fixtures/tado/tadov2.heating.auto_mode.json b/tests/components/tado/fixtures/tadov2.heating.auto_mode.json similarity index 100% rename from tests/fixtures/tado/tadov2.heating.auto_mode.json rename to tests/components/tado/fixtures/tadov2.heating.auto_mode.json diff --git a/tests/fixtures/tado/tadov2.heating.manual_mode.json b/tests/components/tado/fixtures/tadov2.heating.manual_mode.json similarity index 100% rename from tests/fixtures/tado/tadov2.heating.manual_mode.json rename to tests/components/tado/fixtures/tadov2.heating.manual_mode.json diff --git a/tests/fixtures/tado/tadov2.heating.off_mode.json b/tests/components/tado/fixtures/tadov2.heating.off_mode.json similarity index 100% rename from tests/fixtures/tado/tadov2.heating.off_mode.json rename to tests/components/tado/fixtures/tadov2.heating.off_mode.json diff --git a/tests/fixtures/tado/tadov2.water_heater.auto_mode.json b/tests/components/tado/fixtures/tadov2.water_heater.auto_mode.json similarity index 100% rename from tests/fixtures/tado/tadov2.water_heater.auto_mode.json rename to tests/components/tado/fixtures/tadov2.water_heater.auto_mode.json diff --git a/tests/fixtures/tado/tadov2.water_heater.heating.json b/tests/components/tado/fixtures/tadov2.water_heater.heating.json similarity index 100% rename from tests/fixtures/tado/tadov2.water_heater.heating.json rename to tests/components/tado/fixtures/tadov2.water_heater.heating.json diff --git a/tests/fixtures/tado/tadov2.water_heater.manual_mode.json b/tests/components/tado/fixtures/tadov2.water_heater.manual_mode.json similarity index 100% rename from tests/fixtures/tado/tadov2.water_heater.manual_mode.json rename to tests/components/tado/fixtures/tadov2.water_heater.manual_mode.json diff --git a/tests/fixtures/tado/tadov2.water_heater.off_mode.json b/tests/components/tado/fixtures/tadov2.water_heater.off_mode.json similarity index 100% rename from tests/fixtures/tado/tadov2.water_heater.off_mode.json rename to tests/components/tado/fixtures/tadov2.water_heater.off_mode.json diff --git a/tests/fixtures/tado/tadov2.zone_capabilities.json b/tests/components/tado/fixtures/tadov2.zone_capabilities.json similarity index 100% rename from tests/fixtures/tado/tadov2.zone_capabilities.json rename to tests/components/tado/fixtures/tadov2.zone_capabilities.json diff --git a/tests/fixtures/tado/token.json b/tests/components/tado/fixtures/token.json similarity index 100% rename from tests/fixtures/tado/token.json rename to tests/components/tado/fixtures/token.json diff --git a/tests/fixtures/tado/water_heater_zone_capabilities.json b/tests/components/tado/fixtures/water_heater_zone_capabilities.json similarity index 100% rename from tests/fixtures/tado/water_heater_zone_capabilities.json rename to tests/components/tado/fixtures/water_heater_zone_capabilities.json diff --git a/tests/fixtures/tado/weather.json b/tests/components/tado/fixtures/weather.json similarity index 100% rename from tests/fixtures/tado/weather.json rename to tests/components/tado/fixtures/weather.json diff --git a/tests/fixtures/tado/zone_capabilities.json b/tests/components/tado/fixtures/zone_capabilities.json similarity index 100% rename from tests/fixtures/tado/zone_capabilities.json rename to tests/components/tado/fixtures/zone_capabilities.json diff --git a/tests/fixtures/tado/zone_default_overlay.json b/tests/components/tado/fixtures/zone_default_overlay.json similarity index 100% rename from tests/fixtures/tado/zone_default_overlay.json rename to tests/components/tado/fixtures/zone_default_overlay.json diff --git a/tests/fixtures/tado/zone_state.json b/tests/components/tado/fixtures/zone_state.json similarity index 100% rename from tests/fixtures/tado/zone_state.json rename to tests/components/tado/fixtures/zone_state.json diff --git a/tests/fixtures/tado/zone_states.json b/tests/components/tado/fixtures/zone_states.json similarity index 100% rename from tests/fixtures/tado/zone_states.json rename to tests/components/tado/fixtures/zone_states.json diff --git a/tests/fixtures/tado/zone_with_swing_capabilities.json b/tests/components/tado/fixtures/zone_with_swing_capabilities.json similarity index 100% rename from tests/fixtures/tado/zone_with_swing_capabilities.json rename to tests/components/tado/fixtures/zone_with_swing_capabilities.json diff --git a/tests/fixtures/tado/zones.json b/tests/components/tado/fixtures/zones.json similarity index 100% rename from tests/fixtures/tado/zones.json rename to tests/components/tado/fixtures/zones.json diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index b181b78bf16..4c234fee248 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch import requests from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.components.tado.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -126,7 +127,14 @@ async def test_form_homekit(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data={"properties": {"id": "AA:BB:CC:DD:EE:FF"}}, + data=zeroconf.ZeroconfServiceInfo( + host="mock_host", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + type="mock_type", + ), ) assert result["type"] == "form" assert result["errors"] == {} @@ -145,6 +153,13 @@ async def test_form_homekit(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data={"properties": {"id": "AA:BB:CC:DD:EE:FF"}}, + data=zeroconf.ZeroconfServiceInfo( + host="mock_host", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + type="mock_type", + ), ) assert result["type"] == "abort" diff --git a/tests/components/tailscale/__init__.py b/tests/components/tailscale/__init__.py new file mode 100644 index 00000000000..cdae3b16d0b --- /dev/null +++ b/tests/components/tailscale/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tailscale integration.""" diff --git a/tests/components/tailscale/conftest.py b/tests/components/tailscale/conftest.py new file mode 100644 index 00000000000..12f11a5656d --- /dev/null +++ b/tests/components/tailscale/conftest.py @@ -0,0 +1,76 @@ +"""Fixtures for Tailscale integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from tailscale.models import Devices + +from homeassistant.components.tailscale.const import CONF_TAILNET, DOMAIN +from homeassistant.const import CONF_API_KEY +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="homeassistant.github", + domain=DOMAIN, + data={CONF_TAILNET: "homeassistant.github", CONF_API_KEY: "tskey-MOCK"}, + unique_id="homeassistant.github", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.tailscale.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_tailscale_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked Tailscale client.""" + with patch( + "homeassistant.components.tailscale.config_flow.Tailscale", autospec=True + ) as tailscale_mock: + tailscale = tailscale_mock.return_value + tailscale.devices.return_value = Devices.parse_raw( + load_fixture("tailscale/devices.json") + ).devices + yield tailscale + + +@pytest.fixture +def mock_tailscale(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: + """Return a mocked Tailscale client.""" + fixture: str = "tailscale/devices.json" + if hasattr(request, "param") and request.param: + fixture = request.param + + devices = Devices.parse_raw(load_fixture(fixture)).devices + with patch( + "homeassistant.components.tailscale.coordinator.Tailscale", autospec=True + ) as tailscale_mock: + tailscale = tailscale_mock.return_value + tailscale.devices.return_value = devices + yield tailscale + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tailscale: MagicMock +) -> MockConfigEntry: + """Set up the Tailscale 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/tailscale/fixtures/devices.json b/tests/components/tailscale/fixtures/devices.json new file mode 100644 index 00000000000..776c32b5e40 --- /dev/null +++ b/tests/components/tailscale/fixtures/devices.json @@ -0,0 +1,127 @@ +{ + "devices": [ + { + "addresses": [ + "100.11.11.111" + ], + "id": "123456", + "user": "frenck", + "name": "frencks-iphone.homeassistant.github", + "hostname": "Frencks-iPhone", + "clientVersion": "1.12.3-td91ea7286-ge1bbbd90c", + "updateAvailable": true, + "os": "iOS", + "created": "2021-08-19T09:25:22Z", + "lastSeen": "2021-09-16T06:11:23Z", + "keyExpiryDisabled": false, + "expires": "2022-02-15T09:25:22Z", + "authorized": true, + "isExternal": false, + "machineKey": "mkey:mock", + "nodeKey": "nodekey:mock", + "blocksIncomingConnections": false, + "enabledRoutes": [], + "advertisedRoutes": [], + "clientConnectivity": { + "endpoints": [ + "192.0.0.1:41641", + "192.168.11.154:41641" + ], + "derp": "", + "mappingVariesByDestIP": false, + "latency": {}, + "clientSupports": { + "hairPinning": false, + "ipv6": false, + "pcp": false, + "pmp": false, + "udp": true, + "upnp": false + } + } + }, + { + "addresses": [ + "100.11.11.112" + ], + "id": "123457", + "user": "frenck", + "name": "router.homeassistant.github", + "hostname": "router", + "clientVersion": "1.14.0-t5cff36945-g809e87bba", + "updateAvailable": true, + "os": "linux", + "created": "2021-08-29T09:49:06Z", + "lastSeen": "2021-11-15T20:37:03Z", + "keyExpiryDisabled": false, + "expires": "2022-02-25T09:49:06Z", + "authorized": true, + "isExternal": false, + "machineKey": "mkey:mock", + "nodeKey": "nodekey:mock", + "blocksIncomingConnections": false, + "enabledRoutes": [ + "0.0.0.0/0", + "10.10.10.0/23", + "::/0" + ], + "advertisedRoutes": [ + "0.0.0.0/0", + "10.10.10.0/23", + "::/0" + ], + "clientConnectivity": { + "endpoints": [ + "10.10.10.1:41641", + "111.111.111.111:41641" + ], + "derp": "", + "mappingVariesByDestIP": false, + "latency": { + "Bangalore": { + "latencyMs": 143.42505599999998 + }, + "Chicago": { + "latencyMs": 101.123646 + }, + "Dallas": { + "latencyMs": 136.85886 + }, + "Frankfurt": { + "latencyMs": 18.968314 + }, + "London": { + "preferred": true, + "latencyMs": 14.314574 + }, + "New York City": { + "latencyMs": 83.078912 + }, + "San Francisco": { + "latencyMs": 148.215522 + }, + "Seattle": { + "latencyMs": 181.553595 + }, + "Singapore": { + "latencyMs": 164.566539 + }, + "São Paulo": { + "latencyMs": 207.250179 + }, + "Tokyo": { + "latencyMs": 226.90714300000002 + } + }, + "clientSupports": { + "hairPinning": true, + "ipv6": false, + "pcp": false, + "pmp": false, + "udp": true, + "upnp": false + } + } + } + ] +} \ No newline at end of file diff --git a/tests/components/tailscale/test_binary_sensor.py b/tests/components/tailscale/test_binary_sensor.py new file mode 100644 index 00000000000..9caeb7b8eba --- /dev/null +++ b/tests/components/tailscale/test_binary_sensor.py @@ -0,0 +1,118 @@ +"""Tests for the sensors provided by the Tailscale integration.""" +from homeassistant.components.binary_sensor import ( + STATE_OFF, + STATE_ON, + BinarySensorDeviceClass, +) +from homeassistant.components.tailscale.const import DOMAIN +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_ICON +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_tailscale_binary_sensors( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the Tailscale binary sensors.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("binary_sensor.frencks_iphone_client") + entry = entity_registry.async_get("binary_sensor.frencks_iphone_client") + assert entry + assert state + assert entry.unique_id == "123456_update_available" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_ON + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frencks-iPhone Client" + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.UPDATE + assert ATTR_ICON not in state.attributes + + state = hass.states.get("binary_sensor.frencks_iphone_supports_hairpinning") + entry = entity_registry.async_get( + "binary_sensor.frencks_iphone_supports_hairpinning" + ) + assert entry + assert state + assert entry.unique_id == "123456_client_supports_hair_pinning" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_OFF + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Frencks-iPhone Supports Hairpinning" + ) + assert state.attributes.get(ATTR_ICON) == "mdi:wan" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.frencks_iphone_supports_ipv6") + entry = entity_registry.async_get("binary_sensor.frencks_iphone_supports_ipv6") + assert entry + assert state + assert entry.unique_id == "123456_client_supports_ipv6" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frencks-iPhone Supports IPv6" + assert state.attributes.get(ATTR_ICON) == "mdi:wan" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.frencks_iphone_supports_pcp") + entry = entity_registry.async_get("binary_sensor.frencks_iphone_supports_pcp") + assert entry + assert state + assert entry.unique_id == "123456_client_supports_pcp" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frencks-iPhone Supports PCP" + assert state.attributes.get(ATTR_ICON) == "mdi:wan" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.frencks_iphone_supports_nat_pmp") + entry = entity_registry.async_get("binary_sensor.frencks_iphone_supports_nat_pmp") + assert entry + assert state + assert entry.unique_id == "123456_client_supports_pmp" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frencks-iPhone Supports NAT-PMP" + assert state.attributes.get(ATTR_ICON) == "mdi:wan" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.frencks_iphone_supports_udp") + entry = entity_registry.async_get("binary_sensor.frencks_iphone_supports_udp") + assert entry + assert state + assert entry.unique_id == "123456_client_supports_udp" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_ON + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frencks-iPhone Supports UDP" + assert state.attributes.get(ATTR_ICON) == "mdi:wan" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.frencks_iphone_supports_upnp") + entry = entity_registry.async_get("binary_sensor.frencks_iphone_supports_upnp") + assert entry + assert state + assert entry.unique_id == "123456_client_supports_upnp" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frencks-iPhone Supports UPnP" + assert state.attributes.get(ATTR_ICON) == "mdi:wan" + assert ATTR_DEVICE_CLASS not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, "123456")} + assert device_entry.manufacturer == "Tailscale Inc." + assert device_entry.model == "iOS" + assert device_entry.name == "Frencks-iPhone" + assert device_entry.entry_type == dr.DeviceEntryType.SERVICE + assert device_entry.sw_version == "1.12.3-td91ea7286-ge1bbbd90c" + assert ( + device_entry.configuration_url + == "https://login.tailscale.com/admin/machines/100.11.11.111" + ) diff --git a/tests/components/tailscale/test_config_flow.py b/tests/components/tailscale/test_config_flow.py new file mode 100644 index 00000000000..eb070cfdbb2 --- /dev/null +++ b/tests/components/tailscale/test_config_flow.py @@ -0,0 +1,257 @@ +"""Tests for the Tailscale config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from tailscale import TailscaleAuthenticationError, TailscaleConnectionError + +from homeassistant.components.tailscale.const import CONF_TAILNET, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_tailscale_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TAILNET: "homeassistant.github", + CONF_API_KEY: "tskey-FAKE", + }, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "homeassistant.github" + assert result2.get("data") == { + CONF_TAILNET: "homeassistant.github", + CONF_API_KEY: "tskey-FAKE", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_tailscale_config_flow.devices.mock_calls) == 1 + + +async def test_full_flow_with_authentication_error( + hass: HomeAssistant, + mock_tailscale_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user configuration flow with incorrect API key. + + This tests tests a full config flow, with a case the user enters an invalid + Tailscale API key, but recovers by entering the correct one. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + mock_tailscale_config_flow.devices.side_effect = TailscaleAuthenticationError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TAILNET: "homeassistant.github", + CONF_API_KEY: "tskey-INVALID", + }, + ) + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == SOURCE_USER + assert result2.get("errors") == {"base": "invalid_auth"} + assert "flow_id" in result2 + + assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_tailscale_config_flow.devices.mock_calls) == 1 + + mock_tailscale_config_flow.devices.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_TAILNET: "homeassistant.github", + CONF_API_KEY: "tskey-VALID", + }, + ) + + assert result3.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result3.get("title") == "homeassistant.github" + assert result3.get("data") == { + CONF_TAILNET: "homeassistant.github", + CONF_API_KEY: "tskey-VALID", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_tailscale_config_flow.devices.mock_calls) == 2 + + +async def test_connection_error( + hass: HomeAssistant, mock_tailscale_config_flow: MagicMock +) -> None: + """Test API connection error.""" + mock_tailscale_config_flow.devices.side_effect = TailscaleConnectionError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_TAILNET: "homeassistant.github", + CONF_API_KEY: "tskey-FAKE", + }, + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("errors") == {"base": "cannot_connect"} + + assert len(mock_tailscale_config_flow.devices.mock_calls) == 1 + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailscale_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the reauthentication configuration flow.""" + 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 result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "reauth_confirm" + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "tskey-REAUTH"}, + ) + await hass.async_block_till_done() + + assert result2.get("type") == RESULT_TYPE_ABORT + assert result2.get("reason") == "reauth_successful" + assert mock_config_entry.data == { + CONF_TAILNET: "homeassistant.github", + CONF_API_KEY: "tskey-REAUTH", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_tailscale_config_flow.devices.mock_calls) == 1 + + +async def test_reauth_with_authentication_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailscale_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the reauthentication configuration flow with an authentication error. + + This tests tests a reauth flow, with a case the user enters an invalid + API key, but recover by entering the correct one. + """ + 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 result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "reauth_confirm" + assert "flow_id" in result + + mock_tailscale_config_flow.devices.side_effect = TailscaleAuthenticationError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "tskey-INVALID"}, + ) + await hass.async_block_till_done() + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == "reauth_confirm" + assert result2.get("errors") == {"base": "invalid_auth"} + assert "flow_id" in result2 + + assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_tailscale_config_flow.devices.mock_calls) == 1 + + mock_tailscale_config_flow.devices.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={CONF_API_KEY: "tskey-VALID"}, + ) + await hass.async_block_till_done() + + assert result3.get("type") == RESULT_TYPE_ABORT + assert result3.get("reason") == "reauth_successful" + assert mock_config_entry.data == { + CONF_TAILNET: "homeassistant.github", + CONF_API_KEY: "tskey-VALID", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_tailscale_config_flow.devices.mock_calls) == 2 + + +async def test_reauth_api_error( + hass: HomeAssistant, + mock_tailscale_config_flow: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test API error during reauthentication.""" + 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 result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "reauth_confirm" + assert "flow_id" in result + + mock_tailscale_config_flow.devices.side_effect = TailscaleConnectionError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "tskey-VALID"}, + ) + await hass.async_block_till_done() + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == "reauth_confirm" + assert result2.get("errors") == {"base": "cannot_connect"} diff --git a/tests/components/tailscale/test_init.py b/tests/components/tailscale/test_init.py new file mode 100644 index 00000000000..11ca8a910a6 --- /dev/null +++ b/tests/components/tailscale/test_init.py @@ -0,0 +1,72 @@ +"""Tests for the Tailscale integration.""" +from unittest.mock import MagicMock + +from tailscale import TailscaleAuthenticationError, TailscaleConnectionError + +from homeassistant.components.tailscale.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailscale: MagicMock, +) -> None: + """Test the Tailscale 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.state is ConfigEntryState.LOADED + + 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_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailscale: MagicMock, +) -> None: + """Test the Tailscale configuration entry not ready.""" + mock_tailscale.devices.side_effect = TailscaleConnectionError + + 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_tailscale.devices.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_authentication_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailscale: MagicMock, +) -> None: + """Test trigger reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + + mock_tailscale.devices.side_effect = TailscaleAuthenticationError + + 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") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/tailscale/test_sensor.py b/tests/components/tailscale/test_sensor.py new file mode 100644 index 00000000000..911de0eb64a --- /dev/null +++ b/tests/components/tailscale/test_sensor.py @@ -0,0 +1,65 @@ +"""Tests for the sensors provided by the Tailscale integration.""" +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.tailscale.const import DOMAIN +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_ICON +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_tailscale_sensors( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the Tailscale sensors.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.router_expires") + entry = entity_registry.async_get("sensor.router_expires") + assert entry + assert state + assert entry.unique_id == "123457_expires" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == "2022-02-25T09:49:06+00:00" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "router Expires" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.router_last_seen") + entry = entity_registry.async_get("sensor.router_last_seen") + assert entry + assert state + assert entry.unique_id == "123457_last_seen" + assert entry.entity_category is None + assert state.state == "2021-11-15T20:37:03+00:00" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "router Last Seen" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.router_ip_address") + entry = entity_registry.async_get("sensor.router_ip_address") + assert entry + assert state + assert entry.unique_id == "123457_ip" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == "100.11.11.112" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "router IP Address" + assert state.attributes.get(ATTR_ICON) == "mdi:ip-network" + assert ATTR_DEVICE_CLASS not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, "123457")} + assert device_entry.manufacturer == "Tailscale Inc." + assert device_entry.model == "linux" + assert device_entry.name == "router" + assert device_entry.entry_type == dr.DeviceEntryType.SERVICE + assert device_entry.sw_version == "1.14.0-t5cff36945-g809e87bba" + assert ( + device_entry.configuration_url + == "https://login.tailscale.com/admin/machines/100.11.11.112" + ) diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py index 6b13dcc89ec..2ee40428293 100644 --- a/tests/components/tasmota/test_binary_sensor.py +++ b/tests/components/tasmota/test_binary_sensor.py @@ -56,6 +56,7 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("binary_sensor.tasmota_binary_sensor_1") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -121,6 +122,7 @@ async def test_controlling_state_via_mqtt_switchname(hass, mqtt_mock, setup_tasm assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("binary_sensor.custom_name") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -179,6 +181,7 @@ async def test_pushon_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota) assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("binary_sensor.tasmota_binary_sensor_1") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index 9174060ef93..6c296a78006 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -130,7 +130,7 @@ async def help_test_availability_when_connection_lost( get_topic_tele_will(config), config_get_state_online(config), ) - + await hass.async_block_till_done() state = hass.states.get(f"{domain}.{entity_id}") assert state.state != STATE_UNAVAILABLE @@ -158,6 +158,7 @@ async def help_test_availability_when_connection_lost( get_topic_tele_will(config), config_get_state_online(config), ) + await hass.async_block_till_done() state = hass.states.get(f"{domain}.{entity_id}") assert state.state != STATE_UNAVAILABLE @@ -196,7 +197,7 @@ async def help_test_availability( get_topic_tele_will(config), config_get_state_online(config), ) - + await hass.async_block_till_done() state = hass.states.get(f"{domain}.{entity_id}") assert state.state != STATE_UNAVAILABLE @@ -205,7 +206,7 @@ async def help_test_availability( get_topic_tele_will(config), config_get_state_offline(config), ) - + await hass.async_block_till_done() state = hass.states.get(f"{domain}.{entity_id}") assert state.state == STATE_UNAVAILABLE @@ -258,10 +259,12 @@ async def help_test_availability_discovery_update( assert state.state == STATE_UNAVAILABLE async_fire_mqtt_message(hass, availability_topic1, online1) + await hass.async_block_till_done() state = hass.states.get(f"{domain}.{entity_id}") assert state.state != STATE_UNAVAILABLE async_fire_mqtt_message(hass, availability_topic1, offline1) + await hass.async_block_till_done() state = hass.states.get(f"{domain}.{entity_id}") assert state.state == STATE_UNAVAILABLE @@ -273,11 +276,13 @@ async def help_test_availability_discovery_update( async_fire_mqtt_message(hass, availability_topic1, online1) async_fire_mqtt_message(hass, availability_topic1, online2) async_fire_mqtt_message(hass, availability_topic2, online1) + await hass.async_block_till_done() state = hass.states.get(f"{domain}.{entity_id}") assert state.state == STATE_UNAVAILABLE # Verify we are subscribing to the new topic async_fire_mqtt_message(hass, availability_topic2, online2) + await hass.async_block_till_done() state = hass.states.get(f"{domain}.{entity_id}") assert state.state != STATE_UNAVAILABLE @@ -575,10 +580,12 @@ async def help_test_entity_id_update_discovery_update( await hass.async_block_till_done() async_fire_mqtt_message(hass, topic, config_get_state_online(config)) + await hass.async_block_till_done() state = hass.states.get(f"{domain}.{entity_id}") assert state.state != STATE_UNAVAILABLE async_fire_mqtt_message(hass, topic, config_get_state_offline(config)) + await hass.async_block_till_done() state = hass.states.get(f"{domain}.{entity_id}") assert state.state == STATE_UNAVAILABLE @@ -597,5 +604,6 @@ async def help_test_entity_id_update_discovery_update( topic = get_topic_tele_will(config) async_fire_mqtt_message(hass, topic, config_get_state_online(config)) + await hass.async_block_till_done() state = hass.states.get(f"{domain}.milk") assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/tasmota/test_config_flow.py b/tests/components/tasmota/test_config_flow.py index 7d6d0628de1..3413817892b 100644 --- a/tests/components/tasmota/test_config_flow.py +++ b/tests/components/tasmota/test_config_flow.py @@ -1,5 +1,6 @@ """Test config flow.""" from homeassistant import config_entries +from homeassistant.components import mqtt from tests.common import MockConfigEntry @@ -18,9 +19,9 @@ async def test_mqtt_abort_if_existing_entry(hass, mqtt_mock): async def test_mqtt_abort_invalid_topic(hass, mqtt_mock): """Check MQTT flow aborts if discovery topic is invalid.""" - discovery_info = { - "topic": "tasmota/discovery/DC4F220848A2/bla", - "payload": ( + discovery_info = mqtt.MqttServiceInfo( + topic="tasmota/discovery/DC4F220848A2/bla", + payload=( '{"ip":"192.168.0.136","dn":"Tasmota","fn":["Tasmota",null,null,null,null,' 'null,null,null],"hn":"tasmota_0848A2","mac":"DC4F220848A2","md":"Sonoff Basic",' '"ty":0,"if":0,"ofln":"Offline","onln":"Online","state":["OFF","ON",' @@ -30,34 +31,34 @@ async def test_mqtt_abort_invalid_topic(hass, mqtt_mock): '"so":{"4":0,"11":0,"13":0,"17":1,"20":0,"30":0,"68":0,"73":0,"82":0,"114":1,"117":0},' '"lk":1,"lt_st":0,"sho":[0,0,0,0],"ver":1}' ), - "qos": 0, - "retain": False, - "subscribed_topic": "tasmota/discovery/#", - "timestamp": None, - } + qos=0, + retain=False, + subscribed_topic="tasmota/discovery/#", + timestamp=None, + ) result = await hass.config_entries.flow.async_init( "tasmota", context={"source": config_entries.SOURCE_MQTT}, data=discovery_info ) assert result["type"] == "abort" assert result["reason"] == "invalid_discovery_info" - discovery_info = { - "topic": "tasmota/discovery/DC4F220848A2/config", - "payload": "", - "qos": 0, - "retain": False, - "subscribed_topic": "tasmota/discovery/#", - "timestamp": None, - } + discovery_info = mqtt.MqttServiceInfo( + topic="tasmota/discovery/DC4F220848A2/config", + payload="", + qos=0, + retain=False, + subscribed_topic="tasmota/discovery/#", + timestamp=None, + ) result = await hass.config_entries.flow.async_init( "tasmota", context={"source": config_entries.SOURCE_MQTT}, data=discovery_info ) assert result["type"] == "abort" assert result["reason"] == "invalid_discovery_info" - discovery_info = { - "topic": "tasmota/discovery/DC4F220848A2/config", - "payload": ( + discovery_info = mqtt.MqttServiceInfo( + topic="tasmota/discovery/DC4F220848A2/config", + payload=( '{"ip":"192.168.0.136","dn":"Tasmota","fn":["Tasmota",null,null,null,null,' 'null,null,null],"hn":"tasmota_0848A2","mac":"DC4F220848A2","md":"Sonoff Basic",' '"ty":0,"if":0,"ofln":"Offline","onln":"Online","state":["OFF","ON",' @@ -67,11 +68,11 @@ async def test_mqtt_abort_invalid_topic(hass, mqtt_mock): '"so":{"4":0,"11":0,"13":0,"17":1,"20":0,"30":0,"68":0,"73":0,"82":0,"114":1,"117":0},' '"lk":1,"lt_st":0,"sho":[0,0,0,0],"ver":1}' ), - "qos": 0, - "retain": False, - "subscribed_topic": "tasmota/discovery/#", - "timestamp": None, - } + qos=0, + retain=False, + subscribed_topic="tasmota/discovery/#", + timestamp=None, + ) result = await hass.config_entries.flow.async_init( "tasmota", context={"source": config_entries.SOURCE_MQTT}, data=discovery_info ) @@ -80,9 +81,9 @@ async def test_mqtt_abort_invalid_topic(hass, mqtt_mock): async def test_mqtt_setup(hass, mqtt_mock) -> None: """Test we can finish a config flow through MQTT with custom prefix.""" - discovery_info = { - "topic": "tasmota/discovery/DC4F220848A2/config", - "payload": ( + discovery_info = mqtt.MqttServiceInfo( + topic="tasmota/discovery/DC4F220848A2/config", + payload=( '{"ip":"192.168.0.136","dn":"Tasmota","fn":["Tasmota",null,null,null,null,' 'null,null,null],"hn":"tasmota_0848A2","mac":"DC4F220848A2","md":"Sonoff Basic",' '"ty":0,"if":0,"ofln":"Offline","onln":"Online","state":["OFF","ON",' @@ -92,11 +93,11 @@ async def test_mqtt_setup(hass, mqtt_mock) -> None: '"so":{"4":0,"11":0,"13":0,"17":1,"20":0,"30":0,"68":0,"73":0,"82":0,"114":1,"117":0},' '"lk":1,"lt_st":0,"sho":[0,0,0,0],"ver":1}' ), - "qos": 0, - "retain": False, - "subscribed_topic": "tasmota/discovery/#", - "timestamp": None, - } + qos=0, + retain=False, + subscribed_topic="tasmota/discovery/#", + timestamp=None, + ) result = await hass.config_entries.flow.async_init( "tasmota", context={"source": config_entries.SOURCE_MQTT}, data=discovery_info ) diff --git a/tests/components/tasmota/test_cover.py b/tests/components/tasmota/test_cover.py index 131f95842a5..c036f490f6d 100644 --- a/tests/components/tasmota/test_cover.py +++ b/tests/components/tasmota/test_cover.py @@ -9,6 +9,7 @@ from hatasmota.utils import ( get_topic_tele_sensor, get_topic_tele_will, ) +import pytest from homeassistant.components import cover from homeassistant.components.tasmota.const import DEFAULT_PREFIX @@ -34,6 +35,35 @@ async def test_missing_relay(hass, mqtt_mock, setup_tasmota): """Test no cover is discovered if relays are missing.""" +@pytest.mark.parametrize( + "relay_config, num_covers", + [ + ([3, 3, 3, 3, 3, 3, 1, 1, 3, 3], 4), + ([3, 3, 3, 3, 0, 0, 0, 0], 2), + ([3, 3, 1, 1, 0, 0, 0, 0], 1), + ([3, 3, 3, 1, 0, 0, 0, 0], 0), + ], +) +async def test_multiple_covers( + hass, mqtt_mock, setup_tasmota, relay_config, num_covers +): + """Test discovery of multiple covers.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"] = relay_config + mac = config["mac"] + + assert len(hass.states.async_all("cover")) == 0 + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all("cover")) == num_covers + + async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): """Test state update via MQTT.""" config = copy.deepcopy(DEFAULT_CONFIG) @@ -53,6 +83,7 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("cover.tasmota_cover_1") assert state.state == STATE_UNKNOWN assert ( @@ -215,6 +246,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("cover.tasmota_cover_1") assert state.state == STATE_UNKNOWN assert ( @@ -383,6 +415,7 @@ async def test_sending_mqtt_commands(hass, mqtt_mock, setup_tasmota): await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("cover.test_cover_1") assert state.state == STATE_UNKNOWN await hass.async_block_till_done() @@ -446,6 +479,7 @@ async def test_sending_mqtt_commands_inverted(hass, mqtt_mock, setup_tasmota): await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("cover.test_cover_1") assert state.state == STATE_UNKNOWN await hass.async_block_till_done() diff --git a/tests/components/tasmota/test_fan.py b/tests/components/tasmota/test_fan.py index 202a6a5386b..bb2610d466d 100644 --- a/tests/components/tasmota/test_fan.py +++ b/tests/components/tasmota/test_fan.py @@ -50,6 +50,7 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("fan.tasmota") assert state.state == STATE_OFF assert state.attributes["percentage"] is None @@ -101,6 +102,7 @@ async def test_sending_mqtt_commands(hass, mqtt_mock, setup_tasmota): await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("fan.tasmota") assert state.state == STATE_OFF await hass.async_block_till_done() @@ -167,6 +169,7 @@ async def test_invalid_fan_speed_percentage(hass, mqtt_mock, setup_tasmota): await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("fan.tasmota") assert state.state == STATE_OFF await hass.async_block_till_done() diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index a46a1f6851b..f85cf0d3c5b 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -46,6 +46,7 @@ async def test_attributes_on_off(hass, mqtt_mock, setup_tasmota): ) await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.test") @@ -72,6 +73,7 @@ async def test_attributes_dimmer_tuya(hass, mqtt_mock, setup_tasmota): ) await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.test") @@ -97,6 +99,7 @@ async def test_attributes_dimmer(hass, mqtt_mock, setup_tasmota): ) await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.test") @@ -122,6 +125,7 @@ async def test_attributes_ct(hass, mqtt_mock, setup_tasmota): ) await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.test") @@ -148,6 +152,7 @@ async def test_attributes_ct_reduced(hass, mqtt_mock, setup_tasmota): ) await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.test") @@ -173,6 +178,7 @@ async def test_attributes_rgb(hass, mqtt_mock, setup_tasmota): ) await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.test") @@ -207,6 +213,7 @@ async def test_attributes_rgbw(hass, mqtt_mock, setup_tasmota): ) await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.test") @@ -241,6 +248,7 @@ async def test_attributes_rgbww(hass, mqtt_mock, setup_tasmota): ) await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.test") @@ -276,6 +284,7 @@ async def test_attributes_rgbww_reduced(hass, mqtt_mock, setup_tasmota): ) await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.test") @@ -316,6 +325,7 @@ async def test_controlling_state_via_mqtt_on_off(hass, mqtt_mock, setup_tasmota) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("light.test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -364,6 +374,7 @@ async def test_controlling_state_via_mqtt_ct(hass, mqtt_mock, setup_tasmota): assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("light.test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -426,6 +437,7 @@ async def test_controlling_state_via_mqtt_rgbw(hass, mqtt_mock, setup_tasmota): assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("light.test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -524,6 +536,7 @@ async def test_controlling_state_via_mqtt_rgbww(hass, mqtt_mock, setup_tasmota): assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("light.test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -625,6 +638,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya(hass, mqtt_mock, setup_tasm assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("light.test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -729,6 +743,7 @@ async def test_sending_mqtt_commands_on_off(hass, mqtt_mock, setup_tasmota): await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("light.test") assert state.state == STATE_OFF await hass.async_block_till_done() @@ -770,6 +785,7 @@ async def test_sending_mqtt_commands_rgbww_tuya(hass, mqtt_mock, setup_tasmota): await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("light.test") assert state.state == STATE_OFF await hass.async_block_till_done() @@ -817,6 +833,7 @@ async def test_sending_mqtt_commands_rgbw_legacy(hass, mqtt_mock, setup_tasmota) await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("light.test") assert state.state == STATE_OFF await hass.async_block_till_done() @@ -923,6 +940,7 @@ async def test_sending_mqtt_commands_rgbw(hass, mqtt_mock, setup_tasmota): await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("light.test") assert state.state == STATE_OFF await hass.async_block_till_done() @@ -1029,6 +1047,7 @@ async def test_sending_mqtt_commands_rgbww(hass, mqtt_mock, setup_tasmota): await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("light.test") assert state.state == STATE_OFF await hass.async_block_till_done() @@ -1114,6 +1133,7 @@ async def test_sending_mqtt_commands_power_unlinked(hass, mqtt_mock, setup_tasmo await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("light.test") assert state.state == STATE_OFF await hass.async_block_till_done() @@ -1164,6 +1184,7 @@ async def test_transition(hass, mqtt_mock, setup_tasmota): await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("light.test") assert state.state == STATE_OFF await hass.async_block_till_done() @@ -1345,6 +1366,7 @@ async def test_transition_fixed(hass, mqtt_mock, setup_tasmota): await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("light.test") assert state.state == STATE_OFF await hass.async_block_till_done() diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index c6f27f0193c..0cd18c89435 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -155,6 +155,7 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): assert entry.entity_category is None async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("sensor.tasmota_dht11_temperature") assert state.state == STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -200,6 +201,7 @@ async def test_nested_sensor_state_via_mqtt(hass, mqtt_mock, setup_tasmota): assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("sensor.tasmota_tx23_speed_act") assert state.state == STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -245,6 +247,7 @@ async def test_indexed_sensor_state_via_mqtt(hass, mqtt_mock, setup_tasmota): assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("sensor.tasmota_energy_totaltariff_1") assert state.state == STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -293,6 +296,7 @@ async def test_indexed_sensor_state_via_mqtt2(hass, mqtt_mock, setup_tasmota): ) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("sensor.tasmota_energy_total") assert state.state == STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -343,6 +347,7 @@ async def test_indexed_sensor_state_via_mqtt3(hass, mqtt_mock, setup_tasmota): ) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("sensor.tasmota_energy_total_1") assert state.state == STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -396,6 +401,7 @@ async def test_bad_indexed_sensor_state_via_mqtt(hass, mqtt_mock, setup_tasmota) assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("sensor.tasmota_energy_apparentpower_0") assert state.state == STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -507,6 +513,7 @@ async def test_status_sensor_state_via_mqtt(hass, mqtt_mock, setup_tasmota): assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("sensor.tasmota_status") assert state.state == STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -566,6 +573,7 @@ async def test_single_shot_status_sensor_state_via_mqtt(hass, mqtt_mock, setup_t assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("sensor.tasmota_status") assert state.state == STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -650,6 +658,7 @@ async def test_restart_time_status_sensor_state_via_mqtt( assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("sensor.tasmota_status") assert state.state == STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -863,6 +872,7 @@ async def test_enable_status_sensor(hass, mqtt_mock, setup_tasmota): assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("sensor.tasmota_signal") assert state.state == STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) diff --git a/tests/components/tasmota/test_switch.py b/tests/components/tasmota/test_switch.py index 00b0a922e0a..7c5bf66db45 100644 --- a/tests/components/tasmota/test_switch.py +++ b/tests/components/tasmota/test_switch.py @@ -48,6 +48,7 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("switch.test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -87,6 +88,7 @@ async def test_sending_mqtt_commands(hass, mqtt_mock, setup_tasmota): await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() state = hass.states.get("switch.test") assert state.state == STATE_OFF await hass.async_block_till_done() diff --git a/tests/fixtures/telegram/configuration.yaml b/tests/components/telegram/fixtures/configuration.yaml similarity index 100% rename from tests/fixtures/telegram/configuration.yaml rename to tests/components/telegram/fixtures/configuration.yaml diff --git a/tests/components/telegram/test_notify.py b/tests/components/telegram/test_notify.py index 6f8d1f989f9..8c67c5af3a2 100644 --- a/tests/components/telegram/test_notify.py +++ b/tests/components/telegram/test_notify.py @@ -1,5 +1,4 @@ """The tests for the telegram.notify platform.""" -from os import path from unittest.mock import patch from homeassistant import config as hass_config @@ -8,6 +7,8 @@ from homeassistant.components.telegram import DOMAIN from homeassistant.const import SERVICE_RELOAD from homeassistant.setup import async_setup_component +from tests.common import get_fixture_path + async def test_reload_notify(hass): """Verify we can reload the notify service.""" @@ -30,11 +31,7 @@ async def test_reload_notify(hass): assert hass.services.has_service(notify.DOMAIN, DOMAIN) - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "telegram/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "telegram") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( DOMAIN, @@ -46,7 +43,3 @@ async def test_reload_notify(hass): assert not hass.services.has_service(notify.DOMAIN, DOMAIN) assert hass.services.has_service(notify.DOMAIN, "telegram_reloaded") - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/fixtures/template/broken_configuration.yaml b/tests/components/template/fixtures/broken_configuration.yaml similarity index 100% rename from tests/fixtures/template/broken_configuration.yaml rename to tests/components/template/fixtures/broken_configuration.yaml diff --git a/tests/fixtures/template/configuration.yaml.corrupt b/tests/components/template/fixtures/configuration.yaml.corrupt similarity index 100% rename from tests/fixtures/template/configuration.yaml.corrupt rename to tests/components/template/fixtures/configuration.yaml.corrupt diff --git a/tests/fixtures/template/empty_configuration.yaml b/tests/components/template/fixtures/empty_configuration.yaml similarity index 100% rename from tests/fixtures/template/empty_configuration.yaml rename to tests/components/template/fixtures/empty_configuration.yaml diff --git a/tests/fixtures/template/ref_configuration.yaml b/tests/components/template/fixtures/ref_configuration.yaml similarity index 100% rename from tests/fixtures/template/ref_configuration.yaml rename to tests/components/template/fixtures/ref_configuration.yaml diff --git a/tests/fixtures/template/sensor_configuration.yaml b/tests/components/template/fixtures/sensor_configuration.yaml similarity index 100% rename from tests/fixtures/template/sensor_configuration.yaml rename to tests/components/template/fixtures/sensor_configuration.yaml diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index 28caf673d01..08ffdbf6cb0 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -1,6 +1,5 @@ """The test for the Template sensor platform.""" from datetime import timedelta -from os import path from unittest.mock import patch import pytest @@ -11,7 +10,7 @@ from homeassistant.helpers.reload import SERVICE_RELOAD from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, get_fixture_path @pytest.mark.parametrize("count,domain", [(1, "sensor")]) @@ -250,17 +249,9 @@ async def test_reload_sensors_that_reference_other_template_sensors(hass, start_ assert hass.states.get("sensor.test3").state == "2" -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) - - async def async_yaml_patch_helper(hass, filename): """Help update configuration.yaml.""" - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - f"template/{filename}", - ) + yaml_path = get_fixture_path(filename, "template") with patch.object(config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( DOMAIN, diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index e0ee5422439..255cea6348f 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -685,6 +685,7 @@ async def test_level_action_no_template(hass, start_ha, calls): (None, {"replace4": '"{{x - 12}}"'}), (None, {"replace4": '"{{ none }}"'}), (None, {"replace4": '""'}), + (None, {"replace4": "\"{{ state_attr('light.nolight', 'brightness') }}\""}), ], ) @pytest.mark.parametrize( diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 189fe3653f2..0352080bed8 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1094,3 +1094,169 @@ async def test_trigger_entity_available(hass): state = hass.states.get("sensor.maybe_available") assert state.state == "unavailable" + + +async def test_trigger_entity_device_class_parsing_works(hass): + """Test trigger entity device class parsing works.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Date entity", + "state": "{{ now().date() }}", + "device_class": "date", + }, + { + "name": "Timestamp entity", + "state": "{{ now() }}", + "device_class": "timestamp", + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + now = dt_util.now() + + with patch("homeassistant.util.dt.now", return_value=now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + + date_state = hass.states.get("sensor.date_entity") + assert date_state is not None + assert date_state.state == now.date().isoformat() + + ts_state = hass.states.get("sensor.timestamp_entity") + assert ts_state is not None + assert ts_state.state == now.isoformat(timespec="seconds") + + +async def test_trigger_entity_device_class_errors_works(hass): + """Test trigger entity device class errors works.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Date entity", + "state": "invalid", + "device_class": "date", + }, + { + "name": "Timestamp entity", + "state": "invalid", + "device_class": "timestamp", + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + now = dt_util.now() + + with patch("homeassistant.util.dt.now", return_value=now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + + date_state = hass.states.get("sensor.date_entity") + assert date_state is not None + assert date_state.state == STATE_UNKNOWN + + ts_state = hass.states.get("sensor.timestamp_entity") + assert ts_state is not None + assert ts_state.state == STATE_UNKNOWN + + +async def test_entity_device_class_parsing_works(hass): + """Test entity device class parsing works.""" + now = dt_util.now() + + with patch("homeassistant.util.dt.now", return_value=now): + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "sensor": [ + { + "name": "Date entity", + "state": "{{ now().date() }}", + "device_class": "date", + }, + { + "name": "Timestamp entity", + "state": "{{ now() }}", + "device_class": "timestamp", + }, + ], + }, + ], + }, + ) + await hass.async_block_till_done() + + date_state = hass.states.get("sensor.date_entity") + assert date_state is not None + assert date_state.state == now.date().isoformat() + + ts_state = hass.states.get("sensor.timestamp_entity") + assert ts_state is not None + assert ts_state.state == now.isoformat(timespec="seconds") + + +async def test_entity_device_class_errors_works(hass): + """Test entity device class errors works.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "sensor": [ + { + "name": "Date entity", + "state": "invalid", + "device_class": "date", + }, + { + "name": "Timestamp entity", + "state": "invalid", + "device_class": "timestamp", + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + now = dt_util.now() + + with patch("homeassistant.util.dt.now", return_value=now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + + date_state = hass.states.get("sensor.date_entity") + assert date_state is not None + assert date_state.state == STATE_UNKNOWN + + ts_state = hass.states.get("sensor.timestamp_entity") + assert ts_state is not None + assert ts_state.state == STATE_UNKNOWN diff --git a/tests/components/tesla_wall_connector/__init__.py b/tests/components/tesla_wall_connector/__init__.py new file mode 100644 index 00000000000..cd5c6308425 --- /dev/null +++ b/tests/components/tesla_wall_connector/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tesla Wall Connector integration.""" diff --git a/tests/components/tesla_wall_connector/conftest.py b/tests/components/tesla_wall_connector/conftest.py new file mode 100644 index 00000000000..e9f26a58eec --- /dev/null +++ b/tests/components/tesla_wall_connector/conftest.py @@ -0,0 +1,146 @@ +"""Common fixutres with default mocks as well as common test helper methods.""" +from dataclasses import dataclass +from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from tesla_wall_connector.wall_connector import Lifetime, Version, Vitals + +from homeassistant.components.tesla_wall_connector.const import ( + DEFAULT_SCAN_INTERVAL, + DOMAIN, +) +from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.fixture +def mock_wall_connector_version(): + """Fixture to mock get_version calls to the wall connector API.""" + + with patch( + "tesla_wall_connector.WallConnector.async_get_version", + return_value=get_default_version_data(), + ): + yield + + +@pytest.fixture +async def mock_wall_connector_setup(): + """Mock component setup.""" + with patch( + "homeassistant.components.tesla_wall_connector.async_setup_entry", + return_value=True, + ): + yield + + +def get_default_version_data(): + """Return default version data object for a wall connector.""" + return Version( + { + "serial_number": "abc123", + "part_number": "part_123", + "firmware_version": "1.2.3", + } + ) + + +async def create_wall_connector_entry( + hass: HomeAssistant, side_effect=None, vitals_data=None, lifetime_data=None +) -> MockConfigEntry: + """Create a wall connector entry in hass.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.2.3.4"}, + options={CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, + ) + + entry.add_to_hass(hass) + + with patch( + "tesla_wall_connector.WallConnector.async_get_version", + return_value=get_default_version_data(), + side_effect=side_effect, + ), patch( + "tesla_wall_connector.WallConnector.async_get_vitals", + return_value=vitals_data, + side_effect=side_effect, + ), patch( + "tesla_wall_connector.WallConnector.async_get_lifetime", + return_value=lifetime_data, + side_effect=side_effect, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +def get_vitals_mock() -> Vitals: + """Get mocked vitals object.""" + vitals = MagicMock(auto_spec=Vitals) + return vitals + + +def get_lifetime_mock() -> Lifetime: + """Get mocked lifetime object.""" + lifetime = MagicMock(auto_spec=Lifetime) + return lifetime + + +@dataclass +class EntityAndExpectedValues: + """Class for keeping entity id along with expected value for first and second data updates.""" + + entity_id: str + first_value: Any + second_value: Any + + +async def _test_sensors( + hass: HomeAssistant, + entities_and_expected_values, + vitals_first_update: Vitals, + vitals_second_update: Vitals, + lifetime_first_update: Lifetime, + lifetime_second_update: Lifetime, +) -> None: + """Test update of sensor values.""" + + # First Update: Data is fetched when the integration is initialized + await create_wall_connector_entry( + hass, vitals_data=vitals_first_update, lifetime_data=lifetime_first_update + ) + + # Verify expected vs actual values of first update + for entity in entities_and_expected_values: + state = hass.states.get(entity.entity_id) + assert state, f"Unable to get state of {entity.entity_id}" + assert ( + state.state == entity.first_value + ), f"First update: {entity.entity_id} is expected to have state {entity.first_value} but has {state.state}" + + # Simulate second data update + with patch( + "tesla_wall_connector.WallConnector.async_get_vitals", + return_value=vitals_second_update, + ), patch( + "tesla_wall_connector.WallConnector.async_get_lifetime", + return_value=lifetime_second_update, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=DEFAULT_SCAN_INTERVAL) + ) + await hass.async_block_till_done() + + # Verify expected vs actual values of second update + for entity in entities_and_expected_values: + state = hass.states.get(entity.entity_id) + assert ( + state.state == entity.second_value + ), f"Second update: {entity.entity_id} is expected to have state {entity.second_value} but has {state.state}" diff --git a/tests/components/tesla_wall_connector/test_binary_sensor.py b/tests/components/tesla_wall_connector/test_binary_sensor.py new file mode 100644 index 00000000000..09283cb5352 --- /dev/null +++ b/tests/components/tesla_wall_connector/test_binary_sensor.py @@ -0,0 +1,41 @@ +"""Tests for binary sensors.""" +from homeassistant.core import HomeAssistant + +from .conftest import ( + EntityAndExpectedValues, + _test_sensors, + get_lifetime_mock, + get_vitals_mock, +) + + +async def test_sensors(hass: HomeAssistant) -> None: + """Test all binary sensors.""" + + entity_and_expected_values = [ + EntityAndExpectedValues( + "binary_sensor.tesla_wall_connector_contactor_closed", "off", "on" + ), + EntityAndExpectedValues( + "binary_sensor.tesla_wall_connector_vehicle_connected", "on", "off" + ), + ] + + mock_vitals_first_update = get_vitals_mock() + mock_vitals_first_update.contactor_closed = False + mock_vitals_first_update.vehicle_connected = True + + mock_vitals_second_update = get_vitals_mock() + mock_vitals_second_update.contactor_closed = True + mock_vitals_second_update.vehicle_connected = False + + lifetime_mock = get_lifetime_mock() + + await _test_sensors( + hass, + entities_and_expected_values=entity_and_expected_values, + vitals_first_update=mock_vitals_first_update, + vitals_second_update=mock_vitals_second_update, + lifetime_first_update=lifetime_mock, + lifetime_second_update=lifetime_mock, + ) diff --git a/tests/components/tesla_wall_connector/test_config_flow.py b/tests/components/tesla_wall_connector/test_config_flow.py new file mode 100644 index 00000000000..2e286f75b61 --- /dev/null +++ b/tests/components/tesla_wall_connector/test_config_flow.py @@ -0,0 +1,177 @@ +"""Test the Tesla Wall Connector config flow.""" +from unittest.mock import patch + +from tesla_wall_connector.exceptions import WallConnectorConnectionError + +from homeassistant import config_entries +from homeassistant.components import dhcp +from homeassistant.components.tesla_wall_connector.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + + +async def test_form(mock_wall_connector_version, hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.tesla_wall_connector.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Tesla Wall Connector" + assert result2["data"] == {CONF_HOST: "1.1.1.1"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "tesla_wall_connector.WallConnector.async_get_version", + side_effect=WallConnectorConnectionError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_other_error( + mock_wall_connector_version, hass: HomeAssistant +) -> None: + """Test we handle any other error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "tesla_wall_connector.WallConnector.async_get_version", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_already_configured( + mock_wall_connector_setup, mock_wall_connector_version, hass +): + """Test we get already configured.""" + + entry = MockConfigEntry( + domain=DOMAIN, unique_id="abc123", data={CONF_HOST: "0.0.0.0"} + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + + # Test config entry got updated with latest IP + assert entry.data[CONF_HOST] == "1.1.1.1" + + +async def test_dhcp_can_finish( + mock_wall_connector_setup, mock_wall_connector_version, hass +): + """Test DHCP discovery flow can finish right away.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="teslawallconnector_abc", + ip="1.2.3.4", + macaddress="DC:44:27:12:12", + ), + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {CONF_HOST: "1.2.3.4"} + + +async def test_dhcp_already_exists(mock_wall_connector_version, hass): + """Test DHCP discovery flow when device already exists.""" + + entry = MockConfigEntry( + domain=DOMAIN, unique_id="abc123", data={CONF_HOST: "1.2.3.4"} + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="teslawallconnector_aabbcc", + ip="1.2.3.4", + macaddress="aa:bb:cc:dd:ee:ff", + ), + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_dhcp_error_from_wall_connector(mock_wall_connector_version, hass): + """Test DHCP discovery flow when we cannot communicate with the device.""" + + with patch( + "tesla_wall_connector.WallConnector.async_get_version", + side_effect=WallConnectorConnectionError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="teslawallconnector_aabbcc", + ip="1.2.3.4", + macaddress="aa:bb:cc:dd:ee:ff", + ), + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" diff --git a/tests/components/tesla_wall_connector/test_init.py b/tests/components/tesla_wall_connector/test_init.py new file mode 100644 index 00000000000..b86a363dc3c --- /dev/null +++ b/tests/components/tesla_wall_connector/test_init.py @@ -0,0 +1,35 @@ +"""Test the Tesla Wall Connector config flow.""" +from tesla_wall_connector.exceptions import WallConnectorConnectionError + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant + +from .conftest import create_wall_connector_entry + + +async def test_init_success(hass: HomeAssistant) -> None: + """Test setup and that we get the device info, including firmware version.""" + + entry = await create_wall_connector_entry(hass) + + assert entry.state == config_entries.ConfigEntryState.LOADED + + +async def test_init_while_offline(hass: HomeAssistant) -> None: + """Test init with the wall connector offline.""" + entry = await create_wall_connector_entry( + hass, side_effect=WallConnectorConnectionError + ) + + assert entry.state == config_entries.ConfigEntryState.SETUP_RETRY + + +async def test_load_unload(hass): + """Config entry can be unloaded.""" + + entry = await create_wall_connector_entry(hass) + + assert entry.state is config_entries.ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/tesla_wall_connector/test_sensor.py b/tests/components/tesla_wall_connector/test_sensor.py new file mode 100644 index 00000000000..0cafc15c6f1 --- /dev/null +++ b/tests/components/tesla_wall_connector/test_sensor.py @@ -0,0 +1,85 @@ +"""Tests for sensors.""" +from homeassistant.core import HomeAssistant + +from .conftest import ( + EntityAndExpectedValues, + _test_sensors, + get_lifetime_mock, + get_vitals_mock, +) + + +async def test_sensors(hass: HomeAssistant) -> None: + """Test all sensors.""" + + entity_and_expected_values = [ + EntityAndExpectedValues("sensor.tesla_wall_connector_state", "1", "2"), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_handle_temperature", "25.5", "-1.4" + ), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_grid_voltage", "230.2", "229.2" + ), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_grid_frequency", "50.021", "49.981" + ), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_energy", "988022", "989000" + ), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_phase_a_current", "10", "7" + ), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_phase_b_current", "11.1", "8" + ), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_phase_c_current", "12", "9" + ), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_phase_a_voltage", "230.1", "228.1" + ), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_phase_b_voltage", "231", "229.1" + ), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_phase_c_voltage", "232.1", "230" + ), + ] + + mock_vitals_first_update = get_vitals_mock() + mock_vitals_first_update.evse_state = 1 + mock_vitals_first_update.handle_temp_c = 25.51 + mock_vitals_first_update.grid_v = 230.15 + mock_vitals_first_update.grid_hz = 50.021 + mock_vitals_first_update.voltageA_v = 230.1 + mock_vitals_first_update.voltageB_v = 231 + mock_vitals_first_update.voltageC_v = 232.1 + mock_vitals_first_update.currentA_a = 10 + mock_vitals_first_update.currentB_a = 11.1 + mock_vitals_first_update.currentC_a = 12 + + mock_vitals_second_update = get_vitals_mock() + mock_vitals_second_update.evse_state = 2 + mock_vitals_second_update.handle_temp_c = -1.42 + mock_vitals_second_update.grid_v = 229.21 + mock_vitals_second_update.grid_hz = 49.981 + mock_vitals_second_update.voltageA_v = 228.1 + mock_vitals_second_update.voltageB_v = 229.1 + mock_vitals_second_update.voltageC_v = 230 + mock_vitals_second_update.currentA_a = 7 + mock_vitals_second_update.currentB_a = 8 + mock_vitals_second_update.currentC_a = 9 + + lifetime_mock_first_update = get_lifetime_mock() + lifetime_mock_first_update.energy_wh = 988022 + lifetime_mock_second_update = get_lifetime_mock() + lifetime_mock_second_update.energy_wh = 989000 + + await _test_sensors( + hass, + entities_and_expected_values=entity_and_expected_values, + vitals_first_update=mock_vitals_first_update, + vitals_second_update=mock_vitals_second_update, + lifetime_first_update=lifetime_mock_first_update, + lifetime_second_update=lifetime_mock_second_update, + ) diff --git a/tests/components/tolo/__init__.py b/tests/components/tolo/__init__.py new file mode 100644 index 00000000000..d8874d9ceec --- /dev/null +++ b/tests/components/tolo/__init__.py @@ -0,0 +1 @@ +"""Tests for the TOLO Sauna integration.""" diff --git a/tests/components/tolo/test_config_flow.py b/tests/components/tolo/test_config_flow.py new file mode 100644 index 00000000000..9991decc511 --- /dev/null +++ b/tests/components/tolo/test_config_flow.py @@ -0,0 +1,107 @@ +"""Tests for the TOLO Sauna config flow.""" +from unittest.mock import Mock, patch + +import pytest +from tololib.errors import ResponseTimedOutError + +from homeassistant.components import dhcp +from homeassistant.components.tolo.const import DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +MOCK_DHCP_DATA = dhcp.DhcpServiceInfo( + ip="127.0.0.2", macaddress="00:11:22:33:44:55", hostname="mock_hostname" +) + + +@pytest.fixture(name="toloclient") +def toloclient_fixture() -> Mock: + """Patch libraries.""" + with patch("homeassistant.components.tolo.config_flow.ToloClient") as toloclient: + yield toloclient + + +async def test_user_with_timed_out_host(hass: HomeAssistant, toloclient: Mock): + """Test a user initiated config flow with provided host which times out.""" + toloclient().get_status_info.side_effect = ResponseTimedOutError() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "127.0.0.1"}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_user_walkthrough(hass: HomeAssistant, toloclient: Mock): + """Test complete user flow with first wrong and then correct host.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + assert "flow_id" in result + + toloclient().get_status_info.side_effect = lambda *args, **kwargs: None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.2"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == SOURCE_USER + assert result2["errors"] == {"base": "cannot_connect"} + assert "flow_id" in result2 + + toloclient().get_status_info.side_effect = lambda *args, **kwargs: object() + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1"}, + ) + + assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == "TOLO Sauna" + assert result3["data"][CONF_HOST] == "127.0.0.1" + + +async def test_dhcp(hass: HomeAssistant, toloclient: Mock): + """Test starting a flow from discovery.""" + toloclient().get_status_info.side_effect = lambda *args, **kwargs: object() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=MOCK_DHCP_DATA + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "TOLO Sauna" + assert result["data"][CONF_HOST] == "127.0.0.2" + assert result["result"].unique_id == "00:11:22:33:44:55" + + +async def test_dhcp_invalid_device(hass: HomeAssistant, toloclient: Mock): + """Test starting a flow from discovery.""" + toloclient().get_status_info.side_effect = lambda *args, **kwargs: None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=MOCK_DHCP_DATA + ) + assert result["type"] == RESULT_TYPE_ABORT diff --git a/tests/components/totalconnect/test_config_flow.py b/tests/components/totalconnect/test_config_flow.py index a9debb26dd4..631553a4af4 100644 --- a/tests/components/totalconnect/test_config_flow.py +++ b/tests/components/totalconnect/test_config_flow.py @@ -1,6 +1,8 @@ """Tests for the TotalConnect config flow.""" from unittest.mock import patch +from total_connect_client.exceptions import AuthenticationError + from homeassistant import data_entry_flow from homeassistant.components.totalconnect.const import CONF_USERCODES, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER @@ -92,10 +94,7 @@ async def test_abort_if_already_setup(hass): ).add_to_hass(hass) # Should fail, same USERNAME (flow) - with patch( - "homeassistant.components.totalconnect.config_flow.TotalConnectClient" - ) as client_mock: - client_mock.return_value.is_logged_in.return_value = True + with patch("homeassistant.components.totalconnect.config_flow.TotalConnectClient"): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -111,7 +110,7 @@ async def test_login_failed(hass): with patch( "homeassistant.components.totalconnect.config_flow.TotalConnectClient" ) as client_mock: - client_mock.return_value.is_logged_in.return_value = False + client_mock.side_effect = AuthenticationError() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -143,7 +142,7 @@ async def test_reauth(hass): "homeassistant.components.totalconnect.async_setup_entry", return_value=True ): # first test with an invalid password - client_mock.return_value.is_logged_in.return_value = False + client_mock.side_effect = AuthenticationError() result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "password"} @@ -153,7 +152,7 @@ async def test_reauth(hass): assert result["errors"] == {"base": "invalid_auth"} # now test with the password valid - client_mock.return_value.is_logged_in.return_value = True + client_mock.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "password"} @@ -163,3 +162,31 @@ async def test_reauth(hass): await hass.async_block_till_done() assert len(hass.config_entries.async_entries()) == 1 + + +async def test_no_locations(hass): + """Test with no user locations.""" + responses = [ + RESPONSE_AUTHENTICATE, + RESPONSE_PARTITION_DETAILS, + RESPONSE_GET_ZONE_DETAILS_SUCCESS, + RESPONSE_DISARMED, + ] + + with patch(TOTALCONNECT_REQUEST, side_effect=responses,) as mock_request, patch( + "homeassistant.components.totalconnect.async_setup_entry", return_value=True + ), patch( + "homeassistant.components.totalconnect.TotalConnectClient.get_number_locations", + return_value=0, + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=CONFIG_DATA_NO_USERCODES, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "no_locations" + await hass.async_block_till_done() + + assert mock_request.call_count == 1 diff --git a/tests/components/totalconnect/test_init.py b/tests/components/totalconnect/test_init.py index f1797f840ab..4c8c61f7d99 100644 --- a/tests/components/totalconnect/test_init.py +++ b/tests/components/totalconnect/test_init.py @@ -1,6 +1,8 @@ """Tests for the TotalConnect init process.""" from unittest.mock import patch +from total_connect_client.exceptions import AuthenticationError + from homeassistant.components.totalconnect.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.setup import async_setup_component @@ -20,9 +22,8 @@ async def test_reauth_started(hass): with patch( "homeassistant.components.totalconnect.TotalConnectClient", - autospec=True, ) as mock_client: - mock_client.return_value.is_logged_in.return_value = False + mock_client.side_effect = AuthenticationError() assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 4e6dbb9dae7..50249f54f03 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -91,6 +91,7 @@ def _mocked_plug() -> SmartPlug: plug.hw_info = {"sw_ver": "1.0.0"} plug.turn_off = AsyncMock() plug.turn_on = AsyncMock() + plug.set_led = AsyncMock() plug.protocol = _mock_protocol() return plug @@ -111,6 +112,7 @@ def _mocked_strip() -> SmartStrip: strip.hw_info = {"sw_ver": "1.0.0"} strip.turn_off = AsyncMock() strip.turn_on = AsyncMock() + strip.set_led = AsyncMock() strip.protocol = _mock_protocol() plug0 = _mocked_plug() plug0.alias = "Plug0" diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index 03757efa0ca..5a4e672a384 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest from homeassistant import config_entries, setup +from homeassistant.components import dhcp from homeassistant.components.tplink import DOMAIN from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant @@ -308,7 +309,9 @@ async def test_discovered_by_discovery_and_dhcp(hass): result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={"ip": IP_ADDRESS, "macaddress": MAC_ADDRESS, "hostname": ALIAS}, + data=dhcp.DhcpServiceInfo( + ip=IP_ADDRESS, macaddress=MAC_ADDRESS, hostname=ALIAS + ), ) await hass.async_block_till_done() assert result2["type"] == RESULT_TYPE_ABORT @@ -318,7 +321,9 @@ async def test_discovered_by_discovery_and_dhcp(hass): result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={"ip": IP_ADDRESS, "macaddress": "00:00:00:00:00:00"}, + data=dhcp.DhcpServiceInfo( + ip=IP_ADDRESS, macaddress="00:00:00:00:00:00", hostname="mock_hostname" + ), ) await hass.async_block_till_done() assert result3["type"] == RESULT_TYPE_ABORT @@ -328,7 +333,9 @@ async def test_discovered_by_discovery_and_dhcp(hass): result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={"ip": "1.2.3.5", "macaddress": "00:00:00:00:00:01"}, + data=dhcp.DhcpServiceInfo( + ip="1.2.3.5", macaddress="00:00:00:00:00:01", hostname="mock_hostname" + ), ) await hass.async_block_till_done() assert result3["type"] == RESULT_TYPE_ABORT @@ -340,7 +347,7 @@ async def test_discovered_by_discovery_and_dhcp(hass): [ ( config_entries.SOURCE_DHCP, - {"ip": IP_ADDRESS, "macaddress": MAC_ADDRESS, "hostname": ALIAS}, + dhcp.DhcpServiceInfo(ip=IP_ADDRESS, macaddress=MAC_ADDRESS, hostname=ALIAS), ), ( config_entries.SOURCE_DISCOVERY, @@ -381,7 +388,7 @@ async def test_discovered_by_dhcp_or_discovery(hass, source, data): [ ( config_entries.SOURCE_DHCP, - {"ip": IP_ADDRESS, "macaddress": MAC_ADDRESS, "hostname": ALIAS}, + dhcp.DhcpServiceInfo(ip=IP_ADDRESS, macaddress=MAC_ADDRESS, hostname=ALIAS), ), ( config_entries.SOURCE_DISCOVERY, diff --git a/tests/components/tplink/test_switch.py b/tests/components/tplink/test_switch.py index 9e7f9189aab..03dc98f9799 100644 --- a/tests/components/tplink/test_switch.py +++ b/tests/components/tplink/test_switch.py @@ -53,6 +53,38 @@ async def test_plug(hass: HomeAssistant) -> None: plug.turn_on.reset_mock() +async def test_plug_led(hass: HomeAssistant) -> None: + """Test a smart plug LED.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_plug() + with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "switch.my_plug" + state = hass.states.get(entity_id) + + led_entity_id = f"{entity_id}_led" + led_state = hass.states.get(led_entity_id) + assert led_state.state == STATE_ON + assert led_state.name == f"{state.name} LED" + + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: led_entity_id}, blocking=True + ) + plug.set_led.assert_called_once_with(False) + plug.set_led.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: led_entity_id}, blocking=True + ) + plug.set_led.assert_called_once_with(True) + plug.set_led.reset_mock() + + async def test_plug_unique_id(hass: HomeAssistant) -> None: """Test a plug unique id.""" already_migrated_config_entry = MockConfigEntry( @@ -124,6 +156,35 @@ async def test_strip(hass: HomeAssistant) -> None: strip.children[plug_id].turn_on.reset_mock() +async def test_strip_led(hass: HomeAssistant) -> None: + """Test a smart strip LED.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + strip = _mocked_strip() + with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + # We should have a LED entity for the strip + led_entity_id = "switch.my_strip_led" + led_state = hass.states.get(led_entity_id) + assert led_state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: led_entity_id}, blocking=True + ) + strip.set_led.assert_called_once_with(False) + strip.set_led.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: led_entity_id}, blocking=True + ) + strip.set_led.assert_called_once_with(True) + strip.set_led.reset_mock() + + async def test_strip_unique_ids(hass: HomeAssistant) -> None: """Test a strip unique id.""" already_migrated_config_entry = MockConfigEntry( diff --git a/tests/fixtures/trace/automation_saved_traces.json b/tests/components/trace/fixtures/automation_saved_traces.json similarity index 99% rename from tests/fixtures/trace/automation_saved_traces.json rename to tests/components/trace/fixtures/automation_saved_traces.json index 45bcfffc157..7f6ed56a8bc 100644 --- a/tests/fixtures/trace/automation_saved_traces.json +++ b/tests/components/trace/fixtures/automation_saved_traces.json @@ -1,5 +1,6 @@ { "version": 1, + "minor_version": 1, "key": "trace.saved_traces", "data": { "automation.sun": [ diff --git a/tests/fixtures/trace/script_saved_traces.json b/tests/components/trace/fixtures/script_saved_traces.json similarity index 99% rename from tests/fixtures/trace/script_saved_traces.json rename to tests/components/trace/fixtures/script_saved_traces.json index 91677b2a47e..ccd2902d726 100644 --- a/tests/fixtures/trace/script_saved_traces.json +++ b/tests/components/trace/fixtures/script_saved_traces.json @@ -1,5 +1,6 @@ { "version": 1, + "minor_version": 1, "key": "trace.saved_traces", "data": { "script.sun": [ diff --git a/tests/components/tradfri/conftest.py b/tests/components/tradfri/conftest.py index c5310d52b9c..f4e871d79e1 100644 --- a/tests/components/tradfri/conftest.py +++ b/tests/components/tradfri/conftest.py @@ -59,7 +59,7 @@ def mock_gateway_fixture(): def mock_api_fixture(mock_gateway): """Mock api.""" - async def api(command): + async def api(command, timeout=None): """Mock api function.""" # Store the data for "real" command objects. if hasattr(command, "_data") and not isinstance(command, Mock): diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py index 60f3043f4f5..12726bc553a 100644 --- a/tests/components/tradfri/test_config_flow.py +++ b/tests/components/tradfri/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant import config_entries, data_entry_flow +from homeassistant.components import zeroconf from homeassistant.components.tradfri import config_flow from . import TRADFRI_PATH @@ -103,7 +104,14 @@ async def test_discovery_connection(hass, mock_auth, mock_entry_setup): flow = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data={"host": "123.123.123.123", "properties": {"id": "homekit-id"}}, + data=zeroconf.ZeroconfServiceInfo( + host="123.123.123.123", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + type="mock_type", + ), ) result = await hass.config_entries.flow.async_configure( @@ -251,7 +259,14 @@ async def test_discovery_duplicate_aborted(hass): flow = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data={"host": "new-host", "properties": {"id": "homekit-id"}}, + data=zeroconf.ZeroconfServiceInfo( + host="new-host", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + type="mock_type", + ), ) assert flow["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -279,7 +294,14 @@ async def test_duplicate_discovery(hass, mock_auth, mock_entry_setup): result = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data={"host": "123.123.123.123", "properties": {"id": "homekit-id"}}, + data=zeroconf.ZeroconfServiceInfo( + host="123.123.123.123", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + type="mock_type", + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -287,7 +309,14 @@ async def test_duplicate_discovery(hass, mock_auth, mock_entry_setup): result2 = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data={"host": "123.123.123.123", "properties": {"id": "homekit-id"}}, + data=zeroconf.ZeroconfServiceInfo( + host="123.123.123.123", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + type="mock_type", + ), ) assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -304,7 +333,14 @@ async def test_discovery_updates_unique_id(hass): flow = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data={"host": "some-host", "properties": {"id": "homekit-id"}}, + data=zeroconf.ZeroconfServiceInfo( + host="some-host", + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + type="mock_type", + ), ) assert flow["type"] == data_entry_flow.RESULT_TYPE_ABORT diff --git a/tests/components/trafikverket_weatherstation/__init__.py b/tests/components/trafikverket_weatherstation/__init__.py new file mode 100644 index 00000000000..836ee919195 --- /dev/null +++ b/tests/components/trafikverket_weatherstation/__init__.py @@ -0,0 +1 @@ +"""Tests for the Trafikverket weatherstation integration.""" diff --git a/tests/components/trafikverket_weatherstation/test_config_flow.py b/tests/components/trafikverket_weatherstation/test_config_flow.py new file mode 100644 index 00000000000..b8a18181a11 --- /dev/null +++ b/tests/components/trafikverket_weatherstation/test_config_flow.py @@ -0,0 +1,157 @@ +"""Test the Trafikverket weatherstation config flow.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + +DOMAIN = "trafikverket_weatherstation" +CONF_STATION = "station" + + +async def test_form(hass: HomeAssistant) -> 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.trafikverket_weatherstation.config_flow.TrafikverketWeather.async_get_weather", + ), patch( + "homeassistant.components.trafikverket_weatherstation.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_STATION: "Vallby", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Vallby" + assert result2["data"] == { + "api_key": "1234567890", + "station": "Vallby", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_flow_success(hass: HomeAssistant) -> None: + """Test a successful import of yaml.""" + + with patch( + "homeassistant.components.trafikverket_weatherstation.config_flow.TrafikverketWeather.async_get_weather", + ), patch( + "homeassistant.components.trafikverket_weatherstation.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={ + CONF_NAME: "Vallby", + CONF_API_KEY: "1234567890", + CONF_STATION: "Vallby", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Vallby" + assert result2["data"] == { + "api_key": "1234567890", + "station": "Vallby", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_flow_already_exist(hass: HomeAssistant) -> None: + """Test import of yaml already exist.""" + + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + CONF_STATION: "Vallby", + }, + ).add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_weatherstation.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.trafikverket_weatherstation.config_flow.TrafikverketWeather.async_get_weather", + ): + result3 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_NAME: "Vallby", + CONF_API_KEY: "1234567890", + CONF_STATION: "Vallby", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == "abort" + assert result3["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "error_message,base_error", + [ + ( + "Source: Security, message: Invalid authentication", + "invalid_auth", + ), + ( + "Could not find a weather station with the specified name", + "invalid_station", + ), + ( + "Found multiple weather stations with the specified name", + "more_stations", + ), + ( + "Unknown", + "cannot_connect", + ), + ], +) +async def test_flow_fails( + hass: HomeAssistant, error_message: str, base_error: str +) -> None: + """Test config flow errors.""" + result4 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result4["type"] == RESULT_TYPE_FORM + assert result4["step_id"] == config_entries.SOURCE_USER + + with patch( + "homeassistant.components.trafikverket_weatherstation.config_flow.TrafikverketWeather.async_get_weather", + side_effect=ValueError(error_message), + ): + result4 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + CONF_STATION: "Vallby", + }, + ) + + assert result4["errors"] == {"base": base_error} diff --git a/tests/fixtures/trend/configuration.yaml b/tests/components/trend/fixtures/configuration.yaml similarity index 100% rename from tests/fixtures/trend/configuration.yaml rename to tests/components/trend/fixtures/configuration.yaml diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index be414b73042..4716762d2e7 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -1,6 +1,5 @@ """The test for the Trend sensor platform.""" from datetime import timedelta -from os import path from unittest.mock import patch from homeassistant import config as hass_config, setup @@ -8,7 +7,11 @@ from homeassistant.components.trend import DOMAIN from homeassistant.const import SERVICE_RELOAD import homeassistant.util.dt as dt_util -from tests.common import assert_setup_component, get_test_home_assistant +from tests.common import ( + assert_setup_component, + get_fixture_path, + get_test_home_assistant, +) class TestTrendBinarySensor: @@ -395,11 +398,7 @@ async def test_reload(hass): assert hass.states.get("binary_sensor.test_trend_sensor") - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "trend/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "trend") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( DOMAIN, @@ -413,7 +412,3 @@ async def test_reload(hass): assert hass.states.get("binary_sensor.test_trend_sensor") is None assert hass.states.get("binary_sensor.second_test_trend_sensor") - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/components/twentemilieu/conftest.py b/tests/components/twentemilieu/conftest.py new file mode 100644 index 00000000000..530e9723251 --- /dev/null +++ b/tests/components/twentemilieu/conftest.py @@ -0,0 +1,89 @@ +"""Fixtures for the Twente Milieu integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from datetime import date +from unittest.mock import MagicMock, patch + +import pytest +from twentemilieu import WasteType + +from homeassistant.components.twentemilieu.const import ( + CONF_HOUSE_LETTER, + CONF_HOUSE_NUMBER, + CONF_POST_CODE, + DOMAIN, +) +from homeassistant.const import CONF_ID +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="1234AB 1", + domain=DOMAIN, + data={ + CONF_ID: 12345, + CONF_POST_CODE: "1234AB", + CONF_HOUSE_NUMBER: "1", + CONF_HOUSE_LETTER: "A", + }, + unique_id="12345", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.twentemilieu.async_setup_entry", return_value=True + ): + yield + + +@pytest.fixture +def mock_twentemilieu_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked Twente Milieu client.""" + with patch( + "homeassistant.components.twentemilieu.config_flow.TwenteMilieu", autospec=True + ) as twentemilieu_mock: + twentemilieu = twentemilieu_mock.return_value + twentemilieu.unique_id.return_value = 12345 + yield twentemilieu + + +@pytest.fixture +def mock_twentemilieu() -> Generator[None, MagicMock, None]: + """Return a mocked Twente Milieu client.""" + with patch( + "homeassistant.components.twentemilieu.TwenteMilieu", autospec=True + ) as twentemilieu_mock: + twentemilieu = twentemilieu_mock.return_value + twentemilieu.unique_id.return_value = 12345 + twentemilieu.update.return_value = { + WasteType.NON_RECYCLABLE: date(2021, 11, 1), + WasteType.ORGANIC: date(2021, 11, 2), + WasteType.PACKAGES: date(2021, 11, 3), + WasteType.PAPER: None, + WasteType.TREE: date(2022, 1, 6), + } + yield twentemilieu + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twentemilieu: MagicMock, +) -> MockConfigEntry: + """Set up the TwenteMilieu 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/twentemilieu/test_config_flow.py b/tests/components/twentemilieu/test_config_flow.py index e4e4b0c8335..aec0f29e590 100644 --- a/tests/components/twentemilieu/test_config_flow.py +++ b/tests/components/twentemilieu/test_config_flow.py @@ -1,7 +1,9 @@ """Tests for the Twente Milieu config flow.""" -import aiohttp +from unittest.mock import MagicMock -from homeassistant import config_entries, data_entry_flow +from twentemilieu import TwenteMilieuAddressError, TwenteMilieuConnectionError + +from homeassistant import config_entries from homeassistant.components.twentemilieu import config_flow from homeassistant.components.twentemilieu.const import ( CONF_HOUSE_LETTER, @@ -10,113 +12,139 @@ from homeassistant.components.twentemilieu.const import ( DOMAIN, ) from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_ID, CONTENT_TYPE_JSON +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker - -FIXTURE_USER_INPUT = { - CONF_POST_CODE: "1234AB", - CONF_HOUSE_NUMBER: "1", - CONF_HOUSE_LETTER: "A", -} -async def test_show_set_form(hass: HomeAssistant) -> None: - """Test that the setup form is served.""" +async def test_full_user_flow( + hass: HomeAssistant, + mock_twentemilieu_config_flow: MagicMock, + mock_setup_entry: MagicMock, +) -> None: + """Test registering an integration and finishing flow works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result - -async def test_connection_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test we show user form on Twente Milieu connection error.""" - aioclient_mock.post( - "https://twentemilieuapi.ximmio.com/api/FetchAdress", exc=aiohttp.ClientError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_POST_CODE: "1234AB", + CONF_HOUSE_NUMBER: "1", + CONF_HOUSE_LETTER: "A", + }, ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=FIXTURE_USER_INPUT - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "12345" + assert result2.get("data") == { + CONF_ID: 12345, + CONF_POST_CODE: "1234AB", + CONF_HOUSE_NUMBER: "1", + CONF_HOUSE_LETTER: "A", + } async def test_invalid_address( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_twentemilieu_config_flow: MagicMock, + mock_setup_entry: MagicMock, ) -> None: - """Test we show user form on Twente Milieu invalid address error.""" - aioclient_mock.post( - "https://twentemilieuapi.ximmio.com/api/FetchAdress", - json={"dataList": []}, - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=FIXTURE_USER_INPUT - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_address"} - - -async def test_address_already_set_up( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test we abort if address has already been set up.""" - MockConfigEntry( - domain=DOMAIN, data={**FIXTURE_USER_INPUT, CONF_ID: "12345"}, title="12345" - ).add_to_hass(hass) - - aioclient_mock.post( - "https://twentemilieuapi.ximmio.com/api/FetchAdress", - json={"dataList": [{"UniqueId": "12345"}]}, - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -async def test_full_flow_implementation( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test registering an integration and finishing flow works.""" - aioclient_mock.post( - "https://twentemilieuapi.ximmio.com/api/FetchAdress", - json={"dataList": [{"UniqueId": "12345"}]}, - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) + """Test full user flow when the user enters an incorrect address. + This tests also tests if the user recovers from it by entering a valid + address in the second attempt. + """ result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" + 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_configure( + mock_twentemilieu_config_flow.unique_id.side_effect = TwenteMilieuAddressError + result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - FIXTURE_USER_INPUT, + user_input={ + CONF_POST_CODE: "1234", + CONF_HOUSE_NUMBER: "1", + }, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "12345" - assert result["data"][CONF_POST_CODE] == FIXTURE_USER_INPUT[CONF_POST_CODE] - assert result["data"][CONF_HOUSE_NUMBER] == FIXTURE_USER_INPUT[CONF_HOUSE_NUMBER] - assert result["data"][CONF_HOUSE_LETTER] == FIXTURE_USER_INPUT[CONF_HOUSE_LETTER] + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == SOURCE_USER + assert result2.get("errors") == {"base": "invalid_address"} + assert "flow_id" in result2 + + mock_twentemilieu_config_flow.unique_id.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_POST_CODE: "1234AB", + CONF_HOUSE_NUMBER: "1", + }, + ) + + assert result3.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result3.get("title") == "12345" + assert result3.get("data") == { + CONF_ID: 12345, + CONF_POST_CODE: "1234AB", + CONF_HOUSE_NUMBER: "1", + CONF_HOUSE_LETTER: None, + } + + +async def test_connection_error( + hass: HomeAssistant, + mock_twentemilieu_config_flow: MagicMock, +) -> None: + """Test we show user form on Twente Milieu connection error.""" + mock_twentemilieu_config_flow.unique_id.side_effect = TwenteMilieuConnectionError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_POST_CODE: "1234AB", + CONF_HOUSE_NUMBER: "1", + CONF_HOUSE_LETTER: "A", + }, + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert result.get("errors") == {"base": "cannot_connect"} + + +async def test_address_already_set_up( + hass: HomeAssistant, + mock_twentemilieu_config_flow: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort if address has already been set up.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_POST_CODE: "1234AB", + CONF_HOUSE_NUMBER: "1", + CONF_HOUSE_LETTER: "A", + }, + ) + + assert result.get("type") == RESULT_TYPE_ABORT + assert result.get("reason") == "already_configured" diff --git a/tests/components/twentemilieu/test_init.py b/tests/components/twentemilieu/test_init.py new file mode 100644 index 00000000000..d5fd108b67a --- /dev/null +++ b/tests/components/twentemilieu/test_init.py @@ -0,0 +1,60 @@ +"""Tests for the Twente Milieu integration.""" +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.components.twentemilieu.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twentemilieu: AsyncMock, +) -> None: + """Test the Twente Milieu 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.state is ConfigEntryState.LOADED + + 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 + + +@patch( + "homeassistant.components.twentemilieu.TwenteMilieu.update", + side_effect=RuntimeError, +) +async def test_config_entry_not_ready( + mock_request: MagicMock, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Twente Milieu configuration entry not ready.""" + 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_request.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_update_config_entry_unique_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twentemilieu: AsyncMock, +) -> None: + """Test the we update old config entries with an unique ID.""" + mock_config_entry.unique_id = None + 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.unique_id == "12345" diff --git a/tests/components/twentemilieu/test_sensor.py b/tests/components/twentemilieu/test_sensor.py new file mode 100644 index 00000000000..5f09018358e --- /dev/null +++ b/tests/components/twentemilieu/test_sensor.py @@ -0,0 +1,89 @@ +"""Tests for the Twente Milieu sensors.""" +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.twentemilieu.const import DOMAIN +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, +) +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_waste_pickup_sensors( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the Twente Milieu waste pickup sensors.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.christmas_tree_pickup") + entry = entity_registry.async_get("sensor.christmas_tree_pickup") + assert entry + assert state + assert entry.unique_id == "twentemilieu_12345_tree" + assert state.state == "2022-01-06" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Christmas Tree Pickup" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATE + assert state.attributes.get(ATTR_ICON) == "mdi:pine-tree" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + state = hass.states.get("sensor.non_recyclable_waste_pickup") + entry = entity_registry.async_get("sensor.non_recyclable_waste_pickup") + assert entry + assert state + assert entry.unique_id == "twentemilieu_12345_Non-recyclable" + assert state.state == "2021-11-01" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Non-recyclable Waste Pickup" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATE + assert state.attributes.get(ATTR_ICON) == "mdi:delete-empty" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + state = hass.states.get("sensor.organic_waste_pickup") + entry = entity_registry.async_get("sensor.organic_waste_pickup") + assert entry + assert state + assert entry.unique_id == "twentemilieu_12345_Organic" + assert state.state == "2021-11-02" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Organic Waste Pickup" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATE + assert state.attributes.get(ATTR_ICON) == "mdi:delete-empty" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + state = hass.states.get("sensor.packages_waste_pickup") + entry = entity_registry.async_get("sensor.packages_waste_pickup") + assert entry + assert state + assert entry.unique_id == "twentemilieu_12345_Plastic" + assert state.state == "2021-11-03" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Packages Waste Pickup" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATE + assert state.attributes.get(ATTR_ICON) == "mdi:delete-empty" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + state = hass.states.get("sensor.paper_waste_pickup") + entry = entity_registry.async_get("sensor.paper_waste_pickup") + assert entry + assert state + assert entry.unique_id == "twentemilieu_12345_Paper" + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Paper Waste Pickup" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATE + assert state.attributes.get(ATTR_ICON) == "mdi:delete-empty" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, "12345")} + assert device_entry.manufacturer == "Twente Milieu" + assert device_entry.name == "Twente Milieu" + assert device_entry.entry_type is dr.DeviceEntryType.SERVICE + assert device_entry.configuration_url == "https://www.twentemilieu.nl" + assert not device_entry.model + assert not device_entry.sw_version diff --git a/tests/components/twinkly/test_config_flow.py b/tests/components/twinkly/test_config_flow.py index afbe608219f..46566bdf54b 100644 --- a/tests/components/twinkly/test_config_flow.py +++ b/tests/components/twinkly/test_config_flow.py @@ -11,23 +11,24 @@ from homeassistant.components.twinkly.const import ( DOMAIN as TWINKLY_DOMAIN, ) -from tests.components.twinkly import TEST_MODEL, ClientMock +from . import TEST_MODEL, ClientMock async def test_invalid_host(hass): """Test the failure when invalid host provided.""" - result = await hass.config_entries.flow.async_init( - TWINKLY_DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_ENTRY_HOST: "dummy"}, - ) + client = ClientMock() + client.is_offline = True + with patch("twinkly_client.TwinklyClient", return_value=client): + result = await hass.config_entries.flow.async_init( + TWINKLY_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ENTRY_HOST: "dummy"}, + ) assert result["type"] == "form" assert result["step_id"] == "user" diff --git a/tests/components/unifi/__init__.py b/tests/components/unifi/__init__.py index e75b2778d2b..3e26a8e6ea7 100644 --- a/tests/components/unifi/__init__.py +++ b/tests/components/unifi/__init__.py @@ -1 +1 @@ -"""Tests for the UniFi component.""" +"""Tests for the UniFi Network integration.""" diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index 81af3f7243f..42e9db6b958 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -1,4 +1,4 @@ -"""Fixtures for UniFi methods.""" +"""Fixtures for UniFi Network methods.""" from __future__ import annotations from unittest.mock import patch diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index 24628fae60e..8e0e687345d 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -1,4 +1,4 @@ -"""Test UniFi config flow.""" +"""Test UniFi Network config flow.""" import socket from unittest.mock import patch @@ -6,6 +6,7 @@ from unittest.mock import patch import aiounifi from homeassistant import config_entries, data_entry_flow +from homeassistant.components import ssdp from homeassistant.components.unifi.config_flow import async_discover_unifi from homeassistant.components.unifi.const import ( CONF_ALLOW_BANDWIDTH_SENSORS, @@ -547,12 +548,16 @@ async def test_form_ssdp(hass): result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - "friendlyName": "UniFi Dream Machine", - "modelDescription": "UniFi Dream Machine Pro", - "ssdp_location": "http://192.168.208.1:41417/rootDesc.xml", - "serialNumber": "e0:63:da:20:14:a9", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.208.1:41417/rootDesc.xml", + upnp={ + "friendlyName": "UniFi Dream Machine", + "modelDescription": "UniFi Dream Machine Pro", + "serialNumber": "e0:63:da:20:14:a9", + }, + ), ) assert result["type"] == "form" assert result["step_id"] == "user" @@ -579,12 +584,16 @@ async def test_form_ssdp_aborts_if_host_already_exists(hass): result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - "friendlyName": "UniFi Dream Machine", - "modelDescription": "UniFi Dream Machine Pro", - "ssdp_location": "http://192.168.208.1:41417/rootDesc.xml", - "serialNumber": "e0:63:da:20:14:a9", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.208.1:41417/rootDesc.xml", + upnp={ + "friendlyName": "UniFi Dream Machine", + "modelDescription": "UniFi Dream Machine Pro", + "serialNumber": "e0:63:da:20:14:a9", + }, + ), ) assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -602,12 +611,16 @@ async def test_form_ssdp_aborts_if_serial_already_exists(hass): result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - "friendlyName": "UniFi Dream Machine", - "modelDescription": "UniFi Dream Machine Pro", - "ssdp_location": "http://192.168.208.1:41417/rootDesc.xml", - "serialNumber": "e0:63:da:20:14:a9", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.208.1:41417/rootDesc.xml", + upnp={ + "friendlyName": "UniFi Dream Machine", + "modelDescription": "UniFi Dream Machine Pro", + "serialNumber": "e0:63:da:20:14:a9", + }, + ), ) assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -625,12 +638,16 @@ async def test_form_ssdp_gets_form_with_ignored_entry(hass): result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - "friendlyName": "UniFi Dream Machine New", - "modelDescription": "UniFi Dream Machine Pro", - "ssdp_location": "http://1.2.3.4:41417/rootDesc.xml", - "serialNumber": "e0:63:da:20:14:a9", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://1.2.3.4:41417/rootDesc.xml", + upnp={ + "friendlyName": "UniFi Dream Machine New", + "modelDescription": "UniFi Dream Machine Pro", + "serialNumber": "e0:63:da:20:14:a9", + }, + ), ) assert result["type"] == "form" assert result["step_id"] == "user" diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 0f3447b3dd9..738cb28e1e3 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -1,4 +1,4 @@ -"""Test UniFi Controller.""" +"""Test UniFi Network.""" import asyncio from copy import deepcopy @@ -171,7 +171,7 @@ async def setup_unifi_integration( unique_id="1", config_entry_id=DEFAULT_CONFIG_ENTRY_ID, ): - """Create the UniFi controller.""" + """Create the UniFi Network instance.""" assert await async_setup_component(hass, UNIFI_DOMAIN, {}) config_entry = MockConfigEntry( diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 384db693f1c..4014062ee27 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -1,4 +1,4 @@ -"""The tests for the UniFi device tracker platform.""" +"""The tests for the UniFi Network device tracker platform.""" from datetime import timedelta from unittest.mock import patch @@ -900,7 +900,7 @@ async def test_wireless_client_go_wired_issue( ): """Test the solution to catch wireless device go wired UniFi issue. - UniFi has a known issue that when a wireless device goes away it sometimes gets marked as wired. + UniFi Network has a known issue that when a wireless device goes away it sometimes gets marked as wired. """ client = { "essid": "ssid", diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py index 85733d6d686..b2d37ad7ee3 100644 --- a/tests/components/unifi/test_init.py +++ b/tests/components/unifi/test_init.py @@ -1,4 +1,4 @@ -"""Test UniFi setup process.""" +"""Test UniFi Network integration setup process.""" from unittest.mock import AsyncMock, patch from homeassistant.components import unifi @@ -59,8 +59,8 @@ async def test_controller_mac(hass): ) assert device.configuration_url == "https://123:443" assert device.manufacturer == "Ubiquiti Networks" - assert device.model == "UniFi Controller" - assert device.name == "UniFi Controller" + assert device.model == "UniFi Network" + assert device.name == "UniFi Network" assert device.sw_version is None diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 2df279090f3..3794c46988d 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -1,4 +1,4 @@ -"""UniFi sensor platform tests.""" +"""UniFi Network sensor platform tests.""" from datetime import datetime from unittest.mock import patch diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 796434c5cd9..29640b7a4b4 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -1,4 +1,4 @@ -"""UniFi switch platform tests.""" +"""UniFi Network switch platform tests.""" from copy import deepcopy from unittest.mock import patch diff --git a/tests/fixtures/unifi_direct.txt b/tests/components/unifi_direct/fixtures/data.txt similarity index 100% rename from tests/fixtures/unifi_direct.txt rename to tests/components/unifi_direct/fixtures/data.txt diff --git a/tests/components/unifi_direct/test_device_tracker.py b/tests/components/unifi_direct/test_device_tracker.py index 9f594901e94..700f490bba5 100644 --- a/tests/components/unifi_direct/test_device_tracker.py +++ b/tests/components/unifi_direct/test_device_tracker.py @@ -74,7 +74,7 @@ async def test_get_device_name(mock_ssh, hass): CONF_CONSIDER_HOME: timedelta(seconds=180), } } - mock_ssh.return_value.before = load_fixture("unifi_direct.txt") + mock_ssh.return_value.before = load_fixture("data.txt", "unifi_direct") scanner = get_scanner(hass, conf_dict) devices = scanner.scan_devices() assert len(devices) == 23 @@ -132,7 +132,7 @@ async def test_to_get_update(mock_sendline, mock_prompt, mock_login, mock_logout def test_good_response_parses(hass): """Test that the response form the AP parses to JSON correctly.""" - response = _response_to_json(load_fixture("unifi_direct.txt")) + response = _response_to_json(load_fixture("data.txt", "unifi_direct")) assert response != {} diff --git a/tests/fixtures/universal/configuration.yaml b/tests/components/universal/fixtures/configuration.yaml similarity index 100% rename from tests/fixtures/universal/configuration.yaml rename to tests/components/universal/fixtures/configuration.yaml diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 737d37052c2..4c8b125cbce 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -1,7 +1,6 @@ """The tests for the Universal Media player platform.""" import asyncio from copy import copy -from os import path import unittest from unittest.mock import patch @@ -24,7 +23,7 @@ from homeassistant.const import ( from homeassistant.core import Context, callback from homeassistant.setup import async_setup_component, setup_component -from tests.common import get_test_home_assistant, mock_service +from tests.common import get_fixture_path, get_test_home_assistant, mock_service def validate_config(config): @@ -1177,11 +1176,7 @@ async def test_reload(hass): {"activity_list": ["act1", "act2"], "current_activity": "act2"}, ) - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "universal/configuration.yaml", - ) + yaml_path = get_fixture_path("configuration.yaml", "universal") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( "universal", @@ -1199,7 +1194,3 @@ async def test_reload(hass): assert ( "device_class" not in hass.states.get("media_player.master_bed_tv").attributes ) - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index 7c68bfb1db1..d2b1a849ba5 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -30,17 +30,21 @@ TEST_USN = f"{TEST_UDN}::{TEST_ST}" TEST_LOCATION = "http://192.168.1.1/desc.xml" TEST_HOSTNAME = urlparse(TEST_LOCATION).hostname TEST_FRIENDLY_NAME = "friendly name" -TEST_DISCOVERY = { - ssdp.ATTR_SSDP_LOCATION: TEST_LOCATION, - ssdp.ATTR_SSDP_ST: TEST_ST, - ssdp.ATTR_SSDP_USN: TEST_USN, - ssdp.ATTR_UPNP_UDN: TEST_UDN, - "usn": TEST_USN, - "location": TEST_LOCATION, - "_host": TEST_HOSTNAME, - "_udn": TEST_UDN, - "friendlyName": TEST_FRIENDLY_NAME, -} +TEST_DISCOVERY = ssdp.SsdpServiceInfo( + ssdp_usn=TEST_USN, + ssdp_st=TEST_ST, + ssdp_location=TEST_LOCATION, + upnp={ + ssdp.ATTR_UPNP_UDN: TEST_UDN, + "usn": TEST_USN, + "location": TEST_LOCATION, + "_udn": TEST_UDN, + "friendlyName": TEST_FRIENDLY_NAME, + }, + ssdp_headers={ + "_host": TEST_HOSTNAME, + }, +) class MockDevice: @@ -189,7 +193,10 @@ async def ssdp_no_discovery(): ) as mock_register, patch( "homeassistant.components.ssdp.async_get_discovery_info_by_st", return_value=[], - ) as mock_get_info: + ) as mock_get_info, patch( + "homeassistant.components.upnp.config_flow.SSDP_SEARCH_TIMEOUT", + 0.1, + ): yield (mock_register, mock_get_info) diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index a704232ef84..0771e51f890 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -66,12 +66,14 @@ async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: TEST_LOCATION, - ssdp.ATTR_SSDP_ST: TEST_ST, - ssdp.ATTR_SSDP_USN: TEST_USN, - # ssdp.ATTR_UPNP_UDN: TEST_UDN, # Not provided. - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn=TEST_USN, + ssdp_st=TEST_ST, + ssdp_location=TEST_LOCATION, + upnp={ + # ssdp.ATTR_UPNP_UDN: TEST_UDN, # Not provided. + }, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "incomplete_discovery" diff --git a/tests/components/uptimerobot/test_binary_sensor.py b/tests/components/uptimerobot/test_binary_sensor.py index 8a5c4f623e0..25ca76a2914 100644 --- a/tests/components/uptimerobot/test_binary_sensor.py +++ b/tests/components/uptimerobot/test_binary_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import patch from pyuptimerobot import UptimeRobotAuthenticationException -from homeassistant.components.binary_sensor import DEVICE_CLASS_CONNECTIVITY +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.uptimerobot.const import ( ATTRIBUTION, COORDINATOR_UPDATE_INTERVAL, @@ -29,7 +29,7 @@ async def test_presentation(hass: HomeAssistant) -> None: entity = hass.states.get(UPTIMEROBOT_TEST_ENTITY) assert entity.state == STATE_ON - assert entity.attributes["device_class"] == DEVICE_CLASS_CONNECTIVITY + assert entity.attributes["device_class"] == BinarySensorDeviceClass.CONNECTIVITY assert entity.attributes["attribution"] == ATTRIBUTION assert entity.attributes["target"] == MOCK_UPTIMEROBOT_MONITOR["url"] diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 7d620b45984..cc34add6726 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -777,3 +777,27 @@ def test_human_readable_device_name(): assert "Silicon Labs" in name assert "10C4" in name assert "8A2A" in name + + +async def test_service_info_compatibility(hass, caplog): + """Test compatibility with old-style dict. + + To be removed in 2022.6 + """ + discovery_info = usb.UsbServiceInfo( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + + # Ensure first call get logged + assert discovery_info["vid"] == 12345 + assert "Detected code that accessed discovery_info['vid']" in caplog.text + + # Ensure second call doesn't get logged + caplog.clear() + assert discovery_info["vid"] == 12345 + assert "Detected code that accessed discovery_info['vid']" not in caplog.text diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index a41ddcfa9fc..8d9b819f610 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -414,6 +414,63 @@ async def test_non_net_consumption(hass): assert state.state == "0" +async def test_delta_values(hass): + """Test utility meter "delta_values" mode.""" + config = { + "utility_meter": { + "energy_bill": {"source": "sensor.energy", "delta_values": True} + } + } + + now = dt_util.utcnow() + with alter_time(now): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + entity_id = config[DOMAIN]["energy_bill"]["source"] + + async_fire_time_changed(hass, now) + hass.states.async_set( + entity_id, 1, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.attributes.get("status") == PAUSED + + now += timedelta(seconds=30) + with alter_time(now): + async_fire_time_changed(hass, now) + hass.states.async_set( + entity_id, + 3, + {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.attributes.get("status") == COLLECTING + + now += timedelta(seconds=30) + with alter_time(now): + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + hass.states.async_set( + entity_id, + 6, + {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state is not None + + assert state.state == "9" + + def gen_config(cycle, offset=None): """Generate configuration.""" config = { diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py new file mode 100644 index 00000000000..c13ce3127fa --- /dev/null +++ b/tests/components/velbus/conftest.py @@ -0,0 +1,31 @@ +"""Fixtures for the Velbus tests.""" +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.velbus.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.velbus.const import PORT_TCP + + +@pytest.fixture(name="controller") +def mock_controller(): + """Mock a successful velbus controller.""" + controller = AsyncMock() + with patch("velbusaio.controller.Velbus", return_value=controller): + yield controller + + +@pytest.fixture(name="config_entry") +def mock_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Create and register mock config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_PORT: PORT_TCP, CONF_NAME: "velbus home"}, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/velbus/const.py b/tests/components/velbus/const.py new file mode 100644 index 00000000000..374dbce2529 --- /dev/null +++ b/tests/components/velbus/const.py @@ -0,0 +1,3 @@ +"""Constants for the Velbus tests.""" +PORT_SERIAL = "/dev/ttyACME100" +PORT_TCP = "127.0.1.0.1:3788" diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index 723b6664fd7..01a40af1751 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -7,36 +7,36 @@ from velbusaio.exceptions import VelbusConnectionFailed from homeassistant import data_entry_flow from homeassistant.components.velbus import config_flow from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry - -PORT_SERIAL = "/dev/ttyACME100" -PORT_TCP = "127.0.1.0.1:3788" +from .const import PORT_SERIAL, PORT_TCP -@pytest.fixture(name="controller_assert") -def mock_controller_assert(): +@pytest.fixture(autouse=True) +def override_async_setup_entry() -> AsyncMock: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.velbus.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="controller_connection_failed") +def mock_controller_connection_failed(): """Mock the velbus controller with an assert.""" with patch("velbusaio.controller.Velbus", side_effect=VelbusConnectionFailed()): yield -@pytest.fixture(name="controller") -def mock_controller(): - """Mock a successful velbus controller.""" - controller = AsyncMock() - with patch("velbusaio.controller.Velbus", return_value=controller): - yield controller - - -def init_config_flow(hass): +def init_config_flow(hass: HomeAssistant): """Init a configuration flow.""" flow = config_flow.VelbusConfigFlow() flow.hass = hass return flow -async def test_user(hass, controller): +@pytest.mark.usefixtures("controller") +async def test_user(hass: HomeAssistant): """Test user config.""" flow = init_config_flow(hass) @@ -59,7 +59,8 @@ async def test_user(hass, controller): assert result["data"][CONF_PORT] == PORT_TCP -async def test_user_fail(hass, controller_assert): +@pytest.mark.usefixtures("controller_connection_failed") +async def test_user_fail(hass: HomeAssistant): """Test user config.""" flow = init_config_flow(hass) @@ -76,30 +77,11 @@ async def test_user_fail(hass, controller_assert): assert result["errors"] == {CONF_PORT: "cannot_connect"} -async def test_import(hass, controller): - """Test import step.""" +@pytest.mark.usefixtures("config_entry") +async def test_abort_if_already_setup(hass: HomeAssistant): + """Test we abort if Velbus is already setup.""" flow = init_config_flow(hass) - result = await flow.async_step_import({CONF_PORT: PORT_TCP}) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "velbus_import" - - -async def test_abort_if_already_setup(hass): - """Test we abort if Daikin is already setup.""" - flow = init_config_flow(hass) - MockConfigEntry( - domain="velbus", data={CONF_PORT: PORT_TCP, CONF_NAME: "velbus home"} - ).add_to_hass(hass) - - result = await flow.async_step_import( - {CONF_PORT: PORT_TCP, CONF_NAME: "velbus import test"} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - result = await flow.async_step_user( - {CONF_PORT: PORT_TCP, CONF_NAME: "velbus import test"} - ) + result = await flow.async_step_user({CONF_PORT: PORT_TCP, CONF_NAME: "velbus test"}) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {"port": "already_configured"} diff --git a/tests/components/velbus/test_init.py b/tests/components/velbus/test_init.py new file mode 100644 index 00000000000..dee00cce16b --- /dev/null +++ b/tests/components/velbus/test_init.py @@ -0,0 +1,56 @@ +"""Tests for the Velbus component initialisation.""" +import pytest + +from homeassistant.components.velbus.const import DOMAIN +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import mock_device_registry + + +@pytest.mark.usefixtures("controller") +async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Test being able to unload an entry.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + +@pytest.mark.usefixtures("controller") +async def test_device_identifier_migration( + hass: HomeAssistant, config_entry: ConfigEntry +): + """Test being able to unload an entry.""" + original_identifiers = {(DOMAIN, "module_address", "module_serial")} + target_identifiers = {(DOMAIN, "module_address")} + + device_registry = mock_device_registry(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers=original_identifiers, + name="channel_name", + manufacturer="Velleman", + model="module_type_name", + sw_version="module_sw_version", + ) + assert device_registry.async_get_device(original_identifiers) + assert not device_registry.async_get_device(target_identifiers) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert not device_registry.async_get_device(original_identifiers) + device_entry = device_registry.async_get_device(target_identifiers) + assert device_entry + assert device_entry.name == "channel_name" + assert device_entry.manufacturer == "Velleman" + assert device_entry.model == "module_type_name" + assert device_entry.sw_version == "module_sw_version" diff --git a/tests/components/venstar/fixtures/colortouch_alerts.json b/tests/components/venstar/fixtures/colortouch_alerts.json new file mode 100644 index 00000000000..54a29b9eb3a --- /dev/null +++ b/tests/components/venstar/fixtures/colortouch_alerts.json @@ -0,0 +1 @@ +{"alerts":[{"name":"Air Filter","active":true},{"name":"UV Lamp","active":false},{"name":"Service","active":false}]} diff --git a/tests/fixtures/venstar/colortouch_info.json b/tests/components/venstar/fixtures/colortouch_info.json similarity index 100% rename from tests/fixtures/venstar/colortouch_info.json rename to tests/components/venstar/fixtures/colortouch_info.json diff --git a/tests/fixtures/venstar/colortouch_root.json b/tests/components/venstar/fixtures/colortouch_root.json similarity index 100% rename from tests/fixtures/venstar/colortouch_root.json rename to tests/components/venstar/fixtures/colortouch_root.json diff --git a/tests/fixtures/venstar/colortouch_sensors.json b/tests/components/venstar/fixtures/colortouch_sensors.json similarity index 100% rename from tests/fixtures/venstar/colortouch_sensors.json rename to tests/components/venstar/fixtures/colortouch_sensors.json diff --git a/tests/components/venstar/fixtures/t2k_alerts.json b/tests/components/venstar/fixtures/t2k_alerts.json new file mode 100644 index 00000000000..54a29b9eb3a --- /dev/null +++ b/tests/components/venstar/fixtures/t2k_alerts.json @@ -0,0 +1 @@ +{"alerts":[{"name":"Air Filter","active":true},{"name":"UV Lamp","active":false},{"name":"Service","active":false}]} diff --git a/tests/fixtures/venstar/t2k_info.json b/tests/components/venstar/fixtures/t2k_info.json similarity index 100% rename from tests/fixtures/venstar/t2k_info.json rename to tests/components/venstar/fixtures/t2k_info.json diff --git a/tests/fixtures/venstar/t2k_root.json b/tests/components/venstar/fixtures/t2k_root.json similarity index 100% rename from tests/fixtures/venstar/t2k_root.json rename to tests/components/venstar/fixtures/t2k_root.json diff --git a/tests/fixtures/venstar/t2k_sensors.json b/tests/components/venstar/fixtures/t2k_sensors.json similarity index 100% rename from tests/fixtures/venstar/t2k_sensors.json rename to tests/components/venstar/fixtures/t2k_sensors.json diff --git a/tests/components/venstar/test_climate.py b/tests/components/venstar/test_climate.py index 9461032060b..babd946073b 100644 --- a/tests/components/venstar/test_climate.py +++ b/tests/components/venstar/test_climate.py @@ -1,5 +1,7 @@ """The climate tests for the venstar integration.""" +from unittest.mock import patch + from homeassistant.components.climate.const import ( SUPPORT_FAN_MODE, SUPPORT_PRESET_MODE, @@ -18,7 +20,8 @@ EXPECTED_BASE_SUPPORTED_FEATURES = ( async def test_colortouch(hass): """Test interfacing with a venstar colortouch with attached humidifier.""" - await async_init_integration(hass) + with patch("homeassistant.components.onewire.sensor.asyncio.sleep"): + await async_init_integration(hass) state = hass.states.get("climate.colortouch") assert state.state == "heat" @@ -53,7 +56,8 @@ async def test_colortouch(hass): async def test_t2000(hass): """Test interfacing with a venstar T2000 presently turned off.""" - await async_init_integration(hass) + with patch("homeassistant.components.onewire.sensor.asyncio.sleep"): + await async_init_integration(hass) state = hass.states.get("climate.t2000") assert state.state == "off" diff --git a/tests/components/venstar/test_init.py b/tests/components/venstar/test_init.py index 03739f19616..b245f4eef6d 100644 --- a/tests/components/venstar/test_init.py +++ b/tests/components/venstar/test_init.py @@ -33,6 +33,11 @@ async def test_setup_entry(hass: HomeAssistant): ), patch( "homeassistant.components.venstar.VenstarColorTouch.update_info", new=VenstarColorTouchMock.update_info, + ), patch( + "homeassistant.components.venstar.VenstarColorTouch.update_alerts", + new=VenstarColorTouchMock.update_alerts, + ), patch( + "homeassistant.components.onewire.sensor.asyncio.sleep" ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -64,6 +69,9 @@ async def test_setup_entry_exception(hass: HomeAssistant): ), patch( "homeassistant.components.venstar.VenstarColorTouch.update_info", new=VenstarColorTouchMock.broken_update_info, + ), patch( + "homeassistant.components.venstar.VenstarColorTouch.update_alerts", + new=VenstarColorTouchMock.update_alerts, ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/venstar/util.py b/tests/components/venstar/util.py index b86f8475798..aa53a9c0a8d 100644 --- a/tests/components/venstar/util.py +++ b/tests/components/venstar/util.py @@ -33,6 +33,10 @@ def mock_venstar_devices(f): f"http://venstar-{model}.localdomain/query/sensors", text=load_fixture(f"venstar/{model}_sensors.json"), ) + m.get( + f"http://venstar-{model}.localdomain/query/alerts", + text=load_fixture(f"venstar/{model}_alerts.json"), + ) return await f(hass) return wrapper diff --git a/tests/components/verisure/test_config_flow.py b/tests/components/verisure/test_config_flow.py index f850487fe26..356cd8fd63c 100644 --- a/tests/components/verisure/test_config_flow.py +++ b/tests/components/verisure/test_config_flow.py @@ -7,7 +7,7 @@ import pytest from verisure import Error as VerisureError, LoginError as VerisureLoginError from homeassistant import config_entries -from homeassistant.components.dhcp import MAC_ADDRESS +from homeassistant.components import dhcp from homeassistant.components.verisure.const import ( CONF_GIID, CONF_LOCK_CODE_DIGITS, @@ -176,7 +176,9 @@ async def test_dhcp(hass: HomeAssistant) -> None: """Test that DHCP discovery works.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data={MAC_ADDRESS: "01:23:45:67:89:ab"}, + data=dhcp.DhcpServiceInfo( + ip="1.2.3.4", macaddress="01:23:45:67:89:ab", hostname="mock_hostname" + ), context={"source": config_entries.SOURCE_DHCP}, ) diff --git a/tests/components/vicare/__init__.py b/tests/components/vicare/__init__.py new file mode 100644 index 00000000000..f67e50be1d6 --- /dev/null +++ b/tests/components/vicare/__init__.py @@ -0,0 +1,20 @@ +"""Test for ViCare.""" +from homeassistant.components.vicare.const import CONF_HEATING_TYPE +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_NAME, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) + +ENTRY_CONFIG = { + CONF_USERNAME: "foo@bar.com", + CONF_PASSWORD: "1234", + CONF_CLIENT_ID: "5678", + CONF_HEATING_TYPE: "auto", + CONF_SCAN_INTERVAL: 60, + CONF_NAME: "ViCare", +} + +MOCK_MAC = "B874241B7B9" diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py new file mode 100644 index 00000000000..c816f535077 --- /dev/null +++ b/tests/components/vicare/test_config_flow.py @@ -0,0 +1,278 @@ +"""Test the ViCare config flow.""" +from unittest.mock import patch + +from PyViCare.PyViCareUtils import PyViCareInvalidCredentialsError + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components import dhcp +from homeassistant.components.vicare.const import ( + CONF_CIRCUIT, + CONF_HEATING_TYPE, + DOMAIN, +) +from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME + +from . import ENTRY_CONFIG, MOCK_MAC + +from tests.common import MockConfigEntry + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert len(result["errors"]) == 0 + + with patch( + "homeassistant.components.vicare.config_flow.vicare_login", + return_value=None, + ), patch( + "homeassistant.components.vicare.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.vicare.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "foo@bar.com", + CONF_PASSWORD: "1234", + CONF_CLIENT_ID: "5678", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "ViCare" + assert result2["data"] == ENTRY_CONFIG + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import(hass): + """Test that the import works.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.vicare.config_flow.vicare_login", + return_value=True, + ), patch( + "homeassistant.components.vicare.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.vicare.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=ENTRY_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Configuration.yaml" + assert result["data"] == ENTRY_CONFIG + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_removes_circuit(hass): + """Test that the import works.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.vicare.config_flow.vicare_login", + return_value=True, + ), patch( + "homeassistant.components.vicare.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.vicare.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + ENTRY_CONFIG[CONF_CIRCUIT] = 1 + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=ENTRY_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Configuration.yaml" + assert result["data"] == ENTRY_CONFIG + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_adds_heating_type(hass): + """Test that the import works.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.vicare.config_flow.vicare_login", + return_value=True, + ), patch( + "homeassistant.components.vicare.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.vicare.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + del ENTRY_CONFIG[CONF_HEATING_TYPE] + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=ENTRY_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Configuration.yaml" + assert result["data"] == ENTRY_CONFIG + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_invalid_login(hass) -> None: + """Test a flow with an invalid Vicare login.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.vicare.config_flow.vicare_login", + side_effect=PyViCareInvalidCredentialsError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "foo@bar.com", + CONF_PASSWORD: "1234", + CONF_CLIENT_ID: "5678", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_dhcp(hass): + """Test we can setup from dhcp.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + ip="1.1.1.1", + hostname="mock_hostname", + macaddress=MOCK_MAC, + ), + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.vicare.config_flow.vicare_login", + return_value=None, + ), patch( + "homeassistant.components.vicare.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.vicare.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "foo@bar.com", + CONF_PASSWORD: "1234", + CONF_CLIENT_ID: "5678", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "ViCare" + assert result2["data"] == ENTRY_CONFIG + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_already_configured(hass): + """Test that configuring same instance is rejectes.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="Configuration.yaml", + data=ENTRY_CONFIG, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=ENTRY_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_import_single_instance_allowed(hass): + """Test that configuring more than one instance is rejected.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="Configuration.yaml", + data=ENTRY_CONFIG, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=ENTRY_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_single_instance_allowed(hass): + """Test that configuring more than one instance is rejected.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="Configuration.yaml", + data=ENTRY_CONFIG, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + ip="1.1.1.1", + hostname="mock_hostname", + macaddress=MOCK_MAC, + ), + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_user_input_single_instance_allowed(hass): + """Test that configuring more than one instance is rejected.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="ViCare", + data=ENTRY_CONFIG, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index 963947f1bd3..b4a3fc04766 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -1,4 +1,5 @@ """Constants for the Vizio integration tests.""" +from homeassistant.components import zeroconf from homeassistant.components.media_player import ( DEVICE_CLASS_SPEAKER, DEVICE_CLASS_TV, @@ -23,8 +24,6 @@ from homeassistant.const import ( CONF_INCLUDE, CONF_NAME, CONF_PIN, - CONF_PORT, - CONF_TYPE, ) from homeassistant.util import slugify @@ -198,10 +197,11 @@ ZEROCONF_NAME = f"{NAME}.{VIZIO_ZEROCONF_SERVICE_TYPE}" ZEROCONF_HOST = HOST.split(":")[0] ZEROCONF_PORT = HOST.split(":")[1] -MOCK_ZEROCONF_SERVICE_INFO = { - CONF_TYPE: VIZIO_ZEROCONF_SERVICE_TYPE, - CONF_NAME: ZEROCONF_NAME, - CONF_HOST: ZEROCONF_HOST, - CONF_PORT: ZEROCONF_PORT, - "properties": {"name": "SB4031-D5"}, -} +MOCK_ZEROCONF_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( + host=ZEROCONF_HOST, + hostname="mock_hostname", + name=ZEROCONF_NAME, + port=ZEROCONF_PORT, + properties={"name": "SB4031-D5"}, + type=VIZIO_ZEROCONF_SERVICE_TYPE, +) diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py index 544ad2b38cd..817f23d52c5 100644 --- a/tests/components/vizio/test_config_flow.py +++ b/tests/components/vizio/test_config_flow.py @@ -1,4 +1,6 @@ """Tests for Vizio config flow.""" +import dataclasses + import pytest import voluptuous as vol @@ -27,7 +29,6 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PIN, - CONF_PORT, ) from homeassistant.core import HomeAssistant @@ -728,7 +729,7 @@ async def test_zeroconf_flow( vizio_guess_device_type: pytest.fixture, ) -> None: """Test zeroconf config flow.""" - discovery_info = MOCK_ZEROCONF_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) @@ -739,7 +740,13 @@ async def test_zeroconf_flow( # Apply discovery updates to entry to mimic when user hits submit without changing # defaults which were set from discovery parameters - user_input = result["data_schema"](discovery_info) + user_input = result["data_schema"]( + { + CONF_HOST: f"{discovery_info.host}:{discovery_info.port}", + CONF_NAME: discovery_info.name[: -(len(discovery_info.type) + 1)], + CONF_DEVICE_CLASS: "speaker", + } + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=user_input @@ -768,7 +775,7 @@ async def test_zeroconf_flow_already_configured( entry.add_to_hass(hass) # Try rediscovering same device - discovery_info = MOCK_ZEROCONF_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) @@ -794,10 +801,8 @@ async def test_zeroconf_flow_with_port_in_host( entry.add_to_hass(hass) # Try rediscovering same device, this time with port already in host - discovery_info = MOCK_ZEROCONF_SERVICE_INFO.copy() - discovery_info[ - CONF_HOST - ] = f"{discovery_info[CONF_HOST]}:{discovery_info[CONF_PORT]}" + discovery_info = dataclasses.replace(MOCK_ZEROCONF_SERVICE_INFO) + discovery_info.host = f"{discovery_info.host}:{discovery_info.port}" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) @@ -814,7 +819,7 @@ async def test_zeroconf_dupe_fail( vizio_guess_device_type: pytest.fixture, ) -> None: """Test zeroconf config flow when device gets discovered multiple times.""" - discovery_info = MOCK_ZEROCONF_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) @@ -823,7 +828,7 @@ async def test_zeroconf_dupe_fail( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - discovery_info = MOCK_ZEROCONF_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) @@ -848,7 +853,7 @@ async def test_zeroconf_ignore( ) entry.add_to_hass(hass) - discovery_info = MOCK_ZEROCONF_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) @@ -863,7 +868,7 @@ async def test_zeroconf_no_unique_id( ) -> None: """Test zeroconf discovery aborts when unique_id is None.""" - discovery_info = MOCK_ZEROCONF_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) @@ -888,7 +893,7 @@ async def test_zeroconf_abort_when_ignored( ) entry.add_to_hass(hass) - discovery_info = MOCK_ZEROCONF_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) @@ -916,7 +921,7 @@ async def test_zeroconf_flow_already_configured_hostname( entry.add_to_hass(hass) # Try rediscovering same device - discovery_info = MOCK_ZEROCONF_SERVICE_INFO.copy() + discovery_info = dataclasses.replace(MOCK_ZEROCONF_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) diff --git a/tests/components/vlc_telnet/test_config_flow.py b/tests/components/vlc_telnet/test_config_flow.py index 5ef8b6c0400..6a0659b6360 100644 --- a/tests/components/vlc_telnet/test_config_flow.py +++ b/tests/components/vlc_telnet/test_config_flow.py @@ -8,6 +8,7 @@ from aiovlc.exceptions import AuthError, ConnectError import pytest from homeassistant import config_entries +from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.vlc_telnet.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import ( @@ -278,13 +279,15 @@ async def test_hassio_flow(hass: HomeAssistant) -> None: "homeassistant.components.vlc_telnet.async_setup_entry", return_value=True, ) as mock_setup_entry: - test_data = { - "password": "test-password", - "host": "1.1.1.1", - "port": 8888, - "name": "custom name", - "addon": "vlc", - } + test_data = HassioServiceInfo( + config={ + "password": "test-password", + "host": "1.1.1.1", + "port": 8888, + "name": "custom name", + "addon": "vlc", + } + ) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -298,8 +301,8 @@ async def test_hassio_flow(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result2["type"] == RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == test_data["name"] - assert result2["data"] == test_data + assert result2["title"] == test_data.config["name"] + assert result2["data"] == test_data.config assert len(mock_setup_entry.mock_calls) == 1 @@ -320,7 +323,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=entry_data, + data=HassioServiceInfo(config=entry_data), ) await hass.async_block_till_done() @@ -354,13 +357,15 @@ async def test_hassio_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data={ - "password": "test-password", - "host": "1.1.1.1", - "port": 8888, - "name": "custom name", - "addon": "vlc", - }, + data=HassioServiceInfo( + config={ + "password": "test-password", + "host": "1.1.1.1", + "port": 8888, + "name": "custom name", + "addon": "vlc", + } + ), ) await hass.async_block_till_done() diff --git a/tests/components/volumio/test_config_flow.py b/tests/components/volumio/test_config_flow.py index b4b3c8f24ed..3eb254784d1 100644 --- a/tests/components/volumio/test_config_flow.py +++ b/tests/components/volumio/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import patch from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.components.volumio.config_flow import CannotConnectError from homeassistant.components.volumio.const import DOMAIN @@ -16,17 +17,20 @@ TEST_CONNECTION = { } -TEST_DISCOVERY = { - "host": "1.1.1.1", - "port": 3000, - "properties": {"volumioName": "discovered", "UUID": "2222-2222-2222-2222"}, -} +TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + hostname="mock_hostname", + name="mock_name", + port=3000, + properties={"volumioName": "discovered", "UUID": "2222-2222-2222-2222"}, + type="mock_type", +) TEST_DISCOVERY_RESULT = { - "host": TEST_DISCOVERY["host"], - "port": TEST_DISCOVERY["port"], - "id": TEST_DISCOVERY["properties"]["UUID"], - "name": TEST_DISCOVERY["properties"]["volumioName"], + "host": TEST_DISCOVERY.host, + "port": TEST_DISCOVERY.port, + "id": TEST_DISCOVERY.properties["UUID"], + "name": TEST_DISCOVERY.properties["volumioName"], } diff --git a/tests/fixtures/vultr_account_info.json b/tests/components/vultr/fixtures/account_info.json similarity index 100% rename from tests/fixtures/vultr_account_info.json rename to tests/components/vultr/fixtures/account_info.json diff --git a/tests/fixtures/vultr_server_list.json b/tests/components/vultr/fixtures/server_list.json similarity index 100% rename from tests/fixtures/vultr_server_list.json rename to tests/components/vultr/fixtures/server_list.json diff --git a/tests/components/vultr/test_binary_sensor.py b/tests/components/vultr/test_binary_sensor.py index e0d71bf20a8..1b84ddff291 100644 --- a/tests/components/vultr/test_binary_sensor.py +++ b/tests/components/vultr/test_binary_sensor.py @@ -53,12 +53,12 @@ class TestVultrBinarySensorSetup(unittest.TestCase): """Test successful instance.""" mock.get( "https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567", - text=load_fixture("vultr_account_info.json"), + text=load_fixture("account_info.json", "vultr"), ) with patch( "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("vultr_server_list.json")), + return_value=json.loads(load_fixture("server_list.json", "vultr")), ): # Setup hub base_vultr.setup(self.hass, VALID_CONFIG) @@ -113,12 +113,12 @@ class TestVultrBinarySensorSetup(unittest.TestCase): """Test the VultrBinarySensor fails.""" mock.get( "https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567", - text=load_fixture("vultr_account_info.json"), + text=load_fixture("account_info.json", "vultr"), ) with patch( "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("vultr_server_list.json")), + return_value=json.loads(load_fixture("server_list.json", "vultr")), ): # Setup hub base_vultr.setup(self.hass, VALID_CONFIG) diff --git a/tests/components/vultr/test_init.py b/tests/components/vultr/test_init.py index 80480a2cec2..040eac1a674 100644 --- a/tests/components/vultr/test_init.py +++ b/tests/components/vultr/test_init.py @@ -32,7 +32,7 @@ class TestVultr(unittest.TestCase): """Test successful setup.""" with patch( "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("vultr_server_list.json")), + return_value=json.loads(load_fixture("server_list.json", "vultr")), ): response = vultr.setup(self.hass, self.config) assert response diff --git a/tests/components/vultr/test_sensor.py b/tests/components/vultr/test_sensor.py index bacffe8e6af..ac7008d066b 100644 --- a/tests/components/vultr/test_sensor.py +++ b/tests/components/vultr/test_sensor.py @@ -59,12 +59,12 @@ class TestVultrSensorSetup(unittest.TestCase): """Test the Vultr sensor class and methods.""" mock.get( "https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567", - text=load_fixture("vultr_account_info.json"), + text=load_fixture("account_info.json", "vultr"), ) with patch( "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("vultr_server_list.json")), + return_value=json.loads(load_fixture("server_list.json", "vultr")), ): # Setup hub base_vultr.setup(self.hass, VALID_CONFIG) @@ -143,12 +143,12 @@ class TestVultrSensorSetup(unittest.TestCase): """Test the VultrSensor fails.""" mock.get( "https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567", - text=load_fixture("vultr_account_info.json"), + text=load_fixture("account_info.json", "vultr"), ) with patch( "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("vultr_server_list.json")), + return_value=json.loads(load_fixture("server_list.json", "vultr")), ): # Setup hub base_vultr.setup(self.hass, VALID_CONFIG) diff --git a/tests/components/vultr/test_switch.py b/tests/components/vultr/test_switch.py index d6b7392ca9c..ab3698b48f4 100644 --- a/tests/components/vultr/test_switch.py +++ b/tests/components/vultr/test_switch.py @@ -53,12 +53,12 @@ class TestVultrSwitchSetup(unittest.TestCase): """Test successful instance.""" mock.get( "https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567", - text=load_fixture("vultr_account_info.json"), + text=load_fixture("account_info.json", "vultr"), ) with patch( "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("vultr_server_list.json")), + return_value=json.loads(load_fixture("server_list.json", "vultr")), ): # Setup hub base_vultr.setup(self.hass, VALID_CONFIG) @@ -114,7 +114,7 @@ class TestVultrSwitchSetup(unittest.TestCase): """Test turning a subscription on.""" with patch( "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("vultr_server_list.json")), + return_value=json.loads(load_fixture("server_list.json", "vultr")), ), patch("vultr.Vultr.server_start") as mock_start: for device in self.DEVICES: if device.name == "Failed Server": @@ -128,7 +128,7 @@ class TestVultrSwitchSetup(unittest.TestCase): """Test turning a subscription off.""" with patch( "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("vultr_server_list.json")), + return_value=json.loads(load_fixture("server_list.json", "vultr")), ), patch("vultr.Vultr.server_halt") as mock_halt: for device in self.DEVICES: if device.name == "A Server": @@ -147,12 +147,12 @@ class TestVultrSwitchSetup(unittest.TestCase): """Test the VultrSwitch fails.""" mock.get( "https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567", - text=load_fixture("vultr_account_info.json"), + text=load_fixture("account_info.json", "vultr"), ) with patch( "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("vultr_server_list.json")), + return_value=json.loads(load_fixture("server_list.json", "vultr")), ): # Setup hub base_vultr.setup(self.hass, VALID_CONFIG) diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index f8031bd86a4..21d1f0acbc5 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -18,14 +18,9 @@ from homeassistant.components.wallbox.const import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from .const import CONF_ERROR, CONF_JWT, CONF_STATUS, CONF_TTL, CONF_USER_ID + from tests.common import MockConfigEntry -from tests.components.wallbox.const import ( - CONF_ERROR, - CONF_JWT, - CONF_STATUS, - CONF_TTL, - CONF_USER_ID, -) test_response = json.loads( json.dumps( @@ -33,8 +28,8 @@ test_response = json.loads( CONF_CHARGING_POWER_KEY: 0, CONF_MAX_AVAILABLE_POWER_KEY: 25.2, CONF_CHARGING_SPEED_KEY: 0, - CONF_ADDED_RANGE_KEY: "xx", - CONF_ADDED_ENERGY_KEY: "44.697", + CONF_ADDED_RANGE_KEY: 150, + CONF_ADDED_ENERGY_KEY: 44.697, CONF_DATA_KEY: {CONF_MAX_CHARGING_CURRENT_KEY: 24}, } ) diff --git a/tests/components/wallbox/test_config_flow.py b/tests/components/wallbox/test_config_flow.py index ca55c076fea..01993d88968 100644 --- a/tests/components/wallbox/test_config_flow.py +++ b/tests/components/wallbox/test_config_flow.py @@ -18,6 +18,7 @@ from homeassistant.components.wallbox.const import ( ) from homeassistant.core import HomeAssistant +from tests.components.wallbox import entry, setup_integration from tests.components.wallbox.const import ( CONF_ERROR, CONF_JWT, @@ -162,3 +163,83 @@ async def test_form_validate_input(hass): assert result2["title"] == "Wallbox Portal" assert result2["data"]["station"] == "12345" + + +async def test_form_reauth(hass): + """Test we handle reauth flow.""" + await setup_integration(hass) + assert entry.state == config_entries.ConfigEntryState.LOADED + + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://api.wall-box.com/auth/token/user", + text='{"jwt":"fakekeyhere","user_id":12345,"ttl":145656758,"error":false,"status":200}', + status_code=200, + ) + mock_request.get( + "https://api.wall-box.com/chargers/status/12345", + json=test_response, + status_code=200, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "station": "12345", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" + + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_form_reauth_invalid(hass): + """Test we handle reauth invalid flow.""" + await setup_integration(hass) + assert entry.state == config_entries.ConfigEntryState.LOADED + + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://api.wall-box.com/auth/token/user", + text='{"jwt":"fakekeyhere","user_id":12345,"ttl":145656758,"error":false,"status":200}', + status_code=200, + ) + mock_request.get( + "https://api.wall-box.com/chargers/status/12345", + json=test_response, + status_code=200, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "station": "12345678", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "reauth_invalid"} + + await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index 10e6cab99fc..66f0701e42e 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -3,10 +3,7 @@ import json import requests_mock -from homeassistant.components.wallbox import ( - CONF_CONNECTIONS, - CONF_MAX_CHARGING_CURRENT_KEY, -) +from homeassistant.components.wallbox import CONF_MAX_CHARGING_CURRENT_KEY from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -78,7 +75,7 @@ async def test_wallbox_refresh_failed_invalid_auth(hass: HomeAssistant): status_code=403, ) - wallbox = hass.data[DOMAIN][CONF_CONNECTIONS][entry.entry_id] + wallbox = hass.data[DOMAIN][entry.entry_id] await wallbox.async_refresh() @@ -104,7 +101,7 @@ async def test_wallbox_refresh_failed_connection_error(hass: HomeAssistant): status_code=403, ) - wallbox = hass.data[DOMAIN][CONF_CONNECTIONS][entry.entry_id] + wallbox = hass.data[DOMAIN][entry.entry_id] await wallbox.async_refresh() diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py new file mode 100644 index 00000000000..4125e94749a --- /dev/null +++ b/tests/components/weather/test_init.py @@ -0,0 +1,170 @@ +"""The test for weather entity.""" +import pytest +from pytest import approx + +from homeassistant.components.weather import ( + ATTR_CONDITION_SUNNY, + ATTR_FORECAST, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRESSURE, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_WIND_SPEED, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_SPEED, +) +from homeassistant.const import ( + LENGTH_MILES, + LENGTH_MILLIMETERS, + PRESSURE_INHG, + SPEED_METERS_PER_SECOND, + TEMP_FAHRENHEIT, +) +from homeassistant.setup import async_setup_component +from homeassistant.util.distance import convert as convert_distance +from homeassistant.util.pressure import convert as convert_pressure +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 + + +async def create_entity(hass, **kwargs): + """Create the weather entity to run tests on.""" + kwargs = {"temperature": None, "temperature_unit": None, **kwargs} + platform = getattr(hass.components, "test.weather") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockWeatherMockForecast( + name="Test", condition=ATTR_CONDITION_SUNNY, **kwargs + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component( + hass, "weather", {"weather": {"platform": "test"}} + ) + await hass.async_block_till_done() + return entity0 + + +@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) +async def test_temperature_conversion( + hass, + enable_custom_integrations, + unit_system, +): + """Test temperature conversion.""" + hass.config.units = unit_system + native_value = 38 + native_unit = TEMP_FAHRENHEIT + + entity0 = await create_entity( + hass, temperature=native_value, temperature_unit=native_unit + ) + + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + expected = convert_temperature( + native_value, native_unit, unit_system.temperature_unit + ) + assert float(state.attributes[ATTR_WEATHER_TEMPERATURE]) == approx( + expected, rel=0.1 + ) + assert float(forecast[ATTR_FORECAST_TEMP]) == approx(expected, rel=0.1) + assert float(forecast[ATTR_FORECAST_TEMP_LOW]) == approx(expected, rel=0.1) + + +@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) +async def test_pressure_conversion( + hass, + enable_custom_integrations, + unit_system, +): + """Test pressure conversion.""" + hass.config.units = unit_system + native_value = 30 + native_unit = PRESSURE_INHG + + entity0 = await create_entity( + hass, pressure=native_value, pressure_unit=native_unit + ) + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + expected = convert_pressure(native_value, native_unit, unit_system.pressure_unit) + assert float(state.attributes[ATTR_WEATHER_PRESSURE]) == approx(expected, rel=1e-2) + assert float(forecast[ATTR_FORECAST_PRESSURE]) == approx(expected, rel=1e-2) + + +@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) +async def test_wind_speed_conversion( + hass, + enable_custom_integrations, + unit_system, +): + """Test wind speed conversion.""" + hass.config.units = unit_system + native_value = 10 + native_unit = SPEED_METERS_PER_SECOND + + entity0 = await create_entity( + hass, wind_speed=native_value, wind_speed_unit=native_unit + ) + + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + expected = convert_speed(native_value, native_unit, unit_system.wind_speed_unit) + assert float(state.attributes[ATTR_WEATHER_WIND_SPEED]) == approx( + expected, rel=1e-2 + ) + assert float(forecast[ATTR_FORECAST_WIND_SPEED]) == approx(expected, rel=1e-2) + + +@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) +async def test_visibility_conversion( + hass, + enable_custom_integrations, + unit_system, +): + """Test visibility conversion.""" + hass.config.units = unit_system + native_value = 10 + native_unit = LENGTH_MILES + + entity0 = await create_entity( + hass, visibility=native_value, visibility_unit=native_unit + ) + + state = hass.states.get(entity0.entity_id) + expected = convert_distance(native_value, native_unit, unit_system.length_unit) + assert float(state.attributes[ATTR_WEATHER_VISIBILITY]) == approx( + expected, rel=1e-2 + ) + + +@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) +async def test_precipitation_conversion( + hass, + enable_custom_integrations, + unit_system, +): + """Test precipitation conversion.""" + hass.config.units = unit_system + native_value = 30 + native_unit = LENGTH_MILLIMETERS + + entity0 = await create_entity( + hass, precipitation=native_value, precipitation_unit=native_unit + ) + + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + expected = convert_distance( + native_value, native_unit, unit_system.accumulated_precipitation_unit + ) + assert float(forecast[ATTR_FORECAST_PRECIPITATION]) == approx(expected, rel=1e-2) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 447f38f9a9c..1099519a2a0 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -369,7 +369,7 @@ async def test_subscribe_unsubscribe_events(hass, websocket_client): hass.bus.async_fire("test_event", {"hello": "world"}) hass.bus.async_fire("ignore_event") - with timeout(3): + async with timeout(3): msg = await websocket_client.receive_json() assert msg["id"] == 5 @@ -566,7 +566,7 @@ async def test_subscribe_unsubscribe_events_whitelist( hass.bus.async_fire("themes_updated") - with timeout(3): + async with timeout(3): msg = await websocket_client.receive_json() assert msg["id"] == 6 @@ -1051,7 +1051,7 @@ async def test_subscribe_trigger(hass, websocket_client): hass.bus.async_fire("test_event", {"hello": "world"}, context=context) hass.bus.async_fire("ignore_event") - with timeout(3): + async with timeout(3): msg = await websocket_client.receive_json() assert msg["id"] == 5 diff --git a/tests/components/websocket_api/test_decorators.py b/tests/components/websocket_api/test_decorators.py index 45d761f6fed..4fbc1ae1a21 100644 --- a/tests/components/websocket_api/test_decorators.py +++ b/tests/components/websocket_api/test_decorators.py @@ -66,3 +66,26 @@ async def test_async_response_request_context(hass, websocket_client): assert msg["id"] == 7 assert not msg["success"] assert msg["error"]["code"] == "not_found" + + +async def test_supervisor_only(hass, websocket_client): + """Test that only the Supervisor can make requests.""" + + @websocket_api.ws_require_user(only_supervisor=True) + @websocket_api.websocket_command({"type": "test-require-supervisor-user"}) + def require_supervisor_request(hass, connection, msg): + connection.send_result(msg["id"]) + + websocket_api.async_register_command(hass, require_supervisor_request) + + await websocket_client.send_json( + { + "id": 5, + "type": "test-require-supervisor-user", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "only_supervisor" diff --git a/tests/components/wilight/__init__.py b/tests/components/wilight/__init__.py index dd7d83876f8..dbcfbbaaa8c 100644 --- a/tests/components/wilight/__init__.py +++ b/tests/components/wilight/__init__.py @@ -2,8 +2,8 @@ from pywilight.const import DOMAIN +from homeassistant.components import ssdp from homeassistant.components.ssdp import ( - ATTR_SSDP_LOCATION, ATTR_UPNP_MANUFACTURER, ATTR_UPNP_MODEL_NAME, ATTR_UPNP_MODEL_NUMBER, @@ -33,28 +33,40 @@ UPNP_MAC_ADDRESS = "5C:CF:7F:8B:CA:56" UPNP_MANUFACTURER_NOT_WILIGHT = "Test" CONF_COMPONENTS = "components" -MOCK_SSDP_DISCOVERY_INFO_P_B = { - ATTR_SSDP_LOCATION: SSDP_LOCATION, - ATTR_UPNP_MANUFACTURER: UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME: UPNP_MODEL_NAME_P_B, - ATTR_UPNP_MODEL_NUMBER: UPNP_MODEL_NUMBER, - ATTR_UPNP_SERIAL: UPNP_SERIAL, -} +MOCK_SSDP_DISCOVERY_INFO_P_B = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=SSDP_LOCATION, + upnp={ + ATTR_UPNP_MANUFACTURER: UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME: UPNP_MODEL_NAME_P_B, + ATTR_UPNP_MODEL_NUMBER: UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL: UPNP_SERIAL, + }, +) -MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTURER = { - ATTR_SSDP_LOCATION: SSDP_LOCATION, - ATTR_UPNP_MANUFACTURER: UPNP_MANUFACTURER_NOT_WILIGHT, - ATTR_UPNP_MODEL_NAME: UPNP_MODEL_NAME_P_B, - ATTR_UPNP_MODEL_NUMBER: UPNP_MODEL_NUMBER, - ATTR_UPNP_SERIAL: ATTR_UPNP_SERIAL, -} +MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTURER = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=SSDP_LOCATION, + upnp={ + ATTR_UPNP_MANUFACTURER: UPNP_MANUFACTURER_NOT_WILIGHT, + ATTR_UPNP_MODEL_NAME: UPNP_MODEL_NAME_P_B, + ATTR_UPNP_MODEL_NUMBER: UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL: ATTR_UPNP_SERIAL, + }, +) -MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTURER = { - ATTR_SSDP_LOCATION: SSDP_LOCATION, - ATTR_UPNP_MODEL_NAME: UPNP_MODEL_NAME_P_B, - ATTR_UPNP_MODEL_NUMBER: UPNP_MODEL_NUMBER, - ATTR_UPNP_SERIAL: ATTR_UPNP_SERIAL, -} +MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTURER = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=SSDP_LOCATION, + upnp={ + ATTR_UPNP_MODEL_NAME: UPNP_MODEL_NAME_P_B, + ATTR_UPNP_MODEL_NUMBER: UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL: ATTR_UPNP_SERIAL, + }, +) async def setup_integration( diff --git a/tests/components/wilight/test_config_flow.py b/tests/components/wilight/test_config_flow.py index 4835167715d..326224b02c9 100644 --- a/tests/components/wilight/test_config_flow.py +++ b/tests/components/wilight/test_config_flow.py @@ -1,4 +1,5 @@ """Test the WiLight config flow.""" +import dataclasses from unittest.mock import patch import pytest @@ -55,7 +56,7 @@ def mock_dummy_get_components_from_model_wrong(): async def test_show_ssdp_form(hass: HomeAssistant) -> None: """Test that the ssdp confirmation form is served.""" - discovery_info = MOCK_SSDP_DISCOVERY_INFO_P_B.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO_P_B) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info ) @@ -71,7 +72,7 @@ async def test_show_ssdp_form(hass: HomeAssistant) -> None: async def test_ssdp_not_wilight_abort_1(hass: HomeAssistant) -> None: """Test that the ssdp aborts not_wilight.""" - discovery_info = MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTURER.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTURER) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info ) @@ -83,7 +84,7 @@ async def test_ssdp_not_wilight_abort_1(hass: HomeAssistant) -> None: async def test_ssdp_not_wilight_abort_2(hass: HomeAssistant) -> None: """Test that the ssdp aborts not_wilight.""" - discovery_info = MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTURER.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTURER) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info ) @@ -97,7 +98,7 @@ async def test_ssdp_not_wilight_abort_3( ) -> None: """Test that the ssdp aborts not_wilight.""" - discovery_info = MOCK_SSDP_DISCOVERY_INFO_P_B.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO_P_B) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info ) @@ -111,7 +112,7 @@ async def test_ssdp_not_supported_abort( ) -> None: """Test that the ssdp aborts not_supported.""" - discovery_info = MOCK_SSDP_DISCOVERY_INFO_P_B.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO_P_B) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info ) @@ -134,7 +135,7 @@ async def test_ssdp_device_exists_abort(hass: HomeAssistant) -> None: entry.add_to_hass(hass) - discovery_info = MOCK_SSDP_DISCOVERY_INFO_P_B.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO_P_B) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, @@ -148,7 +149,7 @@ async def test_ssdp_device_exists_abort(hass: HomeAssistant) -> None: async def test_full_ssdp_flow_implementation(hass: HomeAssistant) -> None: """Test the full SSDP flow from start to finish.""" - discovery_info = MOCK_SSDP_DISCOVERY_INFO_P_B.copy() + discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO_P_B) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info ) diff --git a/tests/fixtures/wled/rgb.json b/tests/components/wled/fixtures/rgb.json similarity index 97% rename from tests/fixtures/wled/rgb.json rename to tests/components/wled/fixtures/rgb.json index 41d2c69d63c..2f0d4d8fd12 100644 --- a/tests/fixtures/wled/rgb.json +++ b/tests/components/wled/fixtures/rgb.json @@ -41,13 +41,15 @@ "ix": 64, "pal": 1, "sel": true, - "rev": false, + "rev": true, "cln": -1 } ] }, "info": { "ver": "0.8.5", + "version_latest_stable": "0.12.0", + "version_latest_beta": "0.13.0b1", "vid": 1909122, "leds": { "count": 30, diff --git a/tests/fixtures/wled/rgb_single_segment.json b/tests/components/wled/fixtures/rgb_single_segment.json similarity index 97% rename from tests/fixtures/wled/rgb_single_segment.json rename to tests/components/wled/fixtures/rgb_single_segment.json index e53ce680ece..f82ef498fb6 100644 --- a/tests/fixtures/wled/rgb_single_segment.json +++ b/tests/components/wled/fixtures/rgb_single_segment.json @@ -33,7 +33,9 @@ ] }, "info": { - "ver": "0.8.5", + "ver": "0.8.6b1", + "version_latest_stable": "0.8.5", + "version_latest_beta": "0.8.6b2", "vid": 1909122, "leds": { "count": 30, diff --git a/tests/fixtures/wled/rgb_websocket.json b/tests/components/wled/fixtures/rgb_websocket.json similarity index 98% rename from tests/fixtures/wled/rgb_websocket.json rename to tests/components/wled/fixtures/rgb_websocket.json index 7e37b489549..eea1733ee83 100644 --- a/tests/fixtures/wled/rgb_websocket.json +++ b/tests/components/wled/fixtures/rgb_websocket.json @@ -63,6 +63,8 @@ }, "info": { "ver": "0.12.0-b2", + "version_latest_stable": "0.11.0", + "version_latest_beta": "0.12.0-b2", "vid": 2103220, "leds": { "count": 13, diff --git a/tests/fixtures/wled/rgbw.json b/tests/components/wled/fixtures/rgbw.json similarity index 98% rename from tests/fixtures/wled/rgbw.json rename to tests/components/wled/fixtures/rgbw.json index 824612613b1..6d9796c0fb9 100644 --- a/tests/fixtures/wled/rgbw.json +++ b/tests/components/wled/fixtures/rgbw.json @@ -33,7 +33,9 @@ ] }, "info": { - "ver": "0.8.6", + "ver": "0.8.6b4", + "version_latest_stable": "0.8.6", + "version_latest_beta": "0.8.6b5", "vid": 1910255, "leds": { "count": 13, diff --git a/tests/components/wled/test_binary_sensor.py b/tests/components/wled/test_binary_sensor.py new file mode 100644 index 00000000000..5c40f8833e5 --- /dev/null +++ b/tests/components/wled/test_binary_sensor.py @@ -0,0 +1,54 @@ +"""Tests for the WLED binary sensor platform.""" +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ICON, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_update_available( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock +) -> None: + """Test the firmware update binary sensor.""" + entity_registry = er.async_get(hass) + + state = hass.states.get("binary_sensor.wled_rgb_light_firmware") + assert state + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.UPDATE + assert state.state == STATE_ON + assert ATTR_ICON not in state.attributes + + entry = entity_registry.async_get("binary_sensor.wled_rgb_light_firmware") + assert entry + assert entry.unique_id == "aabbccddeeff_update" + assert entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + + +@pytest.mark.parametrize("mock_wled", ["wled/rgb_websocket.json"], indirect=True) +async def test_no_update_available( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock +) -> None: + """Test the update binary sensor. There is no update available.""" + entity_registry = er.async_get(hass) + + state = hass.states.get("binary_sensor.wled_websocket_firmware") + assert state + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.UPDATE + assert state.state == STATE_OFF + assert ATTR_ICON not in state.attributes + + entry = entity_registry.async_get("binary_sensor.wled_websocket_firmware") + assert entry + assert entry.unique_id == "aabbccddeeff_update" + assert entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC diff --git a/tests/components/wled/test_button.py b/tests/components/wled/test_button.py new file mode 100644 index 00000000000..d6eea403d97 --- /dev/null +++ b/tests/components/wled/test_button.py @@ -0,0 +1,180 @@ +"""Tests for the WLED button platform.""" +from unittest.mock import MagicMock + +from freezegun import freeze_time +import pytest +from wled import WLEDConnectionError, WLEDError + +from homeassistant.components.button import ( + DOMAIN as BUTTON_DOMAIN, + SERVICE_PRESS, + ButtonDeviceClass, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ENTITY_CATEGORY_CONFIG, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_button_restart( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock +) -> None: + """Test the creation and values of the WLED button.""" + entity_registry = er.async_get(hass) + + state = hass.states.get("button.wled_rgb_light_restart") + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_DEVICE_CLASS] == ButtonDeviceClass.RESTART + + entry = entity_registry.async_get("button.wled_rgb_light_restart") + assert entry + assert entry.unique_id == "aabbccddeeff_restart" + assert entry.entity_category == ENTITY_CATEGORY_CONFIG + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.wled_rgb_light_restart"}, + blocking=True, + ) + await hass.async_block_till_done() + assert mock_wled.reset.call_count == 1 + mock_wled.reset.assert_called_with() + + +@freeze_time("2021-11-04 17:37:00", tz_offset=-1) +async def test_button_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_wled: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error handling of the WLED buttons.""" + mock_wled.reset.side_effect = WLEDError + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.wled_rgb_light_restart"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("button.wled_rgb_light_restart") + assert state + assert state.state == "2021-11-04T16:37:00+00:00" + assert "Invalid response from API" in caplog.text + + +async def test_button_connection_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_wled: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error handling of the WLED buttons.""" + mock_wled.reset.side_effect = WLEDConnectionError + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.wled_rgb_light_restart"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("button.wled_rgb_light_restart") + assert state + assert state.state == STATE_UNAVAILABLE + assert "Error communicating with API" in caplog.text + + +async def test_button_update_stay_stable( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock +) -> None: + """Test the update button. + + There is both an update for beta and stable available, however, the device + is currently running a stable version. Therefore, the update button should + update the the next stable (even though beta is newer). + """ + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("button.wled_rgb_light_update") + assert entry + assert entry.unique_id == "aabbccddeeff_update" + assert entry.entity_category == ENTITY_CATEGORY_CONFIG + + state = hass.states.get("button.wled_rgb_light_update") + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_DEVICE_CLASS] == ButtonDeviceClass.UPDATE + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.wled_rgb_light_update"}, + blocking=True, + ) + await hass.async_block_till_done() + assert mock_wled.upgrade.call_count == 1 + mock_wled.upgrade.assert_called_with(version="0.12.0") + + +@pytest.mark.parametrize("mock_wled", ["wled/rgbw.json"], indirect=True) +async def test_button_update_beta_to_stable( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock +) -> None: + """Test the update button. + + There is both an update for beta and stable available the device + is currently a beta, however, a newer stable is available. Therefore, the + update button should update to the next stable. + """ + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.wled_rgbw_light_update"}, + blocking=True, + ) + await hass.async_block_till_done() + assert mock_wled.upgrade.call_count == 1 + mock_wled.upgrade.assert_called_with(version="0.8.6") + + +@pytest.mark.parametrize("mock_wled", ["wled/rgb_single_segment.json"], indirect=True) +async def test_button_update_stay_beta( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock +) -> None: + """Test the update button. + + There is an update for beta and the device is currently a beta. Therefore, + the update button should update to the next beta. + """ + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.wled_rgb_light_update"}, + blocking=True, + ) + await hass.async_block_till_done() + assert mock_wled.upgrade.call_count == 1 + mock_wled.upgrade.assert_called_with(version="0.8.6b2") + + +@pytest.mark.parametrize("mock_wled", ["wled/rgb_websocket.json"], indirect=True) +async def test_button_no_update_available( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock +) -> None: + """Test the update button. There is no update available.""" + state = hass.states.get("button.wled_websocket_update") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index 842e7e332e0..770d6abd2f8 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock from wled import WLEDConnectionError +from homeassistant.components import zeroconf from homeassistant.components.wled.const import CONF_KEEP_MASTER_LIGHT, DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME @@ -47,7 +48,14 @@ async def test_full_zeroconf_flow_implementation( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={"host": "192.168.1.123", "hostname": "example.local.", "properties": {}}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.123", + hostname="example.local.", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), ) flows = hass.config_entries.flow.async_progress() @@ -100,7 +108,14 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={"host": "192.168.1.123", "hostname": "example.local.", "properties": {}}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.123", + hostname="example.local.", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), ) assert result.get("type") == RESULT_TYPE_ABORT @@ -120,7 +135,14 @@ async def test_zeroconf_confirm_connection_error( CONF_HOST: "example.com", CONF_NAME: "test", }, - data={"host": "192.168.1.123", "hostname": "example.com.", "properties": {}}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.123", + hostname="example.com.", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), ) assert result.get("type") == RESULT_TYPE_ABORT @@ -152,7 +174,14 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={"host": "192.168.1.123", "hostname": "example.local.", "properties": {}}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.123", + hostname="example.local.", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), ) assert result.get("type") == RESULT_TYPE_ABORT @@ -168,11 +197,14 @@ async def test_zeroconf_with_mac_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={ - "host": "192.168.1.123", - "hostname": "example.local.", - "properties": {CONF_MAC: "aabbccddeeff"}, - }, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.123", + hostname="example.local.", + name="mock_name", + port=None, + properties={CONF_MAC: "aabbccddeeff"}, + type="mock_type", + ), ) assert result.get("type") == RESULT_TYPE_ABORT diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index 2d71126e0be..fb2efce404a 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -14,18 +14,7 @@ from homeassistant.components.light import ( ATTR_TRANSITION, DOMAIN as LIGHT_DOMAIN, ) -from homeassistant.components.wled.const import ( - ATTR_INTENSITY, - ATTR_PALETTE, - ATTR_PRESET, - ATTR_REVERSE, - ATTR_SPEED, - CONF_KEEP_MASTER_LIGHT, - DOMAIN, - SCAN_INTERVAL, - SERVICE_EFFECT, - SERVICE_PRESET, -) +from homeassistant.components.wled.const import CONF_KEEP_MASTER_LIGHT, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ICON, @@ -55,11 +44,6 @@ async def test_rgb_light_state( assert state.attributes.get(ATTR_EFFECT) == "Solid" assert state.attributes.get(ATTR_HS_COLOR) == (37.412, 100.0) assert state.attributes.get(ATTR_ICON) == "mdi:led-strip-variant" - assert state.attributes.get(ATTR_INTENSITY) == 128 - assert state.attributes.get(ATTR_PALETTE) == "Default" - assert state.attributes.get(ATTR_PRESET) is None - assert state.attributes.get(ATTR_REVERSE) is False - assert state.attributes.get(ATTR_SPEED) == 32 assert state.state == STATE_ON entry = entity_registry.async_get("light.wled_rgb_light") @@ -73,11 +57,6 @@ async def test_rgb_light_state( assert state.attributes.get(ATTR_EFFECT) == "Blink" assert state.attributes.get(ATTR_HS_COLOR) == (148.941, 100.0) assert state.attributes.get(ATTR_ICON) == "mdi:led-strip-variant" - assert state.attributes.get(ATTR_INTENSITY) == 64 - assert state.attributes.get(ATTR_PALETTE) == "Random Cycle" - assert state.attributes.get(ATTR_PRESET) is None - assert state.attributes.get(ATTR_REVERSE) is False - assert state.attributes.get(ATTR_SPEED) == 16 assert state.state == STATE_ON entry = entity_registry.async_get("light.wled_rgb_light_segment_1") @@ -400,229 +379,6 @@ async def test_rgbw_light( ) -async def test_effect_service( - hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock -) -> None: - """Test the effect service of a WLED light.""" - await hass.services.async_call( - DOMAIN, - SERVICE_EFFECT, - { - ATTR_EFFECT: "Rainbow", - ATTR_ENTITY_ID: "light.wled_rgb_light", - ATTR_INTENSITY: 200, - ATTR_PALETTE: "Tiamat", - ATTR_REVERSE: True, - ATTR_SPEED: 100, - }, - blocking=True, - ) - await hass.async_block_till_done() - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with( - effect="Rainbow", - intensity=200, - palette="Tiamat", - reverse=True, - segment_id=0, - speed=100, - ) - - await hass.services.async_call( - DOMAIN, - SERVICE_EFFECT, - {ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_EFFECT: 9}, - blocking=True, - ) - await hass.async_block_till_done() - assert mock_wled.segment.call_count == 2 - mock_wled.segment.assert_called_with( - segment_id=0, - effect=9, - intensity=None, - palette=None, - reverse=None, - speed=None, - ) - - await hass.services.async_call( - DOMAIN, - SERVICE_EFFECT, - { - ATTR_ENTITY_ID: "light.wled_rgb_light", - ATTR_INTENSITY: 200, - ATTR_REVERSE: True, - ATTR_SPEED: 100, - }, - blocking=True, - ) - await hass.async_block_till_done() - assert mock_wled.segment.call_count == 3 - mock_wled.segment.assert_called_with( - intensity=200, - reverse=True, - segment_id=0, - speed=100, - effect=None, - palette=None, - ) - - await hass.services.async_call( - DOMAIN, - SERVICE_EFFECT, - { - ATTR_EFFECT: "Rainbow", - ATTR_ENTITY_ID: "light.wled_rgb_light", - ATTR_PALETTE: "Tiamat", - ATTR_REVERSE: True, - ATTR_SPEED: 100, - }, - blocking=True, - ) - await hass.async_block_till_done() - assert mock_wled.segment.call_count == 4 - mock_wled.segment.assert_called_with( - effect="Rainbow", - palette="Tiamat", - reverse=True, - segment_id=0, - speed=100, - intensity=None, - ) - - await hass.services.async_call( - DOMAIN, - SERVICE_EFFECT, - { - ATTR_EFFECT: "Rainbow", - ATTR_ENTITY_ID: "light.wled_rgb_light", - ATTR_INTENSITY: 200, - ATTR_SPEED: 100, - }, - blocking=True, - ) - await hass.async_block_till_done() - assert mock_wled.segment.call_count == 5 - mock_wled.segment.assert_called_with( - effect="Rainbow", - intensity=200, - segment_id=0, - speed=100, - palette=None, - reverse=None, - ) - - await hass.services.async_call( - DOMAIN, - SERVICE_EFFECT, - { - ATTR_EFFECT: "Rainbow", - ATTR_ENTITY_ID: "light.wled_rgb_light", - ATTR_INTENSITY: 200, - ATTR_REVERSE: True, - }, - blocking=True, - ) - await hass.async_block_till_done() - assert mock_wled.segment.call_count == 6 - mock_wled.segment.assert_called_with( - effect="Rainbow", - intensity=200, - reverse=True, - segment_id=0, - palette=None, - speed=None, - ) - - -async def test_effect_service_error( - hass: HomeAssistant, - init_integration: MockConfigEntry, - mock_wled: MagicMock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test error handling of the WLED effect service.""" - mock_wled.segment.side_effect = WLEDError - - await hass.services.async_call( - DOMAIN, - SERVICE_EFFECT, - {ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_EFFECT: 9}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get("light.wled_rgb_light") - assert state - assert state.state == STATE_ON - assert "Invalid response from API" in caplog.text - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with( - effect=9, segment_id=0, intensity=None, palette=None, reverse=None, speed=None - ) - - -async def test_preset_service( - hass: HomeAssistant, - init_integration: MockConfigEntry, - mock_wled: MagicMock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the preset service of a WLED light.""" - await hass.services.async_call( - DOMAIN, - SERVICE_PRESET, - { - ATTR_ENTITY_ID: "light.wled_rgb_light", - ATTR_PRESET: 1, - }, - blocking=True, - ) - await hass.async_block_till_done() - assert mock_wled.preset.call_count == 1 - mock_wled.preset.assert_called_with(preset=1) - - await hass.services.async_call( - DOMAIN, - SERVICE_PRESET, - { - ATTR_ENTITY_ID: "light.wled_rgb_light_master", - ATTR_PRESET: 2, - }, - blocking=True, - ) - await hass.async_block_till_done() - assert mock_wled.preset.call_count == 2 - mock_wled.preset.assert_called_with(preset=2) - - assert "The 'wled.preset' service is deprecated" in caplog.text - - -async def test_preset_service_error( - hass: HomeAssistant, - init_integration: MockConfigEntry, - mock_wled: MagicMock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test error handling of the WLED preset service.""" - mock_wled.preset.side_effect = WLEDError - - await hass.services.async_call( - DOMAIN, - SERVICE_PRESET, - {ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_PRESET: 1}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get("light.wled_rgb_light") - assert state - assert state.state == STATE_ON - assert "Invalid response from API" in caplog.text - assert mock_wled.preset.call_count == 1 - mock_wled.preset.assert_called_with(preset=1) - - @pytest.mark.parametrize("mock_wled", ["wled/rgb_single_segment.json"], indirect=True) async def test_single_segment_with_keep_master_light( hass: HomeAssistant, diff --git a/tests/components/wled/test_select.py b/tests/components/wled/test_select.py index 96ea07f52c4..345c0c632fe 100644 --- a/tests/components/wled/test_select.py +++ b/tests/components/wled/test_select.py @@ -451,3 +451,91 @@ async def test_playlist_select_connection_error( assert "Error communicating with API" in caplog.text assert mock_wled.playlist.call_count == 1 mock_wled.playlist.assert_called_with(playlist="Playlist 2") + + +async def test_live_override( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_wled: MagicMock, +) -> None: + """Test the creation and values of the WLED selects.""" + entity_registry = er.async_get(hass) + + state = hass.states.get("select.wled_rgb_light_live_override") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:theater" + assert state.attributes.get(ATTR_OPTIONS) == ["0", "1", "2"] + assert state.state == "0" + + entry = entity_registry.async_get("select.wled_rgb_light_live_override") + assert entry + assert entry.unique_id == "aabbccddeeff_live_override" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.wled_rgb_light_live_override", + ATTR_OPTION: "2", + }, + blocking=True, + ) + await hass.async_block_till_done() + assert mock_wled.live.call_count == 1 + mock_wled.live.assert_called_with(live=2) + + +async def test_live_select_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_wled: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error handling of the WLED selects.""" + mock_wled.live.side_effect = WLEDError + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.wled_rgb_light_live_override", + ATTR_OPTION: "1", + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.wled_rgb_light_live_override") + assert state + assert state.state == "0" + assert "Invalid response from API" in caplog.text + assert mock_wled.live.call_count == 1 + mock_wled.live.assert_called_with(live=1) + + +async def test_live_select_connection_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_wled: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error handling of the WLED selects.""" + mock_wled.live.side_effect = WLEDConnectionError + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.wled_rgb_light_live_override", + ATTR_OPTION: "2", + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.wled_rgb_light_live_override") + assert state + assert state.state == STATE_UNAVAILABLE + assert "Error communicating with API" in caplog.text + assert mock_wled.live.call_count == 1 + mock_wled.live.assert_called_with(live=2) diff --git a/tests/components/wled/test_sensor.py b/tests/components/wled/test_sensor.py index 28effd2ff07..bc401f574a6 100644 --- a/tests/components/wled/test_sensor.py +++ b/tests/components/wled/test_sensor.py @@ -4,13 +4,8 @@ from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.sensor import ( - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_TIMESTAMP, - DOMAIN as SENSOR_DOMAIN, -) -from homeassistant.components.wled.const import ATTR_LED_COUNT, ATTR_MAX_POWER, DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.components.wled.const import DOMAIN from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, @@ -95,13 +90,10 @@ async def test_sensors( state = hass.states.get("sensor.wled_rgb_light_estimated_current") assert state - assert state.attributes.get(ATTR_ICON) == "mdi:power" - assert state.attributes.get(ATTR_LED_COUNT) == 30 - assert state.attributes.get(ATTR_MAX_POWER) == 850 assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_CURRENT_MILLIAMPERE ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CURRENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CURRENT assert state.state == "470" entry = registry.async_get("sensor.wled_rgb_light_estimated_current") @@ -111,7 +103,7 @@ async def test_sensors( state = hass.states.get("sensor.wled_rgb_light_uptime") assert state - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TIMESTAMP + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None assert state.state == "2019-11-11T09:10:00+00:00" @@ -146,7 +138,7 @@ async def test_sensors( state = hass.states.get("sensor.wled_rgb_light_wifi_rssi") assert state - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_SIGNAL_STRENGTH + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.SIGNAL_STRENGTH assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SIGNAL_STRENGTH_DECIBELS_MILLIWATT diff --git a/tests/components/wled/test_switch.py b/tests/components/wled/test_switch.py index 7ba86960d2b..c47d7012f6e 100644 --- a/tests/components/wled/test_switch.py +++ b/tests/components/wled/test_switch.py @@ -1,8 +1,9 @@ """Tests for the WLED switch platform.""" +import json from unittest.mock import MagicMock import pytest -from wled import WLEDConnectionError, WLEDError +from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.wled.const import ( @@ -10,6 +11,7 @@ from homeassistant.components.wled.const import ( ATTR_FADE, ATTR_TARGET_BRIGHTNESS, ATTR_UDP_PORT, + SCAN_INTERVAL, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -23,8 +25,9 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture async def test_switch_state( @@ -68,6 +71,16 @@ async def test_switch_state( assert entry.unique_id == "aabbccddeeff_sync_receive" assert entry.entity_category == ENTITY_CATEGORY_CONFIG + state = hass.states.get("switch.wled_rgb_light_reverse") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:swap-horizontal-bold" + assert state.state == STATE_OFF + + entry = entity_registry.async_get("switch.wled_rgb_light_reverse") + assert entry + assert entry.unique_id == "aabbccddeeff_reverse_0" + assert entry.entity_category == ENTITY_CATEGORY_CONFIG + async def test_switch_change_state( hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock @@ -137,6 +150,26 @@ async def test_switch_change_state( assert mock_wled.sync.call_count == 4 mock_wled.sync.assert_called_with(receive=True) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.wled_rgb_light_reverse"}, + blocking=True, + ) + await hass.async_block_till_done() + assert mock_wled.segment.call_count == 1 + mock_wled.segment.assert_called_with(segment_id=0, reverse=True) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.wled_rgb_light_reverse"}, + blocking=True, + ) + await hass.async_block_till_done() + assert mock_wled.segment.call_count == 2 + mock_wled.segment.assert_called_with(segment_id=0, reverse=False) + async def test_switch_error( hass: HomeAssistant, @@ -182,3 +215,45 @@ async def test_switch_connection_error( assert state assert state.state == STATE_UNAVAILABLE assert "Error communicating with API" in caplog.text + + +@pytest.mark.parametrize("mock_wled", ["wled/rgb_single_segment.json"], indirect=True) +async def test_switch_dynamically_handle_segments( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_wled: MagicMock, +) -> None: + """Test if a new/deleted segment is dynamically added/removed.""" + segment0 = hass.states.get("switch.wled_rgb_light_reverse") + segment1 = hass.states.get("switch.wled_rgb_light_segment_1_reverse") + assert segment0 + assert segment0.state == STATE_OFF + assert not segment1 + + # Test adding a segment dynamically... + return_value = mock_wled.update.return_value + mock_wled.update.return_value = WLEDDevice( + json.loads(load_fixture("wled/rgb.json")) + ) + + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + segment0 = hass.states.get("switch.wled_rgb_light_reverse") + segment1 = hass.states.get("switch.wled_rgb_light_segment_1_reverse") + assert segment0 + assert segment0.state == STATE_OFF + assert segment1 + assert segment1.state == STATE_ON + + # Test remove segment again... + mock_wled.update.return_value = return_value + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + segment0 = hass.states.get("switch.wled_rgb_light_reverse") + segment1 = hass.states.get("switch.wled_rgb_light_segment_1_reverse") + assert segment0 + assert segment0.state == STATE_OFF + assert segment1 + assert segment1.state == STATE_UNAVAILABLE diff --git a/tests/components/xiaomi_aqara/test_config_flow.py b/tests/components/xiaomi_aqara/test_config_flow.py index 3f445a1fdec..554e0460443 100644 --- a/tests/components/xiaomi_aqara/test_config_flow.py +++ b/tests/components/xiaomi_aqara/test_config_flow.py @@ -5,6 +5,7 @@ from unittest.mock import Mock, patch import pytest from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.components.xiaomi_aqara import config_flow, const from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_PROTOCOL @@ -400,11 +401,14 @@ async def test_zeroconf_success(hass): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - CONF_HOST: TEST_HOST, - ZEROCONF_NAME: TEST_ZEROCONF_NAME, - ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC}, - }, + data=zeroconf.ZeroconfServiceInfo( + host=TEST_HOST, + hostname="mock_hostname", + name=TEST_ZEROCONF_NAME, + port=None, + properties={ZEROCONF_MAC: TEST_MAC}, + type="mock_type", + ), ) assert result["type"] == "form" @@ -443,7 +447,14 @@ async def test_zeroconf_missing_data(hass): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={CONF_HOST: TEST_HOST, ZEROCONF_NAME: TEST_ZEROCONF_NAME}, + data=zeroconf.ZeroconfServiceInfo( + host=TEST_HOST, + hostname="mock_hostname", + name=TEST_ZEROCONF_NAME, + port=None, + properties={}, + type="mock_type", + ), ) assert result["type"] == "abort" @@ -455,11 +466,14 @@ async def test_zeroconf_unknown_device(hass): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - CONF_HOST: TEST_HOST, - ZEROCONF_NAME: "not-a-xiaomi-aqara-gateway", - ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC}, - }, + data=zeroconf.ZeroconfServiceInfo( + host=TEST_HOST, + hostname="mock_hostname", + name="not-a-xiaomi-aqara-gateway", + port=None, + properties={ZEROCONF_MAC: TEST_MAC}, + type="mock_type", + ), ) assert result["type"] == "abort" diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index 206da7ad4ae..3be52e83237 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -7,6 +7,7 @@ from miio import DeviceException 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_NAME, CONF_TOKEN @@ -391,11 +392,14 @@ async def test_zeroconf_gateway_success(hass): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - CONF_HOST: TEST_HOST, - ZEROCONF_NAME: TEST_ZEROCONF_NAME, - ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC}, - }, + data=zeroconf.ZeroconfServiceInfo( + host=TEST_HOST, + hostname="mock_hostname", + name=TEST_ZEROCONF_NAME, + port=None, + properties={ZEROCONF_MAC: TEST_MAC}, + type="mock_type", + ), ) assert result["type"] == "form" @@ -430,11 +434,14 @@ async def test_zeroconf_unknown_device(hass): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - CONF_HOST: TEST_HOST, - ZEROCONF_NAME: "not-a-xiaomi-miio-device", - ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC}, - }, + data=zeroconf.ZeroconfServiceInfo( + host=TEST_HOST, + hostname="mock_hostname", + name="not-a-xiaomi-miio-device", + port=None, + properties={ZEROCONF_MAC: TEST_MAC}, + type="mock_type", + ), ) assert result["type"] == "abort" @@ -444,7 +451,16 @@ async def test_zeroconf_unknown_device(hass): async def test_zeroconf_no_data(hass): """Test a failed zeroconf discovery because of no data.""" result = await hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data={} + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host=None, + hostname="mock_hostname", + name=None, + port=None, + properties={}, + type="mock_type", + ), ) assert result["type"] == "abort" @@ -456,7 +472,14 @@ async def test_zeroconf_missing_data(hass): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={CONF_HOST: TEST_HOST, ZEROCONF_NAME: TEST_ZEROCONF_NAME}, + data=zeroconf.ZeroconfServiceInfo( + host=TEST_HOST, + hostname="mock_hostname", + name=TEST_ZEROCONF_NAME, + port=None, + properties={}, + type="mock_type", + ), ) assert result["type"] == "abort" @@ -746,11 +769,14 @@ async def zeroconf_device_success(hass, zeroconf_name_to_test, model_to_test): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - CONF_HOST: TEST_HOST, - ZEROCONF_NAME: zeroconf_name_to_test, - ZEROCONF_PROP: {"poch": f"0:mac={TEST_MAC_DEVICE}\x00"}, - }, + data=zeroconf.ZeroconfServiceInfo( + host=TEST_HOST, + hostname="mock_hostname", + name=zeroconf_name_to_test, + port=None, + properties={"poch": f"0:mac={TEST_MAC_DEVICE}\x00"}, + type="mock_type", + ), ) assert result["type"] == "form" diff --git a/tests/components/xiaomi_miio/test_vacuum.py b/tests/components/xiaomi_miio/test_vacuum.py index 10f1dd649c8..6c8b808eb94 100644 --- a/tests/components/xiaomi_miio/test_vacuum.py +++ b/tests/components/xiaomi_miio/test_vacuum.py @@ -78,6 +78,12 @@ def mirobo_is_got_error_fixture(): mock_vacuum.status().battery = 82 mock_vacuum.status().clean_area = 123.43218 mock_vacuum.status().clean_time = timedelta(hours=2, minutes=35, seconds=34) + mock_vacuum.last_clean_details().start = datetime( + 2020, 4, 1, 13, 21, 10, tzinfo=dt_util.UTC + ) + mock_vacuum.last_clean_details().end = datetime( + 2020, 4, 1, 13, 21, 10, tzinfo=dt_util.UTC + ) mock_vacuum.consumable_status().main_brush_left = timedelta( hours=12, minutes=35, seconds=34 ) @@ -109,7 +115,9 @@ def mirobo_is_got_error_fixture(): mock_vacuum.timer.return_value = [mock_timer_1, mock_timer_2] - with patch("homeassistant.components.xiaomi_miio.Vacuum") as mock_vacuum_cls: + with patch( + "homeassistant.components.xiaomi_miio.RoborockVacuum" + ) as mock_vacuum_cls: mock_vacuum_cls.return_value = mock_vacuum yield mock_vacuum @@ -136,8 +144,16 @@ def mirobo_old_speeds_fixture(request): mock_vacuum.status().battery = 32 mock_vacuum.fan_speed_presets.return_value = request.param mock_vacuum.status().fanspeed = list(request.param.values())[0] + mock_vacuum.last_clean_details().start = datetime( + 2020, 4, 1, 13, 21, 10, tzinfo=dt_util.UTC + ) + mock_vacuum.last_clean_details().end = datetime( + 2020, 4, 1, 13, 21, 10, tzinfo=dt_util.UTC + ) - with patch("homeassistant.components.xiaomi_miio.Vacuum") as mock_vacuum_cls: + with patch( + "homeassistant.components.xiaomi_miio.RoborockVacuum" + ) as mock_vacuum_cls: mock_vacuum_cls.return_value = mock_vacuum yield mock_vacuum @@ -197,7 +213,9 @@ def mirobo_is_on_fixture(): mock_vacuum.timer.return_value = [mock_timer_1, mock_timer_2] - with patch("homeassistant.components.xiaomi_miio.Vacuum") as mock_vacuum_cls: + with patch( + "homeassistant.components.xiaomi_miio.RoborockVacuum" + ) as mock_vacuum_cls: mock_vacuum_cls.return_value = mock_vacuum yield mock_vacuum diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py index 31d63bac158..bddc350cff8 100644 --- a/tests/components/yamaha_musiccast/test_config_flow.py +++ b/tests/components/yamaha_musiccast/test_config_flow.py @@ -94,10 +94,15 @@ def mock_valid_discovery_information(): with patch( "homeassistant.components.ssdp.async_get_discovery_info_by_st", return_value=[ - { - "ssdp_location": "http://127.0.0.1:9000/MediaRenderer/desc.xml", - "_host": "127.0.0.1", - } + ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://127.0.0.1:9000/MediaRenderer/desc.xml", + ssdp_headers={ + "_host": "127.0.0.1", + }, + upnp={}, + ) ], ): yield @@ -308,11 +313,15 @@ async def test_ssdp_discovery_failed(hass, mock_ssdp_no_yamaha, mock_get_source_ result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: "http://127.0.0.1/desc.xml", - ssdp.ATTR_UPNP_MODEL_NAME: "MC20", - ssdp.ATTR_UPNP_SERIAL: "123456789", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://127.0.0.1/desc.xml", + upnp={ + ssdp.ATTR_UPNP_MODEL_NAME: "MC20", + ssdp.ATTR_UPNP_SERIAL: "123456789", + }, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -326,11 +335,15 @@ async def test_ssdp_discovery_successful_add_device( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: "http://127.0.0.1/desc.xml", - ssdp.ATTR_UPNP_MODEL_NAME: "MC20", - ssdp.ATTR_UPNP_SERIAL: "1234567890", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://127.0.0.1/desc.xml", + upnp={ + ssdp.ATTR_UPNP_MODEL_NAME: "MC20", + ssdp.ATTR_UPNP_SERIAL: "1234567890", + }, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -364,11 +377,15 @@ async def test_ssdp_discovery_existing_device_update( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: "http://127.0.0.1/desc.xml", - ssdp.ATTR_UPNP_MODEL_NAME: "MC20", - ssdp.ATTR_UPNP_SERIAL: "1234567890", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://127.0.0.1/desc.xml", + upnp={ + ssdp.ATTR_UPNP_MODEL_NAME: "MC20", + ssdp.ATTR_UPNP_SERIAL: "1234567890", + }, + ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index 4d673dfaa94..b6bf0b10d67 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -8,7 +8,7 @@ from async_upnp_client.search import SsdpSearchListener from yeelight import BulbException, BulbType from yeelight.main import _MODEL_SPECS -from homeassistant.components import yeelight as hass_yeelight +from homeassistant.components import ssdp, zeroconf from homeassistant.components.yeelight import ( CONF_MODE_MUSIC, CONF_NIGHTLIGHT_SWITCH_TYPE, @@ -16,6 +16,7 @@ from homeassistant.components.yeelight import ( DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, YeelightScanner, + scanner, ) from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME from homeassistant.core import callback @@ -39,14 +40,14 @@ CAPABILITIES = { ID_DECIMAL = f"{int(ID, 16):08d}" -ZEROCONF_DATA = { - "host": IP_ADDRESS, - "port": 54321, - "hostname": f"yeelink-light-strip1_miio{ID_DECIMAL}.local.", - "type": "_miio._udp.local.", - "name": f"yeelink-light-strip1_miio{ID_DECIMAL}._miio._udp.local.", - "properties": {"epoch": "1", "mac": "000000000000"}, -} +ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( + host=IP_ADDRESS, + port=54321, + hostname=f"yeelink-light-strip1_miio{ID_DECIMAL}.local.", + type="_miio._udp.local.", + name=f"yeelink-light-strip1_miio{ID_DECIMAL}._miio._udp.local.", + properties={"epoch": "1", "mac": "000000000000"}, +) NAME = "name" SHORT_ID = hex(int("0x000000000015243f", 16)) @@ -178,23 +179,25 @@ def _patch_discovery(no_device=False, capabilities=None): YeelightScanner._scanner = None # Clear class scanner to reset hass def _generate_fake_ssdp_listener(*args, **kwargs): - return _patched_ssdp_listener( - None if no_device else capabilities or CAPABILITIES, - *args, - **kwargs, - ) + info = None + if not no_device: + info = ssdp.SsdpServiceInfo( + ssdp_usn="", + ssdp_st=scanner.SSDP_ST, + upnp={}, + ssdp_headers=capabilities or CAPABILITIES, + ) + return _patched_ssdp_listener(info, *args, **kwargs) return patch( - "homeassistant.components.yeelight.SsdpSearchListener", + "homeassistant.components.yeelight.scanner.SsdpSearchListener", new=_generate_fake_ssdp_listener, ) def _patch_discovery_interval(): - return patch.object( - hass_yeelight, "DISCOVERY_SEARCH_INTERVAL", timedelta(seconds=0) - ) + return patch.object(scanner, "DISCOVERY_SEARCH_INTERVAL", timedelta(seconds=0)) def _patch_discovery_timeout(): - return patch.object(hass_yeelight, "DISCOVERY_TIMEOUT", 0.0001) + return patch.object(scanner, "DISCOVERY_TIMEOUT", 0.0001) diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 258477c9569..aa5e7f98a45 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -4,7 +4,9 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components.yeelight import ( +from homeassistant.components import dhcp, ssdp, zeroconf +from homeassistant.components.yeelight.config_flow import MODEL_UNKNOWN, CannotConnect +from homeassistant.components.yeelight.const import ( CONF_DETECTED_MODEL, CONF_MODE_MUSIC, CONF_MODEL, @@ -20,7 +22,6 @@ from homeassistant.components.yeelight import ( DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, ) -from homeassistant.components.yeelight.config_flow import MODEL_UNKNOWN, CannotConnect from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM @@ -51,6 +52,13 @@ DEFAULT_CONFIG = { CONF_NIGHTLIGHT_SWITCH: DEFAULT_NIGHTLIGHT_SWITCH, } +SSDP_INFO = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + upnp={}, + ssdp_headers=CAPABILITIES, +) + async def test_discovery(hass: HomeAssistant): """Test setting up discovery.""" @@ -119,7 +127,9 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant): alternate_bulb.capabilities["id"] = "0x000000000099999" alternate_bulb.capabilities["location"] = "yeelight://4.4.4.4" - with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=alternate_bulb): + with _patch_discovery(), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=alternate_bulb + ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -131,7 +141,7 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant): assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(), _patch_discovery_interval(): + with _patch_discovery(), _patch_discovery_timeout(), _patch_discovery_interval(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() await hass.async_block_till_done() @@ -455,7 +465,14 @@ async def test_discovered_by_homekit_and_dhcp(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data={"host": IP_ADDRESS, "properties": {"id": "aa:bb:cc:dd:ee:ff"}}, + data=zeroconf.ZeroconfServiceInfo( + host=IP_ADDRESS, + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + type="mock_type", + ), ) await hass.async_block_till_done() assert result["type"] == RESULT_TYPE_FORM @@ -467,7 +484,9 @@ async def test_discovered_by_homekit_and_dhcp(hass): result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={"ip": IP_ADDRESS, "macaddress": "aa:bb:cc:dd:ee:ff"}, + data=dhcp.DhcpServiceInfo( + ip=IP_ADDRESS, macaddress="aa:bb:cc:dd:ee:ff", hostname="mock_hostname" + ), ) await hass.async_block_till_done() assert result2["type"] == RESULT_TYPE_ABORT @@ -479,7 +498,9 @@ async def test_discovered_by_homekit_and_dhcp(hass): result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={"ip": IP_ADDRESS, "macaddress": "00:00:00:00:00:00"}, + data=dhcp.DhcpServiceInfo( + ip=IP_ADDRESS, macaddress="00:00:00:00:00:00", hostname="mock_hostname" + ), ) await hass.async_block_till_done() assert result3["type"] == RESULT_TYPE_ABORT @@ -493,7 +514,9 @@ async def test_discovered_by_homekit_and_dhcp(hass): result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={"ip": "1.2.3.5", "macaddress": "00:00:00:00:00:01"}, + data=dhcp.DhcpServiceInfo( + ip="1.2.3.5", macaddress="00:00:00:00:00:01", hostname="mock_hostname" + ), ) await hass.async_block_till_done() assert result3["type"] == RESULT_TYPE_ABORT @@ -505,11 +528,20 @@ async def test_discovered_by_homekit_and_dhcp(hass): [ ( config_entries.SOURCE_DHCP, - {"ip": IP_ADDRESS, "macaddress": "aa:bb:cc:dd:ee:ff"}, + dhcp.DhcpServiceInfo( + ip=IP_ADDRESS, macaddress="aa:bb:cc:dd:ee:ff", hostname="mock_hostname" + ), ), ( config_entries.SOURCE_HOMEKIT, - {"host": IP_ADDRESS, "properties": {"id": "aa:bb:cc:dd:ee:ff"}}, + zeroconf.ZeroconfServiceInfo( + host=IP_ADDRESS, + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + type="mock_type", + ), ), ], ) @@ -563,11 +595,20 @@ async def test_discovered_by_dhcp_or_homekit(hass, source, data): [ ( config_entries.SOURCE_DHCP, - {"ip": IP_ADDRESS, "macaddress": "aa:bb:cc:dd:ee:ff"}, + dhcp.DhcpServiceInfo( + ip=IP_ADDRESS, macaddress="aa:bb:cc:dd:ee:ff", hostname="mock_hostname" + ), ), ( config_entries.SOURCE_HOMEKIT, - {"host": IP_ADDRESS, "properties": {"id": "aa:bb:cc:dd:ee:ff"}}, + zeroconf.ZeroconfServiceInfo( + host=IP_ADDRESS, + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + type="mock_type", + ), ), ], ) @@ -595,7 +636,7 @@ async def test_discovered_ssdp(hass): f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=CAPABILITIES + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=SSDP_INFO ) await hass.async_block_till_done() @@ -624,7 +665,7 @@ async def test_discovered_ssdp(hass): f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=CAPABILITIES + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=SSDP_INFO ) await hass.async_block_till_done() @@ -687,7 +728,7 @@ async def test_discovered_zeroconf(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=CAPABILITIES, + data=SSDP_INFO, ) await hass.async_block_till_done() diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index 7e9958a09d2..dc3d602edeb 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -7,7 +7,7 @@ import pytest from yeelight import BulbException, BulbType from yeelight.aio import KEY_CONNECTED -from homeassistant.components.yeelight import ( +from homeassistant.components.yeelight.const import ( CONF_DETECTED_MODEL, CONF_NIGHTLIGHT_SWITCH, CONF_NIGHTLIGHT_SWITCH_TYPE, @@ -21,6 +21,7 @@ from homeassistant.const import ( CONF_HOST, CONF_ID, CONF_NAME, + STATE_ON, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant @@ -530,12 +531,14 @@ async def test_connection_dropped_resyncs_properties(hass: HomeAssistant): assert len(mocked_bulb.async_get_properties.mock_calls) == 1 mocked_bulb._async_callback({KEY_CONNECTED: False}) await hass.async_block_till_done() + assert hass.states.get("light.test_name").state == STATE_UNAVAILABLE assert len(mocked_bulb.async_get_properties.mock_calls) == 1 mocked_bulb._async_callback({KEY_CONNECTED: True}) async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=STATE_CHANGE_TIME) ) await hass.async_block_till_done() + assert hass.states.get("light.test_name").state == STATE_ON assert len(mocked_bulb.async_get_properties.mock_calls) == 2 @@ -585,3 +588,25 @@ async def test_non_oserror_exception_on_first_update( await hass.async_block_till_done() assert hass.states.get("light.test_name").state != STATE_UNAVAILABLE + + +async def test_async_setup_with_discovery_not_working(hass: HomeAssistant): + """Test we can setup even if discovery is broken.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1", CONF_ID: ID}, + options={}, + unique_id=ID, + ) + config_entry.add_to_hass(hass) + + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=_mocked_bulb() + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + assert hass.states.get("light.yeelight_color_0x15243f").state == STATE_ON diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 4377efe129f..059a47b53a1 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -36,7 +36,7 @@ from homeassistant.components.light import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.components.yeelight import ( +from homeassistant.components.yeelight.const import ( ATTR_COUNT, ATTR_MODE_MUSIC, ATTR_TRANSITIONS, diff --git a/tests/components/zeroconf/conftest.py b/tests/components/zeroconf/conftest.py index cbe2ec8dc26..d52f8234922 100644 --- a/tests/components/zeroconf/conftest.py +++ b/tests/components/zeroconf/conftest.py @@ -1,5 +1,4 @@ """Tests for the Zeroconf component.""" -from unittest.mock import AsyncMock, patch import pytest @@ -8,14 +7,3 @@ import pytest def zc_mock_get_source_ip(mock_get_source_ip): """Enable the mock_get_source_ip fixture for all zeroconf tests.""" return mock_get_source_ip - - -@pytest.fixture -def mock_async_zeroconf(mock_zeroconf): - """Mock AsyncZeroconf.""" - with patch("homeassistant.components.zeroconf.HaAsyncZeroconf") as mock_aiozc: - zc = mock_aiozc.return_value - zc.async_register_service = AsyncMock() - zc.zeroconf.async_wait_for_start = AsyncMock() - zc.ha_async_close = AsyncMock() - yield zc diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index edf29a32f69..1bd96a306eb 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -126,6 +126,24 @@ def get_zeroconf_info_mock_manufacturer(manufacturer): return mock_zc_info +def get_zeroconf_info_mock_model(model): + """Return info for get_service_info for an zeroconf device.""" + + def mock_zc_info(service_type, name): + return AsyncServiceInfo( + service_type, + name, + addresses=[b"\n\x00\x00\x14"], + port=80, + weight=0, + priority=0, + server="name.local.", + properties={b"model": model.encode()}, + ) + + return mock_zc_info + + async def test_setup(hass, mock_async_zeroconf): """Test configured options for a device are loaded via config entry.""" with patch.object( @@ -330,6 +348,39 @@ async def test_zeroconf_match_manufacturer(hass, mock_async_zeroconf): assert mock_config_flow.mock_calls[0][1][0] == "samsungtv" +async def test_zeroconf_match_model(hass, mock_async_zeroconf): + """Test matching a specific model in zeroconf.""" + + def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + """Call service update handler.""" + handlers[0]( + zeroconf, + "_airplay._tcp.local.", + "s1000._airplay._tcp.local.", + ServiceStateChange.Added, + ) + + with patch.dict( + zc_gen.ZEROCONF, + {"_airplay._tcp.local.": [{"domain": "appletv", "model": "appletv*"}]}, + clear=True, + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow, patch.object( + zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + ) as mock_service_browser, patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + side_effect=get_zeroconf_info_mock_model("appletv"), + ): + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(mock_service_browser.mock_calls) == 1 + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "appletv" + + async def test_zeroconf_match_manufacturer_not_present(hass, mock_async_zeroconf): """Test matchers reject when a property is missing.""" @@ -979,3 +1030,37 @@ 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_service_info_compatibility(hass, caplog): + """Test compatibility with old-style dict. + + To be removed in 2022.6 + """ + discovery_info = zeroconf.ZeroconfServiceInfo( + host="mock_host", + port=None, + hostname="mock_hostname", + type="mock_type", + name="mock_name", + properties={}, + ) + + # Ensure first call get logged + assert discovery_info["host"] == "mock_host" + assert discovery_info.get("host") == "mock_host" + assert discovery_info.get("host", "fallback_host") == "mock_host" + assert discovery_info.get("invalid_key", "fallback_host") == "fallback_host" + assert "Detected code that accessed discovery_info['host']" in caplog.text + assert "Detected code that accessed discovery_info.get('host')" not in caplog.text + + # Ensure second call doesn't get logged + caplog.clear() + assert discovery_info["host"] == "mock_host" + assert discovery_info.get("host") == "mock_host" + assert "Detected code that accessed discovery_info['host']" not in caplog.text + assert "Detected code that accessed discovery_info.get('host')" not in caplog.text + + discovery_info._warning_logged = False + assert discovery_info.get("host") == "mock_host" + assert "Detected code that accessed discovery_info.get('host')" in caplog.text diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py index e2543181a1a..f4ec40fcb15 100644 --- a/tests/components/zha/test_channels.py +++ b/tests/components/zha/test_channels.py @@ -158,6 +158,7 @@ async def poll_control_device(zha_device_restored, zigpy_device_mock): { "active_power", "active_power_max", + "apparent_power", "rms_current", "rms_current_max", "rms_voltage", diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index bb91ccbeda3..fe03f759304 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -8,11 +8,8 @@ import zigpy.config from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from homeassistant import config_entries -from homeassistant.components.ssdp import ( - ATTR_SSDP_LOCATION, - ATTR_UPNP_MANUFACTURER_URL, - ATTR_UPNP_SERIAL, -) +from homeassistant.components import ssdp, usb, zeroconf +from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL from homeassistant.components.zha import config_flow from homeassistant.components.zha.core.const import ( CONF_BAUDRATE, @@ -52,12 +49,14 @@ def com_port(): @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) async def test_discovery(detect_mock, hass): """Test zeroconf flow -- radio detected.""" - service_info = { - "host": "192.168.1.200", - "port": 6053, - "hostname": "_tube_zb_gw._tcp.local.", - "properties": {"name": "tube_123456"}, - } + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.1.200", + hostname="_tube_zb_gw._tcp.local.", + name="mock_name", + port=6053, + properties={"name": "tube_123456"}, + type="mock_type", + ) flow = await hass.config_entries.flow.async_init( "zha", context={"source": SOURCE_ZEROCONF}, data=service_info ) @@ -94,12 +93,14 @@ async def test_discovery_via_zeroconf_ip_change(detect_mock, hass): ) entry.add_to_hass(hass) - service_info = { - "host": "192.168.1.22", - "port": 6053, - "hostname": "tube_zb_gw_cc2652p2_poe.local.", - "properties": {"address": "tube_zb_gw_cc2652p2_poe.local"}, - } + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.1.22", + hostname="tube_zb_gw_cc2652p2_poe.local.", + name="mock_name", + port=6053, + properties={"address": "tube_zb_gw_cc2652p2_poe.local"}, + type="mock_type", + ) result = await hass.config_entries.flow.async_init( "zha", context={"source": SOURCE_ZEROCONF}, data=service_info ) @@ -124,12 +125,14 @@ async def test_discovery_via_zeroconf_ip_change_ignored(detect_mock, hass): ) entry.add_to_hass(hass) - service_info = { - "host": "192.168.1.22", - "port": 6053, - "hostname": "tube_zb_gw_cc2652p2_poe.local.", - "properties": {"address": "tube_zb_gw_cc2652p2_poe.local"}, - } + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.1.22", + hostname="tube_zb_gw_cc2652p2_poe.local.", + name="mock_name", + port=6053, + properties={"address": "tube_zb_gw_cc2652p2_poe.local"}, + type="mock_type", + ) result = await hass.config_entries.flow.async_init( "zha", context={"source": SOURCE_ZEROCONF}, data=service_info ) @@ -144,14 +147,14 @@ async def test_discovery_via_zeroconf_ip_change_ignored(detect_mock, hass): @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) async def test_discovery_via_usb(detect_mock, hass): """Test usb flow -- radio detected.""" - discovery_info = { - "device": "/dev/ttyZIGBEE", - "pid": "AAAA", - "vid": "AAAA", - "serial_number": "1234", - "description": "zigbee radio", - "manufacturer": "test", - } + discovery_info = usb.UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) result = await hass.config_entries.flow.async_init( "zha", context={"source": SOURCE_USB}, data=discovery_info ) @@ -180,14 +183,14 @@ async def test_discovery_via_usb(detect_mock, hass): @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=False) async def test_discovery_via_usb_no_radio(detect_mock, hass): """Test usb flow -- no radio detected.""" - discovery_info = { - "device": "/dev/null", - "pid": "AAAA", - "vid": "AAAA", - "serial_number": "1234", - "description": "zigbee radio", - "manufacturer": "test", - } + discovery_info = usb.UsbServiceInfo( + device="/dev/null", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) result = await hass.config_entries.flow.async_init( "zha", context={"source": SOURCE_USB}, data=discovery_info ) @@ -213,14 +216,14 @@ async def test_discovery_via_usb_already_setup(detect_mock, hass): domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} ).add_to_hass(hass) - discovery_info = { - "device": "/dev/ttyZIGBEE", - "pid": "AAAA", - "vid": "AAAA", - "serial_number": "1234", - "description": "zigbee radio", - "manufacturer": "test", - } + discovery_info = usb.UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) result = await hass.config_entries.flow.async_init( "zha", context={"source": SOURCE_USB}, data=discovery_info ) @@ -247,14 +250,14 @@ async def test_discovery_via_usb_path_changes(hass): ) entry.add_to_hass(hass) - discovery_info = { - "device": "/dev/ttyZIGBEE", - "pid": "AAAA", - "vid": "AAAA", - "serial_number": "1234", - "description": "zigbee radio", - "manufacturer": "test", - } + discovery_info = usb.UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) result = await hass.config_entries.flow.async_init( "zha", context={"source": SOURCE_USB}, data=discovery_info ) @@ -274,22 +277,26 @@ async def test_discovery_via_usb_deconz_already_discovered(detect_mock, hass): """Test usb flow -- deconz discovered.""" result = await hass.config_entries.flow.async_init( "deconz", - data={ - ATTR_SSDP_LOCATION: "http://1.2.3.4:80/", - ATTR_UPNP_MANUFACTURER_URL: "http://www.dresden-elektronik.de", - ATTR_UPNP_SERIAL: "0000000000000000", - }, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://1.2.3.4:80/", + upnp={ + ATTR_UPNP_MANUFACTURER_URL: "http://www.dresden-elektronik.de", + ATTR_UPNP_SERIAL: "0000000000000000", + }, + ), context={"source": SOURCE_SSDP}, ) await hass.async_block_till_done() - discovery_info = { - "device": "/dev/ttyZIGBEE", - "pid": "AAAA", - "vid": "AAAA", - "serial_number": "1234", - "description": "zigbee radio", - "manufacturer": "test", - } + discovery_info = usb.UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) result = await hass.config_entries.flow.async_init( "zha", context={"source": SOURCE_USB}, data=discovery_info ) @@ -304,14 +311,14 @@ async def test_discovery_via_usb_deconz_already_setup(detect_mock, hass): """Test usb flow -- deconz setup.""" MockConfigEntry(domain="deconz", data={}).add_to_hass(hass) await hass.async_block_till_done() - discovery_info = { - "device": "/dev/ttyZIGBEE", - "pid": "AAAA", - "vid": "AAAA", - "serial_number": "1234", - "description": "zigbee radio", - "manufacturer": "test", - } + discovery_info = usb.UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) result = await hass.config_entries.flow.async_init( "zha", context={"source": SOURCE_USB}, data=discovery_info ) @@ -328,14 +335,14 @@ async def test_discovery_via_usb_deconz_ignored(detect_mock, hass): domain="deconz", source=config_entries.SOURCE_IGNORE, data={} ).add_to_hass(hass) await hass.async_block_till_done() - discovery_info = { - "device": "/dev/ttyZIGBEE", - "pid": "AAAA", - "vid": "AAAA", - "serial_number": "1234", - "description": "zigbee radio", - "manufacturer": "test", - } + discovery_info = usb.UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) result = await hass.config_entries.flow.async_init( "zha", context={"source": SOURCE_USB}, data=discovery_info ) @@ -356,14 +363,14 @@ async def test_discovery_via_usb_zha_ignored_updates(detect_mock, hass): ) entry.add_to_hass(hass) await hass.async_block_till_done() - discovery_info = { - "device": "/dev/ttyZIGBEE", - "pid": "AAAA", - "vid": "AAAA", - "serial_number": "1234", - "description": "zigbee radio", - "manufacturer": "test", - } + discovery_info = usb.UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) result = await hass.config_entries.flow.async_init( "zha", context={"source": SOURCE_USB}, data=discovery_info ) @@ -380,12 +387,14 @@ async def test_discovery_via_usb_zha_ignored_updates(detect_mock, hass): @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) async def test_discovery_already_setup(detect_mock, hass): """Test zeroconf flow -- radio detected.""" - service_info = { - "host": "192.168.1.200", - "port": 6053, - "hostname": "_tube_zb_gw._tcp.local.", - "properties": {"name": "tube_123456"}, - } + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.1.200", + hostname="_tube_zb_gw._tcp.local.", + name="mock_name", + port=6053, + properties={"name": "tube_123456"}, + type="mock_type", + ) MockConfigEntry( domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 918876fe448..af4bb082b03 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -23,6 +23,7 @@ from homeassistant.const import ( ENERGY_KILO_WATT_HOUR, LIGHT_LUX, PERCENTAGE, + POWER_VOLT_AMPERE, POWER_WATT, PRESSURE_HPA, STATE_UNAVAILABLE, @@ -174,6 +175,24 @@ async def async_test_electrical_measurement(hass, cluster, entity_id): assert hass.states.get(entity_id).attributes["active_power_max"] == "8.8" +async def async_test_em_apparent_power(hass, cluster, entity_id): + """Test electrical measurement Apparent Power sensor.""" + # update divisor cached value + await send_attributes_report(hass, cluster, {"ac_power_divisor": 1}) + await send_attributes_report(hass, cluster, {0: 1, 0x050F: 100, 10: 1000}) + assert_state(hass, entity_id, "100", POWER_VOLT_AMPERE) + + await send_attributes_report(hass, cluster, {0: 1, 0x050F: 99, 10: 1000}) + assert_state(hass, entity_id, "99", POWER_VOLT_AMPERE) + + await send_attributes_report(hass, cluster, {"ac_power_divisor": 10}) + await send_attributes_report(hass, cluster, {0: 1, 0x050F: 1000, 10: 5000}) + assert_state(hass, entity_id, "100", POWER_VOLT_AMPERE) + + await send_attributes_report(hass, cluster, {0: 1, 0x050F: 99, 10: 5000}) + assert_state(hass, entity_id, "9.9", POWER_VOLT_AMPERE) + + async def async_test_em_rms_current(hass, cluster, entity_id): """Test electrical measurement RMS Current sensor.""" @@ -290,25 +309,33 @@ async def async_test_powerconfiguration(hass, cluster, entity_id): homeautomation.ElectricalMeasurement.cluster_id, "electrical_measurement", async_test_electrical_measurement, - 6, + 7, {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, - {"rms_current", "rms_voltage"}, + {"apparent_power", "rms_current", "rms_voltage"}, + ), + ( + homeautomation.ElectricalMeasurement.cluster_id, + "electrical_measurement_apparent_power", + async_test_em_apparent_power, + 7, + {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, + {"active_power", "rms_current", "rms_voltage"}, ), ( homeautomation.ElectricalMeasurement.cluster_id, "electrical_measurement_rms_current", async_test_em_rms_current, - 6, + 7, {"ac_current_divisor": 1000, "ac_current_multiplier": 1}, - {"active_power", "rms_voltage"}, + {"active_power", "apparent_power", "rms_voltage"}, ), ( homeautomation.ElectricalMeasurement.cluster_id, "electrical_measurement_rms_voltage", async_test_em_rms_voltage, - 6, + 7, {"ac_voltage_divisor": 10, "ac_voltage_multiplier": 1}, - {"active_power", "rms_current"}, + {"active_power", "apparent_power", "rms_current"}, ), ( general.PowerConfiguration.cluster_id, @@ -561,18 +588,22 @@ async def test_electrical_measurement_init( ( ( homeautomation.ElectricalMeasurement.cluster_id, - {"rms_voltage", "rms_current"}, + {"apparent_power", "rms_voltage", "rms_current"}, {"electrical_measurement"}, { + "electrical_measurement_apparent_power", "electrical_measurement_rms_voltage", "electrical_measurement_rms_current", }, ), ( homeautomation.ElectricalMeasurement.cluster_id, - {"rms_current"}, + {"apparent_power", "rms_current"}, {"electrical_measurement_rms_voltage", "electrical_measurement"}, - {"electrical_measurement_rms_current"}, + { + "electrical_measurement_apparent_power", + "electrical_measurement_rms_current", + }, ), ( homeautomation.ElectricalMeasurement.cluster_id, @@ -580,6 +611,7 @@ async def test_electrical_measurement_init( { "electrical_measurement_rms_voltage", "electrical_measurement", + "electrical_measurement_apparent_power", "electrical_measurement_rms_current", }, set(), @@ -853,6 +885,7 @@ async def test_elec_measurement_skip_unsupported_attribute( all_attrs = { "active_power", "active_power_max", + "apparent_power", "rms_current", "rms_current_max", "rms_voltage", diff --git a/tests/components/zha/test_siren.py b/tests/components/zha/test_siren.py new file mode 100644 index 00000000000..9a10f55f25a --- /dev/null +++ b/tests/components/zha/test_siren.py @@ -0,0 +1,139 @@ +"""Test zha siren.""" +from datetime import timedelta +from unittest.mock import patch + +import pytest +from zigpy.const import SIG_EP_PROFILE +import zigpy.profiles.zha as zha +import zigpy.zcl.clusters.general as general +import zigpy.zcl.clusters.security as security +import zigpy.zcl.foundation as zcl_f + +from homeassistant.components.siren import DOMAIN +from homeassistant.components.siren.const import ( + ATTR_DURATION, + ATTR_TONE, + ATTR_VOLUME_LEVEL, +) +from homeassistant.components.zha.core.const import ( + WARNING_DEVICE_MODE_EMERGENCY_PANIC, + WARNING_DEVICE_SOUND_MEDIUM, +) +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +import homeassistant.util.dt as dt_util + +from .common import async_enable_traffic, find_entity_id +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE + +from tests.common import async_fire_time_changed, mock_coro + + +@pytest.fixture +async def siren(hass, zigpy_device_mock, zha_device_joined_restored): + """Siren fixture.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id, security.IasWd.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.IAS_WARNING_DEVICE, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ) + + zha_device = await zha_device_joined_restored(zigpy_device) + return zha_device, zigpy_device.endpoints[1].ias_wd + + +async def test_siren(hass, siren): + """Test zha siren platform.""" + + zha_device, cluster = siren + assert cluster is not None + entity_id = await find_entity_id(DOMAIN, zha_device, hass) + assert entity_id is not None + + assert hass.states.get(entity_id).state == STATE_OFF + await async_enable_traffic(hass, [zha_device], enabled=False) + # test that the switch was created and that its state is unavailable + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + # test that the state has changed from unavailable to off + assert hass.states.get(entity_id).state == STATE_OFF + + # turn on from HA + with patch( + "zigpy.zcl.Cluster.request", + return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + ): + # turn on via UI + await hass.services.async_call( + DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True + ) + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0 + assert cluster.request.call_args[0][3] == 54 # bitmask for default args + assert cluster.request.call_args[0][4] == 5 # duration in seconds + assert cluster.request.call_args[0][5] == 0 + assert cluster.request.call_args[0][6] == 2 + + # test that the state has changed to on + assert hass.states.get(entity_id).state == STATE_ON + + # turn off from HA + with patch( + "zigpy.zcl.Cluster.request", + return_value=mock_coro([0x01, zcl_f.Status.SUCCESS]), + ): + # turn off via UI + await hass.services.async_call( + DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True + ) + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0 + assert cluster.request.call_args[0][3] == 2 # bitmask for default args + assert cluster.request.call_args[0][4] == 5 # duration in seconds + assert cluster.request.call_args[0][5] == 0 + assert cluster.request.call_args[0][6] == 2 + + # test that the state has changed to off + assert hass.states.get(entity_id).state == STATE_OFF + + # turn on from HA + with patch( + "zigpy.zcl.Cluster.request", + return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + ): + # turn on via UI + await hass.services.async_call( + DOMAIN, + "turn_on", + { + "entity_id": entity_id, + ATTR_DURATION: 10, + ATTR_TONE: WARNING_DEVICE_MODE_EMERGENCY_PANIC, + ATTR_VOLUME_LEVEL: WARNING_DEVICE_SOUND_MEDIUM, + }, + blocking=True, + ) + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0 + assert cluster.request.call_args[0][3] == 101 # bitmask for passed args + assert cluster.request.call_args[0][4] == 10 # duration in seconds + assert cluster.request.call_args[0][5] == 0 + assert cluster.request.call_args[0][6] == 2 + + # test that the state has changed to on + assert hass.states.get(entity_id).state == STATE_ON + + now = dt_util.utcnow() + timedelta(seconds=15) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index e85f4c270d5..06d3f10556c 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -117,6 +117,8 @@ DEVICES = [ }, DEV_SIG_ENTITIES: [ "sensor.centralite_3210_l_77665544_electrical_measurement", + "sensor.centralite_3210_l_77665544_electrical_measurement_apparent_power", + "sensor.centralite_3210_l_77665544_electrical_measurement_apparent_power", "sensor.centralite_3210_l_77665544_electrical_measurement_rms_current", "sensor.centralite_3210_l_77665544_electrical_measurement_rms_voltage", "sensor.centralite_3210_l_77665544_smartenergy_metering", @@ -144,6 +146,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_77665544_electrical_measurement", }, + ("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_77665544_electrical_measurement_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", @@ -586,13 +593,21 @@ DEVICES = [ SIG_EP_PROFILE: 260, } }, - DEV_SIG_ENTITIES: ["binary_sensor.heiman_warningdevice_77665544_ias_zone"], + DEV_SIG_ENTITIES: [ + "binary_sensor.heiman_warningdevice_77665544_ias_zone", + "siren.heiman_warningdevice_77665544_ias_wd", + ], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CHANNELS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_warningdevice_77665544_ias_zone", - } + }, + ("siren", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "ZHASiren", + DEV_SIG_ENT_MAP_ID: "siren.heiman_warningdevice_77665544_ias_wd", + }, }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], SIG_MANUFACTURER: "Heiman", @@ -1448,6 +1463,7 @@ DEVICES = [ "sensor.lumi_lumi_plug_maus01_77665544_analog_input", "sensor.lumi_lumi_plug_maus01_77665544_analog_input_2", "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement", + "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement_apparent_power", "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement_rms_current", "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement_rms_voltage", "switch.lumi_lumi_plug_maus01_77665544_on_off", @@ -1473,6 +1489,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement", }, + ("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_77665544_electrical_measurement_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", @@ -1517,6 +1538,7 @@ DEVICES = [ "light.lumi_lumi_relay_c2acn01_77665544_on_off", "light.lumi_lumi_relay_c2acn01_77665544_on_off_2", "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement", + "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement_apparent_power", "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement_rms_current", "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement_rms_voltage", ], @@ -1531,6 +1553,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement", }, + ("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_77665544_electrical_measurement_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", @@ -2602,6 +2629,7 @@ DEVICES = [ DEV_SIG_ENTITIES: [ "light.osram_lightify_rt_tunable_white_77665544_level_light_color_on_off", "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement", + "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement_apparent_power", "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement_rms_current", "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement_rms_voltage", ], @@ -2616,6 +2644,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement", }, + ("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_77665544_electrical_measurement_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", @@ -2646,6 +2679,7 @@ DEVICES = [ }, DEV_SIG_ENTITIES: [ "sensor.osram_plug_01_77665544_electrical_measurement", + "sensor.osram_plug_01_77665544_electrical_measurement_apparent_power", "sensor.osram_plug_01_77665544_electrical_measurement_rms_current", "sensor.osram_plug_01_77665544_electrical_measurement_rms_voltage", "switch.osram_plug_01_77665544_on_off", @@ -2661,6 +2695,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_77665544_electrical_measurement", }, + ("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_77665544_electrical_measurement_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", @@ -2930,6 +2969,7 @@ DEVICES = [ }, DEV_SIG_ENTITIES: [ "sensor.securifi_ltd_unk_model_77665544_electrical_measurement", + "sensor.securifi_ltd_unk_model_77665544_electrical_measurement_apparent_power", "sensor.securifi_ltd_unk_model_77665544_electrical_measurement_rms_current", "sensor.securifi_ltd_unk_model_77665544_electrical_measurement_rms_voltage", "switch.securifi_ltd_unk_model_77665544_on_off", @@ -2945,6 +2985,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_77665544_electrical_measurement", }, + ("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_77665544_electrical_measurement_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", @@ -3020,6 +3065,7 @@ DEVICES = [ DEV_SIG_ENTITIES: [ "light.sercomm_corp_sz_esw01_77665544_on_off", "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement", + "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement_apparent_power", "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement_rms_current", "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement_rms_voltage", "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering", @@ -3046,6 +3092,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement", }, + ("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_77665544_electrical_measurement_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", @@ -3119,6 +3170,7 @@ DEVICES = [ }, DEV_SIG_ENTITIES: [ "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement", + "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement_apparent_power", "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement_rms_current", "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement_rms_voltage", "switch.sinope_technologies_rm3250zb_77665544_on_off", @@ -3134,6 +3186,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement", }, + ("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_77665544_electrical_measurement_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", @@ -3171,6 +3228,7 @@ DEVICES = [ DEV_SIG_ENTITIES: [ "climate.sinope_technologies_th1123zb_77665544_thermostat", "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement", + "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement_apparent_power", "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement_rms_current", "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement_rms_voltage", "sensor.sinope_technologies_th1123zb_77665544_temperature", @@ -3192,6 +3250,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement", }, + ("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_77665544_electrical_measurement_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", @@ -3234,6 +3297,7 @@ DEVICES = [ }, DEV_SIG_ENTITIES: [ "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement", + "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement_apparent_power", "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement_rms_current", "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement_rms_voltage", "sensor.sinope_technologies_th1124zb_77665544_temperature", @@ -3261,6 +3325,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement", }, + ("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_77665544_electrical_measurement_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", @@ -3291,6 +3360,7 @@ DEVICES = [ }, DEV_SIG_ENTITIES: [ "sensor.smartthings_outletv4_77665544_electrical_measurement", + "sensor.smartthings_outletv4_77665544_electrical_measurement_apparent_power", "sensor.smartthings_outletv4_77665544_electrical_measurement_rms_current", "sensor.smartthings_outletv4_77665544_electrical_measurement_rms_voltage", "switch.smartthings_outletv4_77665544_on_off", @@ -3306,6 +3376,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_77665544_electrical_measurement", }, + ("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_77665544_electrical_measurement_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", @@ -3985,4 +4060,42 @@ DEVICES = [ SIG_MODEL: "XBee3", SIG_NODE_DESC: b"\x01@\x8e\x1e\x10R\xff\x00\x00,\xff\x00\x00", }, + { + DEV_SIG_DEV_NO: 99, + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 0x000C, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0x0000, 0x0001, 0x0402, 0x0408], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, + } + }, + DEV_SIG_ENTITIES: [ + "sensor.efektalab_ru_efekta_pws_77665544_power", + "sensor.efektalab_ru_efekta_pws_77665544_temperature", + "sensor.efektalab_ru_efekta_pws_77665544_soil_moisture", + ], + DEV_SIG_ENT_MAP: { + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_77665544_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_77665544_temperature", + }, + ("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_77665544_soil_moisture", + }, + }, + DEV_SIG_EVT_CHANNELS: [], + SIG_MANUFACTURER: "efektalab.ru", + SIG_MODEL: "EFEKTA_PWS", + SIG_NODE_DESC: b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", + }, ] diff --git a/tests/components/zone/test_trigger.py b/tests/components/zone/test_trigger.py index 978603ae242..ee62dd5df3a 100644 --- a/tests/components/zone/test_trigger.py +++ b/tests/components/zone/test_trigger.py @@ -4,6 +4,7 @@ import pytest from homeassistant.components import automation, zone from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF from homeassistant.core import Context +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import async_mock_service, mock_component @@ -108,6 +109,85 @@ async def test_if_fires_on_zone_enter(hass, calls): assert len(calls) == 1 +async def test_if_fires_on_zone_enter_uuid(hass, calls): + """Test for firing on zone enter when device is specified by entity registry id.""" + context = Context() + + registry = er.async_get(hass) + entry = registry.async_get_or_create( + "test", "hue", "1234", suggested_object_id="entity" + ) + assert entry.entity_id == "test.entity" + + hass.states.async_set( + "test.entity", "hello", {"latitude": 32.881011, "longitude": -117.234758} + ) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "zone", + "entity_id": entry.id, + "zone": "zone.test", + "event": "enter", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "zone.name", + "id", + ) + ) + }, + }, + } + }, + ) + + hass.states.async_set( + "test.entity", + "hello", + {"latitude": 32.880586, "longitude": -117.237564}, + context=context, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].context.parent_id == context.id + assert calls[0].data["some"] == "zone - test.entity - hello - hello - test - 0" + + # Set out of zone again so we can trigger call + hass.states.async_set( + "test.entity", "hello", {"latitude": 32.881011, "longitude": -117.234758} + ) + await hass.async_block_till_done() + + await hass.services.async_call( + automation.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_MATCH_ALL}, + blocking=True, + ) + + hass.states.async_set( + "test.entity", "hello", {"latitude": 32.880586, "longitude": -117.237564} + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + + async def test_if_not_fires_for_enter_on_zone_leave(hass, calls): """Test for not firing on zone leave.""" hass.states.async_set( diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index c16ab00b2eb..d73daacdc75 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -1,6 +1,9 @@ """Provide common test tools for Z-Wave JS.""" AIR_TEMPERATURE_SENSOR = "sensor.multisensor_6_air_temperature" BATTERY_SENSOR = "sensor.multisensor_6_battery_level" +TAMPER_SENSOR = ( + "binary_sensor.multisensor_6_home_security_tampering_product_cover_removed" +) HUMIDITY_SENSOR = "sensor.multisensor_6_humidity" POWER_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed" ENERGY_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_2" diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 422f4b55c16..e2db9fe7a6b 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -326,10 +326,16 @@ def window_cover_state_fixture(): return json.loads(load_fixture("zwave_js/chain_actuator_zws12_state.json")) -@pytest.fixture(name="in_wall_smart_fan_control_state", scope="session") -def in_wall_smart_fan_control_state_fixture(): +@pytest.fixture(name="fan_generic_state", scope="session") +def fan_generic_state_fixture(): """Load the fan node state fixture data.""" - return json.loads(load_fixture("zwave_js/in_wall_smart_fan_control_state.json")) + return json.loads(load_fixture("zwave_js/fan_generic_state.json")) + + +@pytest.fixture(name="hs_fc200_state", scope="session") +def hs_fc200_state_fixture(): + """Load the HS FC200+ node state fixture data.""" + return json.loads(load_fixture("zwave_js/fan_hs_fc200_state.json")) @pytest.fixture(name="gdc_zw062_state", scope="session") @@ -356,6 +362,12 @@ def aeotec_nano_shutter_state_fixture(): return json.loads(load_fixture("zwave_js/cover_aeotec_nano_shutter_state.json")) +@pytest.fixture(name="fibaro_fgr222_shutter_state", scope="session") +def fibaro_fgr222_shutter_state_fixture(): + """Load the Fibaro FGR222 node state fixture data.""" + return json.loads(load_fixture("zwave_js/cover_fibaro_fgr222_state.json")) + + @pytest.fixture(name="aeon_smart_switch_6_state", scope="session") def aeon_smart_switch_6_state_fixture(): """Load the AEON Labs (ZW096) Smart Switch 6 node state fixture data.""" @@ -683,10 +695,18 @@ def window_cover_fixture(client, chain_actuator_zws12_state): return node -@pytest.fixture(name="in_wall_smart_fan_control") -def in_wall_smart_fan_control_fixture(client, in_wall_smart_fan_control_state): +@pytest.fixture(name="fan_generic") +def fan_generic_fixture(client, fan_generic_state): """Mock a fan node.""" - node = Node(client, copy.deepcopy(in_wall_smart_fan_control_state)) + node = Node(client, copy.deepcopy(fan_generic_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + +@pytest.fixture(name="hs_fc200") +def hs_fc200_fixture(client, hs_fc200_state): + """Mock a fan node.""" + node = Node(client, copy.deepcopy(hs_fc200_state)) client.driver.controller.nodes[node.node_id] = node return node @@ -743,6 +763,14 @@ def aeotec_nano_shutter_cover_fixture(client, aeotec_nano_shutter_state): return node +@pytest.fixture(name="fibaro_fgr222_shutter") +def fibaro_fgr222_shutter_cover_fixture(client, fibaro_fgr222_shutter_state): + """Mock a Fibaro FGR222 Shutter node.""" + node = Node(client, copy.deepcopy(fibaro_fgr222_shutter_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="aeon_smart_switch_6") def aeon_smart_switch_6_fixture(client, aeon_smart_switch_6_state): """Mock an AEON Labs (ZW096) Smart Switch 6 node.""" diff --git a/tests/fixtures/zwave_js/aeon_smart_switch_6_state.json b/tests/components/zwave_js/fixtures/aeon_smart_switch_6_state.json similarity index 100% rename from tests/fixtures/zwave_js/aeon_smart_switch_6_state.json rename to tests/components/zwave_js/fixtures/aeon_smart_switch_6_state.json diff --git a/tests/fixtures/zwave_js/aeotec_radiator_thermostat_state.json b/tests/components/zwave_js/fixtures/aeotec_radiator_thermostat_state.json similarity index 100% rename from tests/fixtures/zwave_js/aeotec_radiator_thermostat_state.json rename to tests/components/zwave_js/fixtures/aeotec_radiator_thermostat_state.json diff --git a/tests/fixtures/zwave_js/aeotec_zw164_siren_state.json b/tests/components/zwave_js/fixtures/aeotec_zw164_siren_state.json similarity index 100% rename from tests/fixtures/zwave_js/aeotec_zw164_siren_state.json rename to tests/components/zwave_js/fixtures/aeotec_zw164_siren_state.json diff --git a/tests/fixtures/zwave_js/bulb_6_multi_color_state.json b/tests/components/zwave_js/fixtures/bulb_6_multi_color_state.json similarity index 100% rename from tests/fixtures/zwave_js/bulb_6_multi_color_state.json rename to tests/components/zwave_js/fixtures/bulb_6_multi_color_state.json diff --git a/tests/fixtures/zwave_js/chain_actuator_zws12_state.json b/tests/components/zwave_js/fixtures/chain_actuator_zws12_state.json similarity index 100% rename from tests/fixtures/zwave_js/chain_actuator_zws12_state.json rename to tests/components/zwave_js/fixtures/chain_actuator_zws12_state.json diff --git a/tests/fixtures/zwave_js/climate_danfoss_lc_13_state.json b/tests/components/zwave_js/fixtures/climate_danfoss_lc_13_state.json similarity index 100% rename from tests/fixtures/zwave_js/climate_danfoss_lc_13_state.json rename to tests/components/zwave_js/fixtures/climate_danfoss_lc_13_state.json diff --git a/tests/fixtures/zwave_js/climate_eurotronic_spirit_z_state.json b/tests/components/zwave_js/fixtures/climate_eurotronic_spirit_z_state.json similarity index 100% rename from tests/fixtures/zwave_js/climate_eurotronic_spirit_z_state.json rename to tests/components/zwave_js/fixtures/climate_eurotronic_spirit_z_state.json diff --git a/tests/fixtures/zwave_js/climate_heatit_z_trm2fx_state.json b/tests/components/zwave_js/fixtures/climate_heatit_z_trm2fx_state.json similarity index 100% rename from tests/fixtures/zwave_js/climate_heatit_z_trm2fx_state.json rename to tests/components/zwave_js/fixtures/climate_heatit_z_trm2fx_state.json diff --git a/tests/fixtures/zwave_js/climate_heatit_z_trm3_no_value_state.json b/tests/components/zwave_js/fixtures/climate_heatit_z_trm3_no_value_state.json similarity index 100% rename from tests/fixtures/zwave_js/climate_heatit_z_trm3_no_value_state.json rename to tests/components/zwave_js/fixtures/climate_heatit_z_trm3_no_value_state.json diff --git a/tests/fixtures/zwave_js/climate_heatit_z_trm3_state.json b/tests/components/zwave_js/fixtures/climate_heatit_z_trm3_state.json similarity index 100% rename from tests/fixtures/zwave_js/climate_heatit_z_trm3_state.json rename to tests/components/zwave_js/fixtures/climate_heatit_z_trm3_state.json diff --git a/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_mode_and_setpoint_on_different_endpoints_state.json b/tests/components/zwave_js/fixtures/climate_radio_thermostat_ct100_mode_and_setpoint_on_different_endpoints_state.json similarity index 100% rename from tests/fixtures/zwave_js/climate_radio_thermostat_ct100_mode_and_setpoint_on_different_endpoints_state.json rename to tests/components/zwave_js/fixtures/climate_radio_thermostat_ct100_mode_and_setpoint_on_different_endpoints_state.json diff --git a/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_different_endpoints_state.json b/tests/components/zwave_js/fixtures/climate_radio_thermostat_ct100_plus_different_endpoints_state.json similarity index 100% rename from tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_different_endpoints_state.json rename to tests/components/zwave_js/fixtures/climate_radio_thermostat_ct100_plus_different_endpoints_state.json diff --git a/tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_state.json b/tests/components/zwave_js/fixtures/climate_radio_thermostat_ct100_plus_state.json similarity index 100% rename from tests/fixtures/zwave_js/climate_radio_thermostat_ct100_plus_state.json rename to tests/components/zwave_js/fixtures/climate_radio_thermostat_ct100_plus_state.json diff --git a/tests/fixtures/zwave_js/climate_radio_thermostat_ct101_multiple_temp_units_state.json b/tests/components/zwave_js/fixtures/climate_radio_thermostat_ct101_multiple_temp_units_state.json similarity index 100% rename from tests/fixtures/zwave_js/climate_radio_thermostat_ct101_multiple_temp_units_state.json rename to tests/components/zwave_js/fixtures/climate_radio_thermostat_ct101_multiple_temp_units_state.json diff --git a/tests/fixtures/zwave_js/controller_state.json b/tests/components/zwave_js/fixtures/controller_state.json similarity index 100% rename from tests/fixtures/zwave_js/controller_state.json rename to tests/components/zwave_js/fixtures/controller_state.json diff --git a/tests/fixtures/zwave_js/cover_aeotec_nano_shutter_state.json b/tests/components/zwave_js/fixtures/cover_aeotec_nano_shutter_state.json similarity index 100% rename from tests/fixtures/zwave_js/cover_aeotec_nano_shutter_state.json rename to tests/components/zwave_js/fixtures/cover_aeotec_nano_shutter_state.json diff --git a/tests/components/zwave_js/fixtures/cover_fibaro_fgr222_state.json b/tests/components/zwave_js/fixtures/cover_fibaro_fgr222_state.json new file mode 100644 index 00000000000..59dff945846 --- /dev/null +++ b/tests/components/zwave_js/fixtures/cover_fibaro_fgr222_state.json @@ -0,0 +1,1133 @@ +{ + "nodeId": 42, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": "unknown", + "manufacturerId": 271, + "productId": 4096, + "productType": 770, + "firmwareVersion": "25.25", + "name": "fgr 222 test cover", + "location": "test location", + "deviceConfig": { + "filename": "/usr/src/app/store/.config-db/devices/0x010f/fgr222_24.24.json", + "isEmbedded": true, + "manufacturer": "Fibargroup", + "manufacturerId": 271, + "label": "FGR222", + "description": "Roller Shutter 2", + "devices": [ + { + "productType": 769, + "productId": 4097 + }, + { + "productType": 770, + "productId": 4096 + }, + { + "productType": 770, + "productId": 12288 + }, + { + "productType": 770, + "productId": 16384 + }, + { + "productType": 768, + "productId": 258 + } + ], + "firmwareVersion": { + "min": "24.24", + "max": "255.255" + }, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "proprietary": { + "fibaroCCs": [ + 38 + ] + } + }, + "label": "FGR222", + "interviewAttempts": 0, + "endpoints": [ + { + "nodeId": 42, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [ + 32, + 38, + 37, + 114, + 134 + ], + "mandatoryControlledCCs": [] + } + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value" + }, + "value": true + }, + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ] + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 99 + }, + "value": 99 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 3, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Transition duration" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99 + }, + "value": 96 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Open", + "propertyName": "Open", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Open)", + "ccSpecific": { + "switchType": 3 + } + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Close", + "propertyName": "Close", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Close)", + "ccSpecific": { + "switchType": 3 + } + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 1, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 0, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Dimming duration" + } + }, + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Power", + "propertyName": "Power", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Power", + "ccSpecific": { + "sensorType": 4, + "scale": 0 + }, + "unit": "W" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumed [kWh]", + "ccSpecific": { + "meterType": 1, + "rateType": 1, + "scale": 0 + }, + "unit": "kWh" + }, + "value": 0.48 + }, + { + "endpoint": 0, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumed [W]", + "ccSpecific": { + "meterType": 1, + "rateType": 1, + "scale": 2 + }, + "unit": "W" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values" + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Reports type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "value should be set to 1 if the module operates in Venetian Blind mode.", + "label": "Reports type", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Blind position using Z-Wave Command", + "1": "Blind position via Fibar Command" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Roller Shutter operating modes", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Roller Shutter operating modes", + "default": 1, + "min": 0, + "max": 4, + "states": { + "0": "Roller Blind Mode, without positioning", + "1": "Roller Blind Mode, with positioning", + "2": "Venetian Blind Mode, with positioning", + "3": "Gate Mode, without positioning", + "4": "Gate Mode, with positioning" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Turning time/ delay time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "depending on mode, turning time or delay time", + "label": "Turning time/ delay time", + "default": 150, + "min": 0, + "max": 65535, + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 83 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Lamellas positioning mode", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Parameter influences the lamellas positioning in venetian blind mode", + "label": "Lamellas positioning mode", + "default": 2, + "min": 0, + "max": 2, + "states": { + "0": "only in case of the main controller operation", + "1": "default - controller+switchlimit", + "2": "like 1 + STOP control frame" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyName": "Delay time after S2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "delay till auto turned off or auto gate close", + "label": "Delay time after S2", + "default": 10, + "min": 0, + "max": 255, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyName": "Motor operation detection", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Power threshold to be interpreted as reaching a limit switch.", + "label": "Motor operation detection", + "default": 10, + "min": 0, + "max": 255, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyName": "Motor operation time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Time period for the motor to continue operation.", + "label": "Motor operation time", + "default": 10, + "min": 0, + "max": 65535, + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 240 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 29, + "propertyName": "Forced Roller Shutter calibration", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "set to 1 will enter calibration mode", + "label": "Forced Roller Shutter calibration", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Deactivated", + "1": "Start calibration process" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyName": "Response to General Alarm", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Response to General Alarm", + "default": 2, + "min": 0, + "max": 2, + "states": { + "0": "No response to alarm frames", + "1": "Open Blind", + "2": "Close Blind" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyName": "Response to Water Flood Alarm", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Response to Water Flood Alarm", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No response to alarm frames", + "1": "Open Blind", + "2": "Close Blind" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyName": "Response to Smoke, CO, CO2 Alarm", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Response to Smoke, CO, CO2 Alarm", + "default": 1, + "min": 0, + "max": 2, + "states": { + "0": "No response to alarm frames", + "1": "Open Blind", + "2": "Close Blind" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyName": "Response to Temperature Alarm", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Response to Temperature Alarm", + "default": 1, + "min": 0, + "max": 2, + "states": { + "0": "No response to alarm frames", + "1": "Open Blind", + "2": "Close Blind" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 35, + "propertyName": "Managing lamellas in response to alarm", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "0 no change, 1 extreme position", + "label": "Managing lamellas in response to alarm", + "default": 1, + "min": 0, + "max": 255, + "states": { + "0": "Do not change lamellas position", + "1": "Set lamellas to their extreme position" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyName": "Power reports", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "change that needs to occur to trigger the power report", + "label": "Power reports", + "default": 10, + "min": 0, + "max": 100, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 42, + "propertyName": "Periodic power or energy reports", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Time to the next report. Value of 0 means the reports are turned off.", + "label": "Periodic power or energy reports", + "default": 3600, + "min": 0, + "max": 65534, + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3600 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 43, + "propertyName": "Energy reports", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Energy threshold to trigger report", + "label": "Energy reports", + "default": 10, + "min": 0, + "max": 254, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 44, + "propertyName": "Self-measurement", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "if power and energy reports are to sent to the main controller", + "label": "Self-measurement", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disabled", + "1": "Activated" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 50, + "propertyName": "Scenes/ Associations activation", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "whether scenes or associations are activated by the switch keys", + "label": "Scenes/ Associations activation", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Associations Active", + "1": "Scenes Active" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyName": "Switch type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "either Toggle switches or a single, momentary switch.", + "label": "Switch type", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Momentary switches", + "1": "Toggle switches", + "2": "Single, momentary switch." + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535 + }, + "value": 271 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535 + }, + "value": 770 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535 + }, + "value": 4096 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Local protection state", + "states": { + "0": "Unprotected", + "2": "NoOperationPossible" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "rf", + "propertyName": "rf", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection state", + "states": { + "0": "Unprotected", + "1": "NoControl" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "exclusiveControlNodeId", + "propertyName": "exclusiveControlNodeId", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": true + } + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": true + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + } + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 1, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "3.52" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": [ + "25.25" + ] + }, + { + "endpoint": 0, + "commandClass": 145, + "commandClassName": "Manufacturer Proprietary", + "property": "fibaro", + "propertyKey": "venetianBlindsPosition", + "propertyName": "fibaro", + "propertyKeyName": "venetianBlindsPosition", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Venetian blinds position", + "min": 0, + "max": 99 + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 145, + "commandClassName": "Manufacturer Proprietary", + "property": "fibaro", + "propertyKey": "venetianBlindsTilt", + "propertyName": "fibaro", + "propertyKeyName": "venetianBlindsTilt", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Venetian blinds tilt", + "min": 0, + "max": 99 + }, + "value": 0 + } + ], + "isFrequentListening": false, + "maxDataRate": 40000, + "supportedDataRates": [ + 40000 + ], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [ + 32, + 38, + 37, + 114, + 134 + ], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 3, + "isSecure": false + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 2, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 117, + "name": "Protection", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 2, + "isSecure": false + }, + { + "id": 145, + "name": "Manufacturer Proprietary", + "version": 1, + "isSecure": false + } + ], + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x010f:0x0302:0x1000:25.25", + "statistics": { + "commandsTX": 24, + "commandsRX": 350, + "commandsDroppedRX": 1, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + } +} \ No newline at end of file diff --git a/tests/fixtures/zwave_js/cover_iblinds_v2_state.json b/tests/components/zwave_js/fixtures/cover_iblinds_v2_state.json similarity index 100% rename from tests/fixtures/zwave_js/cover_iblinds_v2_state.json rename to tests/components/zwave_js/fixtures/cover_iblinds_v2_state.json diff --git a/tests/fixtures/zwave_js/cover_qubino_shutter_state.json b/tests/components/zwave_js/fixtures/cover_qubino_shutter_state.json similarity index 100% rename from tests/fixtures/zwave_js/cover_qubino_shutter_state.json rename to tests/components/zwave_js/fixtures/cover_qubino_shutter_state.json diff --git a/tests/fixtures/zwave_js/cover_zw062_state.json b/tests/components/zwave_js/fixtures/cover_zw062_state.json similarity index 100% rename from tests/fixtures/zwave_js/cover_zw062_state.json rename to tests/components/zwave_js/fixtures/cover_zw062_state.json diff --git a/tests/fixtures/zwave_js/eaton_rf9640_dimmer_state.json b/tests/components/zwave_js/fixtures/eaton_rf9640_dimmer_state.json similarity index 100% rename from tests/fixtures/zwave_js/eaton_rf9640_dimmer_state.json rename to tests/components/zwave_js/fixtures/eaton_rf9640_dimmer_state.json diff --git a/tests/fixtures/zwave_js/ecolink_door_sensor_state.json b/tests/components/zwave_js/fixtures/ecolink_door_sensor_state.json similarity index 100% rename from tests/fixtures/zwave_js/ecolink_door_sensor_state.json rename to tests/components/zwave_js/fixtures/ecolink_door_sensor_state.json diff --git a/tests/fixtures/zwave_js/fan_ge_12730_state.json b/tests/components/zwave_js/fixtures/fan_ge_12730_state.json similarity index 100% rename from tests/fixtures/zwave_js/fan_ge_12730_state.json rename to tests/components/zwave_js/fixtures/fan_ge_12730_state.json diff --git a/tests/fixtures/zwave_js/in_wall_smart_fan_control_state.json b/tests/components/zwave_js/fixtures/fan_generic_state.json similarity index 96% rename from tests/fixtures/zwave_js/in_wall_smart_fan_control_state.json rename to tests/components/zwave_js/fixtures/fan_generic_state.json index 74467664955..1f4f55dd220 100644 --- a/tests/fixtures/zwave_js/in_wall_smart_fan_control_state.json +++ b/tests/components/zwave_js/fixtures/fan_generic_state.json @@ -19,22 +19,22 @@ "isSecure": false, "version": 4, "isBeaming": true, - "manufacturerId": 99, - "productId": 12593, - "productType": 18756, + "manufacturerId": 4919, + "productId": 4919, + "productType": 4919, "firmwareVersion": "5.22", "zwavePlusVersion": 1, "nodeType": 0, "roleType": 5, "deviceConfig": { - "manufacturerId": 99, - "manufacturer": "GE/Jasco", + "manufacturerId": 4919, + "manufacturer": "Unknown", "label": "ZW4002", - "description": "In-Wall Smart Fan Control", + "description": "Generic Fan Controller", "devices": [ { - "productType": "0x4944", - "productId": "0x3131" + "productType": "0x1337", + "productId": "0x1337" } ], "firmwareVersion": { @@ -349,4 +349,4 @@ } } ] - } \ No newline at end of file + } diff --git a/tests/components/zwave_js/fixtures/fan_hs_fc200_state.json b/tests/components/zwave_js/fixtures/fan_hs_fc200_state.json new file mode 100644 index 00000000000..f83a1193c22 --- /dev/null +++ b/tests/components/zwave_js/fixtures/fan_hs_fc200_state.json @@ -0,0 +1,10506 @@ +{ + "nodeId": 39, + "index": 0, + "installerIcon": 1024, + "userIcon": 1024, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": "unknown", + "manufacturerId": 12, + "productId": 1, + "productType": 515, + "firmwareVersion": "50.5", + "zwavePlusVersion": 1, + "deviceConfig": { + "filename": "/cache/db/devices/0x000c/hs-fc200.json", + "isEmbedded": true, + "manufacturer": "HomeSeer Technologies", + "manufacturerId": 12, + "label": "HS-FC200+", + "description": "Scene Capable Fan Control Switch", + "devices": [ + { + "productType": 515, + "productId": 1 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "Inclusion: Add the device into your network by a Z-Wave certified controller. HS-FC200+ supports the latest S2 security offered by Z-Wave certified controllers. If your controller supports S2, please refer to the user guide of the controller for detailed instructions on adding devices to the network. You should be able to add HS-FC200+ into your network using the unique QR code or the DSK 5 digit pin located on the product or packaging. In addition, the device can be added or removed using the following 2-step procedure:\n\n1. Put your Z-Wave controller into Inclusion mode. Consult your controller manual if you're unsure how to do this.\n2. Tap the paddle of your new HomeSeer switch to begin the inclusion process. This will take a few moments to complete", + "exclusion": "Exclusion: Remove the device from your network by a Z-Wave certified controller. HS-FC200+ supports the latest S2 security offered by Z-Wave certified controllers. If your controller supports S2, please refer to the user guide of the controller for detailed instructions on removing devices from the network. You should be able to remove HS-FC200+ into your network using the unique QR code or the DSK 5 digit pin located on the product or packaging. In addition, the device can be added or removed using the following 2-step procedure:\n\n1. Put your Z-Wave controller into Exclusion mode. Consult your controller manual if you're unsure how to do this.\n2. Tap the paddle of your new HomeSeer switch to begin the Exclusion process. This will take a few moments to complete", + "reset": "To be used only in the event that the network primary controller is lost or otherwise inoperable. \n\n(1) Turn switch on by tapping the top of the paddle once. \n(2) Quickly ap top of the paddle 3 times. \n(3) Quickly tap bottom of paddle 3 times. \n(4) If the LED turns off then on again, switch is reset. If not, repeat manual rest.", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=MarketCertificationFiles/2957/HS%20FC200%20Manual%20Market%20Cert%20v1.pdf" + } + }, + "label": "HS-FC200+", + "interviewAttempts": 0, + "endpoints": [ + { + "nodeId": 39, + "index": 0, + "installerIcon": 1024, + "userIcon": 1024, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 8, + "label": "Fan Switch" + }, + "mandatorySupportedCCs": [ + 32, + 38, + 133, + 89, + 114, + 115, + 134, + 94 + ], + "mandatoryControlledCCs": [] + } + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 99 + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Transition duration" + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99 + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "On", + "propertyName": "On", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (On)", + "ccSpecific": { + "switchType": 1 + } + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Off", + "propertyName": "Off", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Off)", + "ccSpecific": { + "switchType": 1 + } + } + }, + { + "endpoint": 0, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 1, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 0, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Dimming duration" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 1, + "propertyName": "level", + "propertyKeyName": "1", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (1)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 1, + "propertyName": "dimmingDuration", + "propertyKeyName": "1", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (1)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 2, + "propertyName": "level", + "propertyKeyName": "2", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (2)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 2, + "propertyName": "dimmingDuration", + "propertyKeyName": "2", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (2)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 3, + "propertyName": "level", + "propertyKeyName": "3", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (3)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 3, + "propertyName": "dimmingDuration", + "propertyKeyName": "3", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (3)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 4, + "propertyName": "level", + "propertyKeyName": "4", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (4)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 4, + "propertyName": "dimmingDuration", + "propertyKeyName": "4", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (4)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 5, + "propertyName": "level", + "propertyKeyName": "5", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (5)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 5, + "propertyName": "dimmingDuration", + "propertyKeyName": "5", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (5)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 6, + "propertyName": "level", + "propertyKeyName": "6", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (6)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 6, + "propertyName": "dimmingDuration", + "propertyKeyName": "6", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (6)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 7, + "propertyName": "level", + "propertyKeyName": "7", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (7)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 7, + "propertyName": "dimmingDuration", + "propertyKeyName": "7", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (7)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 8, + "propertyName": "level", + "propertyKeyName": "8", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (8)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 8, + "propertyName": "dimmingDuration", + "propertyKeyName": "8", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (8)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 9, + "propertyName": "level", + "propertyKeyName": "9", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (9)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 9, + "propertyName": "dimmingDuration", + "propertyKeyName": "9", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (9)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 10, + "propertyName": "level", + "propertyKeyName": "10", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (10)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 10, + "propertyName": "dimmingDuration", + "propertyKeyName": "10", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (10)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 11, + "propertyName": "level", + "propertyKeyName": "11", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (11)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 11, + "propertyName": "dimmingDuration", + "propertyKeyName": "11", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (11)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 12, + "propertyName": "level", + "propertyKeyName": "12", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (12)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 12, + "propertyName": "dimmingDuration", + "propertyKeyName": "12", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (12)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 13, + "propertyName": "level", + "propertyKeyName": "13", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (13)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 13, + "propertyName": "dimmingDuration", + "propertyKeyName": "13", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (13)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 14, + "propertyName": "level", + "propertyKeyName": "14", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (14)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 14, + "propertyName": "dimmingDuration", + "propertyKeyName": "14", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (14)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 15, + "propertyName": "level", + "propertyKeyName": "15", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (15)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 15, + "propertyName": "dimmingDuration", + "propertyKeyName": "15", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (15)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 16, + "propertyName": "level", + "propertyKeyName": "16", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (16)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 16, + "propertyName": "dimmingDuration", + "propertyKeyName": "16", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (16)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 17, + "propertyName": "level", + "propertyKeyName": "17", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (17)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 17, + "propertyName": "dimmingDuration", + "propertyKeyName": "17", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (17)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 18, + "propertyName": "level", + "propertyKeyName": "18", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (18)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 18, + "propertyName": "dimmingDuration", + "propertyKeyName": "18", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (18)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 19, + "propertyName": "level", + "propertyKeyName": "19", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (19)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 19, + "propertyName": "dimmingDuration", + "propertyKeyName": "19", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (19)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 20, + "propertyName": "level", + "propertyKeyName": "20", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (20)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 20, + "propertyName": "dimmingDuration", + "propertyKeyName": "20", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (20)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 21, + "propertyName": "level", + "propertyKeyName": "21", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (21)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 21, + "propertyName": "dimmingDuration", + "propertyKeyName": "21", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (21)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 22, + "propertyName": "level", + "propertyKeyName": "22", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (22)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 22, + "propertyName": "dimmingDuration", + "propertyKeyName": "22", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (22)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 23, + "propertyName": "level", + "propertyKeyName": "23", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (23)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 23, + "propertyName": "dimmingDuration", + "propertyKeyName": "23", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (23)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 24, + "propertyName": "level", + "propertyKeyName": "24", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (24)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 24, + "propertyName": "dimmingDuration", + "propertyKeyName": "24", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (24)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 25, + "propertyName": "level", + "propertyKeyName": "25", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (25)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 25, + "propertyName": "dimmingDuration", + "propertyKeyName": "25", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (25)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 26, + "propertyName": "level", + "propertyKeyName": "26", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (26)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 26, + "propertyName": "dimmingDuration", + "propertyKeyName": "26", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (26)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 27, + "propertyName": "level", + "propertyKeyName": "27", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (27)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 27, + "propertyName": "dimmingDuration", + "propertyKeyName": "27", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (27)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 28, + "propertyName": "level", + "propertyKeyName": "28", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (28)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 28, + "propertyName": "dimmingDuration", + "propertyKeyName": "28", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (28)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 29, + "propertyName": "level", + "propertyKeyName": "29", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (29)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 29, + "propertyName": "dimmingDuration", + "propertyKeyName": "29", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (29)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 30, + "propertyName": "level", + "propertyKeyName": "30", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (30)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 30, + "propertyName": "dimmingDuration", + "propertyKeyName": "30", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (30)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 31, + "propertyName": "level", + "propertyKeyName": "31", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (31)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 31, + "propertyName": "dimmingDuration", + "propertyKeyName": "31", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (31)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 32, + "propertyName": "level", + "propertyKeyName": "32", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (32)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 32, + "propertyName": "dimmingDuration", + "propertyKeyName": "32", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (32)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 33, + "propertyName": "level", + "propertyKeyName": "33", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (33)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 33, + "propertyName": "dimmingDuration", + "propertyKeyName": "33", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (33)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 34, + "propertyName": "level", + "propertyKeyName": "34", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (34)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 34, + "propertyName": "dimmingDuration", + "propertyKeyName": "34", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (34)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 35, + "propertyName": "level", + "propertyKeyName": "35", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (35)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 35, + "propertyName": "dimmingDuration", + "propertyKeyName": "35", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (35)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 36, + "propertyName": "level", + "propertyKeyName": "36", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (36)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 36, + "propertyName": "dimmingDuration", + "propertyKeyName": "36", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (36)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 37, + "propertyName": "level", + "propertyKeyName": "37", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (37)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 37, + "propertyName": "dimmingDuration", + "propertyKeyName": "37", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (37)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 38, + "propertyName": "level", + "propertyKeyName": "38", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (38)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 38, + "propertyName": "dimmingDuration", + "propertyKeyName": "38", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (38)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 39, + "propertyName": "level", + "propertyKeyName": "39", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (39)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 39, + "propertyName": "dimmingDuration", + "propertyKeyName": "39", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (39)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 40, + "propertyName": "level", + "propertyKeyName": "40", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (40)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 40, + "propertyName": "dimmingDuration", + "propertyKeyName": "40", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (40)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 41, + "propertyName": "level", + "propertyKeyName": "41", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (41)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 41, + "propertyName": "dimmingDuration", + "propertyKeyName": "41", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (41)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 42, + "propertyName": "level", + "propertyKeyName": "42", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (42)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 42, + "propertyName": "dimmingDuration", + "propertyKeyName": "42", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (42)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 43, + "propertyName": "level", + "propertyKeyName": "43", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (43)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 43, + "propertyName": "dimmingDuration", + "propertyKeyName": "43", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (43)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 44, + "propertyName": "level", + "propertyKeyName": "44", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (44)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 44, + "propertyName": "dimmingDuration", + "propertyKeyName": "44", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (44)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 45, + "propertyName": "level", + "propertyKeyName": "45", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (45)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 45, + "propertyName": "dimmingDuration", + "propertyKeyName": "45", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (45)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 46, + "propertyName": "level", + "propertyKeyName": "46", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (46)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 46, + "propertyName": "dimmingDuration", + "propertyKeyName": "46", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (46)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 47, + "propertyName": "level", + "propertyKeyName": "47", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (47)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 47, + "propertyName": "dimmingDuration", + "propertyKeyName": "47", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (47)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 48, + "propertyName": "level", + "propertyKeyName": "48", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (48)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 48, + "propertyName": "dimmingDuration", + "propertyKeyName": "48", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (48)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 49, + "propertyName": "level", + "propertyKeyName": "49", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (49)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 49, + "propertyName": "dimmingDuration", + "propertyKeyName": "49", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (49)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 50, + "propertyName": "level", + "propertyKeyName": "50", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (50)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 50, + "propertyName": "dimmingDuration", + "propertyKeyName": "50", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (50)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 51, + "propertyName": "level", + "propertyKeyName": "51", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (51)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 51, + "propertyName": "dimmingDuration", + "propertyKeyName": "51", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (51)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 52, + "propertyName": "level", + "propertyKeyName": "52", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (52)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 52, + "propertyName": "dimmingDuration", + "propertyKeyName": "52", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (52)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 53, + "propertyName": "level", + "propertyKeyName": "53", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (53)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 53, + "propertyName": "dimmingDuration", + "propertyKeyName": "53", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (53)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 54, + "propertyName": "level", + "propertyKeyName": "54", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (54)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 54, + "propertyName": "dimmingDuration", + "propertyKeyName": "54", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (54)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 55, + "propertyName": "level", + "propertyKeyName": "55", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (55)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 55, + "propertyName": "dimmingDuration", + "propertyKeyName": "55", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (55)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 56, + "propertyName": "level", + "propertyKeyName": "56", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (56)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 56, + "propertyName": "dimmingDuration", + "propertyKeyName": "56", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (56)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 57, + "propertyName": "level", + "propertyKeyName": "57", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (57)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 57, + "propertyName": "dimmingDuration", + "propertyKeyName": "57", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (57)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 58, + "propertyName": "level", + "propertyKeyName": "58", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (58)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 58, + "propertyName": "dimmingDuration", + "propertyKeyName": "58", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (58)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 59, + "propertyName": "level", + "propertyKeyName": "59", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (59)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 59, + "propertyName": "dimmingDuration", + "propertyKeyName": "59", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (59)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 60, + "propertyName": "level", + "propertyKeyName": "60", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (60)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 60, + "propertyName": "dimmingDuration", + "propertyKeyName": "60", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (60)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 61, + "propertyName": "level", + "propertyKeyName": "61", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (61)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 61, + "propertyName": "dimmingDuration", + "propertyKeyName": "61", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (61)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 62, + "propertyName": "level", + "propertyKeyName": "62", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (62)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 62, + "propertyName": "dimmingDuration", + "propertyKeyName": "62", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (62)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 63, + "propertyName": "level", + "propertyKeyName": "63", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (63)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 63, + "propertyName": "dimmingDuration", + "propertyKeyName": "63", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (63)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 64, + "propertyName": "level", + "propertyKeyName": "64", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (64)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 64, + "propertyName": "dimmingDuration", + "propertyKeyName": "64", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (64)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 65, + "propertyName": "level", + "propertyKeyName": "65", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (65)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 65, + "propertyName": "dimmingDuration", + "propertyKeyName": "65", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (65)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 66, + "propertyName": "level", + "propertyKeyName": "66", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (66)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 66, + "propertyName": "dimmingDuration", + "propertyKeyName": "66", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (66)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 67, + "propertyName": "level", + "propertyKeyName": "67", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (67)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 67, + "propertyName": "dimmingDuration", + "propertyKeyName": "67", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (67)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 68, + "propertyName": "level", + "propertyKeyName": "68", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (68)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 68, + "propertyName": "dimmingDuration", + "propertyKeyName": "68", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (68)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 69, + "propertyName": "level", + "propertyKeyName": "69", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (69)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 69, + "propertyName": "dimmingDuration", + "propertyKeyName": "69", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (69)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 70, + "propertyName": "level", + "propertyKeyName": "70", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (70)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 70, + "propertyName": "dimmingDuration", + "propertyKeyName": "70", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (70)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 71, + "propertyName": "level", + "propertyKeyName": "71", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (71)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 71, + "propertyName": "dimmingDuration", + "propertyKeyName": "71", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (71)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 72, + "propertyName": "level", + "propertyKeyName": "72", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (72)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 72, + "propertyName": "dimmingDuration", + "propertyKeyName": "72", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (72)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 73, + "propertyName": "level", + "propertyKeyName": "73", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (73)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 73, + "propertyName": "dimmingDuration", + "propertyKeyName": "73", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (73)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 74, + "propertyName": "level", + "propertyKeyName": "74", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (74)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 74, + "propertyName": "dimmingDuration", + "propertyKeyName": "74", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (74)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 75, + "propertyName": "level", + "propertyKeyName": "75", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (75)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 75, + "propertyName": "dimmingDuration", + "propertyKeyName": "75", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (75)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 76, + "propertyName": "level", + "propertyKeyName": "76", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (76)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 76, + "propertyName": "dimmingDuration", + "propertyKeyName": "76", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (76)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 77, + "propertyName": "level", + "propertyKeyName": "77", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (77)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 77, + "propertyName": "dimmingDuration", + "propertyKeyName": "77", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (77)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 78, + "propertyName": "level", + "propertyKeyName": "78", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (78)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 78, + "propertyName": "dimmingDuration", + "propertyKeyName": "78", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (78)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 79, + "propertyName": "level", + "propertyKeyName": "79", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (79)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 79, + "propertyName": "dimmingDuration", + "propertyKeyName": "79", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (79)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 80, + "propertyName": "level", + "propertyKeyName": "80", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (80)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 80, + "propertyName": "dimmingDuration", + "propertyKeyName": "80", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (80)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 81, + "propertyName": "level", + "propertyKeyName": "81", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (81)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 81, + "propertyName": "dimmingDuration", + "propertyKeyName": "81", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (81)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 82, + "propertyName": "level", + "propertyKeyName": "82", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (82)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 82, + "propertyName": "dimmingDuration", + "propertyKeyName": "82", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (82)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 83, + "propertyName": "level", + "propertyKeyName": "83", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (83)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 83, + "propertyName": "dimmingDuration", + "propertyKeyName": "83", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (83)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 84, + "propertyName": "level", + "propertyKeyName": "84", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (84)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 84, + "propertyName": "dimmingDuration", + "propertyKeyName": "84", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (84)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 85, + "propertyName": "level", + "propertyKeyName": "85", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (85)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 85, + "propertyName": "dimmingDuration", + "propertyKeyName": "85", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (85)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 86, + "propertyName": "level", + "propertyKeyName": "86", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (86)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 86, + "propertyName": "dimmingDuration", + "propertyKeyName": "86", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (86)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 87, + "propertyName": "level", + "propertyKeyName": "87", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (87)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 87, + "propertyName": "dimmingDuration", + "propertyKeyName": "87", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (87)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 88, + "propertyName": "level", + "propertyKeyName": "88", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (88)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 88, + "propertyName": "dimmingDuration", + "propertyKeyName": "88", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (88)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 89, + "propertyName": "level", + "propertyKeyName": "89", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (89)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 89, + "propertyName": "dimmingDuration", + "propertyKeyName": "89", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (89)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 90, + "propertyName": "level", + "propertyKeyName": "90", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (90)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 90, + "propertyName": "dimmingDuration", + "propertyKeyName": "90", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (90)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 91, + "propertyName": "level", + "propertyKeyName": "91", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (91)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 91, + "propertyName": "dimmingDuration", + "propertyKeyName": "91", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (91)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 92, + "propertyName": "level", + "propertyKeyName": "92", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (92)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 92, + "propertyName": "dimmingDuration", + "propertyKeyName": "92", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (92)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 93, + "propertyName": "level", + "propertyKeyName": "93", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (93)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 93, + "propertyName": "dimmingDuration", + "propertyKeyName": "93", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (93)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 94, + "propertyName": "level", + "propertyKeyName": "94", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (94)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 94, + "propertyName": "dimmingDuration", + "propertyKeyName": "94", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (94)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 95, + "propertyName": "level", + "propertyKeyName": "95", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (95)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 95, + "propertyName": "dimmingDuration", + "propertyKeyName": "95", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (95)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 96, + "propertyName": "level", + "propertyKeyName": "96", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (96)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 96, + "propertyName": "dimmingDuration", + "propertyKeyName": "96", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (96)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 97, + "propertyName": "level", + "propertyKeyName": "97", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (97)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 97, + "propertyName": "dimmingDuration", + "propertyKeyName": "97", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (97)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 98, + "propertyName": "level", + "propertyKeyName": "98", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (98)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 98, + "propertyName": "dimmingDuration", + "propertyKeyName": "98", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (98)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 99, + "propertyName": "level", + "propertyKeyName": "99", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (99)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 99, + "propertyName": "dimmingDuration", + "propertyKeyName": "99", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (99)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 100, + "propertyName": "level", + "propertyKeyName": "100", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (100)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 100, + "propertyName": "dimmingDuration", + "propertyKeyName": "100", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (100)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 101, + "propertyName": "level", + "propertyKeyName": "101", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (101)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 101, + "propertyName": "dimmingDuration", + "propertyKeyName": "101", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (101)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 102, + "propertyName": "level", + "propertyKeyName": "102", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (102)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 102, + "propertyName": "dimmingDuration", + "propertyKeyName": "102", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (102)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 103, + "propertyName": "level", + "propertyKeyName": "103", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (103)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 103, + "propertyName": "dimmingDuration", + "propertyKeyName": "103", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (103)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 104, + "propertyName": "level", + "propertyKeyName": "104", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (104)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 104, + "propertyName": "dimmingDuration", + "propertyKeyName": "104", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (104)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 105, + "propertyName": "level", + "propertyKeyName": "105", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (105)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 105, + "propertyName": "dimmingDuration", + "propertyKeyName": "105", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (105)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 106, + "propertyName": "level", + "propertyKeyName": "106", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (106)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 106, + "propertyName": "dimmingDuration", + "propertyKeyName": "106", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (106)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 107, + "propertyName": "level", + "propertyKeyName": "107", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (107)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 107, + "propertyName": "dimmingDuration", + "propertyKeyName": "107", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (107)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 108, + "propertyName": "level", + "propertyKeyName": "108", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (108)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 108, + "propertyName": "dimmingDuration", + "propertyKeyName": "108", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (108)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 109, + "propertyName": "level", + "propertyKeyName": "109", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (109)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 109, + "propertyName": "dimmingDuration", + "propertyKeyName": "109", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (109)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 110, + "propertyName": "level", + "propertyKeyName": "110", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (110)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 110, + "propertyName": "dimmingDuration", + "propertyKeyName": "110", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (110)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 111, + "propertyName": "level", + "propertyKeyName": "111", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (111)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 111, + "propertyName": "dimmingDuration", + "propertyKeyName": "111", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (111)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 112, + "propertyName": "level", + "propertyKeyName": "112", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (112)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 112, + "propertyName": "dimmingDuration", + "propertyKeyName": "112", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (112)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 113, + "propertyName": "level", + "propertyKeyName": "113", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (113)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 113, + "propertyName": "dimmingDuration", + "propertyKeyName": "113", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (113)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 114, + "propertyName": "level", + "propertyKeyName": "114", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (114)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 114, + "propertyName": "dimmingDuration", + "propertyKeyName": "114", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (114)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 115, + "propertyName": "level", + "propertyKeyName": "115", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (115)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 115, + "propertyName": "dimmingDuration", + "propertyKeyName": "115", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (115)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 116, + "propertyName": "level", + "propertyKeyName": "116", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (116)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 116, + "propertyName": "dimmingDuration", + "propertyKeyName": "116", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (116)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 117, + "propertyName": "level", + "propertyKeyName": "117", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (117)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 117, + "propertyName": "dimmingDuration", + "propertyKeyName": "117", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (117)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 118, + "propertyName": "level", + "propertyKeyName": "118", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (118)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 118, + "propertyName": "dimmingDuration", + "propertyKeyName": "118", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (118)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 119, + "propertyName": "level", + "propertyKeyName": "119", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (119)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 119, + "propertyName": "dimmingDuration", + "propertyKeyName": "119", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (119)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 120, + "propertyName": "level", + "propertyKeyName": "120", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (120)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 120, + "propertyName": "dimmingDuration", + "propertyKeyName": "120", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (120)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 121, + "propertyName": "level", + "propertyKeyName": "121", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (121)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 121, + "propertyName": "dimmingDuration", + "propertyKeyName": "121", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (121)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 122, + "propertyName": "level", + "propertyKeyName": "122", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (122)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 122, + "propertyName": "dimmingDuration", + "propertyKeyName": "122", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (122)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 123, + "propertyName": "level", + "propertyKeyName": "123", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (123)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 123, + "propertyName": "dimmingDuration", + "propertyKeyName": "123", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (123)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 124, + "propertyName": "level", + "propertyKeyName": "124", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (124)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 124, + "propertyName": "dimmingDuration", + "propertyKeyName": "124", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (124)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 125, + "propertyName": "level", + "propertyKeyName": "125", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (125)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 125, + "propertyName": "dimmingDuration", + "propertyKeyName": "125", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (125)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 126, + "propertyName": "level", + "propertyKeyName": "126", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (126)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 126, + "propertyName": "dimmingDuration", + "propertyKeyName": "126", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (126)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 127, + "propertyName": "level", + "propertyKeyName": "127", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (127)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 127, + "propertyName": "dimmingDuration", + "propertyKeyName": "127", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (127)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 128, + "propertyName": "level", + "propertyKeyName": "128", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (128)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 128, + "propertyName": "dimmingDuration", + "propertyKeyName": "128", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (128)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 129, + "propertyName": "level", + "propertyKeyName": "129", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (129)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 129, + "propertyName": "dimmingDuration", + "propertyKeyName": "129", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (129)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 130, + "propertyName": "level", + "propertyKeyName": "130", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (130)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 130, + "propertyName": "dimmingDuration", + "propertyKeyName": "130", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (130)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 131, + "propertyName": "level", + "propertyKeyName": "131", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (131)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 131, + "propertyName": "dimmingDuration", + "propertyKeyName": "131", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (131)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 132, + "propertyName": "level", + "propertyKeyName": "132", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (132)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 132, + "propertyName": "dimmingDuration", + "propertyKeyName": "132", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (132)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 133, + "propertyName": "level", + "propertyKeyName": "133", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (133)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 133, + "propertyName": "dimmingDuration", + "propertyKeyName": "133", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (133)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 134, + "propertyName": "level", + "propertyKeyName": "134", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (134)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 134, + "propertyName": "dimmingDuration", + "propertyKeyName": "134", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (134)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 135, + "propertyName": "level", + "propertyKeyName": "135", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (135)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 135, + "propertyName": "dimmingDuration", + "propertyKeyName": "135", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (135)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 136, + "propertyName": "level", + "propertyKeyName": "136", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (136)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 136, + "propertyName": "dimmingDuration", + "propertyKeyName": "136", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (136)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 137, + "propertyName": "level", + "propertyKeyName": "137", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (137)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 137, + "propertyName": "dimmingDuration", + "propertyKeyName": "137", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (137)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 138, + "propertyName": "level", + "propertyKeyName": "138", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (138)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 138, + "propertyName": "dimmingDuration", + "propertyKeyName": "138", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (138)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 139, + "propertyName": "level", + "propertyKeyName": "139", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (139)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 139, + "propertyName": "dimmingDuration", + "propertyKeyName": "139", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (139)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 140, + "propertyName": "level", + "propertyKeyName": "140", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (140)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 140, + "propertyName": "dimmingDuration", + "propertyKeyName": "140", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (140)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 141, + "propertyName": "level", + "propertyKeyName": "141", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (141)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 141, + "propertyName": "dimmingDuration", + "propertyKeyName": "141", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (141)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 142, + "propertyName": "level", + "propertyKeyName": "142", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (142)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 142, + "propertyName": "dimmingDuration", + "propertyKeyName": "142", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (142)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 143, + "propertyName": "level", + "propertyKeyName": "143", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (143)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 143, + "propertyName": "dimmingDuration", + "propertyKeyName": "143", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (143)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 144, + "propertyName": "level", + "propertyKeyName": "144", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (144)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 144, + "propertyName": "dimmingDuration", + "propertyKeyName": "144", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (144)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 145, + "propertyName": "level", + "propertyKeyName": "145", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (145)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 145, + "propertyName": "dimmingDuration", + "propertyKeyName": "145", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (145)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 146, + "propertyName": "level", + "propertyKeyName": "146", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (146)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 146, + "propertyName": "dimmingDuration", + "propertyKeyName": "146", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (146)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 147, + "propertyName": "level", + "propertyKeyName": "147", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (147)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 147, + "propertyName": "dimmingDuration", + "propertyKeyName": "147", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (147)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 148, + "propertyName": "level", + "propertyKeyName": "148", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (148)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 148, + "propertyName": "dimmingDuration", + "propertyKeyName": "148", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (148)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 149, + "propertyName": "level", + "propertyKeyName": "149", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (149)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 149, + "propertyName": "dimmingDuration", + "propertyKeyName": "149", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (149)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 150, + "propertyName": "level", + "propertyKeyName": "150", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (150)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 150, + "propertyName": "dimmingDuration", + "propertyKeyName": "150", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (150)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 151, + "propertyName": "level", + "propertyKeyName": "151", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (151)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 151, + "propertyName": "dimmingDuration", + "propertyKeyName": "151", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (151)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 152, + "propertyName": "level", + "propertyKeyName": "152", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (152)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 152, + "propertyName": "dimmingDuration", + "propertyKeyName": "152", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (152)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 153, + "propertyName": "level", + "propertyKeyName": "153", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (153)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 153, + "propertyName": "dimmingDuration", + "propertyKeyName": "153", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (153)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 154, + "propertyName": "level", + "propertyKeyName": "154", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (154)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 154, + "propertyName": "dimmingDuration", + "propertyKeyName": "154", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (154)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 155, + "propertyName": "level", + "propertyKeyName": "155", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (155)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 155, + "propertyName": "dimmingDuration", + "propertyKeyName": "155", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (155)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 156, + "propertyName": "level", + "propertyKeyName": "156", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (156)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 156, + "propertyName": "dimmingDuration", + "propertyKeyName": "156", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (156)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 157, + "propertyName": "level", + "propertyKeyName": "157", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (157)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 157, + "propertyName": "dimmingDuration", + "propertyKeyName": "157", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (157)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 158, + "propertyName": "level", + "propertyKeyName": "158", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (158)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 158, + "propertyName": "dimmingDuration", + "propertyKeyName": "158", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (158)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 159, + "propertyName": "level", + "propertyKeyName": "159", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (159)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 159, + "propertyName": "dimmingDuration", + "propertyKeyName": "159", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (159)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 160, + "propertyName": "level", + "propertyKeyName": "160", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (160)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 160, + "propertyName": "dimmingDuration", + "propertyKeyName": "160", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (160)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 161, + "propertyName": "level", + "propertyKeyName": "161", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (161)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 161, + "propertyName": "dimmingDuration", + "propertyKeyName": "161", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (161)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 162, + "propertyName": "level", + "propertyKeyName": "162", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (162)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 162, + "propertyName": "dimmingDuration", + "propertyKeyName": "162", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (162)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 163, + "propertyName": "level", + "propertyKeyName": "163", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (163)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 163, + "propertyName": "dimmingDuration", + "propertyKeyName": "163", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (163)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 164, + "propertyName": "level", + "propertyKeyName": "164", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (164)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 164, + "propertyName": "dimmingDuration", + "propertyKeyName": "164", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (164)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 165, + "propertyName": "level", + "propertyKeyName": "165", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (165)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 165, + "propertyName": "dimmingDuration", + "propertyKeyName": "165", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (165)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 166, + "propertyName": "level", + "propertyKeyName": "166", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (166)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 166, + "propertyName": "dimmingDuration", + "propertyKeyName": "166", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (166)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 167, + "propertyName": "level", + "propertyKeyName": "167", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (167)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 167, + "propertyName": "dimmingDuration", + "propertyKeyName": "167", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (167)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 168, + "propertyName": "level", + "propertyKeyName": "168", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (168)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 168, + "propertyName": "dimmingDuration", + "propertyKeyName": "168", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (168)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 169, + "propertyName": "level", + "propertyKeyName": "169", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (169)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 169, + "propertyName": "dimmingDuration", + "propertyKeyName": "169", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (169)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 170, + "propertyName": "level", + "propertyKeyName": "170", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (170)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 170, + "propertyName": "dimmingDuration", + "propertyKeyName": "170", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (170)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 171, + "propertyName": "level", + "propertyKeyName": "171", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (171)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 171, + "propertyName": "dimmingDuration", + "propertyKeyName": "171", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (171)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 172, + "propertyName": "level", + "propertyKeyName": "172", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (172)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 172, + "propertyName": "dimmingDuration", + "propertyKeyName": "172", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (172)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 173, + "propertyName": "level", + "propertyKeyName": "173", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (173)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 173, + "propertyName": "dimmingDuration", + "propertyKeyName": "173", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (173)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 174, + "propertyName": "level", + "propertyKeyName": "174", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (174)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 174, + "propertyName": "dimmingDuration", + "propertyKeyName": "174", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (174)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 175, + "propertyName": "level", + "propertyKeyName": "175", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (175)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 175, + "propertyName": "dimmingDuration", + "propertyKeyName": "175", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (175)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 176, + "propertyName": "level", + "propertyKeyName": "176", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (176)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 176, + "propertyName": "dimmingDuration", + "propertyKeyName": "176", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (176)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 177, + "propertyName": "level", + "propertyKeyName": "177", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (177)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 177, + "propertyName": "dimmingDuration", + "propertyKeyName": "177", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (177)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 178, + "propertyName": "level", + "propertyKeyName": "178", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (178)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 178, + "propertyName": "dimmingDuration", + "propertyKeyName": "178", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (178)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 179, + "propertyName": "level", + "propertyKeyName": "179", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (179)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 179, + "propertyName": "dimmingDuration", + "propertyKeyName": "179", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (179)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 180, + "propertyName": "level", + "propertyKeyName": "180", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (180)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 180, + "propertyName": "dimmingDuration", + "propertyKeyName": "180", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (180)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 181, + "propertyName": "level", + "propertyKeyName": "181", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (181)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 181, + "propertyName": "dimmingDuration", + "propertyKeyName": "181", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (181)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 182, + "propertyName": "level", + "propertyKeyName": "182", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (182)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 182, + "propertyName": "dimmingDuration", + "propertyKeyName": "182", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (182)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 183, + "propertyName": "level", + "propertyKeyName": "183", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (183)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 183, + "propertyName": "dimmingDuration", + "propertyKeyName": "183", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (183)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 184, + "propertyName": "level", + "propertyKeyName": "184", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (184)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 184, + "propertyName": "dimmingDuration", + "propertyKeyName": "184", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (184)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 185, + "propertyName": "level", + "propertyKeyName": "185", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (185)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 185, + "propertyName": "dimmingDuration", + "propertyKeyName": "185", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (185)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 186, + "propertyName": "level", + "propertyKeyName": "186", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (186)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 186, + "propertyName": "dimmingDuration", + "propertyKeyName": "186", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (186)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 187, + "propertyName": "level", + "propertyKeyName": "187", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (187)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 187, + "propertyName": "dimmingDuration", + "propertyKeyName": "187", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (187)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 188, + "propertyName": "level", + "propertyKeyName": "188", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (188)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 188, + "propertyName": "dimmingDuration", + "propertyKeyName": "188", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (188)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 189, + "propertyName": "level", + "propertyKeyName": "189", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (189)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 189, + "propertyName": "dimmingDuration", + "propertyKeyName": "189", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (189)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 190, + "propertyName": "level", + "propertyKeyName": "190", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (190)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 190, + "propertyName": "dimmingDuration", + "propertyKeyName": "190", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (190)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 191, + "propertyName": "level", + "propertyKeyName": "191", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (191)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 191, + "propertyName": "dimmingDuration", + "propertyKeyName": "191", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (191)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 192, + "propertyName": "level", + "propertyKeyName": "192", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (192)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 192, + "propertyName": "dimmingDuration", + "propertyKeyName": "192", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (192)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 193, + "propertyName": "level", + "propertyKeyName": "193", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (193)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 193, + "propertyName": "dimmingDuration", + "propertyKeyName": "193", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (193)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 194, + "propertyName": "level", + "propertyKeyName": "194", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (194)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 194, + "propertyName": "dimmingDuration", + "propertyKeyName": "194", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (194)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 195, + "propertyName": "level", + "propertyKeyName": "195", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (195)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 195, + "propertyName": "dimmingDuration", + "propertyKeyName": "195", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (195)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 196, + "propertyName": "level", + "propertyKeyName": "196", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (196)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 196, + "propertyName": "dimmingDuration", + "propertyKeyName": "196", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (196)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 197, + "propertyName": "level", + "propertyKeyName": "197", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (197)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 197, + "propertyName": "dimmingDuration", + "propertyKeyName": "197", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (197)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 198, + "propertyName": "level", + "propertyKeyName": "198", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (198)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 198, + "propertyName": "dimmingDuration", + "propertyKeyName": "198", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (198)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 199, + "propertyName": "level", + "propertyKeyName": "199", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (199)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 199, + "propertyName": "dimmingDuration", + "propertyKeyName": "199", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (199)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 200, + "propertyName": "level", + "propertyKeyName": "200", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (200)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 200, + "propertyName": "dimmingDuration", + "propertyKeyName": "200", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (200)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 201, + "propertyName": "level", + "propertyKeyName": "201", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (201)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 201, + "propertyName": "dimmingDuration", + "propertyKeyName": "201", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (201)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 202, + "propertyName": "level", + "propertyKeyName": "202", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (202)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 202, + "propertyName": "dimmingDuration", + "propertyKeyName": "202", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (202)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 203, + "propertyName": "level", + "propertyKeyName": "203", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (203)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 203, + "propertyName": "dimmingDuration", + "propertyKeyName": "203", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (203)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 204, + "propertyName": "level", + "propertyKeyName": "204", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (204)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 204, + "propertyName": "dimmingDuration", + "propertyKeyName": "204", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (204)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 205, + "propertyName": "level", + "propertyKeyName": "205", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (205)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 205, + "propertyName": "dimmingDuration", + "propertyKeyName": "205", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (205)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 206, + "propertyName": "level", + "propertyKeyName": "206", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (206)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 206, + "propertyName": "dimmingDuration", + "propertyKeyName": "206", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (206)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 207, + "propertyName": "level", + "propertyKeyName": "207", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (207)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 207, + "propertyName": "dimmingDuration", + "propertyKeyName": "207", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (207)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 208, + "propertyName": "level", + "propertyKeyName": "208", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (208)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 208, + "propertyName": "dimmingDuration", + "propertyKeyName": "208", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (208)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 209, + "propertyName": "level", + "propertyKeyName": "209", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (209)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 209, + "propertyName": "dimmingDuration", + "propertyKeyName": "209", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (209)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 210, + "propertyName": "level", + "propertyKeyName": "210", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (210)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 210, + "propertyName": "dimmingDuration", + "propertyKeyName": "210", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (210)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 211, + "propertyName": "level", + "propertyKeyName": "211", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (211)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 211, + "propertyName": "dimmingDuration", + "propertyKeyName": "211", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (211)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 212, + "propertyName": "level", + "propertyKeyName": "212", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (212)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 212, + "propertyName": "dimmingDuration", + "propertyKeyName": "212", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (212)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 213, + "propertyName": "level", + "propertyKeyName": "213", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (213)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 213, + "propertyName": "dimmingDuration", + "propertyKeyName": "213", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (213)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 214, + "propertyName": "level", + "propertyKeyName": "214", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (214)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 214, + "propertyName": "dimmingDuration", + "propertyKeyName": "214", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (214)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 215, + "propertyName": "level", + "propertyKeyName": "215", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (215)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 215, + "propertyName": "dimmingDuration", + "propertyKeyName": "215", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (215)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 216, + "propertyName": "level", + "propertyKeyName": "216", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (216)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 216, + "propertyName": "dimmingDuration", + "propertyKeyName": "216", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (216)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 217, + "propertyName": "level", + "propertyKeyName": "217", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (217)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 217, + "propertyName": "dimmingDuration", + "propertyKeyName": "217", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (217)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 218, + "propertyName": "level", + "propertyKeyName": "218", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (218)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 218, + "propertyName": "dimmingDuration", + "propertyKeyName": "218", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (218)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 219, + "propertyName": "level", + "propertyKeyName": "219", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (219)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 219, + "propertyName": "dimmingDuration", + "propertyKeyName": "219", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (219)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 220, + "propertyName": "level", + "propertyKeyName": "220", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (220)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 220, + "propertyName": "dimmingDuration", + "propertyKeyName": "220", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (220)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 221, + "propertyName": "level", + "propertyKeyName": "221", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (221)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 221, + "propertyName": "dimmingDuration", + "propertyKeyName": "221", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (221)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 222, + "propertyName": "level", + "propertyKeyName": "222", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (222)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 222, + "propertyName": "dimmingDuration", + "propertyKeyName": "222", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (222)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 223, + "propertyName": "level", + "propertyKeyName": "223", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (223)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 223, + "propertyName": "dimmingDuration", + "propertyKeyName": "223", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (223)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 224, + "propertyName": "level", + "propertyKeyName": "224", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (224)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 224, + "propertyName": "dimmingDuration", + "propertyKeyName": "224", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (224)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 225, + "propertyName": "level", + "propertyKeyName": "225", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (225)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 225, + "propertyName": "dimmingDuration", + "propertyKeyName": "225", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (225)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 226, + "propertyName": "level", + "propertyKeyName": "226", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (226)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 226, + "propertyName": "dimmingDuration", + "propertyKeyName": "226", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (226)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 227, + "propertyName": "level", + "propertyKeyName": "227", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (227)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 227, + "propertyName": "dimmingDuration", + "propertyKeyName": "227", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (227)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 228, + "propertyName": "level", + "propertyKeyName": "228", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (228)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 228, + "propertyName": "dimmingDuration", + "propertyKeyName": "228", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (228)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 229, + "propertyName": "level", + "propertyKeyName": "229", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (229)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 229, + "propertyName": "dimmingDuration", + "propertyKeyName": "229", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (229)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 230, + "propertyName": "level", + "propertyKeyName": "230", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (230)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 230, + "propertyName": "dimmingDuration", + "propertyKeyName": "230", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (230)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 231, + "propertyName": "level", + "propertyKeyName": "231", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (231)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 231, + "propertyName": "dimmingDuration", + "propertyKeyName": "231", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (231)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 232, + "propertyName": "level", + "propertyKeyName": "232", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (232)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 232, + "propertyName": "dimmingDuration", + "propertyKeyName": "232", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (232)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 233, + "propertyName": "level", + "propertyKeyName": "233", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (233)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 233, + "propertyName": "dimmingDuration", + "propertyKeyName": "233", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (233)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 234, + "propertyName": "level", + "propertyKeyName": "234", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (234)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 234, + "propertyName": "dimmingDuration", + "propertyKeyName": "234", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (234)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 235, + "propertyName": "level", + "propertyKeyName": "235", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (235)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 235, + "propertyName": "dimmingDuration", + "propertyKeyName": "235", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (235)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 236, + "propertyName": "level", + "propertyKeyName": "236", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (236)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 236, + "propertyName": "dimmingDuration", + "propertyKeyName": "236", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (236)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 237, + "propertyName": "level", + "propertyKeyName": "237", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (237)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 237, + "propertyName": "dimmingDuration", + "propertyKeyName": "237", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (237)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 238, + "propertyName": "level", + "propertyKeyName": "238", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (238)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 238, + "propertyName": "dimmingDuration", + "propertyKeyName": "238", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (238)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 239, + "propertyName": "level", + "propertyKeyName": "239", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (239)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 239, + "propertyName": "dimmingDuration", + "propertyKeyName": "239", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (239)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 240, + "propertyName": "level", + "propertyKeyName": "240", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (240)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 240, + "propertyName": "dimmingDuration", + "propertyKeyName": "240", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (240)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 241, + "propertyName": "level", + "propertyKeyName": "241", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (241)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 241, + "propertyName": "dimmingDuration", + "propertyKeyName": "241", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (241)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 242, + "propertyName": "level", + "propertyKeyName": "242", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (242)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 242, + "propertyName": "dimmingDuration", + "propertyKeyName": "242", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (242)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 243, + "propertyName": "level", + "propertyKeyName": "243", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (243)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 243, + "propertyName": "dimmingDuration", + "propertyKeyName": "243", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (243)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 244, + "propertyName": "level", + "propertyKeyName": "244", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (244)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 244, + "propertyName": "dimmingDuration", + "propertyKeyName": "244", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (244)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 245, + "propertyName": "level", + "propertyKeyName": "245", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (245)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 245, + "propertyName": "dimmingDuration", + "propertyKeyName": "245", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (245)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 246, + "propertyName": "level", + "propertyKeyName": "246", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (246)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 246, + "propertyName": "dimmingDuration", + "propertyKeyName": "246", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (246)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 247, + "propertyName": "level", + "propertyKeyName": "247", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (247)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 247, + "propertyName": "dimmingDuration", + "propertyKeyName": "247", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (247)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 248, + "propertyName": "level", + "propertyKeyName": "248", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (248)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 248, + "propertyName": "dimmingDuration", + "propertyKeyName": "248", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (248)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 249, + "propertyName": "level", + "propertyKeyName": "249", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (249)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 249, + "propertyName": "dimmingDuration", + "propertyKeyName": "249", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (249)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 250, + "propertyName": "level", + "propertyKeyName": "250", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (250)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 250, + "propertyName": "dimmingDuration", + "propertyKeyName": "250", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (250)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 251, + "propertyName": "level", + "propertyKeyName": "251", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (251)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 251, + "propertyName": "dimmingDuration", + "propertyKeyName": "251", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (251)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 252, + "propertyName": "level", + "propertyKeyName": "252", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (252)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 252, + "propertyName": "dimmingDuration", + "propertyKeyName": "252", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (252)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 253, + "propertyName": "level", + "propertyKeyName": "253", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (253)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 253, + "propertyName": "dimmingDuration", + "propertyKeyName": "253", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (253)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 254, + "propertyName": "level", + "propertyKeyName": "254", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (254)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 254, + "propertyName": "dimmingDuration", + "propertyKeyName": "254", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (254)" + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 255, + "propertyName": "level", + "propertyKeyName": "255", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (255)", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 255, + "propertyName": "dimmingDuration", + "propertyKeyName": "255", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (255)" + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate" + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "001", + "propertyName": "scene", + "propertyKeyName": "001", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 001", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x", + "5": "KeyPressed4x", + "6": "KeyPressed5x" + } + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x", + "5": "KeyPressed4x", + "6": "KeyPressed5x" + } + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "LED Indicator", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "On when load is off", + "1": "On when load is on" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Inverted Orientation", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Inverted Orientation", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Fan Type", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Fan Type", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "3-speed", + "1": "4-speed" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Enable / Disable Custom LED Status Mode", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Enable / Disable Custom LED Status Mode", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyName": "Default LED Color", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default LED Color", + "default": 0, + "min": 0, + "max": 6, + "states": { + "0": "White", + "1": "Red", + "2": "Green", + "3": "Blue", + "4": "Magenta", + "5": "Yellow", + "6": "Cyan" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 21, + "propertyName": "Status Mode LED 1 Color", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Status Mode LED 1 Color", + "default": 0, + "min": 0, + "max": 7, + "states": { + "0": "Off", + "1": "Red", + "2": "Green", + "3": "Blue", + "4": "Magenta", + "5": "Yellow", + "6": "Cyan", + "7": "White" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyName": "Status Mode LED 2 Color", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Status Mode LED 2 Color", + "default": 0, + "min": 0, + "max": 7, + "states": { + "0": "Off", + "1": "Red", + "2": "Green", + "3": "Blue", + "4": "Magenta", + "5": "Yellow", + "6": "Cyan", + "7": "White" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyName": "Status Mode LED 3 Color", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Status Mode LED 3 Color", + "default": 0, + "min": 0, + "max": 7, + "states": { + "0": "Off", + "1": "Red", + "2": "Green", + "3": "Blue", + "4": "Magenta", + "5": "Yellow", + "6": "Cyan", + "7": "White" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 24, + "propertyName": "Status Mode LED 4 Color", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Status Mode LED 4 Color", + "default": 0, + "min": 0, + "max": 7, + "states": { + "0": "Off", + "1": "Red", + "2": "Green", + "3": "Blue", + "4": "Magenta", + "5": "Yellow", + "6": "Cyan", + "7": "White" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyName": "Blink Frequency", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the blink frequency for LEDs; 0 for off", + "label": "Blink Frequency", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 1, + "propertyName": "Enable / Disable Blinking - LED 1", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Enable / Disable Blinking - LED 1", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 2, + "propertyName": "Enable / Disable Blinking - LED 2", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Enable / Disable Blinking - LED 2", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 4, + "propertyName": "Enable / Disable Blinking - LED 3", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Enable / Disable Blinking - LED 3", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 8, + "propertyName": "Enable / Disable Blinking - LED 4", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Enable / Disable Blinking - LED 4", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535 + }, + "value": 12 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535 + }, + "value": 515 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535 + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + } + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "6.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": [ + "50.5" + ] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version" + }, + "value": "6.81.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version" + }, + "value": "4.1.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number" + }, + "value": 52445 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version" + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "6.1.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number" + }, + "value": 58 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version" + }, + "value": "50.5.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number" + }, + "value": 52445 + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [ + 40000, + 100000 + ], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 8, + "label": "Fan Switch" + }, + "mandatorySupportedCCs": [ + 32, + 38, + 133, + 89, + 114, + 115, + 134, + 94 + ], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 44, + "name": "Scene Actuator Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 3, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 4, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": false + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + } + ], + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x000c:0x0203:0x0001:50.5", + "statistics": { + "commandsTX": 400, + "commandsRX": 402, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 2 + } +} diff --git a/tests/fixtures/zwave_js/fortrezz_ssa1_siren_state.json b/tests/components/zwave_js/fixtures/fortrezz_ssa1_siren_state.json similarity index 100% rename from tests/fixtures/zwave_js/fortrezz_ssa1_siren_state.json rename to tests/components/zwave_js/fixtures/fortrezz_ssa1_siren_state.json diff --git a/tests/fixtures/zwave_js/ge_in_wall_dimmer_switch_state.json b/tests/components/zwave_js/fixtures/ge_in_wall_dimmer_switch_state.json similarity index 100% rename from tests/fixtures/zwave_js/ge_in_wall_dimmer_switch_state.json rename to tests/components/zwave_js/fixtures/ge_in_wall_dimmer_switch_state.json diff --git a/tests/fixtures/zwave_js/hank_binary_switch_state.json b/tests/components/zwave_js/fixtures/hank_binary_switch_state.json similarity index 100% rename from tests/fixtures/zwave_js/hank_binary_switch_state.json rename to tests/components/zwave_js/fixtures/hank_binary_switch_state.json diff --git a/tests/fixtures/zwave_js/inovelli_lzw36_state.json b/tests/components/zwave_js/fixtures/inovelli_lzw36_state.json similarity index 100% rename from tests/fixtures/zwave_js/inovelli_lzw36_state.json rename to tests/components/zwave_js/fixtures/inovelli_lzw36_state.json diff --git a/tests/fixtures/zwave_js/light_color_null_values_state.json b/tests/components/zwave_js/fixtures/light_color_null_values_state.json similarity index 100% rename from tests/fixtures/zwave_js/light_color_null_values_state.json rename to tests/components/zwave_js/fixtures/light_color_null_values_state.json diff --git a/tests/fixtures/zwave_js/lock_august_asl03_state.json b/tests/components/zwave_js/fixtures/lock_august_asl03_state.json similarity index 100% rename from tests/fixtures/zwave_js/lock_august_asl03_state.json rename to tests/components/zwave_js/fixtures/lock_august_asl03_state.json diff --git a/tests/fixtures/zwave_js/lock_id_lock_as_id150_state.json b/tests/components/zwave_js/fixtures/lock_id_lock_as_id150_state.json similarity index 100% rename from tests/fixtures/zwave_js/lock_id_lock_as_id150_state.json rename to tests/components/zwave_js/fixtures/lock_id_lock_as_id150_state.json diff --git a/tests/fixtures/zwave_js/lock_popp_electric_strike_lock_control_state.json b/tests/components/zwave_js/fixtures/lock_popp_electric_strike_lock_control_state.json similarity index 100% rename from tests/fixtures/zwave_js/lock_popp_electric_strike_lock_control_state.json rename to tests/components/zwave_js/fixtures/lock_popp_electric_strike_lock_control_state.json diff --git a/tests/fixtures/zwave_js/lock_schlage_be469_state.json b/tests/components/zwave_js/fixtures/lock_schlage_be469_state.json similarity index 100% rename from tests/fixtures/zwave_js/lock_schlage_be469_state.json rename to tests/components/zwave_js/fixtures/lock_schlage_be469_state.json diff --git a/tests/fixtures/zwave_js/multisensor_6_state.json b/tests/components/zwave_js/fixtures/multisensor_6_state.json similarity index 100% rename from tests/fixtures/zwave_js/multisensor_6_state.json rename to tests/components/zwave_js/fixtures/multisensor_6_state.json diff --git a/tests/fixtures/zwave_js/nortek_thermostat_added_event.json b/tests/components/zwave_js/fixtures/nortek_thermostat_added_event.json similarity index 100% rename from tests/fixtures/zwave_js/nortek_thermostat_added_event.json rename to tests/components/zwave_js/fixtures/nortek_thermostat_added_event.json diff --git a/tests/fixtures/zwave_js/nortek_thermostat_removed_event.json b/tests/components/zwave_js/fixtures/nortek_thermostat_removed_event.json similarity index 100% rename from tests/fixtures/zwave_js/nortek_thermostat_removed_event.json rename to tests/components/zwave_js/fixtures/nortek_thermostat_removed_event.json diff --git a/tests/fixtures/zwave_js/nortek_thermostat_state.json b/tests/components/zwave_js/fixtures/nortek_thermostat_state.json similarity index 100% rename from tests/fixtures/zwave_js/nortek_thermostat_state.json rename to tests/components/zwave_js/fixtures/nortek_thermostat_state.json diff --git a/tests/fixtures/zwave_js/null_name_check_state.json b/tests/components/zwave_js/fixtures/null_name_check_state.json similarity index 100% rename from tests/fixtures/zwave_js/null_name_check_state.json rename to tests/components/zwave_js/fixtures/null_name_check_state.json diff --git a/tests/fixtures/zwave_js/srt321_hrt4_zw_state.json b/tests/components/zwave_js/fixtures/srt321_hrt4_zw_state.json similarity index 100% rename from tests/fixtures/zwave_js/srt321_hrt4_zw_state.json rename to tests/components/zwave_js/fixtures/srt321_hrt4_zw_state.json diff --git a/tests/fixtures/zwave_js/vision_security_zl7432_state.json b/tests/components/zwave_js/fixtures/vision_security_zl7432_state.json similarity index 100% rename from tests/fixtures/zwave_js/vision_security_zl7432_state.json rename to tests/components/zwave_js/fixtures/vision_security_zl7432_state.json diff --git a/tests/fixtures/zwave_js/wallmote_central_scene_state.json b/tests/components/zwave_js/fixtures/wallmote_central_scene_state.json similarity index 100% rename from tests/fixtures/zwave_js/wallmote_central_scene_state.json rename to tests/components/zwave_js/fixtures/wallmote_central_scene_state.json diff --git a/tests/fixtures/zwave_js/zen_31_state.json b/tests/components/zwave_js/fixtures/zen_31_state.json similarity index 100% rename from tests/fixtures/zwave_js/zen_31_state.json rename to tests/components/zwave_js/fixtures/zen_31_state.json diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index e6bfbb45393..4ca733786dc 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -9,7 +9,10 @@ from zwave_js_server.const import ( CommandClass, InclusionStrategy, LogLevel, + Protocols, + QRCodeVersion, SecurityClass, + ZwaveFeature, ) from zwave_js_server.event import Event from zwave_js_server.exceptions import ( @@ -19,31 +22,49 @@ from zwave_js_server.exceptions import ( NotFoundError, SetValueFailed, ) +from zwave_js_server.model.controller import ( + ProvisioningEntry, + QRProvisioningInformation, +) from zwave_js_server.model.node import Node from zwave_js_server.model.value import _get_value_id_from_dict, get_value_id from homeassistant.components.websocket_api.const import ERR_NOT_FOUND from homeassistant.components.zwave_js.api import ( + APPLICATION_VERSION, CLIENT_SIDE_AUTH, COMMAND_CLASS_ID, CONFIG, + DSK, ENABLED, ENTRY_ID, ERR_NOT_LOADED, + FEATURE, FILENAME, FORCE_CONSOLE, + GENERIC_DEVICE_CLASS, ID, INCLUSION_STRATEGY, + INSTALLER_ICON_TYPE, LEVEL, LOG_TO_FILE, + MANUFACTURER_ID, NODE_ID, OPTED_IN, PIN, + PLANNED_PROVISIONING_ENTRY, + PRODUCT_ID, + PRODUCT_TYPE, PROPERTY, PROPERTY_KEY, + QR_CODE_STRING, + QR_PROVISIONING_INFORMATION, SECURITY_CLASSES, + SPECIFIC_DEVICE_CLASS, TYPE, + UNPROVISION, VALUE, + VERSION, ) from homeassistant.components.zwave_js.const import ( CONF_DATA_COLLECTION_OPTED_IN, @@ -421,9 +442,10 @@ async def test_add_node( client.async_send_command.return_value = {"success": True} + # Test inclusion with no provisioning input await ws_client.send_json( { - ID: 3, + ID: 1, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, INCLUSION_STRATEGY: InclusionStrategy.DEFAULT.value, @@ -542,6 +564,193 @@ async def test_add_node( msg = await ws_client.receive_json() assert msg["event"]["event"] == "interview failed" + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"success": True} + + # Test S2 planned provisioning entry + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/add_node", + ENTRY_ID: entry.entry_id, + INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, + PLANNED_PROVISIONING_ENTRY: { + DSK: "test", + SECURITY_CLASSES: [0], + }, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.begin_inclusion", + "options": { + "strategy": InclusionStrategy.SECURITY_S2, + "provisioning": ProvisioningEntry( + "test", [SecurityClass.S2_UNAUTHENTICATED] + ).to_dict(), + }, + } + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"success": True} + + # Test S2 QR provisioning information + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/add_node", + ENTRY_ID: entry.entry_id, + INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, + QR_PROVISIONING_INFORMATION: { + VERSION: 0, + SECURITY_CLASSES: [0], + DSK: "test", + GENERIC_DEVICE_CLASS: 1, + SPECIFIC_DEVICE_CLASS: 1, + INSTALLER_ICON_TYPE: 1, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 1, + PRODUCT_ID: 1, + APPLICATION_VERSION: "test", + }, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.begin_inclusion", + "options": { + "strategy": InclusionStrategy.SECURITY_S2, + "provisioning": QRProvisioningInformation( + QRCodeVersion.S2, + [SecurityClass.S2_UNAUTHENTICATED], + "test", + 1, + 1, + 1, + 1, + 1, + 1, + "test", + None, + None, + None, + ).to_dict(), + }, + } + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"success": True} + + # Test S2 QR code string + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/add_node", + ENTRY_ID: entry.entry_id, + INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, + QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.begin_inclusion", + "options": { + "strategy": InclusionStrategy.SECURITY_S2, + "provisioning": "90testtesttesttesttesttesttesttesttesttesttesttesttest", + }, + } + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"success": True} + + # Test Smart Start QR provisioning information with S2 inclusion strategy fails + await ws_client.send_json( + { + ID: 5, + TYPE: "zwave_js/add_node", + ENTRY_ID: entry.entry_id, + INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, + QR_PROVISIONING_INFORMATION: { + VERSION: 1, + SECURITY_CLASSES: [0], + DSK: "test", + GENERIC_DEVICE_CLASS: 1, + SPECIFIC_DEVICE_CLASS: 1, + INSTALLER_ICON_TYPE: 1, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 1, + PRODUCT_ID: 1, + APPLICATION_VERSION: "test", + }, + } + ) + + msg = await ws_client.receive_json() + assert not msg["success"] + + assert len(client.async_send_command.call_args_list) == 0 + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"success": True} + + # Test QR provisioning information with S0 inclusion strategy fails + await ws_client.send_json( + { + ID: 5, + TYPE: "zwave_js/add_node", + ENTRY_ID: entry.entry_id, + INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S0, + QR_PROVISIONING_INFORMATION: { + VERSION: 1, + SECURITY_CLASSES: [0], + DSK: "test", + GENERIC_DEVICE_CLASS: 1, + SPECIFIC_DEVICE_CLASS: 1, + INSTALLER_ICON_TYPE: 1, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 1, + PRODUCT_ID: 1, + APPLICATION_VERSION: "test", + }, + } + ) + + msg = await ws_client.receive_json() + assert not msg["success"] + + assert len(client.async_send_command.call_args_list) == 0 + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {" success": True} + + # Test ValueError is caught as failure + await ws_client.send_json( + { + ID: 6, + TYPE: "zwave_js/add_node", + ENTRY_ID: entry.entry_id, + INCLUSION_STRATEGY: InclusionStrategy.DEFAULT.value, + QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + } + ) + + msg = await ws_client.receive_json() + assert not msg["success"] + + assert len(client.async_send_command.call_args_list) == 0 + # Test FailedZWaveCommand is caught with patch( "zwave_js_server.model.controller.Controller.async_begin_inclusion", @@ -549,7 +758,7 @@ async def test_add_node( ): await ws_client.send_json( { - ID: 4, + ID: 7, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, } @@ -565,7 +774,7 @@ async def test_add_node( await hass.async_block_till_done() await ws_client.send_json( - {ID: 5, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id} + {ID: 8, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id} ) msg = await ws_client.receive_json() @@ -661,6 +870,465 @@ async def test_validate_dsk_and_enter_pin(hass, integration, client, hass_ws_cli assert msg["error"]["code"] == ERR_NOT_LOADED +async def test_provision_smart_start_node(hass, integration, client, hass_ws_client): + """Test provision_smart_start_node websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = {"success": True} + + # Test provisioning entry + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/provision_smart_start_node", + ENTRY_ID: entry.entry_id, + PLANNED_PROVISIONING_ENTRY: { + DSK: "test", + SECURITY_CLASSES: [0], + }, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.provision_smart_start_node", + "entry": ProvisioningEntry( + "test", [SecurityClass.S2_UNAUTHENTICATED] + ).to_dict(), + } + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"success": True} + + # Test QR provisioning information + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/provision_smart_start_node", + ENTRY_ID: entry.entry_id, + QR_PROVISIONING_INFORMATION: { + VERSION: 1, + SECURITY_CLASSES: [0], + DSK: "test", + GENERIC_DEVICE_CLASS: 1, + SPECIFIC_DEVICE_CLASS: 1, + INSTALLER_ICON_TYPE: 1, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 1, + PRODUCT_ID: 1, + APPLICATION_VERSION: "test", + }, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.provision_smart_start_node", + "entry": QRProvisioningInformation( + QRCodeVersion.SMART_START, + [SecurityClass.S2_UNAUTHENTICATED], + "test", + 1, + 1, + 1, + 1, + 1, + 1, + "test", + None, + None, + None, + ).to_dict(), + } + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"success": True} + + # Test QR code string + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/provision_smart_start_node", + ENTRY_ID: entry.entry_id, + QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.provision_smart_start_node", + "entry": "90testtesttesttesttesttesttesttesttesttesttesttesttest", + } + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"success": True} + + # Test QR provisioning information with S2 version throws error + await ws_client.send_json( + { + ID: 5, + TYPE: "zwave_js/provision_smart_start_node", + ENTRY_ID: entry.entry_id, + QR_PROVISIONING_INFORMATION: { + VERSION: 0, + SECURITY_CLASSES: [0], + DSK: "test", + GENERIC_DEVICE_CLASS: 1, + SPECIFIC_DEVICE_CLASS: 1, + INSTALLER_ICON_TYPE: 1, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 1, + PRODUCT_ID: 1, + APPLICATION_VERSION: "test", + }, + } + ) + + msg = await ws_client.receive_json() + assert not msg["success"] + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"success": True} + assert len(client.async_send_command.call_args_list) == 0 + + # Test no provisioning parameter provided causes failure + await ws_client.send_json( + { + ID: 6, + TYPE: "zwave_js/provision_smart_start_node", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.controller.Controller.async_provision_smart_start_node", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 7, + TYPE: "zwave_js/provision_smart_start_node", + ENTRY_ID: entry.entry_id, + QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 8, + TYPE: "zwave_js/provision_smart_start_node", + ENTRY_ID: entry.entry_id, + QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + +async def test_unprovision_smart_start_node(hass, integration, client, hass_ws_client): + """Test unprovision_smart_start_node websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = {} + + # Test node ID as input + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/unprovision_smart_start_node", + ENTRY_ID: entry.entry_id, + NODE_ID: 1, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.unprovision_smart_start_node", + "dskOrNodeId": 1, + } + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {} + + # Test DSK as input + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/unprovision_smart_start_node", + ENTRY_ID: entry.entry_id, + DSK: "test", + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.unprovision_smart_start_node", + "dskOrNodeId": "test", + } + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {} + + # Test not including DSK or node ID as input fails + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/unprovision_smart_start_node", + ENTRY_ID: entry.entry_id, + } + ) + + msg = await ws_client.receive_json() + assert not msg["success"] + + assert len(client.async_send_command.call_args_list) == 0 + + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.controller.Controller.async_unprovision_smart_start_node", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 6, + TYPE: "zwave_js/unprovision_smart_start_node", + ENTRY_ID: entry.entry_id, + DSK: "test", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 7, + TYPE: "zwave_js/unprovision_smart_start_node", + ENTRY_ID: entry.entry_id, + DSK: "test", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + +async def test_get_provisioning_entries(hass, integration, client, hass_ws_client): + """Test get_provisioning_entries websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = { + "entries": [{"dsk": "test", "securityClasses": [0], "fake": "test"}] + } + + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/get_provisioning_entries", + ENTRY_ID: entry.entry_id, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] == [ + { + "dsk": "test", + "security_classes": [SecurityClass.S2_UNAUTHENTICATED], + "additional_properties": {"fake": "test"}, + } + ] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.get_provisioning_entries", + } + + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.controller.Controller.async_get_provisioning_entries", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 6, + TYPE: "zwave_js/get_provisioning_entries", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + {ID: 7, TYPE: "zwave_js/get_provisioning_entries", ENTRY_ID: entry.entry_id} + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + +async def test_parse_qr_code_string(hass, integration, client, hass_ws_client): + """Test parse_qr_code_string websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = { + "qrProvisioningInformation": { + "version": 0, + "securityClasses": [0], + "dsk": "test", + "genericDeviceClass": 1, + "specificDeviceClass": 1, + "installerIconType": 1, + "manufacturerId": 1, + "productType": 1, + "productId": 1, + "applicationVersion": "test", + "maxInclusionRequestInterval": 1, + "uuid": "test", + "supportedProtocols": [0], + } + } + + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/parse_qr_code_string", + ENTRY_ID: entry.entry_id, + QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] == { + "version": 0, + "security_classes": [SecurityClass.S2_UNAUTHENTICATED], + "dsk": "test", + "generic_device_class": 1, + "specific_device_class": 1, + "installer_icon_type": 1, + "manufacturer_id": 1, + "product_type": 1, + "product_id": 1, + "application_version": "test", + "max_inclusion_request_interval": 1, + "uuid": "test", + "supported_protocols": [Protocols.ZWAVE], + } + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "utils.parse_qr_code_string", + "qr": "90testtesttesttesttesttesttesttesttesttesttesttesttest", + } + + # Test FailedZWaveCommand is caught + with patch( + "homeassistant.components.zwave_js.api.async_parse_qr_code_string", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 6, + TYPE: "zwave_js/parse_qr_code_string", + ENTRY_ID: entry.entry_id, + QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 7, + TYPE: "zwave_js/parse_qr_code_string", + ENTRY_ID: entry.entry_id, + QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + +async def test_supports_feature(hass, integration, client, hass_ws_client): + """Test supports_feature websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = {"supported": True} + + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/supports_feature", + ENTRY_ID: entry.entry_id, + FEATURE: ZwaveFeature.SMART_START, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] == {"supported": True} + + async def test_cancel_inclusion_exclusion(hass, integration, client, hass_ws_client): """Test cancelling the inclusion and exclusion process.""" entry = integration @@ -754,12 +1422,17 @@ async def test_remove_node( client.async_send_command.return_value = {"success": True} await ws_client.send_json( - {ID: 3, TYPE: "zwave_js/remove_node", ENTRY_ID: entry.entry_id} + {ID: 1, TYPE: "zwave_js/remove_node", ENTRY_ID: entry.entry_id} ) msg = await ws_client.receive_json() assert msg["success"] + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.begin_exclusion", + } + event = Event( type="exclusion started", data={ @@ -792,6 +1465,28 @@ async def test_remove_node( ) assert device is None + # Test unprovision parameter + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"success": True} + + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/remove_node", + ENTRY_ID: entry.entry_id, + UNPROVISION: True, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.begin_exclusion", + "unprovision": True, + } + # Test FailedZWaveCommand is caught with patch( "zwave_js_server.model.controller.Controller.async_begin_exclusion", @@ -847,11 +1542,12 @@ async def test_replace_failed_node( client.async_send_command.return_value = {"success": True} + # Test replace failed node with no provisioning information # Order of events we receive for a successful replacement is `inclusion started`, # `inclusion stopped`, `node removed`, `node added`, then interview stages. await ws_client.send_json( { - ID: 3, + ID: 1, TYPE: "zwave_js/replace_failed_node", ENTRY_ID: entry.entry_id, NODE_ID: 67, @@ -997,6 +1693,140 @@ async def test_replace_failed_node( msg = await ws_client.receive_json() assert msg["event"]["event"] == "interview failed" + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"success": True} + + # Test S2 planned provisioning entry + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/replace_failed_node", + ENTRY_ID: entry.entry_id, + NODE_ID: 67, + INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, + PLANNED_PROVISIONING_ENTRY: { + DSK: "test", + SECURITY_CLASSES: [0], + }, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.replace_failed_node", + "nodeId": 67, + "options": { + "strategy": InclusionStrategy.SECURITY_S2, + "provisioning": ProvisioningEntry( + "test", [SecurityClass.S2_UNAUTHENTICATED] + ).to_dict(), + }, + } + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"success": True} + + # Test S2 QR provisioning information + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/replace_failed_node", + ENTRY_ID: entry.entry_id, + NODE_ID: 67, + INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, + QR_PROVISIONING_INFORMATION: { + VERSION: 0, + SECURITY_CLASSES: [0], + DSK: "test", + GENERIC_DEVICE_CLASS: 1, + SPECIFIC_DEVICE_CLASS: 1, + INSTALLER_ICON_TYPE: 1, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 1, + PRODUCT_ID: 1, + APPLICATION_VERSION: "test", + }, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.replace_failed_node", + "nodeId": 67, + "options": { + "strategy": InclusionStrategy.SECURITY_S2, + "provisioning": QRProvisioningInformation( + QRCodeVersion.S2, + [SecurityClass.S2_UNAUTHENTICATED], + "test", + 1, + 1, + 1, + 1, + 1, + 1, + "test", + None, + None, + None, + ).to_dict(), + }, + } + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"success": True} + + # Test S2 QR code string + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/replace_failed_node", + ENTRY_ID: entry.entry_id, + NODE_ID: 67, + INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, + QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.replace_failed_node", + "nodeId": 67, + "options": { + "strategy": InclusionStrategy.SECURITY_S2, + "provisioning": "90testtesttesttesttesttesttesttesttesttesttesttesttest", + }, + } + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"success": True} + + # Test ValueError is caught as failure + await ws_client.send_json( + { + ID: 6, + TYPE: "zwave_js/replace_failed_node", + ENTRY_ID: entry.entry_id, + NODE_ID: 67, + INCLUSION_STRATEGY: InclusionStrategy.DEFAULT.value, + QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + } + ) + + msg = await ws_client.receive_json() + assert not msg["success"] + + assert len(client.async_send_command.call_args_list) == 0 + # Test FailedZWaveCommand is caught with patch( "zwave_js_server.model.controller.Controller.async_replace_failed_node", @@ -1004,7 +1834,7 @@ async def test_replace_failed_node( ): await ws_client.send_json( { - ID: 4, + ID: 7, TYPE: "zwave_js/replace_failed_node", ENTRY_ID: entry.entry_id, NODE_ID: 67, @@ -1022,7 +1852,7 @@ async def test_replace_failed_node( await ws_client.send_json( { - ID: 5, + ID: 8, TYPE: "zwave_js/replace_failed_node", ENTRY_ID: entry.entry_id, NODE_ID: 67, @@ -2324,7 +3154,7 @@ async def test_update_log_config(hass, client, integration, hass_ws_client): ) msg = await ws_client.receive_json() assert not msg["success"] - assert "error" in msg and "value must be one of" in msg["error"]["message"] + assert "error" in msg and msg["error"]["code"] == "invalid_format" # Test error without service data await ws_client.send_json( diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 421c808bc0b..d5aab6cd0f9 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -1,8 +1,19 @@ """Test the Z-Wave JS binary sensor platform.""" from zwave_js_server.event import Event +from zwave_js_server.model.node import Node -from homeassistant.components.binary_sensor import DEVICE_CLASS_MOTION -from homeassistant.const import DEVICE_CLASS_BATTERY, STATE_OFF, STATE_ON +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_DOOR, + DEVICE_CLASS_MOTION, + DEVICE_CLASS_TAMPER, +) +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .common import ( @@ -11,8 +22,11 @@ from .common import ( LOW_BATTERY_BINARY_SENSOR, NOTIFICATION_MOTION_BINARY_SENSOR, PROPERTY_DOOR_STATUS_BINARY_SENSOR, + TAMPER_SENSOR, ) +from tests.common import MockConfigEntry + async def test_low_battery_sensor(hass, multisensor_6, integration): """Test boolean binary sensor of type low battery.""" @@ -22,6 +36,12 @@ async def test_low_battery_sensor(hass, multisensor_6, integration): assert state.state == STATE_OFF assert state.attributes["device_class"] == DEVICE_CLASS_BATTERY + registry = er.async_get(hass) + entity_entry = registry.async_get(LOW_BATTERY_BINARY_SENSOR) + + assert entity_entry + assert entity_entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + async def test_enabled_legacy_sensor(hass, ecolink_door_sensor, integration): """Test enabled legacy boolean binary sensor.""" @@ -87,14 +107,59 @@ async def test_notification_sensor(hass, multisensor_6, integration): assert state.state == STATE_ON assert state.attributes["device_class"] == DEVICE_CLASS_MOTION + state = hass.states.get(TAMPER_SENSOR) + + assert state + assert state.state == STATE_OFF + assert state.attributes["device_class"] == DEVICE_CLASS_TAMPER + + registry = er.async_get(hass) + entity_entry = registry.async_get(TAMPER_SENSOR) + + assert entity_entry + assert entity_entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + + +async def test_notification_off_state( + hass: HomeAssistant, + lock_popp_electric_strike_lock_control: Node, +): + """Test the description off_state attribute of certain notification sensors.""" + node = lock_popp_electric_strike_lock_control + # Remove all other values except the door state value. + node.values = { + value_id: value + for value_id, value in node.values.items() + if value_id == "62-113-0-Access Control-Door state" + } + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + door_states = [ + state + for state in hass.states.async_all("binary_sensor") + if state.attributes.get("device_class") == DEVICE_CLASS_DOOR + ] + + # Only one entity should be created for the Door state notification states. + assert len(door_states) == 1 + + state = door_states[0] + assert state + assert state.entity_id == "binary_sensor.node_62_access_control_window_door_is_open" + async def test_property_sensor_door_status(hass, lock_august_pro, integration): """Test property binary sensor with sensor mapping (doorStatus).""" node = lock_august_pro state = hass.states.get(PROPERTY_DOOR_STATUS_BINARY_SENSOR) - assert state is not None + assert state assert state.state == STATE_OFF + assert state.attributes["device_class"] == DEVICE_CLASS_DOOR # open door event = Event( @@ -116,6 +181,7 @@ async def test_property_sensor_door_status(hass, lock_august_pro, integration): ) node.receive_event(event) state = hass.states.get(PROPERTY_DOOR_STATUS_BINARY_SENSOR) + assert state assert state.state == STATE_ON # close door @@ -138,4 +204,5 @@ async def test_property_sensor_door_status(hass, lock_august_pro, integration): ) node.receive_event(event) state = hass.states.get(PROPERTY_DOOR_STATUS_BINARY_SENSOR) + assert state assert state.state == STATE_OFF diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index a61d13be8eb..66fc9934ee1 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -7,6 +7,8 @@ import pytest from zwave_js_server.version import VersionInfo from homeassistant import config_entries +from homeassistant.components import usb +from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.zwave_js.config_flow import SERVER_VERSION_TIMEOUT, TITLE from homeassistant.components.zwave_js.const import DOMAIN @@ -20,32 +22,32 @@ ADDON_DISCOVERY_INFO = { } -USB_DISCOVERY_INFO = { - "device": "/dev/zwave", - "pid": "AAAA", - "vid": "AAAA", - "serial_number": "1234", - "description": "zwave radio", - "manufacturer": "test", -} +USB_DISCOVERY_INFO = usb.UsbServiceInfo( + device="/dev/zwave", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zwave radio", + manufacturer="test", +) -NORTEK_ZIGBEE_DISCOVERY_INFO = { - "device": "/dev/zigbee", - "pid": "8A2A", - "vid": "10C4", - "serial_number": "1234", - "description": "nortek zigbee radio", - "manufacturer": "nortek", -} +NORTEK_ZIGBEE_DISCOVERY_INFO = usb.UsbServiceInfo( + device="/dev/zigbee", + pid="8A2A", + vid="10C4", + serial_number="1234", + description="nortek zigbee radio", + manufacturer="nortek", +) -CP2652_ZIGBEE_DISCOVERY_INFO = { - "device": "/dev/zigbee", - "pid": "EA60", - "vid": "10C4", - "serial_number": "", - "description": "cp2652", - "manufacturer": "generic", -} +CP2652_ZIGBEE_DISCOVERY_INFO = usb.UsbServiceInfo( + device="/dev/zigbee", + pid="EA60", + vid="10C4", + serial_number="", + description="cp2652", + manufacturer="generic", +) @pytest.fixture(name="setup_entry") @@ -281,7 +283,7 @@ async def test_supervisor_discovery( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=ADDON_DISCOVERY_INFO, + data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), ) with patch( @@ -321,7 +323,7 @@ async def test_supervisor_discovery_cannot_connect( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=ADDON_DISCOVERY_INFO, + data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), ) assert result["type"] == "abort" @@ -343,7 +345,7 @@ async def test_clean_discovery_on_user_create( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=ADDON_DISCOVERY_INFO, + data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), ) assert result["type"] == "form" @@ -406,7 +408,7 @@ async def test_abort_discovery_with_existing_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=ADDON_DISCOVERY_INFO, + data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), ) assert result["type"] == "abort" @@ -430,7 +432,7 @@ 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=ADDON_DISCOVERY_INFO, + data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), ) assert result2["type"] == "abort" @@ -474,7 +476,6 @@ async def test_usb_discovery( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "usb_path": "/test", "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -487,7 +488,7 @@ async def test_usb_discovery( "core_zwave_js", { "options": { - "device": "/test", + "device": USB_DISCOVERY_INFO.device, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -515,7 +516,7 @@ async def test_usb_discovery( assert result["title"] == TITLE assert result["data"] == { "url": "ws://host1:3001", - "usb_path": "/test", + "usb_path": USB_DISCOVERY_INFO.device, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -556,7 +557,6 @@ async def test_usb_discovery_addon_not_running( # Make sure the discovered usb device is preferred. data_schema = result["data_schema"] assert data_schema({}) == { - "usb_path": USB_DISCOVERY_INFO["device"], "s0_legacy_key": "", "s2_access_control_key": "", "s2_authenticated_key": "", @@ -566,7 +566,6 @@ async def test_usb_discovery_addon_not_running( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "usb_path": USB_DISCOVERY_INFO["device"], "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -579,7 +578,7 @@ async def test_usb_discovery_addon_not_running( "core_zwave_js", { "options": { - "device": USB_DISCOVERY_INFO["device"], + "device": USB_DISCOVERY_INFO.device, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -607,7 +606,7 @@ async def test_usb_discovery_addon_not_running( assert result["title"] == TITLE assert result["data"] == { "url": "ws://host1:3001", - "usb_path": USB_DISCOVERY_INFO["device"], + "usb_path": USB_DISCOVERY_INFO.device, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -628,7 +627,7 @@ async def test_discovery_addon_not_running( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=ADDON_DISCOVERY_INFO, + data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), ) assert result["step_id"] == "hassio_confirm" @@ -710,7 +709,7 @@ async def test_discovery_addon_not_installed( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=ADDON_DISCOVERY_INFO, + data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), ) assert result["step_id"] == "hassio_confirm" @@ -791,7 +790,7 @@ 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=ADDON_DISCOVERY_INFO, + data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), ) assert result["type"] == "form" diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index 1afe7a114da..d6d3376d0e6 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -3,6 +3,7 @@ from zwave_js_server.event import Event from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, DEVICE_CLASS_BLIND, DEVICE_CLASS_GARAGE, DEVICE_CLASS_SHUTTER, @@ -25,6 +26,7 @@ GDC_COVER_ENTITY = "cover.aeon_labs_garage_door_controller_gen5" BLIND_COVER_ENTITY = "cover.window_blind_controller" SHUTTER_COVER_ENTITY = "cover.flush_shutter" AEOTEC_SHUTTER_COVER_ENTITY = "cover.nano_shutter_v_3" +FIBARO_SHUTTER_COVER_ENTITY = "cover.fgr_222_test_cover" async def test_window_cover(hass, client, chain_actuator_zws12, integration): @@ -307,6 +309,85 @@ async def test_window_cover(hass, client, chain_actuator_zws12, integration): assert state.state == "closed" +async def test_fibaro_FGR222_shutter_cover( + hass, client, fibaro_fgr222_shutter, integration +): + """Test tilt function of the Fibaro Shutter devices.""" + state = hass.states.get(FIBARO_SHUTTER_COVER_ENTITY) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_SHUTTER + + assert state.state == "open" + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + # Test opening tilts + await hass.services.async_call( + "cover", + "open_cover_tilt", + {"entity_id": FIBARO_SHUTTER_COVER_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 42 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 145, + "commandClassName": "Manufacturer Proprietary", + "property": "fibaro", + "propertyKey": "venetianBlindsTilt", + "propertyName": "fibaro", + "propertyKeyName": "venetianBlindsTilt", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Venetian blinds tilt", + "min": 0, + "max": 99, + }, + "value": 0, + } + assert args["value"] == 99 + + client.async_send_command.reset_mock() + # Test closing tilts + await hass.services.async_call( + "cover", + "close_cover_tilt", + {"entity_id": FIBARO_SHUTTER_COVER_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 42 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 145, + "commandClassName": "Manufacturer Proprietary", + "property": "fibaro", + "propertyKey": "venetianBlindsTilt", + "propertyName": "fibaro", + "propertyKeyName": "venetianBlindsTilt", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Venetian blinds tilt", + "min": 0, + "max": 99, + }, + "value": 0, + } + assert args["value"] == 0 + + async def test_aeotec_nano_shutter_cover( hass, client, aeotec_nano_shutter, integration ): diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index dfdbb16c8e8..725d9574605 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -552,7 +552,7 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): with pytest.raises(HomeAssistantError): await device_condition.async_condition_from_config( - {"type": "failed.test", "device_id": device.id}, False + hass, {"type": "failed.test", "device_id": device.id} ) with patch( diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 5bd856c664a..74ad642127f 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -1,16 +1,22 @@ """Test the Z-Wave JS fan platform.""" +import math + import pytest from zwave_js_server.event import Event -from homeassistant.components.fan import ATTR_SPEED, SPEED_MEDIUM - -FAN_ENTITY = "fan.in_wall_smart_fan_control" +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + ATTR_SPEED, + SPEED_MEDIUM, +) -async def test_fan(hass, client, in_wall_smart_fan_control, integration): - """Test the fan entity.""" - node = in_wall_smart_fan_control - state = hass.states.get(FAN_ENTITY) +async def test_generic_fan(hass, client, fan_generic, integration): + """Test the fan entity for a generic fan that lacks specific speed configuration.""" + node = fan_generic + entity_id = "fan.generic_fan_controller" + state = hass.states.get(entity_id) assert state assert state.state == "off" @@ -19,7 +25,7 @@ async def test_fan(hass, client, in_wall_smart_fan_control, integration): await hass.services.async_call( "fan", "turn_on", - {"entity_id": FAN_ENTITY, "speed": SPEED_MEDIUM}, + {"entity_id": entity_id, "speed": SPEED_MEDIUM}, blocking=True, ) @@ -52,7 +58,7 @@ async def test_fan(hass, client, in_wall_smart_fan_control, integration): await hass.services.async_call( "fan", "set_speed", - {"entity_id": FAN_ENTITY, "speed": 99}, + {"entity_id": entity_id, "speed": 99}, blocking=True, ) @@ -62,7 +68,7 @@ async def test_fan(hass, client, in_wall_smart_fan_control, integration): await hass.services.async_call( "fan", "turn_on", - {"entity_id": FAN_ENTITY}, + {"entity_id": entity_id}, blocking=True, ) @@ -94,7 +100,7 @@ async def test_fan(hass, client, in_wall_smart_fan_control, integration): await hass.services.async_call( "fan", "turn_off", - {"entity_id": FAN_ENTITY}, + {"entity_id": entity_id}, blocking=True, ) @@ -142,7 +148,7 @@ async def test_fan(hass, client, in_wall_smart_fan_control, integration): ) node.receive_event(event) - state = hass.states.get(FAN_ENTITY) + state = hass.states.get(entity_id) assert state.state == "on" assert state.attributes[ATTR_SPEED] == "high" @@ -167,6 +173,138 @@ async def test_fan(hass, client, in_wall_smart_fan_control, integration): ) node.receive_event(event) - state = hass.states.get(FAN_ENTITY) + state = hass.states.get(entity_id) assert state.state == "off" assert state.attributes[ATTR_SPEED] == "off" + + +async def test_configurable_speeds_fan(hass, client, hs_fc200, integration): + """Test a fan entity with configurable speeds.""" + node = hs_fc200 + node_id = 39 + entity_id = "fan.scene_capable_fan_control_switch" + + async def get_zwave_speed_from_percentage(percentage): + """Set the fan to a particular percentage and get the resulting Zwave speed.""" + client.async_send_command.reset_mock() + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": entity_id, "percentage": percentage}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node_id + return args["value"] + + async def get_percentage_from_zwave_speed(zwave_speed): + """Set the underlying device speed and get the resulting percentage.""" + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node_id, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": zwave_speed, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + state = hass.states.get(entity_id) + return state.attributes[ATTR_PERCENTAGE] + + # In 3-speed mode, the speeds are: + # low = 1-33, med=34-66, high=67-99 + percentages_to_zwave_speeds = [ + [[0], [0]], + [range(1, 34), range(1, 34)], + [range(34, 68), range(34, 67)], + [range(68, 101), range(67, 100)], + ] + + for percentages, zwave_speeds in percentages_to_zwave_speeds: + for percentage in percentages: + actual_zwave_speed = await get_zwave_speed_from_percentage(percentage) + assert actual_zwave_speed in zwave_speeds + for zwave_speed in zwave_speeds: + actual_percentage = await get_percentage_from_zwave_speed(zwave_speed) + assert actual_percentage in percentages + + state = hass.states.get(entity_id) + assert math.isclose(state.attributes[ATTR_PERCENTAGE_STEP], 33.3333, rel_tol=1e-3) + + +async def test_fixed_speeds_fan(hass, client, ge_12730, integration): + """Test a fan entity with fixed speeds.""" + node = ge_12730 + node_id = 24 + entity_id = "fan.in_wall_smart_fan_control" + + async def get_zwave_speed_from_percentage(percentage): + """Set the fan to a particular percentage and get the resulting Zwave speed.""" + client.async_send_command.reset_mock() + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": entity_id, "percentage": percentage}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node_id + return args["value"] + + async def get_percentage_from_zwave_speed(zwave_speed): + """Set the underlying device speed and get the resulting percentage.""" + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node_id, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": zwave_speed, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + state = hass.states.get(entity_id) + return state.attributes[ATTR_PERCENTAGE] + + # This device has the speeds: + # low = 1-33, med = 34-67, high = 68-99 + percentages_to_zwave_speeds = [ + [[0], [0]], + [range(1, 34), range(1, 34)], + [range(34, 68), range(34, 68)], + [range(68, 101), range(68, 100)], + ] + + for percentages, zwave_speeds in percentages_to_zwave_speeds: + for percentage in percentages: + actual_zwave_speed = await get_zwave_speed_from_percentage(percentage) + assert actual_zwave_speed in zwave_speeds + for zwave_speed in zwave_speeds: + actual_percentage = await get_percentage_from_zwave_speed(zwave_speed) + assert actual_percentage in percentages + + state = hass.states.get(entity_id) + assert math.isclose(state.attributes[ATTR_PERCENTAGE_STEP], 33.3333, rel_tol=1e-3) diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index 5ed0804723c..82f84372ee0 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -1,14 +1,25 @@ """Test the Z-Wave JS number platform.""" -from zwave_js_server.event import Event +from unittest.mock import MagicMock -from homeassistant.const import STATE_UNKNOWN +from zwave_js_server.event import Event +from zwave_js_server.model.node import Node + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_CONFIG, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er 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" -async def test_default_tone_select(hass, client, aeotec_zw164_siren, integration): +async def test_default_tone_select( + hass: HomeAssistant, + client: MagicMock, + aeotec_zw164_siren: Node, + integration: ConfigEntry, +) -> None: """Test the default tone select entity.""" node = aeotec_zw164_siren state = hass.states.get(DEFAULT_TONE_SELECT_ENTITY) @@ -48,6 +59,12 @@ async def test_default_tone_select(hass, client, aeotec_zw164_siren, integration "30DOOR~1 (27 sec)", ] + entity_registry = er.async_get(hass) + entity_entry = entity_registry.async_get(DEFAULT_TONE_SELECT_ENTITY) + + assert entity_entry + assert entity_entry.entity_category == ENTITY_CATEGORY_CONFIG + # Test select option with string value await hass.services.async_call( "select", @@ -102,10 +119,16 @@ async def test_default_tone_select(hass, client, aeotec_zw164_siren, integration node.receive_event(event) state = hass.states.get(DEFAULT_TONE_SELECT_ENTITY) + assert state assert state.state == "30DOOR~1 (27 sec)" -async def test_protection_select(hass, client, inovelli_lzw36, integration): +async def test_protection_select( + hass: HomeAssistant, + client: MagicMock, + inovelli_lzw36: Node, + integration: ConfigEntry, +) -> None: """Test the default tone select entity.""" node = inovelli_lzw36 state = hass.states.get(PROTECTION_SELECT_ENTITY) @@ -119,6 +142,12 @@ async def test_protection_select(hass, client, inovelli_lzw36, integration): "NoOperationPossible", ] + entity_registry = er.async_get(hass) + entity_entry = entity_registry.async_get(PROTECTION_SELECT_ENTITY) + + assert entity_entry + assert entity_entry.entity_category == ENTITY_CATEGORY_CONFIG + # Test select option with string value await hass.services.async_call( "select", @@ -176,6 +205,7 @@ async def test_protection_select(hass, client, inovelli_lzw36, integration): node.receive_event(event) state = hass.states.get(PROTECTION_SELECT_ENTITY) + assert state assert state.state == "ProtectedBySequence" # Test null value @@ -199,6 +229,7 @@ async def test_protection_select(hass, client, inovelli_lzw36, integration): node.receive_event(event) state = hass.states.get(PROTECTION_SELECT_ENTITY) + assert state assert state.state == STATE_UNKNOWN diff --git a/tests/conftest.py b/tests/conftest.py index 845145c2ec2..0107e218335 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,9 +6,10 @@ import logging import socket import ssl import threading -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohttp.test_utils import make_mocked_request +import freezegun import multidict import pytest import pytest_socket @@ -25,8 +26,7 @@ from homeassistant.components.websocket_api.auth import ( TYPE_AUTH_REQUIRED, ) from homeassistant.components.websocket_api.http import URL -from homeassistant.const import ATTR_NOW, EVENT_TIME_CHANGED -from homeassistant.exceptions import ServiceNotFound +from homeassistant.const import ATTR_NOW, EVENT_TIME_CHANGED, HASSIO_USER_NAME from homeassistant.helpers import config_entry_oauth2_flow, event from homeassistant.setup import async_setup_component from homeassistant.util import location @@ -64,15 +64,24 @@ def pytest_configure(config): def pytest_runtest_setup(): - """Throw if tests attempt to open sockets. + """Prepare pytest_socket and freezegun. + + pytest_socket: + Throw if tests attempt to open sockets. allow_unix_socket is set to True because it's needed by asyncio. Important: socket_allow_hosts must be called before disable_socket, otherwise all destinations will be allowed. + + freezegun: + Modified to include https://github.com/spulec/freezegun/pull/424 """ pytest_socket.socket_allow_hosts(["127.0.0.1"]) disable_socket(allow_unix_socket=True) + freezegun.api.datetime_to_fakedatetime = ha_datetime_to_fakedatetime + freezegun.api.FakeDatetime = HAFakeDatetime + @pytest.fixture def socket_disabled(pytestconfig): @@ -127,6 +136,43 @@ def disable_socket(allow_unix_socket=False): socket.socket = GuardedSocket +def ha_datetime_to_fakedatetime(datetime): + """Convert datetime to FakeDatetime. + + Modified to include https://github.com/spulec/freezegun/pull/424. + """ + return freezegun.api.FakeDatetime( + datetime.year, + datetime.month, + datetime.day, + datetime.hour, + datetime.minute, + datetime.second, + datetime.microsecond, + datetime.tzinfo, + fold=datetime.fold, + ) + + +class HAFakeDatetime(freezegun.api.FakeDatetime): + """Modified to include https://github.com/spulec/freezegun/pull/424.""" + + @classmethod + def now(cls, tz=None): + """Return frozen now.""" + now = cls._time_to_freeze() or freezegun.api.real_datetime.now() + if tz: + result = tz.fromutc(now.replace(tzinfo=tz)) + else: + result = now + + # Add the _tz_offset only if it's non-zero to preserve fold + if cls._tz_offset(): + result += cls._tz_offset() + + return ha_datetime_to_fakedatetime(result) + + def check_real(func): """Force a function to require a keyword _test_real to be passed in.""" @@ -232,8 +278,6 @@ def hass(loop, load_registries, hass_storage, request): request.function.__name__, ) in IGNORE_UNCAUGHT_EXCEPTIONS: continue - if isinstance(ex, ServiceNotFound): - continue raise ex @@ -361,6 +405,26 @@ def hass_read_only_access_token(hass, hass_read_only_user, local_auth): return hass.auth.async_create_access_token(refresh_token) +@pytest.fixture +def hass_supervisor_user(hass, local_auth): + """Return the Home Assistant Supervisor user.""" + admin_group = hass.loop.run_until_complete( + hass.auth.async_get_group(GROUP_ID_ADMIN) + ) + return MockUser( + name=HASSIO_USER_NAME, groups=[admin_group], system_generated=True + ).add_to_hass(hass) + + +@pytest.fixture +def hass_supervisor_access_token(hass, hass_supervisor_user, local_auth): + """Return a Home Assistant Supervisor access token.""" + refresh_token = hass.loop.run_until_complete( + hass.auth.async_create_refresh_token(hass_supervisor_user) + ) + return hass.auth.async_create_access_token(refresh_token) + + @pytest.fixture def legacy_auth(hass): """Load legacy API password provider.""" @@ -554,7 +618,7 @@ async def mqtt_mock(hass, mqtt_client_mock, mqtt_config): return component -@pytest.fixture +@pytest.fixture(autouse=True) def mock_get_source_ip(): """Mock network util's async_get_source_ip.""" with patch( @@ -573,6 +637,21 @@ def mock_zeroconf(): yield +@pytest.fixture +def mock_async_zeroconf(mock_zeroconf): + """Mock AsyncZeroconf.""" + with patch("homeassistant.components.zeroconf.HaAsyncZeroconf") as mock_aiozc: + zc = mock_aiozc.return_value + zc.async_unregister_service = AsyncMock() + zc.async_register_service = AsyncMock() + zc.async_update_service = AsyncMock() + zc.zeroconf.async_wait_for_start = AsyncMock() + zc.zeroconf.done = False + zc.async_close = AsyncMock() + zc.ha_async_close = AsyncMock() + yield zc + + @pytest.fixture def legacy_patchable_time(): """Allow time to be patchable by using event listeners instead of asyncio loop.""" diff --git a/tests/fixtures/griddy/getnow.json b/tests/fixtures/griddy/getnow.json deleted file mode 100644 index 2bf685dac44..00000000000 --- a/tests/fixtures/griddy/getnow.json +++ /dev/null @@ -1,600 +0,0 @@ -{ - "now": { - "date": "2020-03-08T18:10:16Z", - "hour_num": "18", - "min_num": "10", - "settlement_point": "LZ_HOUSTON", - "price_type": "lmp", - "price_ckwh": "1.26900000000000000000", - "value_score": "11", - "mean_price_ckwh": "1.429706", - "diff_mean_ckwh": "-0.160706", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.544065", - "price_display": "1.3", - "price_display_sign": "¢", - "date_local_tz": "2020-03-08T13:10:16-05:00" - }, - "forecast": [ - { - "date": "2020-03-08T19:00:00Z", - "hour_num": "19", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.32000000", - "value_score": "12", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.113030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.3", - "price_display_sign": "¢", - "date_local_tz": "2020-03-08T14:00:00-05:00" - }, - { - "date": "2020-03-08T20:00:00Z", - "hour_num": "20", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.37400000", - "value_score": "12", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.059030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.4", - "price_display_sign": "¢", - "date_local_tz": "2020-03-08T15:00:00-05:00" - }, - { - "date": "2020-03-08T21:00:00Z", - "hour_num": "21", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.44700000", - "value_score": "13", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "0.013970", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.4", - "price_display_sign": "¢", - "date_local_tz": "2020-03-08T16:00:00-05:00" - }, - { - "date": "2020-03-08T22:00:00Z", - "hour_num": "22", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.52600000", - "value_score": "13", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "0.092970", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.5", - "price_display_sign": "¢", - "date_local_tz": "2020-03-08T17:00:00-05:00" - }, - { - "date": "2020-03-08T23:00:00Z", - "hour_num": "23", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "2.05100000", - "value_score": "17", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "0.617970", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "2.1", - "price_display_sign": "¢", - "date_local_tz": "2020-03-08T18:00:00-05:00" - }, - { - "date": "2020-03-09T00:00:00Z", - "hour_num": "0", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "2.07400000", - "value_score": "17", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "0.640970", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "2.1", - "price_display_sign": "¢", - "date_local_tz": "2020-03-08T19:00:00-05:00" - }, - { - "date": "2020-03-09T01:00:00Z", - "hour_num": "1", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.94400000", - "value_score": "16", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "0.510970", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.9", - "price_display_sign": "¢", - "date_local_tz": "2020-03-08T20:00:00-05:00" - }, - { - "date": "2020-03-09T02:00:00Z", - "hour_num": "2", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.57500000", - "value_score": "14", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "0.141970", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.6", - "price_display_sign": "¢", - "date_local_tz": "2020-03-08T21:00:00-05:00" - }, - { - "date": "2020-03-09T03:00:00Z", - "hour_num": "3", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.23700000", - "value_score": "11", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.196030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.2", - "price_display_sign": "¢", - "date_local_tz": "2020-03-08T22:00:00-05:00" - }, - { - "date": "2020-03-09T04:00:00Z", - "hour_num": "4", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "0.96200000", - "value_score": "9", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.471030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1", - "price_display_sign": "¢", - "date_local_tz": "2020-03-08T23:00:00-05:00" - }, - { - "date": "2020-03-09T05:00:00Z", - "hour_num": "5", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "0.80000000", - "value_score": "8", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.633030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "0.8", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T00:00:00-05:00" - }, - { - "date": "2020-03-09T06:00:00Z", - "hour_num": "6", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "0.70000000", - "value_score": "7", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.733030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "0.7", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T01:00:00-05:00" - }, - { - "date": "2020-03-09T07:00:00Z", - "hour_num": "7", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "0.70000000", - "value_score": "7", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.733030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "0.7", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T02:00:00-05:00" - }, - { - "date": "2020-03-09T08:00:00Z", - "hour_num": "8", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "0.70000000", - "value_score": "7", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.733030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "0.7", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T03:00:00-05:00" - }, - { - "date": "2020-03-09T09:00:00Z", - "hour_num": "9", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "0.70000000", - "value_score": "7", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.733030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "0.7", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T04:00:00-05:00" - }, - { - "date": "2020-03-09T10:00:00Z", - "hour_num": "10", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "0.90000000", - "value_score": "8", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.533030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "0.9", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T05:00:00-05:00" - }, - { - "date": "2020-03-09T11:00:00Z", - "hour_num": "11", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.00000000", - "value_score": "9", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.433030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T06:00:00-05:00" - }, - { - "date": "2020-03-09T12:00:00Z", - "hour_num": "12", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.10000000", - "value_score": "10", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.333030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.1", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T07:00:00-05:00" - }, - { - "date": "2020-03-09T13:00:00Z", - "hour_num": "13", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.10000000", - "value_score": "10", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.333030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.1", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T08:00:00-05:00" - }, - { - "date": "2020-03-09T14:00:00Z", - "hour_num": "14", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.20000000", - "value_score": "11", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.233030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.2", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T09:00:00-05:00" - }, - { - "date": "2020-03-09T15:00:00Z", - "hour_num": "15", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.20000000", - "value_score": "11", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.233030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.2", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T10:00:00-05:00" - }, - { - "date": "2020-03-09T16:00:00Z", - "hour_num": "16", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.30000000", - "value_score": "11", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.133030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.3", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T11:00:00-05:00" - }, - { - "date": "2020-03-09T17:00:00Z", - "hour_num": "17", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.40000000", - "value_score": "12", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.033030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.4", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T12:00:00-05:00" - }, - { - "date": "2020-03-09T18:00:00Z", - "hour_num": "18", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.50000000", - "value_score": "13", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "0.066970", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.5", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T13:00:00-05:00" - }, - { - "date": "2020-03-09T19:00:00Z", - "hour_num": "19", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.60000000", - "value_score": "14", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "0.166970", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.6", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T14:00:00-05:00" - }, - { - "date": "2020-03-09T20:00:00Z", - "hour_num": "20", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.60000000", - "value_score": "14", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "0.166970", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.6", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T15:00:00-05:00" - }, - { - "date": "2020-03-09T21:00:00Z", - "hour_num": "21", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.60000000", - "value_score": "14", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "0.166970", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.6", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T16:00:00-05:00" - }, - { - "date": "2020-03-09T22:00:00Z", - "hour_num": "22", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "2.10000000", - "value_score": "18", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "0.666970", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "2.1", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T17:00:00-05:00" - }, - { - "date": "2020-03-09T23:00:00Z", - "hour_num": "23", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "3.20000000", - "value_score": "27", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "1.766970", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "3.2", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T18:00:00-05:00" - }, - { - "date": "2020-03-10T00:00:00Z", - "hour_num": "0", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "2.40000000", - "value_score": "20", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "0.966970", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "2.4", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T19:00:00-05:00" - }, - { - "date": "2020-03-10T01:00:00Z", - "hour_num": "1", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "2.00000000", - "value_score": "17", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "0.566970", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "2", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T20:00:00-05:00" - }, - { - "date": "2020-03-10T02:00:00Z", - "hour_num": "2", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.70000000", - "value_score": "15", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "0.266970", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.7", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T21:00:00-05:00" - }, - { - "date": "2020-03-10T03:00:00Z", - "hour_num": "3", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.40000000", - "value_score": "12", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.033030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.4", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T22:00:00-05:00" - }, - { - "date": "2020-03-10T04:00:00Z", - "hour_num": "4", - "min_num": "0", - "settlement_point": "LZ_HOUSTON", - "price_type": "dam", - "price_ckwh": "1.20000000", - "value_score": "11", - "mean_price_ckwh": "1.433030", - "diff_mean_ckwh": "-0.233030", - "high_ckwh": "3.200000", - "low_ckwh": "0.700000", - "std_dev_ckwh": "0.552149", - "price_display": "1.2", - "price_display_sign": "¢", - "date_local_tz": "2020-03-09T23:00:00-05:00" - } - ], - "seconds_until_refresh": "26" -} diff --git a/tests/fixtures/here_travel_time/attribution_response.json b/tests/fixtures/here_travel_time/attribution_response.json deleted file mode 100644 index 9b682f6c51f..00000000000 --- a/tests/fixtures/here_travel_time/attribution_response.json +++ /dev/null @@ -1,276 +0,0 @@ -{ - "response": { - "metaInfo": { - "timestamp": "2019-09-21T15:17:31Z", - "mapVersion": "8.30.100.154", - "moduleVersion": "7.2.201937-5251", - "interfaceVersion": "2.6.70", - "availableMapVersion": [ - "8.30.100.154" - ] - }, - "route": [ - { - "waypoint": [ - { - "linkId": "+565790671", - "mappedPosition": { - "latitude": 50.0378591, - "longitude": 14.3924721 - }, - "originalPosition": { - "latitude": 50.0377513, - "longitude": 14.3923344 - }, - "type": "stopOver", - "spot": 0.3, - "sideOfStreet": "left", - "mappedRoadName": "V Bokách III", - "label": "V Bokách III", - "shapeIndex": 0, - "source": "user" - }, - { - "linkId": "+748931502", - "mappedPosition": { - "latitude": 50.0798786, - "longitude": 14.4260037 - }, - "originalPosition": { - "latitude": 50.0799383, - "longitude": 14.4258216 - }, - "type": "stopOver", - "spot": 1.0, - "sideOfStreet": "left", - "mappedRoadName": "Štěpánská", - "label": "Štěpánská", - "shapeIndex": 116, - "source": "user" - } - ], - "mode": { - "type": "shortest", - "transportModes": [ - "publicTransportTimeTable" - ], - "trafficMode": "enabled", - "feature": [] - }, - "leg": [ - { - "start": { - "linkId": "+565790671", - "mappedPosition": { - "latitude": 50.0378591, - "longitude": 14.3924721 - }, - "originalPosition": { - "latitude": 50.0377513, - "longitude": 14.3923344 - }, - "type": "stopOver", - "spot": 0.3, - "sideOfStreet": "left", - "mappedRoadName": "V Bokách III", - "label": "V Bokách III", - "shapeIndex": 0, - "source": "user" - }, - "end": { - "linkId": "+748931502", - "mappedPosition": { - "latitude": 50.0798786, - "longitude": 14.4260037 - }, - "originalPosition": { - "latitude": 50.0799383, - "longitude": 14.4258216 - }, - "type": "stopOver", - "spot": 1.0, - "sideOfStreet": "left", - "mappedRoadName": "Štěpánská", - "label": "Štěpánská", - "shapeIndex": 116, - "source": "user" - }, - "length": 7835, - "travelTime": 2413, - "maneuver": [ - { - "position": { - "latitude": 50.0378591, - "longitude": 14.3924721 - }, - "instruction": "Head northwest on Kosořská. Go for 28 m.", - "travelTime": 32, - "length": 28, - "id": "M1", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 50.0380039, - "longitude": 14.3921542 - }, - "instruction": "Turn left onto Kosořská. Go for 24 m.", - "travelTime": 24, - "length": 24, - "id": "M2", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 50.0380039, - "longitude": 14.3918109 - }, - "instruction": "Take the street on the left, Slivenecká. Go for 343 m.", - "travelTime": 354, - "length": 343, - "id": "M3", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 50.0376499, - "longitude": 14.3871975 - }, - "instruction": "Turn left onto Slivenecká. Go for 64 m.", - "travelTime": 72, - "length": 64, - "id": "M4", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 50.0373602, - "longitude": 14.3879807 - }, - "instruction": "Turn right onto Slivenecká. Go for 91 m.", - "travelTime": 95, - "length": 91, - "id": "M5", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 50.0365448, - "longitude": 14.3878305 - }, - "instruction": "Turn left onto K Barrandovu. Go for 124 m.", - "travelTime": 126, - "length": 124, - "id": "M6", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 50.0363168, - "longitude": 14.3894618 - }, - "instruction": "Go to the Tram station Geologicka and take the rail 5 toward Ústřední dílny DP. Follow for 13 stations.", - "travelTime": 1440, - "length": 6911, - "id": "M7", - "stopName": "Geologicka", - "_type": "PublicTransportManeuverType" - }, - { - "position": { - "latitude": 50.0800508, - "longitude": 14.423403 - }, - "instruction": "Get off at Vodickova.", - "travelTime": 0, - "length": 0, - "id": "M8", - "stopName": "Vodickova", - "nextRoadName": "Vodičkova", - "_type": "PublicTransportManeuverType" - }, - { - "position": { - "latitude": 50.0800508, - "longitude": 14.423403 - }, - "instruction": "Head northeast on Vodičkova. Go for 65 m.", - "travelTime": 74, - "length": 65, - "id": "M9", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 50.0804901, - "longitude": 14.4239759 - }, - "instruction": "Turn right onto V Jámě. Go for 163 m.", - "travelTime": 174, - "length": 163, - "id": "M10", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 50.0796962, - "longitude": 14.4258857 - }, - "instruction": "Turn left onto Štěpánská. Go for 22 m.", - "travelTime": 22, - "length": 22, - "id": "M11", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 50.0798786, - "longitude": 14.4260037 - }, - "instruction": "Arrive at Štěpánská. Your destination is on the left.", - "travelTime": 0, - "length": 0, - "id": "M12", - "_type": "PrivateTransportManeuverType" - } - ] - } - ], - "publicTransportLine": [ - { - "lineName": "5", - "lineForeground": "#F5ADCE", - "lineBackground": "#F5ADCE", - "companyName": "HERE Technologies", - "destination": "Ústřední dílny DP", - "type": "railLight", - "id": "L1" - } - ], - "summary": { - "distance": 7835, - "baseTime": 2413, - "flags": [ - "noThroughRoad", - "builtUpArea" - ], - "text": "The trip takes 7.8 km and 40 mins.", - "travelTime": 2413, - "departure": "2019-09-21T17:16:17+02:00", - "timetableExpiration": "2019-09-21T00:00:00Z", - "_type": "PublicTransportRouteSummaryType" - } - } - ], - "language": "en-us", - "sourceAttribution": { - "attribution": "With the support of HERE Technologies. All information is provided without warranty of any kind.", - "supplier": [ - { - "title": "HERE Technologies", - "href": "https://transit.api.here.com/r?appId=Mt1bOYh3m9uxE7r3wuUx&u=https://wego.here.com" - } - ] - } - } -} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/bike_response.json b/tests/fixtures/here_travel_time/bike_response.json deleted file mode 100644 index a3af39129d0..00000000000 --- a/tests/fixtures/here_travel_time/bike_response.json +++ /dev/null @@ -1,274 +0,0 @@ -{ - "response": { - "metaInfo": { - "timestamp": "2019-07-24T10:17:40Z", - "mapVersion": "8.30.98.154", - "moduleVersion": "7.2.201929-4522", - "interfaceVersion": "2.6.64", - "availableMapVersion": [ - "8.30.98.154" - ] - }, - "route": [ - { - "waypoint": [ - { - "linkId": "-1230414527", - "mappedPosition": { - "latitude": 41.9797859, - "longitude": -87.8790879 - }, - "originalPosition": { - "latitude": 41.9798, - "longitude": -87.8801 - }, - "type": "stopOver", - "spot": 0.5079365, - "sideOfStreet": "right", - "mappedRoadName": "Mannheim Rd", - "label": "Mannheim Rd - US-12", - "shapeIndex": 0, - "source": "user" - }, - { - "linkId": "+924115108", - "mappedPosition": { - "latitude": 41.90413, - "longitude": -87.9223502 - }, - "originalPosition": { - "latitude": 41.9043, - "longitude": -87.9216001 - }, - "type": "stopOver", - "spot": 0.1925926, - "sideOfStreet": "right", - "mappedRoadName": "", - "label": "", - "shapeIndex": 87, - "source": "user" - } - ], - "mode": { - "type": "fastest", - "transportModes": [ - "bicycle" - ], - "trafficMode": "enabled", - "feature": [] - }, - "leg": [ - { - "start": { - "linkId": "-1230414527", - "mappedPosition": { - "latitude": 41.9797859, - "longitude": -87.8790879 - }, - "originalPosition": { - "latitude": 41.9798, - "longitude": -87.8801 - }, - "type": "stopOver", - "spot": 0.5079365, - "sideOfStreet": "right", - "mappedRoadName": "Mannheim Rd", - "label": "Mannheim Rd - US-12", - "shapeIndex": 0, - "source": "user" - }, - "end": { - "linkId": "+924115108", - "mappedPosition": { - "latitude": 41.90413, - "longitude": -87.9223502 - }, - "originalPosition": { - "latitude": 41.9043, - "longitude": -87.9216001 - }, - "type": "stopOver", - "spot": 0.1925926, - "sideOfStreet": "right", - "mappedRoadName": "", - "label": "", - "shapeIndex": 87, - "source": "user" - }, - "length": 12613, - "travelTime": 3292, - "maneuver": [ - { - "position": { - "latitude": 41.9797859, - "longitude": -87.8790879 - }, - "instruction": "Head south on Mannheim Rd (US-12/US-45). Go for 2.6 km.", - "travelTime": 646, - "length": 2648, - "id": "M1", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9579244, - "longitude": -87.8838551 - }, - "instruction": "Keep left onto Mannheim Rd (US-12/US-45). Go for 2.4 km.", - "travelTime": 621, - "length": 2427, - "id": "M2", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9364238, - "longitude": -87.8849387 - }, - "instruction": "Turn right onto W Belmont Ave. Go for 595 m.", - "travelTime": 158, - "length": 595, - "id": "M3", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9362521, - "longitude": -87.8921163 - }, - "instruction": "Turn left onto Cullerton St. Go for 669 m.", - "travelTime": 180, - "length": 669, - "id": "M4", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9305658, - "longitude": -87.8932428 - }, - "instruction": "Continue on N Landen Dr. Go for 976 m.", - "travelTime": 246, - "length": 976, - "id": "M5", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9217896, - "longitude": -87.8928781 - }, - "instruction": "Turn right onto E Fullerton Ave. Go for 904 m.", - "travelTime": 238, - "length": 904, - "id": "M6", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.921618, - "longitude": -87.9038107 - }, - "instruction": "Turn left onto N Wolf Rd. Go for 1.6 km.", - "travelTime": 417, - "length": 1604, - "id": "M7", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.907177, - "longitude": -87.9032314 - }, - "instruction": "Turn right onto W North Ave (IL-64). Go for 2.0 km.", - "travelTime": 574, - "length": 2031, - "id": "M8", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9065225, - "longitude": -87.9277039 - }, - "instruction": "Turn left onto N Clinton Ave. Go for 275 m.", - "travelTime": 78, - "length": 275, - "id": "M9", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9040549, - "longitude": -87.9277253 - }, - "instruction": "Turn left onto E Third St. Go for 249 m.", - "travelTime": 63, - "length": 249, - "id": "M10", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9040334, - "longitude": -87.9247105 - }, - "instruction": "Continue on N Caroline Ave. Go for 96 m.", - "travelTime": 37, - "length": 96, - "id": "M11", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9038832, - "longitude": -87.9236054 - }, - "instruction": "Turn slightly left. Go for 113 m.", - "travelTime": 28, - "length": 113, - "id": "M12", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9039047, - "longitude": -87.9222536 - }, - "instruction": "Turn left. Go for 26 m.", - "travelTime": 6, - "length": 26, - "id": "M13", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.90413, - "longitude": -87.9223502 - }, - "instruction": "Arrive at your destination on the right.", - "travelTime": 0, - "length": 0, - "id": "M14", - "_type": "PrivateTransportManeuverType" - } - ] - } - ], - "summary": { - "distance": 12613, - "baseTime": 3292, - "flags": [ - "noThroughRoad", - "builtUpArea", - "park" - ], - "text": "The trip takes 12.6 km and 55 mins.", - "travelTime": 3292, - "_type": "RouteSummaryType" - } - } - ], - "language": "en-us" - } -} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/car_enabled_response.json b/tests/fixtures/here_travel_time/car_enabled_response.json deleted file mode 100644 index 08da738f046..00000000000 --- a/tests/fixtures/here_travel_time/car_enabled_response.json +++ /dev/null @@ -1,298 +0,0 @@ -{ - "response": { - "metaInfo": { - "timestamp": "2019-07-21T21:21:31Z", - "mapVersion": "8.30.98.154", - "moduleVersion": "7.2.201928-4478", - "interfaceVersion": "2.6.64", - "availableMapVersion": [ - "8.30.98.154" - ] - }, - "route": [ - { - "waypoint": [ - { - "linkId": "-1128310200", - "mappedPosition": { - "latitude": 38.9026523, - "longitude": -77.048338 - }, - "originalPosition": { - "latitude": 38.9029809, - "longitude": -77.048338 - }, - "type": "stopOver", - "spot": 0.3538462, - "sideOfStreet": "right", - "mappedRoadName": "K St NW", - "label": "K St NW", - "shapeIndex": 0, - "source": "user" - }, - { - "linkId": "-18459081", - "mappedPosition": { - "latitude": 39.0422511, - "longitude": -77.1193526 - }, - "originalPosition": { - "latitude": 39.042158, - "longitude": -77.119116 - }, - "type": "stopOver", - "spot": 0.7253521, - "sideOfStreet": "left", - "mappedRoadName": "Commonwealth Dr", - "label": "Commonwealth Dr", - "shapeIndex": 283, - "source": "user" - } - ], - "mode": { - "type": "fastest", - "transportModes": [ - "car" - ], - "trafficMode": "enabled", - "feature": [] - }, - "leg": [ - { - "start": { - "linkId": "-1128310200", - "mappedPosition": { - "latitude": 38.9026523, - "longitude": -77.048338 - }, - "originalPosition": { - "latitude": 38.9029809, - "longitude": -77.048338 - }, - "type": "stopOver", - "spot": 0.3538462, - "sideOfStreet": "right", - "mappedRoadName": "K St NW", - "label": "K St NW", - "shapeIndex": 0, - "source": "user" - }, - "end": { - "linkId": "-18459081", - "mappedPosition": { - "latitude": 39.0422511, - "longitude": -77.1193526 - }, - "originalPosition": { - "latitude": 39.042158, - "longitude": -77.119116 - }, - "type": "stopOver", - "spot": 0.7253521, - "sideOfStreet": "left", - "mappedRoadName": "Commonwealth Dr", - "label": "Commonwealth Dr", - "shapeIndex": 283, - "source": "user" - }, - "length": 23381, - "travelTime": 1817, - "maneuver": [ - { - "position": { - "latitude": 38.9026523, - "longitude": -77.048338 - }, - "instruction": "Head toward 22nd St NW on K St NW. Go for 140 m.", - "travelTime": 36, - "length": 140, - "id": "M1", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 38.9027703, - "longitude": -77.0494902 - }, - "instruction": "Take the 3rd exit from Washington Cir NW roundabout onto K St NW. Go for 325 m.", - "travelTime": 81, - "length": 325, - "id": "M2", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 38.9026523, - "longitude": -77.0529449 - }, - "instruction": "Keep left onto K St NW (US-29). Go for 201 m.", - "travelTime": 29, - "length": 201, - "id": "M3", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 38.9025235, - "longitude": -77.0552516 - }, - "instruction": "Keep right onto Whitehurst Fwy (US-29). Go for 1.4 km.", - "travelTime": 143, - "length": 1381, - "id": "M4", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 38.9050448, - "longitude": -77.0701969 - }, - "instruction": "Turn left onto M St NW. Go for 784 m.", - "travelTime": 80, - "length": 784, - "id": "M5", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 38.9060318, - "longitude": -77.0790696 - }, - "instruction": "Turn slightly left onto Canal Rd NW. Go for 4.2 km.", - "travelTime": 287, - "length": 4230, - "id": "M6", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 38.9303219, - "longitude": -77.1117926 - }, - "instruction": "Continue on Clara Barton Pkwy. Go for 844 m.", - "travelTime": 55, - "length": 844, - "id": "M7", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 38.9368558, - "longitude": -77.1166742 - }, - "instruction": "Continue on Clara Barton Pkwy. Go for 4.7 km.", - "travelTime": 294, - "length": 4652, - "id": "M8", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 38.9706838, - "longitude": -77.1461463 - }, - "instruction": "Keep right onto Cabin John Pkwy N toward I-495 N. Go for 2.1 km.", - "travelTime": 90, - "length": 2069, - "id": "M9", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 38.9858222, - "longitude": -77.1571326 - }, - "instruction": "Take left ramp onto I-495 N (Capital Beltway). Go for 2.9 km.", - "travelTime": 129, - "length": 2890, - "id": "M10", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 39.0104449, - "longitude": -77.1508026 - }, - "instruction": "Keep left onto I-270-SPUR toward I-270/Rockville/Frederick. Go for 1.1 km.", - "travelTime": 48, - "length": 1136, - "id": "M11", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 39.0192747, - "longitude": -77.144773 - }, - "instruction": "Take exit 1 toward Democracy Blvd/Old Georgetown Rd/MD-187 onto Democracy Blvd. Go for 1.8 km.", - "travelTime": 205, - "length": 1818, - "id": "M12", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 39.0247464, - "longitude": -77.1253431 - }, - "instruction": "Turn left onto Old Georgetown Rd (MD-187). Go for 2.3 km.", - "travelTime": 230, - "length": 2340, - "id": "M13", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 39.0447772, - "longitude": -77.1203649 - }, - "instruction": "Turn right onto Nicholson Ln. Go for 208 m.", - "travelTime": 31, - "length": 208, - "id": "M14", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 39.0448952, - "longitude": -77.1179724 - }, - "instruction": "Turn right onto Commonwealth Dr. Go for 341 m.", - "travelTime": 75, - "length": 341, - "id": "M15", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 39.0422511, - "longitude": -77.1193526 - }, - "instruction": "Arrive at Commonwealth Dr. Your destination is on the left.", - "travelTime": 4, - "length": 22, - "id": "M16", - "_type": "PrivateTransportManeuverType" - } - ] - } - ], - "summary": { - "distance": 23381, - "trafficTime": 1782, - "baseTime": 1712, - "flags": [ - "noThroughRoad", - "motorway", - "builtUpArea", - "park" - ], - "text": "The trip takes 23.4 km and 30 mins.", - "travelTime": 1782, - "_type": "RouteSummaryType" - } - } - ], - "language": "en-us" - } -} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/car_shortest_response.json b/tests/fixtures/here_travel_time/car_shortest_response.json deleted file mode 100644 index 765c438c1cd..00000000000 --- a/tests/fixtures/here_travel_time/car_shortest_response.json +++ /dev/null @@ -1,231 +0,0 @@ -{ - "response": { - "metaInfo": { - "timestamp": "2019-07-21T21:05:28Z", - "mapVersion": "8.30.98.154", - "moduleVersion": "7.2.201928-4478", - "interfaceVersion": "2.6.64", - "availableMapVersion": [ - "8.30.98.154" - ] - }, - "route": [ - { - "waypoint": [ - { - "linkId": "-1128310200", - "mappedPosition": { - "latitude": 38.9026523, - "longitude": -77.048338 - }, - "originalPosition": { - "latitude": 38.9029809, - "longitude": -77.048338 - }, - "type": "stopOver", - "spot": 0.3538462, - "sideOfStreet": "right", - "mappedRoadName": "K St NW", - "label": "K St NW", - "shapeIndex": 0, - "source": "user" - }, - { - "linkId": "-18459081", - "mappedPosition": { - "latitude": 39.0422511, - "longitude": -77.1193526 - }, - "originalPosition": { - "latitude": 39.042158, - "longitude": -77.119116 - }, - "type": "stopOver", - "spot": 0.7253521, - "sideOfStreet": "left", - "mappedRoadName": "Commonwealth Dr", - "label": "Commonwealth Dr", - "shapeIndex": 162, - "source": "user" - } - ], - "mode": { - "type": "shortest", - "transportModes": [ - "car" - ], - "trafficMode": "enabled", - "feature": [] - }, - "leg": [ - { - "start": { - "linkId": "-1128310200", - "mappedPosition": { - "latitude": 38.9026523, - "longitude": -77.048338 - }, - "originalPosition": { - "latitude": 38.9029809, - "longitude": -77.048338 - }, - "type": "stopOver", - "spot": 0.3538462, - "sideOfStreet": "right", - "mappedRoadName": "K St NW", - "label": "K St NW", - "shapeIndex": 0, - "source": "user" - }, - "end": { - "linkId": "-18459081", - "mappedPosition": { - "latitude": 39.0422511, - "longitude": -77.1193526 - }, - "originalPosition": { - "latitude": 39.042158, - "longitude": -77.119116 - }, - "type": "stopOver", - "spot": 0.7253521, - "sideOfStreet": "left", - "mappedRoadName": "Commonwealth Dr", - "label": "Commonwealth Dr", - "shapeIndex": 162, - "source": "user" - }, - "length": 18388, - "travelTime": 2493, - "maneuver": [ - { - "position": { - "latitude": 38.9026523, - "longitude": -77.048338 - }, - "instruction": "Head west on K St NW. Go for 79 m.", - "travelTime": 22, - "length": 79, - "id": "M1", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 38.9026523, - "longitude": -77.048825 - }, - "instruction": "Turn right onto 22nd St NW. Go for 141 m.", - "travelTime": 79, - "length": 141, - "id": "M2", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 38.9039075, - "longitude": -77.048825 - }, - "instruction": "Keep left onto 22nd St NW. Go for 841 m.", - "travelTime": 256, - "length": 841, - "id": "M3", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 38.9114928, - "longitude": -77.0487821 - }, - "instruction": "Turn left onto Massachusetts Ave NW. Go for 145 m.", - "travelTime": 22, - "length": 145, - "id": "M4", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 38.9120293, - "longitude": -77.0502949 - }, - "instruction": "Take the 1st exit from Massachusetts Ave NW roundabout onto Massachusetts Ave NW. Go for 2.8 km.", - "travelTime": 301, - "length": 2773, - "id": "M5", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 38.9286053, - "longitude": -77.073158 - }, - "instruction": "Turn right onto Wisconsin Ave NW. Go for 3.8 km.", - "travelTime": 610, - "length": 3801, - "id": "M6", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 38.9607918, - "longitude": -77.0857322 - }, - "instruction": "Continue on Wisconsin Ave (MD-355). Go for 9.7 km.", - "travelTime": 1013, - "length": 9686, - "id": "M7", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 39.0447664, - "longitude": -77.1116638 - }, - "instruction": "Turn left onto Nicholson Ln. Go for 559 m.", - "travelTime": 111, - "length": 559, - "id": "M8", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 39.0448952, - "longitude": -77.1179724 - }, - "instruction": "Turn left onto Commonwealth Dr. Go for 341 m.", - "travelTime": 75, - "length": 341, - "id": "M9", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 39.0422511, - "longitude": -77.1193526 - }, - "instruction": "Arrive at Commonwealth Dr. Your destination is on the left.", - "travelTime": 4, - "length": 22, - "id": "M10", - "_type": "PrivateTransportManeuverType" - } - ] - } - ], - "summary": { - "distance": 18388, - "trafficTime": 2427, - "baseTime": 2150, - "flags": [ - "noThroughRoad", - "builtUpArea", - "park" - ], - "text": "The trip takes 18.4 km and 40 mins.", - "travelTime": 2427, - "_type": "RouteSummaryType" - } - } - ], - "language": "en-us" - } -} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/pedestrian_response.json b/tests/fixtures/here_travel_time/pedestrian_response.json deleted file mode 100644 index 07881e8bd3d..00000000000 --- a/tests/fixtures/here_travel_time/pedestrian_response.json +++ /dev/null @@ -1,308 +0,0 @@ -{ - "response": { - "metaInfo": { - "timestamp": "2019-07-21T18:40:10Z", - "mapVersion": "8.30.98.154", - "moduleVersion": "7.2.201928-4478", - "interfaceVersion": "2.6.64", - "availableMapVersion": [ - "8.30.98.154" - ] - }, - "route": [ - { - "waypoint": [ - { - "linkId": "-1230414527", - "mappedPosition": { - "latitude": 41.9797859, - "longitude": -87.8790879 - }, - "originalPosition": { - "latitude": 41.9798, - "longitude": -87.8801 - }, - "type": "stopOver", - "spot": 0.5079365, - "sideOfStreet": "right", - "mappedRoadName": "Mannheim Rd", - "label": "Mannheim Rd - US-12", - "shapeIndex": 0, - "source": "user" - }, - { - "linkId": "+924115108", - "mappedPosition": { - "latitude": 41.90413, - "longitude": -87.9223502 - }, - "originalPosition": { - "latitude": 41.9043, - "longitude": -87.9216001 - }, - "type": "stopOver", - "spot": 0.1925926, - "sideOfStreet": "right", - "mappedRoadName": "", - "label": "", - "shapeIndex": 122, - "source": "user" - } - ], - "mode": { - "type": "fastest", - "transportModes": [ - "pedestrian" - ], - "trafficMode": "disabled", - "feature": [] - }, - "leg": [ - { - "start": { - "linkId": "-1230414527", - "mappedPosition": { - "latitude": 41.9797859, - "longitude": -87.8790879 - }, - "originalPosition": { - "latitude": 41.9798, - "longitude": -87.8801 - }, - "type": "stopOver", - "spot": 0.5079365, - "sideOfStreet": "right", - "mappedRoadName": "Mannheim Rd", - "label": "Mannheim Rd - US-12", - "shapeIndex": 0, - "source": "user" - }, - "end": { - "linkId": "+924115108", - "mappedPosition": { - "latitude": 41.90413, - "longitude": -87.9223502 - }, - "originalPosition": { - "latitude": 41.9043, - "longitude": -87.9216001 - }, - "type": "stopOver", - "spot": 0.1925926, - "sideOfStreet": "right", - "mappedRoadName": "", - "label": "", - "shapeIndex": 122, - "source": "user" - }, - "length": 12533, - "travelTime": 12631, - "maneuver": [ - { - "position": { - "latitude": 41.9797859, - "longitude": -87.8790879 - }, - "instruction": "Head south on Mannheim Rd. Go for 848 m.", - "travelTime": 848, - "length": 848, - "id": "M1", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9722581, - "longitude": -87.8776109 - }, - "instruction": "Take the street on the left, Mannheim Rd. Go for 4.2 km.", - "travelTime": 4239, - "length": 4227, - "id": "M2", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9364238, - "longitude": -87.8849387 - }, - "instruction": "Turn right onto W Belmont Ave. Go for 595 m.", - "travelTime": 605, - "length": 595, - "id": "M3", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9362521, - "longitude": -87.8921163 - }, - "instruction": "Turn left onto Cullerton St. Go for 406 m.", - "travelTime": 411, - "length": 406, - "id": "M4", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9326043, - "longitude": -87.8919983 - }, - "instruction": "Turn right onto Cullerton St. Go for 1.2 km.", - "travelTime": 1249, - "length": 1239, - "id": "M5", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9217896, - "longitude": -87.8928781 - }, - "instruction": "Turn right onto E Fullerton Ave. Go for 786 m.", - "travelTime": 796, - "length": 786, - "id": "M6", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9216394, - "longitude": -87.9023838 - }, - "instruction": "Turn left onto La Porte Ave. Go for 424 m.", - "travelTime": 430, - "length": 424, - "id": "M7", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9180024, - "longitude": -87.9028559 - }, - "instruction": "Turn right onto E Palmer Ave. Go for 864 m.", - "travelTime": 875, - "length": 864, - "id": "M8", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9175196, - "longitude": -87.9132199 - }, - "instruction": "Turn left onto N Railroad Ave. Go for 1.2 km.", - "travelTime": 1180, - "length": 1170, - "id": "M9", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9070268, - "longitude": -87.9130161 - }, - "instruction": "Turn right onto W North Ave. Go for 638 m.", - "travelTime": 638, - "length": 638, - "id": "M10", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9068551, - "longitude": -87.9207087 - }, - "instruction": "Take the street on the left, E North Ave. Go for 354 m.", - "travelTime": 354, - "length": 354, - "id": "M11", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9065869, - "longitude": -87.9249573 - }, - "instruction": "Take the street on the left, E North Ave. Go for 228 m.", - "travelTime": 242, - "length": 228, - "id": "M12", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9065225, - "longitude": -87.9277039 - }, - "instruction": "Turn left. Go for 409 m.", - "travelTime": 419, - "length": 409, - "id": "M13", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9040334, - "longitude": -87.9260409 - }, - "instruction": "Turn left onto E Third St. Go for 206 m.", - "travelTime": 206, - "length": 206, - "id": "M14", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9038832, - "longitude": -87.9236054 - }, - "instruction": "Turn left. Go for 113 m.", - "travelTime": 113, - "length": 113, - "id": "M15", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9039047, - "longitude": -87.9222536 - }, - "instruction": "Turn left. Go for 26 m.", - "travelTime": 26, - "length": 26, - "id": "M16", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.90413, - "longitude": -87.9223502 - }, - "instruction": "Arrive at your destination on the right.", - "travelTime": 0, - "length": 0, - "id": "M17", - "_type": "PrivateTransportManeuverType" - } - ] - } - ], - "summary": { - "distance": 12533, - "baseTime": 12631, - "flags": [ - "noThroughRoad", - "builtUpArea", - "park", - "privateRoad" - ], - "text": "The trip takes 12.5 km and 3:31 h.", - "travelTime": 12631, - "_type": "RouteSummaryType" - } - } - ], - "language": "en-us" - } -} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/public_response.json b/tests/fixtures/here_travel_time/public_response.json deleted file mode 100644 index 149b4d06c39..00000000000 --- a/tests/fixtures/here_travel_time/public_response.json +++ /dev/null @@ -1,294 +0,0 @@ -{ - "response": { - "metaInfo": { - "timestamp": "2019-07-21T18:40:37Z", - "mapVersion": "8.30.98.154", - "moduleVersion": "7.2.201928-4478", - "interfaceVersion": "2.6.64", - "availableMapVersion": [ - "8.30.98.154" - ] - }, - "route": [ - { - "waypoint": [ - { - "linkId": "-1230414527", - "mappedPosition": { - "latitude": 41.9797859, - "longitude": -87.8790879 - }, - "originalPosition": { - "latitude": 41.9798, - "longitude": -87.8801 - }, - "type": "stopOver", - "spot": 0.5079365, - "sideOfStreet": "right", - "mappedRoadName": "Mannheim Rd", - "label": "Mannheim Rd - US-12", - "shapeIndex": 0, - "source": "user" - }, - { - "linkId": "+924115108", - "mappedPosition": { - "latitude": 41.90413, - "longitude": -87.9223502 - }, - "originalPosition": { - "latitude": 41.9043, - "longitude": -87.9216001 - }, - "type": "stopOver", - "spot": 0.1925926, - "sideOfStreet": "right", - "mappedRoadName": "", - "label": "", - "shapeIndex": 191, - "source": "user" - } - ], - "mode": { - "type": "fastest", - "transportModes": [ - "publicTransport" - ], - "trafficMode": "disabled", - "feature": [] - }, - "leg": [ - { - "start": { - "linkId": "-1230414527", - "mappedPosition": { - "latitude": 41.9797859, - "longitude": -87.8790879 - }, - "originalPosition": { - "latitude": 41.9798, - "longitude": -87.8801 - }, - "type": "stopOver", - "spot": 0.5079365, - "sideOfStreet": "right", - "mappedRoadName": "Mannheim Rd", - "label": "Mannheim Rd - US-12", - "shapeIndex": 0, - "source": "user" - }, - "end": { - "linkId": "+924115108", - "mappedPosition": { - "latitude": 41.90413, - "longitude": -87.9223502 - }, - "originalPosition": { - "latitude": 41.9043, - "longitude": -87.9216001 - }, - "type": "stopOver", - "spot": 0.1925926, - "sideOfStreet": "right", - "mappedRoadName": "", - "label": "", - "shapeIndex": 191, - "source": "user" - }, - "length": 22325, - "travelTime": 5350, - "maneuver": [ - { - "position": { - "latitude": 41.9797859, - "longitude": -87.8790879 - }, - "instruction": "Head south on Mannheim Rd. Go for 848 m.", - "travelTime": 848, - "length": 848, - "id": "M1", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9722581, - "longitude": -87.8776109 - }, - "instruction": "Take the street on the left, Mannheim Rd. Go for 825 m.", - "travelTime": 825, - "length": 825, - "id": "M2", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9650483, - "longitude": -87.8769565 - }, - "instruction": "Go to the stop Mannheim/Lawrence and take the bus 332 toward Palmer/Schiller. Follow for 7 stops.", - "travelTime": 475, - "length": 4360, - "id": "M3", - "stopName": "Mannheim/Lawrence", - "_type": "PublicTransportManeuverType" - }, - { - "position": { - "latitude": 41.9541478, - "longitude": -87.9133594 - }, - "instruction": "Get off at Irving Park/Taft.", - "travelTime": 0, - "length": 0, - "id": "M4", - "stopName": "Irving Park/Taft", - "_type": "PublicTransportManeuverType" - }, - { - "position": { - "latitude": 41.9541478, - "longitude": -87.9133594 - }, - "instruction": "Take the bus 332 toward Cargo Rd./Delta Cargo. Follow for 1 stop.", - "travelTime": 155, - "length": 3505, - "id": "M5", - "stopName": "Irving Park/Taft", - "_type": "PublicTransportManeuverType" - }, - { - "position": { - "latitude": 41.9599199, - "longitude": -87.9162776 - }, - "instruction": "Get off at Cargo Rd./S. Access Rd./Lufthansa.", - "travelTime": 0, - "length": 0, - "id": "M6", - "stopName": "Cargo Rd./S. Access Rd./Lufthansa", - "_type": "PublicTransportManeuverType" - }, - { - "position": { - "latitude": 41.9599199, - "longitude": -87.9162776 - }, - "instruction": "Take the bus 332 toward Palmer/Schiller. Follow for 41 stops.", - "travelTime": 1510, - "length": 11261, - "id": "M7", - "stopName": "Cargo Rd./S. Access Rd./Lufthansa", - "_type": "PublicTransportManeuverType" - }, - { - "position": { - "latitude": 41.9041729, - "longitude": -87.9399669 - }, - "instruction": "Get off at York/Third.", - "travelTime": 0, - "length": 0, - "id": "M8", - "stopName": "York/Third", - "nextRoadName": "N York St", - "_type": "PublicTransportManeuverType" - }, - { - "position": { - "latitude": 41.9041729, - "longitude": -87.9399669 - }, - "instruction": "Head east on N York St. Go for 33 m.", - "travelTime": 43, - "length": 33, - "id": "M9", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9039476, - "longitude": -87.9398811 - }, - "instruction": "Turn left onto E Third St. Go for 1.4 km.", - "travelTime": 1355, - "length": 1354, - "id": "M10", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9038832, - "longitude": -87.9236054 - }, - "instruction": "Turn left. Go for 113 m.", - "travelTime": 113, - "length": 113, - "id": "M11", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9039047, - "longitude": -87.9222536 - }, - "instruction": "Turn left. Go for 26 m.", - "travelTime": 26, - "length": 26, - "id": "M12", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.90413, - "longitude": -87.9223502 - }, - "instruction": "Arrive at your destination on the right.", - "travelTime": 0, - "length": 0, - "id": "M13", - "_type": "PrivateTransportManeuverType" - } - ] - } - ], - "publicTransportLine": [ - { - "lineName": "332", - "companyName": "", - "destination": "Palmer/Schiller", - "type": "busPublic", - "id": "L1" - }, - { - "lineName": "332", - "companyName": "", - "destination": "Cargo Rd./Delta Cargo", - "type": "busPublic", - "id": "L2" - }, - { - "lineName": "332", - "companyName": "", - "destination": "Palmer/Schiller", - "type": "busPublic", - "id": "L3" - } - ], - "summary": { - "distance": 22325, - "baseTime": 5350, - "flags": [ - "noThroughRoad", - "builtUpArea", - "park" - ], - "text": "The trip takes 22.3 km and 1:29 h.", - "travelTime": 5350, - "departure": "1970-01-01T00:00:00Z", - "_type": "PublicTransportRouteSummaryType" - } - } - ], - "language": "en-us" - } -} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/public_time_table_response.json b/tests/fixtures/here_travel_time/public_time_table_response.json deleted file mode 100644 index 52df0d4eb35..00000000000 --- a/tests/fixtures/here_travel_time/public_time_table_response.json +++ /dev/null @@ -1,308 +0,0 @@ -{ - "response": { - "metaInfo": { - "timestamp": "2019-08-06T06:43:24Z", - "mapVersion": "8.30.99.152", - "moduleVersion": "7.2.201931-4739", - "interfaceVersion": "2.6.66", - "availableMapVersion": [ - "8.30.99.152" - ] - }, - "route": [ - { - "waypoint": [ - { - "linkId": "-1230414527", - "mappedPosition": { - "latitude": 41.9797859, - "longitude": -87.8790879 - }, - "originalPosition": { - "latitude": 41.9798, - "longitude": -87.8801 - }, - "type": "stopOver", - "spot": 0.5079365, - "sideOfStreet": "right", - "mappedRoadName": "Mannheim Rd", - "label": "Mannheim Rd - US-12", - "shapeIndex": 0, - "source": "user" - }, - { - "linkId": "+924115108", - "mappedPosition": { - "latitude": 41.90413, - "longitude": -87.9223502 - }, - "originalPosition": { - "latitude": 41.9043, - "longitude": -87.9216001 - }, - "type": "stopOver", - "spot": 0.1925926, - "sideOfStreet": "right", - "mappedRoadName": "", - "label": "", - "shapeIndex": 111, - "source": "user" - } - ], - "mode": { - "type": "fastest", - "transportModes": [ - "publicTransportTimeTable" - ], - "trafficMode": "disabled", - "feature": [] - }, - "leg": [ - { - "start": { - "linkId": "-1230414527", - "mappedPosition": { - "latitude": 41.9797859, - "longitude": -87.8790879 - }, - "originalPosition": { - "latitude": 41.9798, - "longitude": -87.8801 - }, - "type": "stopOver", - "spot": 0.5079365, - "sideOfStreet": "right", - "mappedRoadName": "Mannheim Rd", - "label": "Mannheim Rd - US-12", - "shapeIndex": 0, - "source": "user" - }, - "end": { - "linkId": "+924115108", - "mappedPosition": { - "latitude": 41.90413, - "longitude": -87.9223502 - }, - "originalPosition": { - "latitude": 41.9043, - "longitude": -87.9216001 - }, - "type": "stopOver", - "spot": 0.1925926, - "sideOfStreet": "right", - "mappedRoadName": "", - "label": "", - "shapeIndex": 111, - "source": "user" - }, - "length": 14775, - "travelTime": 4784, - "maneuver": [ - { - "position": { - "latitude": 41.9797859, - "longitude": -87.8790879 - }, - "instruction": "Head south on Mannheim Rd. Go for 848 m.", - "travelTime": 848, - "length": 848, - "id": "M1", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9722581, - "longitude": -87.8776109 - }, - "instruction": "Take the street on the left, Mannheim Rd. Go for 812 m.", - "travelTime": 812, - "length": 812, - "id": "M2", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.965051, - "longitude": -87.8769591 - }, - "instruction": "Go to the Bus stop Mannheim/Lawrence and take the bus 330 toward Archer/Harlem (Terminal). Follow for 33 stops.", - "travelTime": 900, - "length": 7815, - "id": "M3", - "stopName": "Mannheim/Lawrence", - "_type": "PublicTransportManeuverType" - }, - { - "position": { - "latitude": 41.896836, - "longitude": -87.883771 - }, - "instruction": "Get off at Mannheim/Lake.", - "travelTime": 0, - "length": 0, - "id": "M4", - "stopName": "Mannheim/Lake", - "_type": "PublicTransportManeuverType" - }, - { - "position": { - "latitude": 41.896836, - "longitude": -87.883771 - }, - "instruction": "Walk to Bus Lake/Mannheim.", - "travelTime": 300, - "length": 72, - "id": "M5", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.897263, - "longitude": -87.8842648 - }, - "instruction": "Take the bus 309 toward Elmhurst Metra Station. Follow for 18 stops.", - "travelTime": 1020, - "length": 4362, - "id": "M6", - "stopName": "Lake/Mannheim", - "_type": "PublicTransportManeuverType" - }, - { - "position": { - "latitude": 41.9066347, - "longitude": -87.928671 - }, - "instruction": "Get off at North/Berteau.", - "travelTime": 0, - "length": 0, - "id": "M7", - "stopName": "North/Berteau", - "nextRoadName": "E Berteau Ave", - "_type": "PublicTransportManeuverType" - }, - { - "position": { - "latitude": 41.9066347, - "longitude": -87.928671 - }, - "instruction": "Head north on E Berteau Ave. Go for 23 m.", - "travelTime": 40, - "length": 23, - "id": "M8", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9067693, - "longitude": -87.9284549 - }, - "instruction": "Turn right onto E Berteau Ave. Go for 40 m.", - "travelTime": 44, - "length": 40, - "id": "M9", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9065011, - "longitude": -87.9282939 - }, - "instruction": "Turn left onto E North Ave. Go for 49 m.", - "travelTime": 56, - "length": 49, - "id": "M10", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9065225, - "longitude": -87.9277039 - }, - "instruction": "Turn slightly right. Go for 409 m.", - "travelTime": 419, - "length": 409, - "id": "M11", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9040334, - "longitude": -87.9260409 - }, - "instruction": "Turn left onto E Third St. Go for 206 m.", - "travelTime": 206, - "length": 206, - "id": "M12", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9038832, - "longitude": -87.9236054 - }, - "instruction": "Turn left. Go for 113 m.", - "travelTime": 113, - "length": 113, - "id": "M13", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9039047, - "longitude": -87.9222536 - }, - "instruction": "Turn left. Go for 26 m.", - "travelTime": 26, - "length": 26, - "id": "M14", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.90413, - "longitude": -87.9223502 - }, - "instruction": "Arrive at your destination on the right.", - "travelTime": 0, - "length": 0, - "id": "M15", - "_type": "PrivateTransportManeuverType" - } - ] - } - ], - "publicTransportLine": [ - { - "lineName": "330", - "companyName": "PACE", - "destination": "Archer/Harlem (Terminal)", - "type": "busPublic", - "id": "L1" - }, - { - "lineName": "309", - "companyName": "PACE", - "destination": "Elmhurst Metra Station", - "type": "busPublic", - "id": "L2" - } - ], - "summary": { - "distance": 14775, - "baseTime": 4784, - "flags": [ - "noThroughRoad", - "builtUpArea", - "park" - ], - "text": "The trip takes 14.8 km and 1:20 h.", - "travelTime": 4784, - "departure": "2019-08-06T05:09:20-05:00", - "timetableExpiration": "2019-08-04T00:00:00Z", - "_type": "PublicTransportRouteSummaryType" - } - } - ], - "language": "en-us" - } -} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/routing_error_invalid_credentials.json b/tests/fixtures/here_travel_time/routing_error_invalid_credentials.json deleted file mode 100644 index 81fb246178c..00000000000 --- a/tests/fixtures/here_travel_time/routing_error_invalid_credentials.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "_type": "ns2:RoutingServiceErrorType", - "type": "PermissionError", - "subtype": "InvalidCredentials", - "details": "This is not a valid app_id and app_code pair. Please verify that the values are not swapped between the app_id and app_code and the values provisioned by HERE (either by your customer representative or via http://developer.here.com/myapps) were copied correctly into the request.", - "metaInfo": { - "timestamp": "2019-07-10T09:43:14Z", - "mapVersion": "8.30.98.152", - "moduleVersion": "7.2.201927-4307", - "interfaceVersion": "2.6.64", - "availableMapVersion": [ - "8.30.98.152" - ] - } -} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/routing_error_no_route_found.json b/tests/fixtures/here_travel_time/routing_error_no_route_found.json deleted file mode 100644 index a776fa91c43..00000000000 --- a/tests/fixtures/here_travel_time/routing_error_no_route_found.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "_type": "ns2:RoutingServiceErrorType", - "type": "ApplicationError", - "subtype": "NoRouteFound", - "details": "Error is NGEO_ERROR_ROUTE_NO_END_POINT", - "additionalData": [ - { - "key": "error_code", - "value": "NGEO_ERROR_ROUTE_NO_END_POINT" - } - ], - "metaInfo": { - "timestamp": "2019-07-10T09:51:04Z", - "mapVersion": "8.30.98.152", - "moduleVersion": "7.2.201927-4307", - "interfaceVersion": "2.6.64", - "availableMapVersion": [ - "8.30.98.152" - ] - } -} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/truck_response.json b/tests/fixtures/here_travel_time/truck_response.json deleted file mode 100644 index a302d564902..00000000000 --- a/tests/fixtures/here_travel_time/truck_response.json +++ /dev/null @@ -1,187 +0,0 @@ -{ - "response": { - "metaInfo": { - "timestamp": "2019-07-21T14:25:00Z", - "mapVersion": "8.30.98.154", - "moduleVersion": "7.2.201928-4478", - "interfaceVersion": "2.6.64", - "availableMapVersion": [ - "8.30.98.154" - ] - }, - "route": [ - { - "waypoint": [ - { - "linkId": "+930461269", - "mappedPosition": { - "latitude": 41.9800687, - "longitude": -87.8805614 - }, - "originalPosition": { - "latitude": 41.9798, - "longitude": -87.8801 - }, - "type": "stopOver", - "spot": 0.5555556, - "sideOfStreet": "right", - "mappedRoadName": "", - "label": "", - "shapeIndex": 0, - "source": "user" - }, - { - "linkId": "-1035319462", - "mappedPosition": { - "latitude": 41.9042909, - "longitude": -87.9216528 - }, - "originalPosition": { - "latitude": 41.9043, - "longitude": -87.9216001 - }, - "type": "stopOver", - "spot": 0, - "sideOfStreet": "left", - "mappedRoadName": "Eisenhower Expy E", - "label": "Eisenhower Expy E - I-290", - "shapeIndex": 135, - "source": "user" - } - ], - "mode": { - "type": "fastest", - "transportModes": [ - "truck" - ], - "trafficMode": "disabled", - "feature": [] - }, - "leg": [ - { - "start": { - "linkId": "+930461269", - "mappedPosition": { - "latitude": 41.9800687, - "longitude": -87.8805614 - }, - "originalPosition": { - "latitude": 41.9798, - "longitude": -87.8801 - }, - "type": "stopOver", - "spot": 0.5555556, - "sideOfStreet": "right", - "mappedRoadName": "", - "label": "", - "shapeIndex": 0, - "source": "user" - }, - "end": { - "linkId": "-1035319462", - "mappedPosition": { - "latitude": 41.9042909, - "longitude": -87.9216528 - }, - "originalPosition": { - "latitude": 41.9043, - "longitude": -87.9216001 - }, - "type": "stopOver", - "spot": 0, - "sideOfStreet": "left", - "mappedRoadName": "Eisenhower Expy E", - "label": "Eisenhower Expy E - I-290", - "shapeIndex": 135, - "source": "user" - }, - "length": 13049, - "travelTime": 812, - "maneuver": [ - { - "position": { - "latitude": 41.9800687, - "longitude": -87.8805614 - }, - "instruction": "Take ramp onto I-190. Go for 631 m.", - "travelTime": 53, - "length": 631, - "id": "M1", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.98259, - "longitude": -87.8744352 - }, - "instruction": "Take exit 1D toward Indiana onto I-294 S (Tri-State Tollway). Go for 10.9 km.", - "travelTime": 573, - "length": 10872, - "id": "M2", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9059324, - "longitude": -87.9199362 - }, - "instruction": "Take exit 33 toward Rockford/US-20/IL-64 onto I-290 W (Eisenhower Expy W). Go for 475 m.", - "travelTime": 54, - "length": 475, - "id": "M3", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9067156, - "longitude": -87.9237771 - }, - "instruction": "Take exit 13B toward North Ave onto IL-64 W (E North Ave). Go for 435 m.", - "travelTime": 51, - "length": 435, - "id": "M4", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9065869, - "longitude": -87.9249573 - }, - "instruction": "Take ramp onto I-290 E (Eisenhower Expy E) toward Chicago/I-294 S. Go for 636 m.", - "travelTime": 81, - "length": 636, - "id": "M5", - "_type": "PrivateTransportManeuverType" - }, - { - "position": { - "latitude": 41.9042909, - "longitude": -87.9216528 - }, - "instruction": "Arrive at Eisenhower Expy E (I-290). Your destination is on the left.", - "travelTime": 0, - "length": 0, - "id": "M6", - "_type": "PrivateTransportManeuverType" - } - ] - } - ], - "summary": { - "distance": 13049, - "trafficTime": 812, - "baseTime": 812, - "flags": [ - "tollroad", - "motorway", - "builtUpArea" - ], - "text": "The trip takes 13.0 km and 14 mins.", - "travelTime": 812, - "_type": "RouteSummaryType" - } - } - ], - "language": "en-us" - } -} \ No newline at end of file diff --git a/tests/fixtures/netatmo/homesdata.json b/tests/fixtures/netatmo/homesdata.json deleted file mode 100644 index 9c5e985218f..00000000000 --- a/tests/fixtures/netatmo/homesdata.json +++ /dev/null @@ -1,430 +0,0 @@ -{ - "body": { - "homes": [ - { - "id": "91763b24c43d3e344f424e8b", - "name": "MYHOME", - "altitude": 112, - "coordinates": [ - 52.516263, - 13.377726 - ], - "country": "DE", - "timezone": "Europe/Berlin", - "rooms": [ - { - "id": "2746182631", - "name": "Livingroom", - "type": "livingroom", - "module_ids": [ - "12:34:56:00:01:ae" - ] - }, - { - "id": "3688132631", - "name": "Hall", - "type": "custom", - "module_ids": [ - "12:34:56:00:f1:62" - ] - }, - { - "id": "2833524037", - "name": "Entrada", - "type": "lobby", - "module_ids": [ - "12:34:56:03:a5:54" - ] - }, - { - "id": "2940411577", - "name": "Cocina", - "type": "kitchen", - "module_ids": [ - "12:34:56:03:a0:ac" - ] - } - ], - "modules": [ - { - "id": "12:34:56:00:fa:d0", - "type": "NAPlug", - "name": "Thermostat", - "setup_date": 1494963356, - "modules_bridged": [ - "12:34:56:00:01:ae", - "12:34:56:03:a0:ac", - "12:34:56:03:a5:54" - ] - }, - { - "id": "12:34:56:00:01:ae", - "type": "NATherm1", - "name": "Livingroom", - "setup_date": 1494963356, - "room_id": "2746182631", - "bridge": "12:34:56:00:fa:d0" - }, - { - "id": "12:34:56:03:a5:54", - "type": "NRV", - "name": "Valve1", - "setup_date": 1554549767, - "room_id": "2833524037", - "bridge": "12:34:56:00:fa:d0" - }, - { - "id": "12:34:56:03:a0:ac", - "type": "NRV", - "name": "Valve2", - "setup_date": 1554554444, - "room_id": "2940411577", - "bridge": "12:34:56:00:fa:d0" - }, - { - "id": "12:34:56:00:f1:62", - "type": "NACamera", - "name": "Hall", - "setup_date": 1544828430, - "room_id": "3688132631" - } - ], - "schedules": [ - { - "zones": [ - { - "type": 0, - "name": "Comfort", - "rooms_temp": [ - { - "temp": 21, - "room_id": "2746182631" - } - ], - "id": 0 - }, - { - "type": 1, - "name": "Night", - "rooms_temp": [ - { - "temp": 17, - "room_id": "2746182631" - } - ], - "id": 1 - }, - { - "type": 5, - "name": "Eco", - "rooms_temp": [ - { - "temp": 17, - "room_id": "2746182631" - } - ], - "id": 4 - } - ], - "timetable": [ - { - "zone_id": 1, - "m_offset": 0 - }, - { - "zone_id": 0, - "m_offset": 360 - }, - { - "zone_id": 4, - "m_offset": 420 - }, - { - "zone_id": 0, - "m_offset": 960 - }, - { - "zone_id": 1, - "m_offset": 1410 - }, - { - "zone_id": 0, - "m_offset": 1800 - }, - { - "zone_id": 4, - "m_offset": 1860 - }, - { - "zone_id": 0, - "m_offset": 2400 - }, - { - "zone_id": 1, - "m_offset": 2850 - }, - { - "zone_id": 0, - "m_offset": 3240 - }, - { - "zone_id": 4, - "m_offset": 3300 - }, - { - "zone_id": 0, - "m_offset": 3840 - }, - { - "zone_id": 1, - "m_offset": 4290 - }, - { - "zone_id": 0, - "m_offset": 4680 - }, - { - "zone_id": 4, - "m_offset": 4740 - }, - { - "zone_id": 0, - "m_offset": 5280 - }, - { - "zone_id": 1, - "m_offset": 5730 - }, - { - "zone_id": 0, - "m_offset": 6120 - }, - { - "zone_id": 4, - "m_offset": 6180 - }, - { - "zone_id": 0, - "m_offset": 6720 - }, - { - "zone_id": 1, - "m_offset": 7170 - }, - { - "zone_id": 0, - "m_offset": 7620 - }, - { - "zone_id": 1, - "m_offset": 8610 - }, - { - "zone_id": 0, - "m_offset": 9060 - }, - { - "zone_id": 1, - "m_offset": 10050 - } - ], - "hg_temp": 7, - "away_temp": 14, - "name": "Default", - "selected": true, - "id": "591b54a2764ff4d50d8b5795", - "type": "therm" - }, - { - "zones": [ - { - "type": 0, - "name": "Comfort", - "rooms_temp": [ - { - "temp": 21, - "room_id": "2746182631" - } - ], - "id": 0 - }, - { - "type": 1, - "name": "Night", - "rooms_temp": [ - { - "temp": 17, - "room_id": "2746182631" - } - ], - "id": 1 - }, - { - "type": 5, - "name": "Eco", - "rooms_temp": [ - { - "temp": 17, - "room_id": "2746182631" - } - ], - "id": 4 - } - ], - "timetable": [ - { - "zone_id": 1, - "m_offset": 0 - }, - { - "zone_id": 0, - "m_offset": 360 - }, - { - "zone_id": 4, - "m_offset": 420 - }, - { - "zone_id": 0, - "m_offset": 960 - }, - { - "zone_id": 1, - "m_offset": 1410 - }, - { - "zone_id": 0, - "m_offset": 1800 - }, - { - "zone_id": 4, - "m_offset": 1860 - }, - { - "zone_id": 0, - "m_offset": 2400 - }, - { - "zone_id": 1, - "m_offset": 2850 - }, - { - "zone_id": 0, - "m_offset": 3240 - }, - { - "zone_id": 4, - "m_offset": 3300 - }, - { - "zone_id": 0, - "m_offset": 3840 - }, - { - "zone_id": 1, - "m_offset": 4290 - }, - { - "zone_id": 0, - "m_offset": 4680 - }, - { - "zone_id": 4, - "m_offset": 4740 - }, - { - "zone_id": 0, - "m_offset": 5280 - }, - { - "zone_id": 1, - "m_offset": 5730 - }, - { - "zone_id": 0, - "m_offset": 6120 - }, - { - "zone_id": 4, - "m_offset": 6180 - }, - { - "zone_id": 0, - "m_offset": 6720 - }, - { - "zone_id": 1, - "m_offset": 7170 - }, - { - "zone_id": 0, - "m_offset": 7620 - }, - { - "zone_id": 1, - "m_offset": 8610 - }, - { - "zone_id": 0, - "m_offset": 9060 - }, - { - "zone_id": 1, - "m_offset": 10050 - } - ], - "hg_temp": 7, - "away_temp": 14, - "name": "Winter", - "id": "b1b54a2f45795764f59d50d8", - "type": "therm" - } - ], - "therm_setpoint_default_duration": 120, - "persons": [ - { - "id": "91827374-7e04-5298-83ad-a0cb8372dff1", - "pseudo": "John Doe", - "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c24b808a89f8d1730fb69ecdf2bb8b72039d2c69928b029d67fc40cb2d7" - }, - { - "id": "91827375-7e04-5298-83ae-a0cb8372dff2", - "pseudo": "Jane Doe", - "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c24b808a89f8d1730039d2c69928b029d67fc40cb2d7fb69ecdf2bb8b72" - }, - { - "id": "91827376-7e04-5298-83af-a0cb8372dff3", - "pseudo": "Richard Doe", - "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c2d1730fb69ecdf2bb8b72039d2c69928b029d67fc40cb2d74b808a89f8" - } - ], - "therm_mode": "schedule" - }, - { - "id": "91763b24c43d3e344f424e8c", - "altitude": 112, - "coordinates": [ - 52.516263, - 13.377726 - ], - "country": "DE", - "timezone": "Europe/Berlin", - "therm_setpoint_default_duration": 180, - "therm_mode": "schedule" - } - ], - "user": { - "email": "john@doe.com", - "language": "de-DE", - "locale": "de-DE", - "feel_like_algorithm": 0, - "unit_pressure": 0, - "unit_system": 0, - "unit_wind": 0, - "id": "91763b24c43d3e344f424e8b" - } - }, - "status": "ok", - "time_exec": 0.056135892868042, - "time_server": 1559171003 -} \ No newline at end of file diff --git a/tests/hassfest/test_requirements.py b/tests/hassfest/test_requirements.py index 079e77f909b..91496b7fa6f 100644 --- a/tests/hassfest/test_requirements.py +++ b/tests/hassfest/test_requirements.py @@ -45,7 +45,14 @@ def test_validate_requirements_format_wrongly_pinned(integration: Integration): def test_validate_requirements_format_ignore_pin_for_custom(integration: Integration): """Test requirement ignore pinning for custom.""" - integration.manifest["requirements"] = ["test_package>=1", "test_package"] + integration.manifest["requirements"] = [ + "test_package>=1", + "test_package", + "test_package>=1.2.3,<3.2.1", + "test_package~=0.5.0", + "test_package>=1.4.2,<1.4.99,>=1.7,<1.8.99", + "test_package>=1.4.2,<1.9,!=1.5", + ] integration.path = Path("") assert validate_requirements_format(integration) assert len(integration.errors) == 0 diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index b1cbff83e33..c4ceff89b64 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -16,7 +16,12 @@ from homeassistant.const import ( SUN_EVENT_SUNSET, ) from homeassistant.exceptions import ConditionError, HomeAssistantError -from homeassistant.helpers import condition, trace +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, + trace, +) from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -106,25 +111,25 @@ async def test_invalid_condition(hass): async def test_and_condition(hass): """Test the 'and' condition.""" - test = await condition.async_from_config( - hass, - { - "alias": "And Condition", - "condition": "and", - "conditions": [ - { - "condition": "state", - "entity_id": "sensor.temperature", - "state": "100", - }, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "below": 110, - }, - ], - }, - ) + config = { + "alias": "And Condition", + "condition": "and", + "conditions": [ + { + "condition": "state", + "entity_id": "sensor.temperature", + "state": "100", + }, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": 110, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) with pytest.raises(ConditionError): test(hass) @@ -179,25 +184,25 @@ async def test_and_condition(hass): async def test_and_condition_raises(hass): """Test the 'and' condition.""" - test = await condition.async_from_config( - hass, - { - "alias": "And Condition", - "condition": "and", - "conditions": [ - { - "condition": "state", - "entity_id": "sensor.temperature", - "state": "100", - }, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature2", - "above": 110, - }, - ], - }, - ) + config = { + "alias": "And Condition", + "condition": "and", + "conditions": [ + { + "condition": "state", + "entity_id": "sensor.temperature", + "state": "100", + }, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature2", + "above": 110, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) # All subconditions raise, the AND-condition should raise with pytest.raises(ConditionError): @@ -252,24 +257,24 @@ async def test_and_condition_raises(hass): async def test_and_condition_with_template(hass): """Test the 'and' condition.""" - test = await condition.async_from_config( - hass, - { - "condition": "and", - "conditions": [ - { - "alias": "Template Condition", - "condition": "template", - "value_template": '{{ states.sensor.temperature.state == "100" }}', - }, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "below": 110, - }, - ], - }, - ) + config = { + "condition": "and", + "conditions": [ + { + "alias": "Template Condition", + "condition": "template", + "value_template": '{{ states.sensor.temperature.state == "100" }}', + }, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": 110, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 120) assert not test(hass) @@ -291,25 +296,25 @@ async def test_and_condition_with_template(hass): async def test_or_condition(hass): """Test the 'or' condition.""" - test = await condition.async_from_config( - hass, - { - "alias": "Or Condition", - "condition": "or", - "conditions": [ - { - "condition": "state", - "entity_id": "sensor.temperature", - "state": "100", - }, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "below": 110, - }, - ], - }, - ) + config = { + "alias": "Or Condition", + "condition": "or", + "conditions": [ + { + "condition": "state", + "entity_id": "sensor.temperature", + "state": "100", + }, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": 110, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) with pytest.raises(ConditionError): test(hass) @@ -374,25 +379,25 @@ async def test_or_condition(hass): async def test_or_condition_raises(hass): """Test the 'or' condition.""" - test = await condition.async_from_config( - hass, - { - "alias": "Or Condition", - "condition": "or", - "conditions": [ - { - "condition": "state", - "entity_id": "sensor.temperature", - "state": "100", - }, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature2", - "above": 110, - }, - ], - }, - ) + config = { + "alias": "Or Condition", + "condition": "or", + "conditions": [ + { + "condition": "state", + "entity_id": "sensor.temperature", + "state": "100", + }, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature2", + "above": 110, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) # All subconditions raise, the OR-condition should raise with pytest.raises(ConditionError): @@ -447,20 +452,20 @@ async def test_or_condition_raises(hass): async def test_or_condition_with_template(hass): """Test the 'or' condition.""" - test = await condition.async_from_config( - hass, - { - "condition": "or", - "conditions": [ - {'{{ states.sensor.temperature.state == "100" }}'}, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "below": 110, - }, - ], - }, - ) + config = { + "condition": "or", + "conditions": [ + {'{{ states.sensor.temperature.state == "100" }}'}, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": 110, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 120) assert not test(hass) @@ -474,25 +479,25 @@ async def test_or_condition_with_template(hass): async def test_not_condition(hass): """Test the 'not' condition.""" - test = await condition.async_from_config( - hass, - { - "alias": "Not Condition", - "condition": "not", - "conditions": [ - { - "condition": "state", - "entity_id": "sensor.temperature", - "state": "100", - }, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "below": 50, - }, - ], - }, - ) + config = { + "alias": "Not Condition", + "condition": "not", + "conditions": [ + { + "condition": "state", + "entity_id": "sensor.temperature", + "state": "100", + }, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": 50, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) with pytest.raises(ConditionError): test(hass) @@ -573,25 +578,25 @@ async def test_not_condition(hass): async def test_not_condition_raises(hass): """Test the 'and' condition.""" - test = await condition.async_from_config( - hass, - { - "alias": "Not Condition", - "condition": "not", - "conditions": [ - { - "condition": "state", - "entity_id": "sensor.temperature", - "state": "100", - }, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature2", - "below": 50, - }, - ], - }, - ) + config = { + "alias": "Not Condition", + "condition": "not", + "conditions": [ + { + "condition": "state", + "entity_id": "sensor.temperature", + "state": "100", + }, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature2", + "below": 50, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) # All subconditions raise, the NOT-condition should raise with pytest.raises(ConditionError): @@ -640,23 +645,23 @@ async def test_not_condition_raises(hass): async def test_not_condition_with_template(hass): """Test the 'or' condition.""" - test = await condition.async_from_config( - hass, - { - "condition": "not", - "conditions": [ - { - "condition": "template", - "value_template": '{{ states.sensor.temperature.state == "100" }}', - }, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "below": 50, - }, - ], - }, - ) + config = { + "condition": "not", + "conditions": [ + { + "condition": "template", + "value_template": '{{ states.sensor.temperature.state == "100" }}', + }, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": 50, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 101) assert test(hass) @@ -676,14 +681,24 @@ async def test_time_window(hass): sixam = "06:00:00" sixpm = "18:00:00" - test1 = await condition.async_from_config( - hass, - {"alias": "Time Cond", "condition": "time", "after": sixam, "before": sixpm}, - ) - test2 = await condition.async_from_config( - hass, - {"alias": "Time Cond", "condition": "time", "after": sixpm, "before": sixam}, - ) + config1 = { + "alias": "Time Cond", + "condition": "time", + "after": sixam, + "before": sixpm, + } + config1 = cv.CONDITION_SCHEMA(config1) + config1 = await condition.async_validate_condition_config(hass, config1) + config2 = { + "alias": "Time Cond", + "condition": "time", + "after": sixpm, + "before": sixam, + } + config2 = cv.CONDITION_SCHEMA(config2) + config2 = await condition.async_validate_condition_config(hass, config2) + test1 = await condition.async_from_config(hass, config1) + test2 = await condition.async_from_config(hass, config2) with patch( "homeassistant.helpers.condition.dt_util.now", @@ -925,64 +940,79 @@ async def test_state_raises(hass): condition.state(hass, entity=None, req_state="missing") # Unknown entities - test = await condition.async_from_config( - hass, - { - "condition": "state", - "entity_id": ["sensor.door_unknown", "sensor.window_unknown"], - "state": "open", - }, - ) + config = { + "condition": "state", + "entity_id": ["sensor.door_unknown", "sensor.window_unknown"], + "state": "open", + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) with pytest.raises(ConditionError, match="unknown entity.*door"): test(hass) with pytest.raises(ConditionError, match="unknown entity.*window"): test(hass) - # Unknown attribute - with pytest.raises(ConditionError, match=r"attribute .* does not exist"): - test = await condition.async_from_config( - hass, - { - "condition": "state", - "entity_id": "sensor.door", - "attribute": "model", - "state": "acme", - }, - ) - - hass.states.async_set("sensor.door", "open") - test(hass) - # Unknown state entity with pytest.raises(ConditionError, match="input_text.missing"): - test = await condition.async_from_config( - hass, - { - "condition": "state", - "entity_id": "sensor.door", - "state": "input_text.missing", - }, - ) + config = { + "condition": "state", + "entity_id": "sensor.door", + "state": "input_text.missing", + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.door", "open") test(hass) +async def test_state_unknown_attribute(hass): + """Test that state returns False on unknown attribute.""" + # Unknown attribute + config = { + "condition": "state", + "entity_id": "sensor.door", + "attribute": "model", + "state": "acme", + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + hass.states.async_set("sensor.door", "open") + assert not test(hass) + assert_condition_trace( + { + "": [{"result": {"result": False}}], + "entity_id/0": [ + { + "result": { + "result": False, + "message": "attribute 'model' of entity sensor.door does not exist", + } + } + ], + } + ) + + async def test_state_multiple_entities(hass): """Test with multiple entities in condition.""" - test = await condition.async_from_config( - hass, - { - "condition": "and", - "conditions": [ - { - "condition": "state", - "entity_id": ["sensor.temperature_1", "sensor.temperature_2"], - "state": "100", - }, - ], - }, - ) + config = { + "condition": "and", + "conditions": [ + { + "condition": "state", + "entity_id": ["sensor.temperature_1", "sensor.temperature_2"], + "state": "100", + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature_1", 100) hass.states.async_set("sensor.temperature_2", 100) @@ -999,20 +1029,20 @@ async def test_state_multiple_entities(hass): async def test_multiple_states(hass): """Test with multiple states in condition.""" - test = await condition.async_from_config( - hass, - { - "condition": "and", - "conditions": [ - { - "alias": "State Condition", - "condition": "state", - "entity_id": "sensor.temperature", - "state": ["100", "200"], - }, - ], - }, - ) + config = { + "condition": "and", + "conditions": [ + { + "alias": "State Condition", + "condition": "state", + "entity_id": "sensor.temperature", + "state": ["100", "200"], + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 100) assert test(hass) @@ -1026,24 +1056,23 @@ async def test_multiple_states(hass): async def test_state_attribute(hass): """Test with state attribute in condition.""" - test = await condition.async_from_config( - hass, - { - "condition": "and", - "conditions": [ - { - "condition": "state", - "entity_id": "sensor.temperature", - "attribute": "attribute1", - "state": 200, - }, - ], - }, - ) + config = { + "condition": "and", + "conditions": [ + { + "condition": "state", + "entity_id": "sensor.temperature", + "attribute": "attribute1", + "state": 200, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 100, {"unknown_attr": 200}) - with pytest.raises(ConditionError): - test(hass) + assert not test(hass) hass.states.async_set("sensor.temperature", 100, {"attribute1": 200}) assert test(hass) @@ -1060,15 +1089,15 @@ async def test_state_attribute(hass): async def test_state_attribute_boolean(hass): """Test with boolean state attribute in condition.""" - test = await condition.async_from_config( - hass, - { - "condition": "state", - "entity_id": "sensor.temperature", - "attribute": "happening", - "state": False, - }, - ) + config = { + "condition": "state", + "entity_id": "sensor.temperature", + "attribute": "happening", + "state": False, + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 100, {"happening": 200}) assert not test(hass) @@ -1077,13 +1106,35 @@ async def test_state_attribute_boolean(hass): assert not test(hass) hass.states.async_set("sensor.temperature", 100, {"no_happening": 201}) - with pytest.raises(ConditionError): - test(hass) + assert not test(hass) hass.states.async_set("sensor.temperature", 100, {"happening": False}) assert test(hass) +async def test_state_entity_registry_id(hass): + """Test with entity specified by entity registry id.""" + registry = er.async_get(hass) + entry = registry.async_get_or_create( + "switch", "hue", "1234", suggested_object_id="test" + ) + assert entry.entity_id == "switch.test" + config = { + "condition": "state", + "entity_id": entry.id, + "state": "on", + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + hass.states.async_set("switch.test", "on") + assert test(hass) + + hass.states.async_set("switch.test", "off") + assert not test(hass) + + async def test_state_using_input_entities(hass): """Test state conditions using input_* entities.""" await async_setup_component( @@ -1106,23 +1157,23 @@ async def test_state_using_input_entities(hass): }, ) - test = await condition.async_from_config( - hass, - { - "condition": "and", - "conditions": [ - { - "condition": "state", - "entity_id": "sensor.salut", - "state": [ - "input_text.hello", - "input_select.hello", - "salut", - ], - }, - ], - }, - ) + config = { + "condition": "and", + "conditions": [ + { + "condition": "state", + "entity_id": "sensor.salut", + "state": [ + "input_text.hello", + "input_select.hello", + "salut", + ], + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.salut", "goodbye") assert test(hass) @@ -1168,93 +1219,106 @@ async def test_state_using_input_entities(hass): async def test_numeric_state_known_non_matching(hass): """Test that numeric_state doesn't match on known non-matching states.""" hass.states.async_set("sensor.temperature", "unavailable") - test = await condition.async_from_config( - hass, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "above": 0, - }, - ) + config = { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "above": 0, + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) # Unavailable state assert not test(hass) + assert_condition_trace( + { + "": [{"result": {"result": False}}], + "entity_id/0": [ + { + "result": { + "result": False, + "message": "value 'unavailable' is non-numeric and treated as False", + } + } + ], + } + ) + # Unknown state hass.states.async_set("sensor.temperature", "unknown") assert not test(hass) + assert_condition_trace( + { + "": [{"result": {"result": False}}], + "entity_id/0": [ + { + "result": { + "result": False, + "message": "value 'unknown' is non-numeric and treated as False", + } + } + ], + } + ) + async def test_numeric_state_raises(hass): """Test that numeric_state raises ConditionError on errors.""" # Unknown entities - test = await condition.async_from_config( - hass, - { - "condition": "numeric_state", - "entity_id": ["sensor.temperature_unknown", "sensor.humidity_unknown"], - "above": 0, - }, - ) + config = { + "condition": "numeric_state", + "entity_id": ["sensor.temperature_unknown", "sensor.humidity_unknown"], + "above": 0, + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) with pytest.raises(ConditionError, match="unknown entity.*temperature"): test(hass) with pytest.raises(ConditionError, match="unknown entity.*humidity"): test(hass) - # Unknown attribute - with pytest.raises(ConditionError, match=r"attribute .* does not exist"): - test = await condition.async_from_config( - hass, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "attribute": "temperature", - "above": 0, - }, - ) - - hass.states.async_set("sensor.temperature", 50) - test(hass) - # Template error with pytest.raises(ConditionError, match="ZeroDivisionError"): - test = await condition.async_from_config( - hass, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "value_template": "{{ 1 / 0 }}", - "above": 0, - }, - ) + config = { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "value_template": "{{ 1 / 0 }}", + "above": 0, + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 50) test(hass) # Bad number with pytest.raises(ConditionError, match="cannot be processed as a number"): - test = await condition.async_from_config( - hass, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "above": 0, - }, - ) + config = { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "above": 0, + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", "fifty") test(hass) # Below entity missing with pytest.raises(ConditionError, match="'below' entity"): - test = await condition.async_from_config( - hass, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "below": "input_number.missing", - }, - ) + config = { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": "input_number.missing", + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 50) test(hass) @@ -1269,14 +1333,14 @@ async def test_numeric_state_raises(hass): # Above entity missing with pytest.raises(ConditionError, match="'above' entity"): - test = await condition.async_from_config( - hass, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "above": "input_number.missing", - }, - ) + config = { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "above": "input_number.missing", + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 50) test(hass) @@ -1290,22 +1354,52 @@ async def test_numeric_state_raises(hass): test(hass) +async def test_numeric_state_unknown_attribute(hass): + """Test that numeric_state returns False on unknown attribute.""" + # Unknown attribute + config = { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "attribute": "temperature", + "above": 0, + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + hass.states.async_set("sensor.temperature", 50) + assert not test(hass) + assert_condition_trace( + { + "": [{"result": {"result": False}}], + "entity_id/0": [ + { + "result": { + "result": False, + "message": "attribute 'temperature' of entity sensor.temperature does not exist", + } + } + ], + } + ) + + async def test_numeric_state_multiple_entities(hass): """Test with multiple entities in condition.""" - test = await condition.async_from_config( - hass, - { - "condition": "and", - "conditions": [ - { - "alias": "Numeric State Condition", - "condition": "numeric_state", - "entity_id": ["sensor.temperature_1", "sensor.temperature_2"], - "below": 50, - }, - ], - }, - ) + config = { + "condition": "and", + "conditions": [ + { + "alias": "Numeric State Condition", + "condition": "numeric_state", + "entity_id": ["sensor.temperature_1", "sensor.temperature_2"], + "below": 50, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature_1", 49) hass.states.async_set("sensor.temperature_2", 49) @@ -1322,24 +1416,23 @@ async def test_numeric_state_multiple_entities(hass): async def test_numeric_state_attribute(hass): """Test with numeric state attribute in condition.""" - test = await condition.async_from_config( - hass, - { - "condition": "and", - "conditions": [ - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "attribute": "attribute1", - "below": 50, - }, - ], - }, - ) + config = { + "condition": "and", + "conditions": [ + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "attribute": "attribute1", + "below": 50, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 100, {"unknown_attr": 10}) - with pytest.raises(ConditionError): - assert test(hass) + assert not test(hass) hass.states.async_set("sensor.temperature", 100, {"attribute1": 49}) assert test(hass) @@ -1351,8 +1444,30 @@ async def test_numeric_state_attribute(hass): assert not test(hass) hass.states.async_set("sensor.temperature", 100, {"attribute1": None}) - with pytest.raises(ConditionError): - assert test(hass) + assert not test(hass) + + +async def test_numeric_state_entity_registry_id(hass): + """Test with entity specified by entity registry id.""" + registry = er.async_get(hass) + entry = registry.async_get_or_create( + "sensor", "hue", "1234", suggested_object_id="test" + ) + assert entry.entity_id == "sensor.test" + config = { + "condition": "numeric_state", + "entity_id": entry.id, + "above": 100, + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + hass.states.async_set("sensor.test", "110") + assert test(hass) + + hass.states.async_set("sensor.test", "90") + assert not test(hass) async def test_numeric_state_using_input_number(hass): @@ -1368,20 +1483,20 @@ async def test_numeric_state_using_input_number(hass): }, ) - test = await condition.async_from_config( - hass, - { - "condition": "and", - "conditions": [ - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "below": "input_number.high", - "above": "number.low", - }, - ], - }, - ) + config = { + "condition": "and", + "conditions": [ + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": "input_number.high", + "above": "number.low", + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set("sensor.temperature", 42) assert test(hass) @@ -1427,14 +1542,14 @@ async def test_numeric_state_using_input_number(hass): async def test_zone_raises(hass): """Test that zone raises ConditionError on errors.""" - test = await condition.async_from_config( - hass, - { - "condition": "zone", - "entity_id": "device_tracker.cat", - "zone": "zone.home", - }, - ) + config = { + "condition": "zone", + "entity_id": "device_tracker.cat", + "zone": "zone.home", + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) with pytest.raises(ConditionError, match="no zone"): condition.zone(hass, zone_ent=None, entity="sensor.any") @@ -1481,14 +1596,14 @@ async def test_zone_raises(hass): # All okay, now test multiple failed conditions assert test(hass) - test = await condition.async_from_config( - hass, - { - "condition": "zone", - "entity_id": ["device_tracker.cat", "device_tracker.dog"], - "zone": ["zone.home", "zone.work"], - }, - ) + config = { + "condition": "zone", + "entity_id": ["device_tracker.cat", "device_tracker.dog"], + "zone": ["zone.home", "zone.work"], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) with pytest.raises(ConditionError, match="dog"): test(hass) @@ -1513,20 +1628,20 @@ async def test_zone_raises(hass): async def test_zone_multiple_entities(hass): """Test with multiple entities in condition.""" - test = await condition.async_from_config( - hass, - { - "condition": "and", - "conditions": [ - { - "alias": "Zone Condition", - "condition": "zone", - "entity_id": ["device_tracker.person_1", "device_tracker.person_2"], - "zone": "zone.home", - }, - ], - }, - ) + config = { + "condition": "and", + "conditions": [ + { + "alias": "Zone Condition", + "condition": "zone", + "entity_id": ["device_tracker.person_1", "device_tracker.person_2"], + "zone": "zone.home", + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set( "zone.home", @@ -1573,19 +1688,19 @@ async def test_zone_multiple_entities(hass): async def test_multiple_zones(hass): """Test with multiple entities in condition.""" - test = await condition.async_from_config( - hass, - { - "condition": "and", - "conditions": [ - { - "condition": "zone", - "entity_id": "device_tracker.person", - "zone": ["zone.home", "zone.work"], - }, - ], - }, - ) + config = { + "condition": "and", + "conditions": [ + { + "condition": "zone", + "entity_id": "device_tracker.person", + "zone": ["zone.home", "zone.work"], + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) hass.states.async_set( "zone.home", @@ -1747,9 +1862,10 @@ async def test_extract_devices(): async def test_condition_template_error(hass): """Test invalid template.""" - test = await condition.async_from_config( - hass, {"condition": "template", "value_template": "{{ undefined.state }}"} - ) + config = {"condition": "template", "value_template": "{{ undefined.state }}"} + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) with pytest.raises(ConditionError, match="template"): test(hass) @@ -1757,24 +1873,28 @@ async def test_condition_template_error(hass): async def test_condition_template_invalid_results(hass): """Test template condition render false with invalid results.""" - test = await condition.async_from_config( - hass, {"condition": "template", "value_template": "{{ 'string' }}"} - ) + config = {"condition": "template", "value_template": "{{ 'string' }}"} + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) assert not test(hass) - test = await condition.async_from_config( - hass, {"condition": "template", "value_template": "{{ 10.1 }}"} - ) + config = {"condition": "template", "value_template": "{{ 10.1 }}"} + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) assert not test(hass) - test = await condition.async_from_config( - hass, {"condition": "template", "value_template": "{{ 42 }}"} - ) + config = {"condition": "template", "value_template": "{{ 42 }}"} + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) assert not test(hass) - test = await condition.async_from_config( - hass, {"condition": "template", "value_template": "{{ [1, 2, 3] }}"} - ) + config = {"condition": "template", "value_template": "{{ [1, 2, 3] }}"} + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) assert not test(hass) @@ -2840,10 +2960,10 @@ async def test_if_action_after_sunset_no_offset_kotzebue(hass, hass_ws_client, c async def test_trigger(hass): """Test trigger condition.""" - test = await condition.async_from_config( - hass, - {"alias": "Trigger Cond", "condition": "trigger", "id": "123456"}, - ) + config = {"alias": "Trigger Cond", "condition": "trigger", "id": "123456"} + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) assert not test(hass) assert not test(hass, {}) diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 52dda703f1e..ddd92bb943f 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -220,7 +220,9 @@ async def test_step_discovery(hass, flow_handler, local_impl): ) result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF} + TEST_DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=data_entry_flow.BaseServiceInfo(), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -235,14 +237,18 @@ async def test_abort_discovered_multiple(hass, flow_handler, local_impl): ) result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": config_entries.SOURCE_SSDP} + TEST_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=data_entry_flow.BaseServiceInfo(), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "pick_implementation" result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF} + TEST_DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=data_entry_flow.BaseServiceInfo(), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -263,7 +269,9 @@ async def test_abort_discovered_existing_entries(hass, flow_handler, local_impl) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": config_entries.SOURCE_SSDP} + TEST_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=data_entry_flow.BaseServiceInfo(), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index cf832dfde50..8327eb2e320 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -172,9 +172,10 @@ def test_entity_id(): assert schema("sensor.LIGHT") == "sensor.light" -def test_entity_ids(): +@pytest.mark.parametrize("validator", [cv.entity_ids, cv.entity_ids_or_uuids]) +def test_entity_ids(validator): """Test entity ID validation.""" - schema = vol.Schema(cv.entity_ids) + schema = vol.Schema(validator) options = ( "invalid_entity", @@ -194,6 +195,32 @@ def test_entity_ids(): assert schema("sensor.LIGHT, light.kitchen ") == ["sensor.light", "light.kitchen"] +def test_entity_ids_or_uuids(): + """Test entity ID validation.""" + schema = vol.Schema(cv.entity_ids_or_uuids) + + valid_uuid = "a266a680b608c32770e6c45bfe6b8411" + valid_uuid2 = "a266a680b608c32770e6c45bfe6b8412" + invalid_uuid_capital_letters = "A266A680B608C32770E6C45bfE6B8412" + options = ( + "invalid_uuid", + invalid_uuid_capital_letters, + f"{valid_uuid},invalid_uuid", + ["invalid_uuid"], + [valid_uuid, "invalid_uuid"], + [f"{valid_uuid},invalid_uuid"], + ) + for value in options: + with pytest.raises(vol.MultipleInvalid): + schema(value) + + options = ([], [valid_uuid], valid_uuid) + for value in options: + schema(value) + + assert schema(f"{valid_uuid}, {valid_uuid2} ") == [valid_uuid, valid_uuid2] + + def test_entity_domain(): """Test entity domain validation.""" schema = vol.Schema(cv.entity_domain("sensor")) @@ -712,6 +739,46 @@ def test_deprecated_with_no_optionals(caplog, schema): assert test_data == output +def test_deprecated_or_removed_param_and_raise(caplog, schema): + """ + Test removed or deprecation options and fail the config validation by raising an exception. + + Expected behavior: + - Outputs the appropriate deprecation or removed from support error if key is detected + """ + removed_schema = vol.All(cv.deprecated("mars", raise_if_present=True), schema) + + test_data = {"mars": True} + with pytest.raises(vol.Invalid) as excinfo: + removed_schema(test_data) + assert ( + "The 'mars' option is deprecated, please remove it from your configuration" + in str(excinfo.value) + ) + assert len(caplog.records) == 0 + + test_data = {"venus": True} + output = removed_schema(test_data.copy()) + assert len(caplog.records) == 0 + assert test_data == output + + deprecated_schema = vol.All(cv.removed("mars"), schema) + + test_data = {"mars": True} + with pytest.raises(vol.Invalid) as excinfo: + deprecated_schema(test_data) + assert ( + "The 'mars' option has been removed, please remove it from your configuration" + in str(excinfo.value) + ) + assert len(caplog.records) == 0 + + test_data = {"venus": True} + output = deprecated_schema(test_data.copy()) + assert len(caplog.records) == 0 + assert test_data == output + + def test_deprecated_with_replacement_key(caplog, schema): """ Test deprecation behaves correctly when only a replacement key is provided. @@ -846,17 +913,43 @@ def test_deprecated_cant_find_module(): default=False, ) + with patch("inspect.getmodule", return_value=None): + # This used to raise. + cv.removed( + "mars", + default=False, + ) -def test_deprecated_logger_with_config_attributes(caplog): + +def test_deprecated_or_removed_logger_with_config_attributes(caplog): """Test if the logger outputs the correct message if the line and file attribute is available in config.""" file: str = "configuration.yaml" line: int = 54 - replacement = f"'mars' option near {file}:{line} is deprecated" + + # test as deprecated option + replacement_key = "jupiter" + option_status = "is deprecated" + replacement = f"'mars' option near {file}:{line} {option_status}, please replace it with '{replacement_key}'" config = OrderedDict([("mars", "blah")]) setattr(config, "__config_file__", file) setattr(config, "__line__", line) - cv.deprecated("mars", replacement_key="jupiter", default=False)(config) + cv.deprecated("mars", replacement_key=replacement_key, default=False)(config) + + assert len(caplog.records) == 1 + assert replacement in caplog.text + + caplog.clear() + assert len(caplog.records) == 0 + + # test as removed option + option_status = "has been removed" + replacement = f"'mars' option near {file}:{line} {option_status}, please remove it from your configuration" + config = OrderedDict([("mars", "blah")]) + setattr(config, "__config_file__", file) + setattr(config, "__line__", line) + + cv.removed("mars", default=False, raise_if_present=False)(config) assert len(caplog.records) == 1 assert replacement in caplog.text diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 557647c5c7f..455c90b8f65 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -167,23 +167,25 @@ async def test_multiple_config_entries(registry): async def test_loading_from_storage(hass, hass_storage): """Test loading stored devices on start.""" hass_storage[device_registry.STORAGE_KEY] = { - "version": device_registry.STORAGE_VERSION, + "version": device_registry.STORAGE_VERSION_MAJOR, + "minor_version": device_registry.STORAGE_VERSION_MINOR, "data": { "devices": [ { + "area_id": "12345A", "config_entries": ["1234"], + "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], + "disabled_by": device_registry.DeviceEntryDisabler.USER, + "entry_type": device_registry.DeviceEntryType.SERVICE, "id": "abcdefghijklm", "identifiers": [["serial", "12:34:56:AB:CD:EF"]], "manufacturer": "manufacturer", "model": "model", + "name_by_user": "Test Friendly Name", "name": "name", "sw_version": "version", - "entry_type": "service", - "area_id": "12345A", - "name_by_user": "Test Friendly Name", - "disabled_by": device_registry.DISABLED_USER, - "suggested_area": "Kitchen", + "via_device_id": None, } ], "deleted_devices": [ @@ -192,6 +194,7 @@ async def test_loading_from_storage(hass, hass_storage): "connections": [["Zigbee", "23.45.67.89.01"]], "id": "bcdefghijklmn", "identifiers": [["serial", "34:56:AB:CD:EF:12"]], + "orphaned_timestamp": None, } ], }, @@ -212,8 +215,8 @@ async def test_loading_from_storage(hass, hass_storage): assert entry.id == "abcdefghijklm" assert entry.area_id == "12345A" assert entry.name_by_user == "Test Friendly Name" - assert entry.entry_type == "service" - assert entry.disabled_by == device_registry.DISABLED_USER + assert entry.entry_type is device_registry.DeviceEntryType.SERVICE + assert entry.disabled_by is device_registry.DeviceEntryDisabler.USER assert isinstance(entry.config_entries, set) assert isinstance(entry.connections, set) assert isinstance(entry.identifiers, set) @@ -231,6 +234,107 @@ async def test_loading_from_storage(hass, hass_storage): assert isinstance(entry.identifiers, set) +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_1_1_to_1_2(hass, hass_storage): + """Test migration from version 1.1 to 1.2.""" + hass_storage[device_registry.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "devices": [ + { + "config_entries": ["1234"], + "connections": [["Zigbee", "01.23.45.67.89"]], + "entry_type": "service", + "id": "abcdefghijklm", + "identifiers": [["serial", "12:34:56:AB:CD:EF"]], + "manufacturer": "manufacturer", + "model": "model", + "name": "name", + "sw_version": "version", + }, + # Invalid entry type + { + "config_entries": [None], + "connections": [], + "entry_type": "INVALID_VALUE", + "id": "invalid-entry-type", + "identifiers": [["serial", "mock-id-invalid-entry"]], + "manufacturer": None, + "model": None, + "name": None, + "sw_version": None, + }, + ], + }, + } + + await device_registry.async_load(hass) + registry = device_registry.async_get(hass) + + # Test data was loaded + entry = registry.async_get_or_create( + config_entry_id="1234", + connections={("Zigbee", "01.23.45.67.89")}, + identifiers={("serial", "12:34:56:AB:CD:EF")}, + ) + assert entry.id == "abcdefghijklm" + + # Update to trigger a store + entry = registry.async_get_or_create( + config_entry_id="1234", + connections={("Zigbee", "01.23.45.67.89")}, + identifiers={("serial", "12:34:56:AB:CD:EF")}, + sw_version="new_version", + ) + assert entry.id == "abcdefghijklm" + + # Check we store migrated data + await flush_store(registry._store) + assert hass_storage[device_registry.STORAGE_KEY] == { + "version": device_registry.STORAGE_VERSION_MAJOR, + "minor_version": device_registry.STORAGE_VERSION_MINOR, + "key": device_registry.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": ["1234"], + "configuration_url": None, + "connections": [["Zigbee", "01.23.45.67.89"]], + "disabled_by": None, + "entry_type": "service", + "id": "abcdefghijklm", + "identifiers": [["serial", "12:34:56:AB:CD:EF"]], + "manufacturer": "manufacturer", + "model": "model", + "name": "name", + "name_by_user": None, + "sw_version": "new_version", + "via_device_id": None, + }, + { + "area_id": None, + "config_entries": [None], + "configuration_url": None, + "connections": [], + "disabled_by": None, + "entry_type": None, + "id": "invalid-entry-type", + "identifiers": [["serial", "mock-id-invalid-entry"]], + "manufacturer": None, + "model": None, + "name_by_user": None, + "name": None, + "sw_version": None, + "via_device_id": None, + }, + ], + "deleted_devices": [], + }, + } + + async def test_removing_config_entries(hass, registry, update_events): """Make sure we do not get duplicate entries.""" entry = registry.async_get_or_create( @@ -420,7 +524,7 @@ async def test_deleted_device_removing_area_id(registry): async def test_specifying_via_device_create(registry): - """Test specifying a via_device and updating.""" + """Test specifying a via_device and removal of the hub device.""" via = registry.async_get_or_create( config_entry_id="123", connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, @@ -440,6 +544,10 @@ async def test_specifying_via_device_create(registry): assert light.via_device_id == via.id + registry.async_remove_device(via.id) + light = registry.async_get_device({("hue", "456")}) + assert light.via_device_id is None + async def test_specifying_via_device_update(registry): """Test specifying a via_device and updating.""" @@ -484,7 +592,7 @@ async def test_loading_saving_data(hass, registry, area_registry): model="via", name="Original Name", sw_version="Orig SW 1", - entry_type="device", + entry_type=None, ) orig_light = registry.async_get_or_create( @@ -494,7 +602,7 @@ async def test_loading_saving_data(hass, registry, area_registry): manufacturer="manufacturer", model="light", via_device=("hue", "0123"), - disabled_by=device_registry.DISABLED_USER, + disabled_by=device_registry.DeviceEntryDisabler.USER, ) orig_light2 = registry.async_get_or_create( @@ -543,7 +651,7 @@ async def test_loading_saving_data(hass, registry, area_registry): manufacturer="manufacturer", model="light", via_device=("hue", "0123"), - disabled_by=device_registry.DISABLED_USER, + disabled_by=device_registry.DeviceEntryDisabler.USER, suggested_area="Kitchen", ) @@ -652,7 +760,7 @@ async def test_update(registry): name_by_user="Test Friendly Name", new_identifiers=new_identifiers, via_device_id="98765B", - disabled_by=device_registry.DISABLED_USER, + disabled_by=device_registry.DeviceEntryDisabler.USER, ) assert mock_save.call_count == 1 @@ -663,7 +771,7 @@ async def test_update(registry): assert updated_entry.name_by_user == "Test Friendly Name" assert updated_entry.identifiers == new_identifiers assert updated_entry.via_device_id == "98765B" - assert updated_entry.disabled_by == device_registry.DISABLED_USER + assert updated_entry.disabled_by is device_registry.DeviceEntryDisabler.USER assert registry.async_get_device({("hue", "456")}) is None assert registry.async_get_device({("bla", "123")}) is None @@ -1227,7 +1335,7 @@ async def test_disable_config_entry_disables_devices(hass, registry): entry2 = registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(device_registry.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")}, - disabled_by=device_registry.DISABLED_USER, + disabled_by=device_registry.DeviceEntryDisabler.USER, ) assert not entry1.disabled @@ -1240,10 +1348,10 @@ async def test_disable_config_entry_disables_devices(hass, registry): entry1 = registry.async_get(entry1.id) assert entry1.disabled - assert entry1.disabled_by == device_registry.DISABLED_CONFIG_ENTRY + assert entry1.disabled_by is device_registry.DeviceEntryDisabler.CONFIG_ENTRY entry2 = registry.async_get(entry2.id) assert entry2.disabled - assert entry2.disabled_by == device_registry.DISABLED_USER + assert entry2.disabled_by is device_registry.DeviceEntryDisabler.USER await hass.config_entries.async_set_disabled_by(config_entry.entry_id, None) await hass.async_block_till_done() @@ -1252,7 +1360,7 @@ async def test_disable_config_entry_disables_devices(hass, registry): assert not entry1.disabled entry2 = registry.async_get(entry2.id) assert entry2.disabled - assert entry2.disabled_by == device_registry.DISABLED_USER + assert entry2.disabled_by is device_registry.DeviceEntryDisabler.USER async def test_only_disable_device_if_all_config_entries_are_disabled(hass, registry): @@ -1288,7 +1396,7 @@ async def test_only_disable_device_if_all_config_entries_are_disabled(hass, regi entry1 = registry.async_get(entry1.id) assert entry1.disabled - assert entry1.disabled_by == device_registry.DISABLED_CONFIG_ENTRY + assert entry1.disabled_by is device_registry.DeviceEntryDisabler.CONFIG_ENTRY await hass.config_entries.async_set_disabled_by(config_entry1.entry_id, None) await hass.async_block_till_done() diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index e334e6d2c56..d4051613c03 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -819,13 +819,13 @@ async def test_entity_category_property(hass): key="abc", entity_category="ignore_me" ) mock_entity1.entity_id = "hello.world" - mock_entity1._attr_entity_category = "config" + mock_entity1._attr_entity_category = entity.EntityCategory.CONFIG assert mock_entity1.entity_category == "config" mock_entity2 = entity.Entity() mock_entity2.hass = hass mock_entity2.entity_description = entity.EntityDescription( - key="abc", entity_category="config" + key="abc", entity_category=entity.EntityCategory.CONFIG ) mock_entity2.entity_id = "hello.world" assert mock_entity2.entity_category == "config" diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index a151a3b7ef3..baaea35b62c 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -2,19 +2,19 @@ import asyncio from datetime import timedelta import logging -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch import pytest from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, PERCENTAGE -from homeassistant.core import CoreState, callback +from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import ( device_registry as dr, entity_platform, entity_registry as er, ) -from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers.entity import DeviceInfo, async_generate_entity_id from homeassistant.helpers.entity_component import ( DEFAULT_SCAN_INTERVAL, EntityComponent, @@ -839,7 +839,7 @@ async def test_device_info_called(hass): "name": "test-name", "sw_version": "test-sw", "suggested_area": "Heliport", - "entry_type": "service", + "entry_type": dr.DeviceEntryType.SERVICE, "via_device": ("hue", "via-id"), }, ), @@ -863,7 +863,7 @@ async def test_device_info_called(hass): assert device.identifiers == {("hue", "1234")} assert device.configuration_url == "http://192.168.0.100/config" assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "abcd")} - assert device.entry_type == "service" + assert device.entry_type is dr.DeviceEntryType.SERVICE assert device.manufacturer == "test-manuf" assert device.model == "test-model" assert device.name == "test-name" @@ -1080,15 +1080,56 @@ async def test_entity_disabled_by_integration(hass): assert entry_disabled.disabled_by == er.DISABLED_INTEGRATION +async def test_entity_disabled_by_device(hass: HomeAssistant): + """Test entity disabled by device.""" + + connections = {(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")} + entity_disabled = MockEntity( + unique_id="disabled", device_info=DeviceInfo(connections=connections) + ) + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities([entity_disabled]) + return True + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id", domain=DOMAIN) + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections=connections, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + + assert await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + + assert entity_disabled.hass is None + assert entity_disabled.platform is None + + registry = er.async_get(hass) + + entry_disabled = registry.async_get_or_create(DOMAIN, DOMAIN, "disabled") + assert entry_disabled.disabled_by == er.DISABLED_DEVICE + + async def test_entity_info_added_to_entity_registry(hass): """Test entity info is written to entity registry.""" component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) entity_default = MockEntity( - unique_id="default", capability_attributes={"max": 100}, - supported_features=5, device_class="mock-device-class", + entity_category="config", + icon="nice:icon", + name="best name", + supported_features=5, + unique_id="default", unit_of_measurement=PERCENTAGE, ) @@ -1097,10 +1138,22 @@ async def test_entity_info_added_to_entity_registry(hass): registry = er.async_get(hass) entry_default = registry.async_get_or_create(DOMAIN, DOMAIN, "default") - assert entry_default.capabilities == {"max": 100} - assert entry_default.supported_features == 5 - assert entry_default.device_class == "mock-device-class" - assert entry_default.unit_of_measurement == PERCENTAGE + assert entry_default == er.RegistryEntry( + "test_domain.best_name", + "default", + "test_domain", + capabilities={"max": 100}, + device_class=None, + entity_category="config", + icon=None, + id=ANY, + name=None, + original_device_class="mock-device-class", + original_icon="nice:icon", + original_name="best name", + supported_features=5, + unit_of_measurement=PERCENTAGE, + ) async def test_override_restored_entities(hass): diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index fe445e32c96..e34e33db005 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +import voluptuous as vol from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_START, STATE_UNAVAILABLE @@ -71,26 +72,39 @@ def test_get_or_create_updates_data(registry): "light", "hue", "5678", + area_id="mock-area-id", + capabilities={"max": 100}, config_entry=orig_config_entry, device_id="mock-dev-id", - capabilities={"max": 100}, - supported_features=5, - device_class="mock-device-class", disabled_by=er.DISABLED_HASS, - unit_of_measurement="initial-unit_of_measurement", - original_name="initial-original_name", + entity_category="config", + original_device_class="mock-device-class", original_icon="initial-original_icon", + original_name="initial-original_name", + supported_features=5, + unit_of_measurement="initial-unit_of_measurement", ) - assert orig_entry.config_entry_id == orig_config_entry.entry_id - assert orig_entry.device_id == "mock-dev-id" - assert orig_entry.capabilities == {"max": 100} - assert orig_entry.supported_features == 5 - assert orig_entry.device_class == "mock-device-class" - assert orig_entry.disabled_by == er.DISABLED_HASS - assert orig_entry.unit_of_measurement == "initial-unit_of_measurement" - assert orig_entry.original_name == "initial-original_name" - assert orig_entry.original_icon == "initial-original_icon" + assert orig_entry == er.RegistryEntry( + "light.hue_5678", + "5678", + "hue", + area_id="mock-area-id", + capabilities={"max": 100}, + config_entry_id=orig_config_entry.entry_id, + device_class=None, + device_id="mock-dev-id", + disabled_by=er.DISABLED_HASS, + entity_category="config", + icon=None, + id=orig_entry.id, + name=None, + original_device_class="mock-device-class", + original_icon="initial-original_icon", + original_name="initial-original_name", + supported_features=5, + unit_of_measurement="initial-unit_of_measurement", + ) new_config_entry = MockConfigEntry(domain="light") @@ -98,27 +112,39 @@ def test_get_or_create_updates_data(registry): "light", "hue", "5678", + area_id="new-mock-area-id", + capabilities={"new-max": 100}, config_entry=new_config_entry, device_id="new-mock-dev-id", - capabilities={"new-max": 100}, - supported_features=10, - device_class="new-mock-device-class", disabled_by=er.DISABLED_USER, - unit_of_measurement="updated-unit_of_measurement", - original_name="updated-original_name", + entity_category=None, + original_device_class="new-mock-device-class", original_icon="updated-original_icon", + original_name="updated-original_name", + supported_features=10, + unit_of_measurement="updated-unit_of_measurement", ) - assert new_entry.config_entry_id == new_config_entry.entry_id - assert new_entry.device_id == "new-mock-dev-id" - assert new_entry.capabilities == {"new-max": 100} - assert new_entry.supported_features == 10 - assert new_entry.device_class == "new-mock-device-class" - assert new_entry.unit_of_measurement == "updated-unit_of_measurement" - assert new_entry.original_name == "updated-original_name" - assert new_entry.original_icon == "updated-original_icon" - # Should not be updated - assert new_entry.disabled_by == er.DISABLED_HASS + assert new_entry == er.RegistryEntry( + "light.hue_5678", + "5678", + "hue", + area_id="new-mock-area-id", + capabilities={"new-max": 100}, + config_entry_id=new_config_entry.entry_id, + device_class=None, + device_id="new-mock-dev-id", + disabled_by=er.DISABLED_HASS, # Should not be updated + entity_category="config", + icon=None, + id=orig_entry.id, + name=None, + original_device_class="new-mock-device-class", + original_icon="updated-original_icon", + original_name="updated-original_name", + supported_features=10, + unit_of_measurement="updated-unit_of_measurement", + ) def test_get_or_create_suggested_object_id_conflict_register(registry): @@ -158,18 +184,23 @@ async def test_loading_saving_data(hass, registry): "light", "hue", "5678", - device_id="mock-dev-id", area_id="mock-area-id", - config_entry=mock_config, capabilities={"max": 100}, - supported_features=5, - device_class="mock-device-class", + config_entry=mock_config, + device_id="mock-dev-id", disabled_by=er.DISABLED_HASS, - original_name="Original Name", + entity_category="config", + original_device_class="mock-device-class", original_icon="hass:original-icon", + original_name="Original Name", + supported_features=5, + unit_of_measurement="initial-unit_of_measurement", ) orig_entry2 = registry.async_update_entity( - orig_entry2.entity_id, name="User Name", icon="hass:user-icon" + orig_entry2.entity_id, + device_class="user-class", + name="User Name", + icon="hass:user-icon", ) assert len(registry.entities) == 2 @@ -187,16 +218,20 @@ async def test_loading_saving_data(hass, registry): assert orig_entry1 == new_entry1 assert orig_entry2 == new_entry2 - assert new_entry2.device_id == "mock-dev-id" assert new_entry2.area_id == "mock-area-id" - assert new_entry2.disabled_by == er.DISABLED_HASS assert new_entry2.capabilities == {"max": 100} - assert new_entry2.supported_features == 5 - assert new_entry2.device_class == "mock-device-class" - assert new_entry2.name == "User Name" + assert new_entry2.config_entry_id == mock_config.entry_id + assert new_entry2.device_class == "user-class" + assert new_entry2.device_id == "mock-dev-id" + assert new_entry2.disabled_by == er.DISABLED_HASS + assert new_entry2.entity_category == "config" assert new_entry2.icon == "hass:user-icon" - assert new_entry2.original_name == "Original Name" + assert new_entry2.name == "User Name" + assert new_entry2.original_device_class == "mock-device-class" assert new_entry2.original_icon == "hass:original-icon" + assert new_entry2.original_name == "Original Name" + assert new_entry2.supported_features == 5 + assert new_entry2.unit_of_measurement == "initial-unit_of_measurement" def test_generate_entity_considers_registered_entities(registry): @@ -223,7 +258,8 @@ def test_is_registered(registry): async def test_loading_extra_values(hass, hass_storage): """Test we load extra data from the registry.""" hass_storage[er.STORAGE_KEY] = { - "version": er.STORAGE_VERSION, + "version": er.STORAGE_VERSION_MAJOR, + "minor_version": 1, "data": { "entities": [ { @@ -353,8 +389,8 @@ async def test_removing_area_id(registry): @pytest.mark.parametrize("load_registries", [False]) -async def test_migration(hass): - """Test migration from old data to new.""" +async def test_migration_yaml_to_json(hass): + """Test migration from old (yaml) data to new.""" mock_config = MockConfigEntry(domain="test-platform", entry_id="test-config-id") old_conf = { @@ -384,51 +420,88 @@ async def test_migration(hass): assert entry.config_entry_id == "test-config-id" -async def test_loading_invalid_entity_id(hass, hass_storage): - """Test we autofix invalid entity IDs.""" +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_1_1(hass, hass_storage): + """Test migration from version 1.1.""" hass_storage[er.STORAGE_KEY] = { - "version": er.STORAGE_VERSION, + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "device_class": "best_class", + "entity_id": "test.entity", + "platform": "super_platform", + "unique_id": "very_unique", + }, + ] + }, + } + + await er.async_load(hass) + registry = er.async_get(hass) + + entry = registry.async_get_or_create("test", "super_platform", "very_unique") + + assert entry.device_class is None + assert entry.original_device_class == "best_class" + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_loading_invalid_entity_id(hass, hass_storage): + """Test we skip entities with invalid entity IDs.""" + hass_storage[er.STORAGE_KEY] = { + "version": er.STORAGE_VERSION_MAJOR, + "minor_version": er.STORAGE_VERSION_MINOR, "data": { "entities": [ { "entity_id": "test.invalid__middle", "platform": "super_platform", "unique_id": "id-invalid-middle", - "name": "registry override", + "name": "registry override 1", }, { "entity_id": "test.invalid_end_", "platform": "super_platform", "unique_id": "id-invalid-end", + "name": "registry override 2", }, { "entity_id": "test._invalid_start", "platform": "super_platform", "unique_id": "id-invalid-start", + "name": "registry override 3", }, ] }, } + await er.async_load(hass) registry = er.async_get(hass) + assert len(registry.entities) == 0 entity_invalid_middle = registry.async_get_or_create( "test", "super_platform", "id-invalid-middle" ) assert valid_entity_id(entity_invalid_middle.entity_id) + # Check name to make sure we created a new entity + assert entity_invalid_middle.name is None entity_invalid_end = registry.async_get_or_create( "test", "super_platform", "id-invalid-end" ) assert valid_entity_id(entity_invalid_end.entity_id) + assert entity_invalid_end.name is None entity_invalid_start = registry.async_get_or_create( "test", "super_platform", "id-invalid-start" ) assert valid_entity_id(entity_invalid_start.entity_id) + assert entity_invalid_start.name is None async def test_update_entity_unique_id(registry): @@ -562,7 +635,7 @@ async def test_restore_states(hass): suggested_object_id="all_info_set", capabilities={"max": 100}, supported_features=5, - device_class="mock-device-class", + original_device_class="mock-device-class", original_name="Mock Original Name", original_icon="hass:original-icon", ) @@ -612,14 +685,14 @@ async def test_async_get_device_class_lookup(hass): "light", "battery_charging", device_id="light_device_entry_id", - device_class="battery_charging", + original_device_class="battery_charging", ) ent_reg.async_get_or_create( "sensor", "light", "battery", device_id="light_device_entry_id", - device_class="battery", + original_device_class="battery", ) ent_reg.async_get_or_create( "light", "light", "demo", device_id="light_device_entry_id" @@ -629,14 +702,14 @@ async def test_async_get_device_class_lookup(hass): "vacuum", "battery_charging", device_id="vacuum_device_entry_id", - device_class="battery_charging", + original_device_class="battery_charging", ) ent_reg.async_get_or_create( "sensor", "vacuum", "battery", device_id="vacuum_device_entry_id", - device_class="battery", + original_device_class="battery", ) ent_reg.async_get_or_create( "vacuum", "vacuum", "demo", device_id="vacuum_device_entry_id" @@ -646,7 +719,7 @@ async def test_async_get_device_class_lookup(hass): "remote", "battery_charging", device_id="remote_device_entry_id", - device_class="battery_charging", + original_device_class="battery_charging", ) ent_reg.async_get_or_create( "remote", "remote", "demo", device_id="remote_device_entry_id" @@ -778,7 +851,9 @@ async def test_disable_device_disables_entities(hass, registry): assert entry2.disabled assert entry3.disabled - device_registry.async_update_device(device_entry.id, disabled_by=er.DISABLED_USER) + device_registry.async_update_device( + device_entry.id, disabled_by=dr.DeviceEntryDisabler.USER + ) await hass.async_block_till_done() entry1 = registry.async_get(entry1.entity_id) @@ -949,3 +1024,73 @@ async def test_entity_max_length_exceeded(hass, registry): assert exc_info.value.property_name == "generated_entity_id" assert exc_info.value.max_length == 255 assert exc_info.value.value == f"sensor.{long_entity_id_name}_2" + + +async def test_resolve_entity_ids(hass, registry): + """Test resolving entity IDs.""" + + entry1 = registry.async_get_or_create( + "light", "hue", "1234", suggested_object_id="beer" + ) + assert entry1.entity_id == "light.beer" + + entry2 = registry.async_get_or_create( + "light", "hue", "2345", suggested_object_id="milk" + ) + assert entry2.entity_id == "light.milk" + + expected = ["light.beer", "light.milk"] + assert er.async_resolve_entity_ids(registry, [entry1.id, entry2.id]) == expected + + expected = ["light.beer", "light.milk"] + assert er.async_resolve_entity_ids(registry, ["light.beer", entry2.id]) == expected + + with pytest.raises(vol.Invalid): + er.async_resolve_entity_ids(registry, ["light.beer", "bad_uuid"]) + + expected = ["light.unknown"] + assert er.async_resolve_entity_ids(registry, ["light.unknown"]) == expected + + with pytest.raises(vol.Invalid): + er.async_resolve_entity_ids(registry, ["unknown_uuid"]) + + +def test_entity_registry_items(): + """Test the EntityRegistryItems container.""" + entities = er.EntityRegistryItems() + assert entities.get_entity_id(("a", "b", "c")) is None + assert entities.get_entry("abc") is None + + entry1 = er.RegistryEntry("test.entity1", "1234", "hue") + entry2 = er.RegistryEntry("test.entity2", "2345", "hue") + entities["test.entity1"] = entry1 + entities["test.entity2"] = entry2 + + assert entities["test.entity1"] is entry1 + assert entities["test.entity2"] is entry2 + + assert entities.get_entity_id(("test", "hue", "1234")) is entry1.entity_id + assert entities.get_entry(entry1.id) is entry1 + assert entities.get_entity_id(("test", "hue", "2345")) is entry2.entity_id + assert entities.get_entry(entry2.id) is entry2 + + entities.pop("test.entity1") + del entities["test.entity2"] + + assert entities.get_entity_id(("test", "hue", "1234")) is None + assert entities.get_entry(entry1.id) is None + assert entities.get_entity_id(("test", "hue", "2345")) is None + assert entities.get_entry(entry2.id) is None + + +async def test_deprecated_disabled_by_str(hass, registry, caplog): + """Test deprecated str use of disabled_by converts to enum and logs a warning.""" + entry = registry.async_get_or_create( + "light", + "hue", + "5678", + disabled_by=er.RegistryEntryDisabler.USER.value, + ) + + assert entry.disabled_by is er.RegistryEntryDisabler.USER + assert " str for entity registry disabled_by. This is deprecated " in caplog.text diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 9d48b0c0ada..4f62d50da34 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -1,7 +1,7 @@ """Test event helpers.""" # pylint: disable=protected-access import asyncio -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from unittest.mock import patch from astral import LocationInfo @@ -1022,6 +1022,73 @@ async def test_track_template_result(hass): assert len(wildercard_runs) == 4 +async def test_track_template_result_none(hass): + """Test tracking template.""" + specific_runs = [] + wildcard_runs = [] + wildercard_runs = [] + + template_condition = Template("{{state_attr('sensor.test', 'battery')}}", hass) + template_condition_var = Template( + "{{(state_attr('sensor.test', 'battery')|int) + test }}", hass + ) + + def specific_run_callback(event, updates): + track_result = updates.pop() + result = int(track_result.result) if track_result.result is not None else None + specific_runs.append(result) + + async_track_template_result( + hass, [TrackTemplate(template_condition, None)], specific_run_callback + ) + + @ha.callback + def wildcard_run_callback(event, updates): + track_result = updates.pop() + last_result = ( + int(track_result.last_result) + if track_result.last_result is not None + else None + ) + result = int(track_result.result) if track_result.result is not None else None + wildcard_runs.append((last_result, result)) + + async_track_template_result( + hass, [TrackTemplate(template_condition, None)], wildcard_run_callback + ) + + async def wildercard_run_callback(event, updates): + track_result = updates.pop() + last_result = ( + int(track_result.last_result) + if track_result.last_result is not None + else None + ) + result = int(track_result.result) if track_result.result is not None else None + wildercard_runs.append((last_result, result)) + + async_track_template_result( + hass, + [TrackTemplate(template_condition_var, {"test": 5})], + wildercard_run_callback, + ) + await hass.async_block_till_done() + + hass.states.async_set("sensor.test", "-") + await hass.async_block_till_done() + + assert specific_runs == [None] + assert wildcard_runs == [(None, None)] + assert wildercard_runs == [(None, 5)] + + hass.states.async_set("sensor.test", "-", {"battery": 5}) + await hass.async_block_till_done() + + assert specific_runs == [None, 5] + assert wildcard_runs == [(None, None), (None, 5)] + assert wildercard_runs == [(None, 5), (5, 10)] + + async def test_track_template_result_super_template(hass): """Test tracking template with super template listening to same entity.""" specific_runs = [] @@ -1166,6 +1233,151 @@ async def test_track_template_result_super_template(hass): assert len(wildercard_runs_availability) == 6 +async def test_track_template_result_super_template_initially_false(hass): + """Test tracking template with super template listening to same entity.""" + specific_runs = [] + specific_runs_availability = [] + wildcard_runs = [] + wildcard_runs_availability = [] + wildercard_runs = [] + wildercard_runs_availability = [] + + template_availability = Template("{{ is_number(states('sensor.test')) }}", hass) + template_condition = Template("{{states.sensor.test.state}}", hass) + template_condition_var = Template( + "{{(states.sensor.test.state|int) + test }}", hass + ) + + # Make the super template initially false + hass.states.async_set("sensor.test", "unavailable") + await hass.async_block_till_done() + + def specific_run_callback(event, updates): + for track_result in updates: + if track_result.template is template_condition: + specific_runs.append(int(track_result.result)) + elif track_result.template is template_availability: + specific_runs_availability.append(track_result.result) + + async_track_template_result( + hass, + [ + TrackTemplate(template_availability, None), + TrackTemplate(template_condition, None), + ], + specific_run_callback, + has_super_template=True, + ) + + @ha.callback + def wildcard_run_callback(event, updates): + for track_result in updates: + if track_result.template is template_condition: + wildcard_runs.append( + (int(track_result.last_result or 0), int(track_result.result)) + ) + elif track_result.template is template_availability: + wildcard_runs_availability.append(track_result.result) + + async_track_template_result( + hass, + [ + TrackTemplate(template_availability, None), + TrackTemplate(template_condition, None), + ], + wildcard_run_callback, + has_super_template=True, + ) + + async def wildercard_run_callback(event, updates): + for track_result in updates: + if track_result.template is template_condition_var: + wildercard_runs.append( + (int(track_result.last_result or 0), int(track_result.result)) + ) + elif track_result.template is template_availability: + wildercard_runs_availability.append(track_result.result) + + async_track_template_result( + hass, + [ + TrackTemplate(template_availability, None), + TrackTemplate(template_condition_var, {"test": 5}), + ], + wildercard_run_callback, + has_super_template=True, + ) + await hass.async_block_till_done() + + assert specific_runs_availability == [] + assert wildcard_runs_availability == [] + assert wildercard_runs_availability == [] + assert specific_runs == [] + assert wildcard_runs == [] + assert wildercard_runs == [] + + hass.states.async_set("sensor.test", 5) + await hass.async_block_till_done() + + assert specific_runs_availability == [True] + assert wildcard_runs_availability == [True] + assert wildercard_runs_availability == [True] + assert specific_runs == [5] + assert wildcard_runs == [(0, 5)] + assert wildercard_runs == [(0, 10)] + + hass.states.async_set("sensor.test", "unknown") + await hass.async_block_till_done() + + assert specific_runs_availability == [True, False] + assert wildcard_runs_availability == [True, False] + assert wildercard_runs_availability == [True, False] + + hass.states.async_set("sensor.test", 30) + await hass.async_block_till_done() + + assert specific_runs_availability == [True, False, True] + assert wildcard_runs_availability == [True, False, True] + assert wildercard_runs_availability == [True, False, True] + + assert specific_runs == [5, 30] + assert wildcard_runs == [(0, 5), (5, 30)] + assert wildercard_runs == [(0, 10), (10, 35)] + + hass.states.async_set("sensor.test", "other") + await hass.async_block_till_done() + + hass.states.async_set("sensor.test", 30) + await hass.async_block_till_done() + + assert len(specific_runs) == 2 + assert len(wildcard_runs) == 2 + assert len(wildercard_runs) == 2 + assert len(specific_runs_availability) == 5 + assert len(wildcard_runs_availability) == 5 + assert len(wildercard_runs_availability) == 5 + + hass.states.async_set("sensor.test", 30) + await hass.async_block_till_done() + + assert len(specific_runs) == 2 + assert len(wildcard_runs) == 2 + assert len(wildercard_runs) == 2 + assert len(specific_runs_availability) == 5 + assert len(wildcard_runs_availability) == 5 + assert len(wildercard_runs_availability) == 5 + + hass.states.async_set("sensor.test", 31) + await hass.async_block_till_done() + + assert len(specific_runs) == 3 + assert len(wildcard_runs) == 3 + assert len(wildercard_runs) == 3 + assert len(specific_runs_availability) == 5 + assert len(wildcard_runs_availability) == 5 + assert len(wildercard_runs_availability) == 5 + + @pytest.mark.parametrize( "availability_template", [ @@ -1303,6 +1515,145 @@ async def test_track_template_result_super_template_2(hass, availability_templat assert wildercard_runs == [(0, 10), (10, 35)] +@pytest.mark.parametrize( + "availability_template", + [ + "{{ states('sensor.test2') != 'unavailable' }}", + "{% if states('sensor.test2') != 'unavailable' -%} true {%- else -%} false {%- endif %}", + "{% if states('sensor.test2') != 'unavailable' -%} 1 {%- else -%} 0 {%- endif %}", + "{% if states('sensor.test2') != 'unavailable' -%} yes {%- else -%} no {%- endif %}", + "{% if states('sensor.test2') != 'unavailable' -%} on {%- else -%} off {%- endif %}", + "{% if states('sensor.test2') != 'unavailable' -%} enable {%- else -%} disable {%- endif %}", + # This will throw when sensor.test2 is not "unavailable" + "{% if states('sensor.test2') != 'unavailable' -%} {{'a' + 5}} {%- else -%} false {%- endif %}", + ], +) +async def test_track_template_result_super_template_2_initially_false( + hass, availability_template +): + """Test tracking template with super template listening to different entities.""" + specific_runs = [] + specific_runs_availability = [] + wildcard_runs = [] + wildcard_runs_availability = [] + wildercard_runs = [] + wildercard_runs_availability = [] + + template_availability = Template(availability_template) + template_condition = Template("{{states.sensor.test.state}}", hass) + template_condition_var = Template( + "{{(states.sensor.test.state|int) + test }}", hass + ) + + hass.states.async_set("sensor.test2", "unavailable") + await hass.async_block_till_done() + + def _super_template_as_boolean(result): + if isinstance(result, TemplateError): + return True + + return result_as_boolean(result) + + def specific_run_callback(event, updates): + for track_result in updates: + if track_result.template is template_condition: + specific_runs.append(int(track_result.result)) + elif track_result.template is template_availability: + specific_runs_availability.append( + _super_template_as_boolean(track_result.result) + ) + + async_track_template_result( + hass, + [ + TrackTemplate(template_availability, None), + TrackTemplate(template_condition, None), + ], + specific_run_callback, + has_super_template=True, + ) + + @ha.callback + def wildcard_run_callback(event, updates): + for track_result in updates: + if track_result.template is template_condition: + wildcard_runs.append( + (int(track_result.last_result or 0), int(track_result.result)) + ) + elif track_result.template is template_availability: + wildcard_runs_availability.append( + _super_template_as_boolean(track_result.result) + ) + + async_track_template_result( + hass, + [ + TrackTemplate(template_availability, None), + TrackTemplate(template_condition, None), + ], + wildcard_run_callback, + has_super_template=True, + ) + + async def wildercard_run_callback(event, updates): + for track_result in updates: + if track_result.template is template_condition_var: + wildercard_runs.append( + (int(track_result.last_result or 0), int(track_result.result)) + ) + elif track_result.template is template_availability: + wildercard_runs_availability.append( + _super_template_as_boolean(track_result.result) + ) + + async_track_template_result( + hass, + [ + TrackTemplate(template_availability, None), + TrackTemplate(template_condition_var, {"test": 5}), + ], + wildercard_run_callback, + has_super_template=True, + ) + await hass.async_block_till_done() + + assert specific_runs_availability == [] + assert wildcard_runs_availability == [] + assert wildercard_runs_availability == [] + assert specific_runs == [] + assert wildcard_runs == [] + assert wildercard_runs == [] + + hass.states.async_set("sensor.test", 5) + hass.states.async_set("sensor.test2", "available") + await hass.async_block_till_done() + + assert specific_runs_availability == [True] + assert wildcard_runs_availability == [True] + assert wildercard_runs_availability == [True] + assert specific_runs == [5] + assert wildcard_runs == [(0, 5)] + assert wildercard_runs == [(0, 10)] + + hass.states.async_set("sensor.test2", "unknown") + await hass.async_block_till_done() + + assert specific_runs_availability == [True] + assert wildcard_runs_availability == [True] + assert wildercard_runs_availability == [True] + + hass.states.async_set("sensor.test2", "available") + hass.states.async_set("sensor.test", 30) + await hass.async_block_till_done() + + assert specific_runs_availability == [True] + assert wildcard_runs_availability == [True] + assert wildercard_runs_availability == [True] + assert specific_runs == [5, 30] + assert wildcard_runs == [(0, 5), (5, 30)] + assert wildercard_runs == [(0, 10), (10, 35)] + + async def test_track_template_result_complex(hass): """Test tracking template.""" specific_runs = [] @@ -3393,66 +3744,56 @@ async def test_periodic_task_duplicate_time(hass): unsub() -async def test_periodic_task_entering_dst(hass): +# DST starts early morning March 28th 2021 +@pytest.mark.freeze_time("2021-03-28 01:28:00+01:00") +async def test_periodic_task_entering_dst(hass, freezer): """Test periodic task behavior when entering dst.""" timezone = dt_util.get_time_zone("Europe/Vienna") dt_util.set_default_time_zone(timezone) specific_runs = [] - # DST starts early morning March 27th 2022 - yy = 2022 - mm = 3 - dd = 27 + today = date.today().isoformat() + tomorrow = (date.today() + timedelta(days=1)).isoformat() - # There's no 2022-03-27 02:30, the event should not fire until 2022-03-28 02:30 - time_that_will_not_match_right_away = datetime( - yy, mm, dd, 1, 28, 0, tzinfo=timezone, fold=0 - ) # Make sure we enter DST during the test - assert ( - time_that_will_not_match_right_away.utcoffset() - != (time_that_will_not_match_right_away + timedelta(hours=2)).utcoffset() + now_local = dt_util.now() + assert now_local.utcoffset() != (now_local + timedelta(hours=2)).utcoffset() + + unsub = async_track_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour=2, + minute=30, + second=0, ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour=2, - minute=30, - second=0, - ) - - async_fire_time_changed( - hass, datetime(yy, mm, dd, 1, 50, 0, 999999, tzinfo=timezone) - ) + freezer.move_to(f"{today} 01:50:00.999999+01:00") + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(specific_runs) == 0 - async_fire_time_changed( - hass, datetime(yy, mm, dd, 3, 50, 0, 999999, tzinfo=timezone) - ) + # There was no 02:30 today, the event should not fire until tomorrow + freezer.move_to(f"{today} 03:50:00.999999+02:00") + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(specific_runs) == 0 - async_fire_time_changed( - hass, datetime(yy, mm, dd + 1, 1, 50, 0, 999999, tzinfo=timezone) - ) + freezer.move_to(f"{tomorrow} 01:50:00.999999+02:00") + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(specific_runs) == 0 - async_fire_time_changed( - hass, datetime(yy, mm, dd + 1, 2, 50, 0, 999999, tzinfo=timezone) - ) + freezer.move_to(f"{tomorrow} 02:50:00.999999+02:00") + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(specific_runs) == 1 unsub() -async def test_periodic_task_entering_dst_2(hass): +# DST starts early morning March 28th 2021 +@pytest.mark.freeze_time("2021-03-28 01:59:59+01:00") +async def test_periodic_task_entering_dst_2(hass, freezer): """Test periodic task behavior when entering dst. This tests a task firing every second in the range 0..58 (not *:*:59) @@ -3461,220 +3802,182 @@ async def test_periodic_task_entering_dst_2(hass): dt_util.set_default_time_zone(timezone) specific_runs = [] - # DST starts early morning March 27th 2022 - yy = 2022 - mm = 3 - dd = 27 + today = date.today().isoformat() + tomorrow = (date.today() + timedelta(days=1)).isoformat() - # There's no 2022-03-27 02:00:00, the event should not fire until 2022-03-28 03:00:00 - time_that_will_not_match_right_away = datetime( - yy, mm, dd, 1, 59, 59, tzinfo=timezone, fold=0 - ) # Make sure we enter DST during the test - assert ( - time_that_will_not_match_right_away.utcoffset() - != (time_that_will_not_match_right_away + timedelta(hours=2)).utcoffset() + now_local = dt_util.now() + assert now_local.utcoffset() != (now_local + timedelta(hours=2)).utcoffset() + + unsub = async_track_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + second=list(range(59)), ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - second=list(range(59)), - ) - - async_fire_time_changed( - hass, datetime(yy, mm, dd, 1, 59, 59, 999999, tzinfo=timezone) - ) + freezer.move_to(f"{today} 01:59:59.999999+01:00") + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(specific_runs) == 0 - async_fire_time_changed( - hass, datetime(yy, mm, dd, 3, 0, 0, 999999, tzinfo=timezone) - ) + freezer.move_to(f"{today} 03:00:00.999999+02:00") + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed( - hass, datetime(yy, mm, dd, 3, 0, 1, 999999, tzinfo=timezone) - ) + freezer.move_to(f"{today} 03:00:01.999999+02:00") + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(specific_runs) == 2 - async_fire_time_changed( - hass, datetime(yy, mm, dd + 1, 1, 59, 59, 999999, tzinfo=timezone) - ) + freezer.move_to(f"{tomorrow} 01:59:59.999999+02:00") + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(specific_runs) == 3 - async_fire_time_changed( - hass, datetime(yy, mm, dd + 1, 2, 0, 0, 999999, tzinfo=timezone) - ) + freezer.move_to(f"{tomorrow} 02:00:00.999999+02:00") + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(specific_runs) == 4 unsub() -async def test_periodic_task_leaving_dst(hass): +# DST ends early morning October 31st 2021 +@pytest.mark.freeze_time("2021-10-31 02:28:00+02:00") +async def test_periodic_task_leaving_dst(hass, freezer): """Test periodic task behavior when leaving dst.""" timezone = dt_util.get_time_zone("Europe/Vienna") dt_util.set_default_time_zone(timezone) specific_runs = [] - # DST ends early morning Ocotber 30th 2022 - yy = 2022 - mm = 10 - dd = 30 - - time_that_will_not_match_right_away = datetime( - yy, mm, dd, 2, 28, 0, tzinfo=timezone, fold=0 - ) + today = date.today().isoformat() + tomorrow = (date.today() + timedelta(days=1)).isoformat() # Make sure we leave DST during the test - assert ( - time_that_will_not_match_right_away.utcoffset() - != time_that_will_not_match_right_away.replace(fold=1).utcoffset() - ) + now_local = dt_util.now() + assert now_local.utcoffset() != (now_local + timedelta(hours=1)).utcoffset() - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour=2, - minute=30, - second=0, - ) + unsub = async_track_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour=2, + minute=30, + second=0, + ) # The task should not fire yet - async_fire_time_changed( - hass, datetime(yy, mm, dd, 2, 28, 0, 999999, tzinfo=timezone, fold=0) - ) + freezer.move_to(f"{today} 02:28:00.999999+02:00") + async_fire_time_changed(hass) + assert dt_util.now().fold == 0 await hass.async_block_till_done() assert len(specific_runs) == 0 # The task should fire - async_fire_time_changed( - hass, datetime(yy, mm, dd, 2, 30, 0, 999999, tzinfo=timezone, fold=0) - ) + freezer.move_to(f"{today} 02:30:00.999999+02:00") + async_fire_time_changed(hass) + assert dt_util.now().fold == 0 await hass.async_block_till_done() assert len(specific_runs) == 1 # The task should not fire again - async_fire_time_changed( - hass, datetime(yy, mm, dd, 2, 55, 0, 999999, tzinfo=timezone, fold=0) - ) + freezer.move_to(f"{today} 02:55:00.999999+02:00") + async_fire_time_changed(hass) + assert dt_util.now().fold == 0 await hass.async_block_till_done() assert len(specific_runs) == 1 # DST has ended, the task should not fire yet - async_fire_time_changed( - hass, - datetime(yy, mm, dd, 2, 15, 0, 999999, tzinfo=timezone, fold=1), - ) + freezer.move_to(f"{today} 02:15:00.999999+01:00") + async_fire_time_changed(hass) + assert dt_util.now().fold == 1 # DST has ended await hass.async_block_till_done() assert len(specific_runs) == 1 # The task should fire - async_fire_time_changed( - hass, - datetime(yy, mm, dd, 2, 45, 0, 999999, tzinfo=timezone, fold=1), - ) + freezer.move_to(f"{today} 02:45:00.999999+01:00") + async_fire_time_changed(hass) + assert dt_util.now().fold == 1 await hass.async_block_till_done() assert len(specific_runs) == 2 # The task should not fire again - async_fire_time_changed( - hass, - datetime(yy, mm, dd, 2, 55, 0, 999999, tzinfo=timezone, fold=1), - ) + freezer.move_to(f"{today} 02:55:00.999999+01:00") + async_fire_time_changed(hass) + assert dt_util.now().fold == 1 await hass.async_block_till_done() assert len(specific_runs) == 2 # The task should fire again the next day - async_fire_time_changed( - hass, datetime(yy, mm, dd + 1, 2, 55, 0, 999999, tzinfo=timezone, fold=1) - ) + freezer.move_to(f"{tomorrow} 02:55:00.999999+01:00") + async_fire_time_changed(hass) + assert dt_util.now().fold == 0 await hass.async_block_till_done() assert len(specific_runs) == 3 unsub() -async def test_periodic_task_leaving_dst_2(hass): +# DST ends early morning October 31st 2021 +@pytest.mark.freeze_time("2021-10-31 02:28:00+02:00") +async def test_periodic_task_leaving_dst_2(hass, freezer): """Test periodic task behavior when leaving dst.""" timezone = dt_util.get_time_zone("Europe/Vienna") dt_util.set_default_time_zone(timezone) specific_runs = [] - # DST ends early morning Ocotber 30th 2022 - yy = 2022 - mm = 10 - dd = 30 + today = date.today().isoformat() - time_that_will_not_match_right_away = datetime( - yy, mm, dd, 2, 28, 0, tzinfo=timezone, fold=0 - ) # Make sure we leave DST during the test - assert ( - time_that_will_not_match_right_away.utcoffset() - != time_that_will_not_match_right_away.replace(fold=1).utcoffset() - ) + now_local = dt_util.now() + assert now_local.utcoffset() != (now_local + timedelta(hours=1)).utcoffset() - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - minute=30, - second=0, - ) + unsub = async_track_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + minute=30, + second=0, + ) # The task should not fire yet - async_fire_time_changed( - hass, datetime(yy, mm, dd, 2, 28, 0, 999999, tzinfo=timezone, fold=0) - ) + freezer.move_to(f"{today} 02:28:00.999999+02:00") + async_fire_time_changed(hass) + assert dt_util.now().fold == 0 await hass.async_block_till_done() assert len(specific_runs) == 0 # The task should fire - async_fire_time_changed( - hass, datetime(yy, mm, dd, 2, 55, 0, 999999, tzinfo=timezone, fold=0) - ) + freezer.move_to(f"{today} 02:55:00.999999+02:00") + async_fire_time_changed(hass) + assert dt_util.now().fold == 0 await hass.async_block_till_done() assert len(specific_runs) == 1 # DST has ended, the task should not fire yet - async_fire_time_changed( - hass, datetime(yy, mm, dd, 2, 15, 0, 999999, tzinfo=timezone, fold=1) - ) + freezer.move_to(f"{today} 02:15:00.999999+01:00") + async_fire_time_changed(hass) + assert dt_util.now().fold == 1 await hass.async_block_till_done() assert len(specific_runs) == 1 # The task should fire - async_fire_time_changed( - hass, datetime(yy, mm, dd, 2, 45, 0, 999999, tzinfo=timezone, fold=1) - ) + freezer.move_to(f"{today} 02:45:00.999999+01:00") + async_fire_time_changed(hass) + assert dt_util.now().fold == 1 await hass.async_block_till_done() assert len(specific_runs) == 2 # The task should not fire again - async_fire_time_changed( - hass, - datetime(yy, mm, dd, 2, 55, 0, 999999, tzinfo=timezone, fold=1), - ) + freezer.move_to(f"{today} 02:55:00.999999+01:00") + async_fire_time_changed(hass) + assert dt_util.now().fold == 1 await hass.async_block_till_done() assert len(specific_runs) == 2 # The task should fire again the next hour - async_fire_time_changed( - hass, datetime(yy, mm, dd, 3, 55, 0, 999999, tzinfo=timezone, fold=0) - ) + freezer.move_to(f"{today} 03:55:00.999999+01:00") + async_fire_time_changed(hass) + assert dt_util.now().fold == 0 await hass.async_block_till_done() assert len(specific_runs) == 3 diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 5e48b2aec5f..37f3e7ec95f 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -1,4 +1,5 @@ """Test the frame helper.""" +# pylint: disable=protected-access from unittest.mock import Mock, patch import pytest @@ -70,3 +71,24 @@ async def test_extract_frame_no_integration(caplog): ], ), pytest.raises(frame.MissingIntegrationFrame): frame.get_integration_frame() + + +@pytest.mark.usefixtures("mock_integration_frame") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +async def test_prevent_flooding(caplog): + """Test to ensure a report is only written once to the log.""" + + what = "accessed hi instead of hello" + key = "/home/paulus/homeassistant/components/hue/light.py:23" + + frame.report(what, error_if_core=False) + assert what in caplog.text + assert key in frame._REPORTED_INTEGRATIONS + assert len(frame._REPORTED_INTEGRATIONS) == 1 + + caplog.clear() + + frame.report(what, error_if_core=False) + assert what not in caplog.text + assert key in frame._REPORTED_INTEGRATIONS + assert len(frame._REPORTED_INTEGRATIONS) == 1 diff --git a/tests/helpers/test_recorder.py b/tests/helpers/test_recorder.py index 60d60a2335e..c0663cba165 100644 --- a/tests/helpers/test_recorder.py +++ b/tests/helpers/test_recorder.py @@ -10,23 +10,23 @@ from tests.common import async_init_recorder_component async def test_async_migration_in_progress(hass): """Test async_migration_in_progress wraps the recorder.""" with patch( - "homeassistant.components.recorder.async_migration_in_progress", + "homeassistant.components.recorder.util.async_migration_in_progress", return_value=False, ): - assert await recorder.async_migration_in_progress(hass) is False + assert recorder.async_migration_in_progress(hass) is False # The recorder is not loaded with patch( - "homeassistant.components.recorder.async_migration_in_progress", + "homeassistant.components.recorder.util.async_migration_in_progress", return_value=True, ): - assert await recorder.async_migration_in_progress(hass) is False + assert recorder.async_migration_in_progress(hass) is False await async_init_recorder_component(hass) # The recorder is now loaded with patch( - "homeassistant.components.recorder.async_migration_in_progress", + "homeassistant.components.recorder.util.async_migration_in_progress", return_value=True, ): - assert await recorder.async_migration_in_progress(hass) is True + assert recorder.async_migration_in_progress(hass) is True diff --git a/tests/helpers/test_reload.py b/tests/helpers/test_reload.py index b4f47fa65b1..b99951493ca 100644 --- a/tests/helpers/test_reload.py +++ b/tests/helpers/test_reload.py @@ -1,6 +1,5 @@ """Tests for the reload helper.""" import logging -from os import path from unittest.mock import AsyncMock, Mock, patch import pytest @@ -20,6 +19,7 @@ from homeassistant.loader import async_get_integration from tests.common import ( MockModule, MockPlatform, + get_fixture_path, mock_entity_platform, mock_integration, ) @@ -57,11 +57,7 @@ async def test_reload_platform(hass): assert platform.platform_name == PLATFORM assert platform.domain == DOMAIN - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "helpers/reload_configuration.yaml", - ) + yaml_path = get_fixture_path("helpers/reload_configuration.yaml") with patch.object(config, "YAML_CONFIG_FILE", yaml_path): await async_reload_integration_platforms(hass, PLATFORM, [DOMAIN]) @@ -99,11 +95,7 @@ async def test_setup_reload_service(hass): await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "helpers/reload_configuration.yaml", - ) + yaml_path = get_fixture_path("helpers/reload_configuration.yaml") with patch.object(config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( PLATFORM, @@ -142,11 +134,7 @@ async def test_setup_reload_service_when_async_process_component_config_fails(ha await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "helpers/reload_configuration.yaml", - ) + yaml_path = get_fixture_path("helpers/reload_configuration.yaml") with patch.object(config, "YAML_CONFIG_FILE", yaml_path), patch.object( config, "async_process_component_config", return_value=None ): @@ -196,11 +184,7 @@ async def test_setup_reload_service_with_platform_that_provides_async_reset_plat await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "helpers/reload_configuration.yaml", - ) + yaml_path = get_fixture_path("helpers/reload_configuration.yaml") with patch.object(config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( PLATFORM, @@ -218,11 +202,7 @@ async def test_async_integration_yaml_config(hass): """Test loading yaml config for an integration.""" mock_integration(hass, MockModule(DOMAIN)) - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - f"helpers/{DOMAIN}_configuration.yaml", - ) + yaml_path = get_fixture_path(f"helpers/{DOMAIN}_configuration.yaml") with patch.object(config, "YAML_CONFIG_FILE", yaml_path): processed_config = await async_integration_yaml_config(hass, DOMAIN) @@ -233,16 +213,8 @@ async def test_async_integration_missing_yaml_config(hass): """Test loading missing yaml config for an integration.""" mock_integration(hass, MockModule(DOMAIN)) - yaml_path = path.join( - _get_fixtures_base_path(), - "fixtures", - "helpers/does_not_exist_configuration.yaml", - ) + yaml_path = get_fixture_path("helpers/does_not_exist_configuration.yaml") with pytest.raises(FileNotFoundError), patch.object( config, "YAML_CONFIG_FILE", yaml_path ): await async_integration_yaml_config(hass, DOMAIN) - - -def _get_fixtures_base_path(): - return path.dirname(path.dirname(__file__)) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index fd98145cab2..b0a93c85b2b 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -3,7 +3,9 @@ import asyncio from contextlib import contextmanager from datetime import timedelta +from functools import reduce import logging +import operator from types import MappingProxyType from unittest import mock from unittest.mock import AsyncMock, patch @@ -23,7 +25,13 @@ from homeassistant.const import ( ) from homeassistant.core import SERVICE_CALL_LIMIT, Context, CoreState, callback from homeassistant.exceptions import ConditionError, ServiceNotFound -from homeassistant.helpers import config_validation as cv, script, trace +from homeassistant.helpers import ( + config_validation as cv, + entity_registry as er, + script, + template, + trace, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -48,8 +56,11 @@ def compare_trigger_item(actual_trigger, expected_trigger): assert actual_trigger["description"] == expected_trigger["description"] -def compare_result_item(key, actual, expected): - """Compare an item in the result dict.""" +def compare_result_item(key, actual, expected, path): + """Compare an item in the result dict. + + Note: Unused variable 'path' is passed to get helpful errors from pytest. + """ if key == "wait" and (expected.get("trigger") is not None): assert "trigger" in actual expected_trigger = expected.pop("trigger") @@ -70,7 +81,7 @@ def assert_element(trace_element, expected_element, path): # The redundant set operation gives helpful errors from pytest assert not set(expected_result) - set(trace_element._result or {}) for result_key, result in expected_result.items(): - compare_result_item(result_key, trace_element._result[result_key], result) + compare_result_item(result_key, trace_element._result[result_key], result, path) assert trace_element._result[result_key] == result # Check for unexpected items in trace_element @@ -746,6 +757,7 @@ async def test_wait_basic(hass, action_type): "to": "off", } sequence = cv.SCRIPT_SCHEMA(action) + sequence = await script.async_validate_actions_config(hass, sequence) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") wait_started_flag = async_watch_for_action(script_obj, wait_alias) @@ -846,6 +858,7 @@ async def test_wait_basic_times_out(hass, action_type): "to": "off", } sequence = cv.SCRIPT_SCHEMA(action) + sequence = await script.async_validate_actions_config(hass, sequence) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") wait_started_flag = async_watch_for_action(script_obj, wait_alias) timed_out = False @@ -858,7 +871,7 @@ async def test_wait_basic_times_out(hass, action_type): assert script_obj.last_action == wait_alias hass.states.async_set("switch.test", "not_on") - with timeout(0.1): + async with timeout(0.1): await hass.async_block_till_done() except asyncio.TimeoutError: timed_out = True @@ -902,6 +915,7 @@ async def test_multiple_runs_wait(hass, action_type): {"event": event, "event_data": {"value": 2}}, ] ) + sequence = await script.async_validate_actions_config(hass, sequence) script_obj = script.Script( hass, sequence, "Test Name", "test_domain", script_mode="parallel", max_runs=2 ) @@ -950,6 +964,7 @@ async def test_cancel_wait(hass, action_type): } } sequence = cv.SCRIPT_SCHEMA([action, {"event": event}]) + sequence = await script.async_validate_actions_config(hass, sequence) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") wait_started_flag = async_watch_for_action(script_obj, "wait") @@ -1047,6 +1062,7 @@ async def test_wait_timeout(hass, caplog, timeout_param, action_type): action["timeout"] = timeout_param action["continue_on_timeout"] = True sequence = cv.SCRIPT_SCHEMA([action, {"event": event}]) + sequence = await script.async_validate_actions_config(hass, sequence) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") wait_started_flag = async_watch_for_action(script_obj, "wait") @@ -1114,6 +1130,7 @@ async def test_wait_continue_on_timeout( if continue_on_timeout is not None: action["continue_on_timeout"] = continue_on_timeout sequence = cv.SCRIPT_SCHEMA([action, {"event": event}]) + sequence = await script.async_validate_actions_config(hass, sequence) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") wait_started_flag = async_watch_for_action(script_obj, "wait") @@ -1238,7 +1255,7 @@ async def test_wait_template_with_utcnow_no_match(hass): ): async_fire_time_changed(hass, second_non_matching_time) - with timeout(0.1): + async with timeout(0.1): await hass.async_block_till_done() except asyncio.TimeoutError: timed_out = True @@ -1285,6 +1302,7 @@ async def test_wait_variables_out(hass, mode, action_type): }, ] sequence = cv.SCRIPT_SCHEMA(sequence) + sequence = await script.async_validate_actions_config(hass, sequence) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") wait_started_flag = async_watch_for_action(script_obj, "wait") @@ -1324,11 +1342,13 @@ async def test_wait_variables_out(hass, mode, action_type): async def test_wait_for_trigger_bad(hass, caplog): """Test bad wait_for_trigger.""" + sequence = cv.SCRIPT_SCHEMA( + {"wait_for_trigger": {"platform": "state", "entity_id": "sensor.abc"}} + ) + sequence = await script.async_validate_actions_config(hass, sequence) script_obj = script.Script( hass, - cv.SCRIPT_SCHEMA( - {"wait_for_trigger": {"platform": "state", "entity_id": "sensor.abc"}} - ), + sequence, "Test Name", "test_domain", ) @@ -1354,11 +1374,13 @@ async def test_wait_for_trigger_bad(hass, caplog): async def test_wait_for_trigger_generated_exception(hass, caplog): """Test bad wait_for_trigger.""" + sequence = cv.SCRIPT_SCHEMA( + {"wait_for_trigger": {"platform": "state", "entity_id": "sensor.abc"}} + ) + sequence = await script.async_validate_actions_config(hass, sequence) script_obj = script.Script( hass, - cv.SCRIPT_SCHEMA( - {"wait_for_trigger": {"platform": "state", "entity_id": "sensor.abc"}} - ), + sequence, "Test Name", "test_domain", ) @@ -1479,6 +1501,81 @@ async def test_condition_basic(hass, caplog): ) +async def test_condition_validation(hass, caplog): + """Test if we can use conditions which validate late in a script.""" + registry = er.async_get(hass) + entry = registry.async_get_or_create( + "test", "hue", "1234", suggested_object_id="entity" + ) + assert entry.entity_id == "test.entity" + event = "test_event" + events = async_capture_events(hass, event) + alias = "condition step" + sequence = cv.SCRIPT_SCHEMA( + [ + {"event": event}, + { + "alias": alias, + "condition": "state", + "entity_id": entry.id, + "state": "hello", + }, + {"event": event}, + ] + ) + sequence = await script.async_validate_actions_config(hass, sequence) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + hass.states.async_set("test.entity", "hello") + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + assert f"Test condition {alias}: True" in caplog.text + caplog.clear() + assert len(events) == 2 + + assert_action_trace( + { + "0": [{"result": {"event": "test_event", "event_data": {}}}], + "1": [{"result": {"result": True}}], + "1/entity_id/0": [ + {"result": {"result": True, "state": "hello", "wanted_state": "hello"}} + ], + "2": [{"result": {"event": "test_event", "event_data": {}}}], + } + ) + + hass.states.async_set("test.entity", "goodbye") + + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + assert f"Test condition {alias}: False" in caplog.text + assert len(events) == 3 + + assert_action_trace( + { + "0": [{"result": {"event": "test_event", "event_data": {}}}], + "1": [ + { + "error_type": script._StopScript, + "result": {"result": False}, + } + ], + "1/entity_id/0": [ + { + "result": { + "result": False, + "state": "goodbye", + "wanted_state": "hello", + } + } + ], + }, + expected_script_execution="aborted", + ) + + @patch("homeassistant.helpers.script.condition.async_from_config") async def test_condition_created_once(async_from_config, hass): """Test that the conditions do not get created multiple times.""" @@ -1725,6 +1822,126 @@ async def test_repeat_conditional(hass, condition, direct_template): assert event.data.get("index") == index + 1 +async def test_repeat_until_condition_validation(hass, caplog): + """Test if we can use conditions in repeat until conditions which validate late.""" + registry = er.async_get(hass) + entry = registry.async_get_or_create( + "test", "hue", "1234", suggested_object_id="entity" + ) + assert entry.entity_id == "test.entity" + event = "test_event" + events = async_capture_events(hass, event) + sequence = cv.SCRIPT_SCHEMA( + [ + { + "repeat": { + "sequence": [ + {"event": event}, + ], + "until": [ + { + "condition": "state", + "entity_id": entry.id, + "state": "hello", + } + ], + } + }, + ] + ) + hass.states.async_set("test.entity", "hello") + sequence = await script.async_validate_actions_config(hass, sequence) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + caplog.clear() + assert len(events) == 1 + + assert_action_trace( + { + "0": [{"result": {}}], + "0/repeat/sequence/0": [ + { + "result": {"event": "test_event", "event_data": {}}, + "variables": {"repeat": {"first": True, "index": 1}}, + } + ], + "0/repeat": [ + { + "result": {"result": True}, + "variables": {"repeat": {"first": True, "index": 1}}, + } + ], + "0/repeat/until/0": [{"result": {"result": True}}], + "0/repeat/until/0/entity_id/0": [ + {"result": {"result": True, "state": "hello", "wanted_state": "hello"}} + ], + } + ) + + +async def test_repeat_while_condition_validation(hass, caplog): + """Test if we can use conditions in repeat while conditions which validate late.""" + registry = er.async_get(hass) + entry = registry.async_get_or_create( + "test", "hue", "1234", suggested_object_id="entity" + ) + assert entry.entity_id == "test.entity" + event = "test_event" + events = async_capture_events(hass, event) + sequence = cv.SCRIPT_SCHEMA( + [ + { + "repeat": { + "sequence": [ + {"event": event}, + ], + "while": [ + { + "condition": "state", + "entity_id": entry.id, + "state": "hello", + } + ], + } + }, + ] + ) + hass.states.async_set("test.entity", "goodbye") + sequence = await script.async_validate_actions_config(hass, sequence) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + caplog.clear() + assert len(events) == 0 + + assert_action_trace( + { + "0": [{"result": {}}], + "0/repeat": [ + { + "result": {"result": False}, + "variables": {"repeat": {"first": True, "index": 1}}, + } + ], + "0/repeat/while/0": [{"result": {"result": False}}], + "0/repeat/while/0/entity_id/0": [ + { + "result": { + "result": False, + "state": "goodbye", + "wanted_state": "hello", + } + } + ], + } + ) + + @pytest.mark.parametrize("condition", ["while", "until"]) async def test_repeat_var_in_condition(hass, condition): """Test repeat action w/ while option.""" @@ -2088,6 +2305,88 @@ async def test_choose(hass, caplog, var, result): assert_action_trace(expected_trace) +async def test_choose_condition_validation(hass, caplog): + """Test if we can use conditions in choose actions which validate late.""" + registry = er.async_get(hass) + entry = registry.async_get_or_create( + "test", "hue", "1234", suggested_object_id="entity" + ) + assert entry.entity_id == "test.entity" + event = "test_event" + events = async_capture_events(hass, event) + sequence = cv.SCRIPT_SCHEMA( + [ + {"event": event}, + { + "choose": [ + { + "alias": "choice one", + "conditions": { + "condition": "state", + "entity_id": entry.id, + "state": "hello", + }, + "sequence": { + "alias": "sequence one", + "event": event, + "event_data": {"choice": "first"}, + }, + }, + ] + }, + ] + ) + sequence = await script.async_validate_actions_config(hass, sequence) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + hass.states.async_set("test.entity", "hello") + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + caplog.clear() + assert len(events) == 2 + + assert_action_trace( + { + "0": [{"result": {"event": "test_event", "event_data": {}}}], + "1": [{"result": {"choice": 0}}], + "1/choose/0": [{"result": {"result": True}}], + "1/choose/0/conditions/0": [{"result": {"result": True}}], + "1/choose/0/conditions/0/entity_id/0": [ + {"result": {"result": True, "state": "hello", "wanted_state": "hello"}} + ], + "1/choose/0/sequence/0": [ + {"result": {"event": "test_event", "event_data": {"choice": "first"}}} + ], + } + ) + + hass.states.async_set("test.entity", "goodbye") + + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + assert len(events) == 3 + + assert_action_trace( + { + "0": [{"result": {"event": "test_event", "event_data": {}}}], + "1": [{"result": {}}], + "1/choose/0": [{"result": {"result": False}}], + "1/choose/0/conditions/0": [{"result": {"result": False}}], + "1/choose/0/conditions/0/entity_id/0": [ + { + "result": { + "result": False, + "state": "goodbye", + "wanted_state": "hello", + } + } + ], + }, + ) + + @pytest.mark.parametrize( "action", [ @@ -3021,6 +3320,15 @@ async def test_set_redefines_variable(hass, caplog): async def test_validate_action_config(hass): """Validate action config.""" + + def templated_device_action(message): + return { + "device_id": "abcd", + "domain": "mobile_app", + "message": f"{message} {{{{ 5 + 5}}}}", + "type": "notify", + } + configs = { cv.SCRIPT_ACTION_CALL_SERVICE: {"service": "light.turn_on"}, cv.SCRIPT_ACTION_DELAY: {"delay": 5}, @@ -3029,26 +3337,26 @@ async def test_validate_action_config(hass): }, cv.SCRIPT_ACTION_FIRE_EVENT: {"event": "my_event"}, cv.SCRIPT_ACTION_CHECK_CONDITION: { - "condition": "{{ states.light.kitchen.state == 'on' }}" - }, - cv.SCRIPT_ACTION_DEVICE_AUTOMATION: { - "domain": "light", + "condition": "state", "entity_id": "light.kitchen", - "device_id": "abcd", - "type": "turn_on", + "state": "on", }, + cv.SCRIPT_ACTION_DEVICE_AUTOMATION: templated_device_action("device"), cv.SCRIPT_ACTION_ACTIVATE_SCENE: {"scene": "scene.relax"}, cv.SCRIPT_ACTION_REPEAT: { - "repeat": {"count": 3, "sequence": [{"event": "repeat_event"}]} + "repeat": { + "count": 3, + "sequence": [templated_device_action("repeat_event")], + } }, cv.SCRIPT_ACTION_CHOOSE: { "choose": [ { - "condition": "{{ states.light.kitchen.state == 'on' }}", - "sequence": [{"event": "choose_event"}], + "conditions": "{{ states.light.kitchen.state == 'on' }}", + "sequence": [templated_device_action("choose_event")], } ], - "default": [{"event": "choose_default_event"}], + "default": [templated_device_action("choose_default_event")], }, cv.SCRIPT_ACTION_WAIT_FOR_TRIGGER: { "wait_for_trigger": [ @@ -3057,9 +3365,17 @@ async def test_validate_action_config(hass): }, cv.SCRIPT_ACTION_VARIABLES: {"variables": {"hello": "world"}}, } + expected_templates = { + cv.SCRIPT_ACTION_CHECK_CONDITION: None, + cv.SCRIPT_ACTION_DEVICE_AUTOMATION: [[]], + cv.SCRIPT_ACTION_REPEAT: [["repeat", "sequence", 0]], + cv.SCRIPT_ACTION_CHOOSE: [["choose", 0, "sequence", 0], ["default", 0]], + cv.SCRIPT_ACTION_WAIT_FOR_TRIGGER: None, + } for key in cv.ACTION_TYPE_SCHEMAS: assert key in configs, f"No validate config test found for {key}" + assert key in expected_templates or key in script.STATIC_VALIDATION_ACTION_TYPES # Verify we raise if we don't know the action type with patch( @@ -3068,13 +3384,28 @@ async def test_validate_action_config(hass): ), pytest.raises(ValueError): await script.async_validate_action_config(hass, {}) + # Verify each action can validate + validated_config = {} for action_type, config in configs.items(): assert cv.determine_script_action(config) == action_type try: - await script.async_validate_action_config(hass, config) + validated_config[action_type] = cv.ACTION_TYPE_SCHEMAS[action_type](config) + validated_config[action_type] = await script.async_validate_action_config( + hass, validated_config[action_type] + ) except vol.Invalid as err: assert False, f"{action_type} config invalid: {err}" + # Verify non-static actions have validated + for action_type, paths_to_templates in expected_templates.items(): + if paths_to_templates is None: + continue + for path_to_template in paths_to_templates: + device_action = reduce( + operator.getitem, path_to_template, validated_config[action_type] + ) + assert isinstance(device_action["message"], template.Template) + async def test_embedded_wait_for_trigger_in_automation(hass): """Test an embedded wait for trigger.""" diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 74c333f2448..0478c17e299 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -17,6 +17,9 @@ from homeassistant.util import dt from tests.common import async_fire_time_changed MOCK_VERSION = 1 +MOCK_VERSION_2 = 2 +MOCK_MINOR_VERSION_1 = 1 +MOCK_MINOR_VERSION_2 = 2 MOCK_KEY = "storage-test" MOCK_DATA = {"hello": "world"} MOCK_DATA2 = {"goodbye": "cruel world"} @@ -28,6 +31,30 @@ def store(hass): return storage.Store(hass, MOCK_VERSION, MOCK_KEY) +@pytest.fixture +def store_v_1_1(hass): + """Fixture of a store that prevents writing on Home Assistant stop.""" + return storage.Store( + hass, MOCK_VERSION, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1 + ) + + +@pytest.fixture +def store_v_1_2(hass): + """Fixture of a store that prevents writing on Home Assistant stop.""" + return storage.Store( + hass, MOCK_VERSION, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_2 + ) + + +@pytest.fixture +def store_v_2_1(hass): + """Fixture of a store that prevents writing on Home Assistant stop.""" + return storage.Store( + hass, MOCK_VERSION_2, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1 + ) + + async def test_loading(hass, store): """Test we can save and load data.""" await store.async_save(MOCK_DATA) @@ -78,6 +105,7 @@ async def test_saving_with_delay(hass, store, hass_storage): await hass.async_block_till_done() assert hass_storage[store.key] == { "version": MOCK_VERSION, + "minor_version": 1, "key": MOCK_KEY, "data": MOCK_DATA, } @@ -101,6 +129,7 @@ async def test_saving_on_final_write(hass, hass_storage): await hass.async_block_till_done() assert hass_storage[store.key] == { "version": MOCK_VERSION, + "minor_version": 1, "key": MOCK_KEY, "data": MOCK_DATA, } @@ -148,6 +177,7 @@ async def test_loading_while_delay(hass, store, hass_storage): await store.async_save({"delay": "no"}) assert hass_storage[store.key] == { "version": MOCK_VERSION, + "minor_version": 1, "key": MOCK_KEY, "data": {"delay": "no"}, } @@ -155,6 +185,7 @@ async def test_loading_while_delay(hass, store, hass_storage): store.async_delay_save(lambda: {"delay": "yes"}, 1) assert hass_storage[store.key] == { "version": MOCK_VERSION, + "minor_version": 1, "key": MOCK_KEY, "data": {"delay": "no"}, } @@ -170,6 +201,7 @@ async def test_writing_while_writing_delay(hass, store, hass_storage): await store.async_save({"delay": "no"}) assert hass_storage[store.key] == { "version": MOCK_VERSION, + "minor_version": 1, "key": MOCK_KEY, "data": {"delay": "no"}, } @@ -178,6 +210,7 @@ async def test_writing_while_writing_delay(hass, store, hass_storage): await hass.async_block_till_done() assert hass_storage[store.key] == { "version": MOCK_VERSION, + "minor_version": 1, "key": MOCK_KEY, "data": {"delay": "no"}, } @@ -196,6 +229,7 @@ async def test_multiple_delay_save_calls(hass, store, hass_storage): await store.async_save({"delay": "no"}) assert hass_storage[store.key] == { "version": MOCK_VERSION, + "minor_version": 1, "key": MOCK_KEY, "data": {"delay": "no"}, } @@ -204,6 +238,7 @@ async def test_multiple_delay_save_calls(hass, store, hass_storage): await hass.async_block_till_done() assert hass_storage[store.key] == { "version": MOCK_VERSION, + "minor_version": 1, "key": MOCK_KEY, "data": {"delay": "no"}, } @@ -221,6 +256,7 @@ async def test_multiple_save_calls(hass, store, hass_storage): await asyncio.gather(*tasks) assert hass_storage[store.key] == { "version": MOCK_VERSION, + "minor_version": 1, "key": MOCK_KEY, "data": {"savecount": 5}, } @@ -252,6 +288,7 @@ async def test_migrator_existing_config(hass, store, hass_storage): assert hass_storage[store.key] == { "key": MOCK_KEY, "version": MOCK_VERSION, + "minor_version": 1, "data": data, } @@ -277,10 +314,130 @@ async def test_migrator_transforming_config(hass, store, hass_storage): assert hass_storage[store.key] == { "key": MOCK_KEY, "version": MOCK_VERSION, + "minor_version": 1, "data": data, } +async def test_minor_version_default(hass, store, hass_storage): + """Test minor version default.""" + + await store.async_save(MOCK_DATA) + assert hass_storage[store.key]["minor_version"] == 1 + + +async def test_minor_version(hass, store_v_1_2, hass_storage): + """Test minor version.""" + + await store_v_1_2.async_save(MOCK_DATA) + assert hass_storage[store_v_1_2.key]["minor_version"] == MOCK_MINOR_VERSION_2 + + +async def test_migrate_major_not_implemented_raises(hass, store, store_v_2_1): + """Test migrating between major versions fails if not implemented.""" + + await store_v_2_1.async_save(MOCK_DATA) + with pytest.raises(NotImplementedError): + await store.async_load() + + +async def test_migrate_minor_not_implemented( + hass, hass_storage, store_v_1_1, store_v_1_2 +): + """Test migrating between minor versions does not fail if not implemented.""" + + assert store_v_1_1.key == store_v_1_2.key + + await store_v_1_1.async_save(MOCK_DATA) + assert hass_storage[store_v_1_1.key] == { + "key": MOCK_KEY, + "version": MOCK_VERSION, + "minor_version": MOCK_MINOR_VERSION_1, + "data": MOCK_DATA, + } + data = await store_v_1_2.async_load() + assert hass_storage[store_v_1_1.key]["data"] == data + + await store_v_1_2.async_save(MOCK_DATA) + assert hass_storage[store_v_1_2.key] == { + "key": MOCK_KEY, + "version": MOCK_VERSION, + "minor_version": MOCK_MINOR_VERSION_2, + "data": MOCK_DATA, + } + + +async def test_migration(hass, hass_storage, store_v_1_2): + """Test migration.""" + calls = 0 + + class CustomStore(storage.Store): + async def _async_migrate_func( + self, old_major_version, old_minor_version, old_data: dict + ): + nonlocal calls + calls += 1 + assert old_major_version == store_v_1_2.version + assert old_minor_version == store_v_1_2.minor_version + return old_data + + await store_v_1_2.async_save(MOCK_DATA) + assert hass_storage[store_v_1_2.key] == { + "key": MOCK_KEY, + "version": MOCK_VERSION, + "minor_version": MOCK_MINOR_VERSION_2, + "data": MOCK_DATA, + } + assert calls == 0 + + legacy_store = CustomStore(hass, 2, store_v_1_2.key, minor_version=1) + data = await legacy_store.async_load() + assert calls == 1 + assert hass_storage[store_v_1_2.key]["data"] == data + + await legacy_store.async_save(MOCK_DATA) + assert hass_storage[legacy_store.key] == { + "key": MOCK_KEY, + "version": 2, + "minor_version": 1, + "data": MOCK_DATA, + } + + +async def test_legacy_migration(hass, hass_storage, store_v_1_2): + """Test legacy migration method signature.""" + calls = 0 + + class LegacyStore(storage.Store): + async def _async_migrate_func(self, old_version, old_data: dict): + nonlocal calls + calls += 1 + assert old_version == store_v_1_2.version + return old_data + + await store_v_1_2.async_save(MOCK_DATA) + assert hass_storage[store_v_1_2.key] == { + "key": MOCK_KEY, + "version": MOCK_VERSION, + "minor_version": MOCK_MINOR_VERSION_2, + "data": MOCK_DATA, + } + assert calls == 0 + + legacy_store = LegacyStore(hass, 2, store_v_1_2.key, minor_version=1) + data = await legacy_store.async_load() + assert calls == 1 + assert hass_storage[store_v_1_2.key]["data"] == data + + await legacy_store.async_save(MOCK_DATA) + assert hass_storage[legacy_store.key] == { + "key": MOCK_KEY, + "version": 2, + "minor_version": 1, + "data": MOCK_DATA, + } + + async def test_changing_delayed_written_data(hass, store, hass_storage): """Test changing data that is written with delay.""" data_to_store = {"hello": "world"} @@ -297,6 +454,7 @@ async def test_changing_delayed_written_data(hass, store, hass_storage): await hass.async_block_till_done() assert hass_storage[store.key] == { "version": MOCK_VERSION, + "minor_version": 1, "key": MOCK_KEY, "data": {"hello": "world"}, } diff --git a/tests/helpers/test_system_info.py b/tests/helpers/test_system_info.py index fd9d488596f..f4cb70f421a 100644 --- a/tests/helpers/test_system_info.py +++ b/tests/helpers/test_system_info.py @@ -27,3 +27,10 @@ async def test_container_installationtype(hass): ), patch("homeassistant.helpers.system_info.getuser", return_value="user"): info = await hass.helpers.system_info.async_get_system_info() assert info["installation_type"] == "Unknown" + + +async def test_getuser_keyerror(hass): + """Test getuser keyerror.""" + with patch("homeassistant.helpers.system_info.getuser", side_effect=KeyError): + info = await hass.helpers.system_info.async_get_system_info() + assert info["user"] is None diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 557ec81c2dc..4a97b99d05d 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1,9 +1,11 @@ """Test Home Assistant template helper methods.""" -from datetime import datetime +from datetime import datetime, timedelta +import logging import math import random from unittest.mock import patch +from freezegun import freeze_time import pytest import voluptuous as vol @@ -12,13 +14,16 @@ from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, LENGTH_METERS, + LENGTH_MILLIMETERS, MASS_GRAMS, PRESSURE_PA, + SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, VOLUME_LITERS, ) from homeassistant.exceptions import TemplateError -from homeassistant.helpers import device_registry as dr, template +from homeassistant.helpers import device_registry as dr, entity, template +from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import UnitSystem @@ -34,7 +39,14 @@ from tests.common import ( def _set_up_units(hass): """Set up the tests.""" hass.config.units = UnitSystem( - "custom", TEMP_CELSIUS, LENGTH_METERS, VOLUME_LITERS, MASS_GRAMS, PRESSURE_PA + "custom", + TEMP_CELSIUS, + LENGTH_METERS, + SPEED_KILOMETERS_PER_HOUR, + VOLUME_LITERS, + MASS_GRAMS, + PRESSURE_PA, + LENGTH_MILLIMETERS, ) @@ -681,7 +693,7 @@ def test_timestamp_custom(hass): def test_timestamp_local(hass): """Test the timestamps to local filter.""" - tests = {None: None, 1469119144: "2016-07-21 16:39:04"} + tests = {None: None, 1469119144: "2016-07-21T16:39:04+00:00"} for inp, out in tests.items(): assert ( @@ -721,6 +733,36 @@ def test_as_datetime(hass, input): ) +def test_as_datetime_from_timestamp(hass): + """Test converting a UNIX timestamp to a date object.""" + tests = [ + (1469119144, "2016-07-21 16:39:04+00:00"), + (1469119144.0, "2016-07-21 16:39:04+00:00"), + (-1, "1969-12-31 23:59:59+00:00"), + ] + for input, output in tests: + # expected = dt_util.parse_datetime(input) + if output is not None: + output = str(output) + + assert ( + template.Template(f"{{{{ as_datetime({input}) }}}}", hass).async_render() + == output + ) + assert ( + template.Template(f"{{{{ {input} | as_datetime }}}}", hass).async_render() + == output + ) + assert ( + template.Template(f"{{{{ as_datetime('{input}') }}}}", hass).async_render() + == output + ) + assert ( + template.Template(f"{{{{ '{input}' | as_datetime }}}}", hass).async_render() + == output + ) + + def test_as_local(hass): """Test converting time to local.""" @@ -746,6 +788,21 @@ def test_to_json(hass): assert actual_result == expected_result +def test_to_json_string(hass): + """Test the object to JSON string filter.""" + + # Note that we're not testing the actual json.loads and json.dumps methods, + # only the filters, so we don't need to be exhaustive with our sample JSON. + actual_value_ascii = template.Template( + "{{ 'Bar ҝ éèà' | to_json }}", hass + ).async_render() + assert actual_value_ascii == '"Bar \\u049d \\u00e9\\u00e8\\u00e0"' + actual_value = template.Template( + "{{ 'Bar ҝ éèà' | to_json(ensure_ascii=False) }}", hass + ).async_render() + assert actual_value == '"Bar ҝ éèà"' + + def test_from_json(hass): """Test the JSON string to object filter.""" @@ -833,8 +890,8 @@ def test_timestamp_utc(hass): now = dt_util.utcnow() tests = { None: None, - 1469119144: "2016-07-21 16:39:04", - dt_util.as_timestamp(now): now.strftime("%Y-%m-%d %H:%M:%S"), + 1469119144: "2016-07-21T16:39:04+00:00", + dt_util.as_timestamp(now): now.isoformat(), } for inp, out in tests.items(): @@ -1093,42 +1150,68 @@ def test_utcnow(mock_is_safe, hass): assert info.has_time is True +@pytest.mark.parametrize( + "now, expected, expected_midnight, timezone_str", + [ + # Host clock in UTC + ( + "2021-11-24 03:00:00+00:00", + "2021-11-23T10:00:00-08:00", + "2021-11-23T00:00:00-08:00", + "America/Los_Angeles", + ), + # Host clock in local time + ( + "2021-11-23 19:00:00-08:00", + "2021-11-23T10:00:00-08:00", + "2021-11-23T00:00:00-08:00", + "America/Los_Angeles", + ), + ], +) @patch( "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", return_value=True, ) -def test_today_at(mock_is_safe, hass): +def test_today_at(mock_is_safe, hass, now, expected, expected_midnight, timezone_str): """Test today_at method.""" - now = dt_util.now() - with patch("homeassistant.util.dt.now", return_value=now): - now = now.replace(hour=10, minute=0, second=0, microsecond=0) - result = template.Template( - "{{ today_at('10:00').isoformat() }}", - hass, - ).async_render() - assert result == now.isoformat() + freezer = freeze_time(now) + freezer.start() - result = template.Template( - "{{ today_at('10:00:00').isoformat() }}", - hass, - ).async_render() - assert result == now.isoformat() + original_tz = dt_util.DEFAULT_TIME_ZONE - result = template.Template( - "{{ ('10:00:00' | today_at).isoformat() }}", - hass, - ).async_render() - assert result == now.isoformat() + timezone = dt_util.get_time_zone(timezone_str) + dt_util.set_default_time_zone(timezone) - now = now.replace(hour=0) - result = template.Template( - "{{ today_at().isoformat() }}", - hass, - ).async_render() - assert result == now.isoformat() + result = template.Template( + "{{ today_at('10:00').isoformat() }}", + hass, + ).async_render() + assert result == expected - with pytest.raises(TemplateError): - template.Template("{{ today_at('bad') }}", hass).async_render() + result = template.Template( + "{{ today_at('10:00:00').isoformat() }}", + hass, + ).async_render() + assert result == expected + + result = template.Template( + "{{ ('10:00:00' | today_at).isoformat() }}", + hass, + ).async_render() + assert result == expected + + result = template.Template( + "{{ today_at().isoformat() }}", + hass, + ).async_render() + assert result == expected_midnight + + with pytest.raises(TemplateError): + template.Template("{{ today_at('bad') }}", hass).async_render() + + freezer.stop() + dt_util.set_default_time_zone(original_tz) @patch( @@ -1422,6 +1505,140 @@ def test_bitwise_or(hass): assert tpl.async_render() == 8 | 2 +def test_pack(hass, caplog): + """Test struct pack method.""" + + # render as filter + tpl = template.Template( + """ +{{ value | pack('>I') }} + """, + hass, + ) + variables = { + "value": 0xDEADBEEF, + } + assert tpl.async_render(variables=variables) == b"\xde\xad\xbe\xef" + + # render as function + tpl = template.Template( + """ +{{ pack(value, '>I') }} + """, + hass, + ) + variables = { + "value": 0xDEADBEEF, + } + assert tpl.async_render(variables=variables) == b"\xde\xad\xbe\xef" + + # test with None value + tpl = template.Template( + """ +{{ pack(value, '>I') }} + """, + hass, + ) + variables = { + "value": None, + } + # "Template warning: 'pack' unable to pack object with type '%s' and format_string '%s' see https://docs.python.org/3/library/struct.html for more information" + assert tpl.async_render(variables=variables) is None + assert ( + "Template warning: 'pack' unable to pack object 'None' with type 'NoneType' and format_string '>I' see https://docs.python.org/3/library/struct.html for more information" + in caplog.text + ) + + # test with invalid filter + tpl = template.Template( + """ +{{ pack(value, 'invalid filter') }} + """, + hass, + ) + variables = { + "value": 0xDEADBEEF, + } + # "Template warning: 'pack' unable to pack object with type '%s' and format_string '%s' see https://docs.python.org/3/library/struct.html for more information" + assert tpl.async_render(variables=variables) is None + assert ( + "Template warning: 'pack' unable to pack object '3735928559' with type 'int' and format_string 'invalid filter' see https://docs.python.org/3/library/struct.html for more information" + in caplog.text + ) + + +def test_unpack(hass, caplog): + """Test struct unpack method.""" + + # render as filter + tpl = template.Template( + """ +{{ value | unpack('>I') }} + """, + hass, + ) + variables = { + "value": b"\xde\xad\xbe\xef", + } + assert tpl.async_render(variables=variables) == 0xDEADBEEF + + # render as function + tpl = template.Template( + """ +{{ unpack(value, '>I') }} + """, + hass, + ) + variables = { + "value": b"\xde\xad\xbe\xef", + } + assert tpl.async_render(variables=variables) == 0xDEADBEEF + + # unpack with offset + tpl = template.Template( + """ +{{ unpack(value, '>H', offset=2) }} + """, + hass, + ) + variables = { + "value": b"\xde\xad\xbe\xef", + } + assert tpl.async_render(variables=variables) == 0xBEEF + + # test with an empty bytes object + tpl = template.Template( + """ +{{ unpack(value, '>I') }} + """, + hass, + ) + variables = { + "value": b"", + } + assert tpl.async_render(variables=variables) is None + assert ( + "Template warning: 'unpack' unable to unpack object 'b''' with format_string '>I' and offset 0 see https://docs.python.org/3/library/struct.html for more information" + in caplog.text + ) + + # test with invalid filter + tpl = template.Template( + """ +{{ unpack(value, 'invalid filter') }} + """, + hass, + ) + variables = { + "value": b"", + } + assert tpl.async_render(variables=variables) is None + assert ( + "Template warning: 'unpack' unable to unpack object 'b''' with format_string 'invalid filter' and offset 0 see https://docs.python.org/3/library/struct.html for more information" + in caplog.text + ) + + def test_distance_function_with_1_state(hass): """Test distance function with 1 state.""" _set_up_units(hass) @@ -1825,6 +2042,44 @@ async def test_device_entities(hass): assert info.rate_limit is None +async def test_integration_entities(hass): + """Test integration_entities function.""" + entity_registry = mock_registry(hass) + + # test entities for given config entry title + config_entry = MockConfigEntry(domain="mock", title="Mock bridge 2") + config_entry.add_to_hass(hass) + entity_entry = entity_registry.async_get_or_create( + "sensor", "mock", "test", config_entry=config_entry + ) + info = render_to_info(hass, "{{ integration_entities('Mock bridge 2') }}") + assert_result_info(info, [entity_entry.entity_id]) + assert info.rate_limit is None + + # test integration entities not in entity registry + mock_entity = entity.Entity() + mock_entity.hass = hass + mock_entity.entity_id = "light.test_entity" + mock_entity.platform = EntityPlatform( + hass=hass, + logger=logging.getLogger(__name__), + domain="light", + platform_name="entryless_integration", + platform=None, + scan_interval=timedelta(seconds=30), + entity_namespace=None, + ) + await mock_entity.async_internal_added_to_hass() + info = render_to_info(hass, "{{ integration_entities('entryless_integration') }}") + assert_result_info(info, ["light.test_entity"]) + assert info.rate_limit is None + + # Test non existing integration/entry title + info = render_to_info(hass, "{{ integration_entities('abc123') }}") + assert_result_info(info, []) + 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_config.py b/tests/test_config.py index 441029d27dc..9f2cc56b1b7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -444,6 +444,7 @@ async def test_updating_configuration(hass, hass_storage): }, "key": "core.config", "version": 1, + "minor_version": 1, } hass_storage["core.config"] = dict(core_data) await config_util.async_process_ha_core_config( diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 85d64de70a2..0e743fda91e 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -7,9 +7,10 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from homeassistant import config_entries, data_entry_flow, loader +from homeassistant.components.hassio import HassioServiceInfo from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import CoreState, callback -from homeassistant.data_entry_flow import RESULT_TYPE_ABORT +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, BaseServiceInfo from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, @@ -2350,13 +2351,13 @@ async def test_async_setup_update_entry(hass): @pytest.mark.parametrize( "discovery_source", ( - config_entries.SOURCE_DISCOVERY, - config_entries.SOURCE_SSDP, - config_entries.SOURCE_USB, - config_entries.SOURCE_HOMEKIT, - config_entries.SOURCE_DHCP, - config_entries.SOURCE_ZEROCONF, - config_entries.SOURCE_HASSIO, + (config_entries.SOURCE_DISCOVERY, {}), + (config_entries.SOURCE_SSDP, BaseServiceInfo()), + (config_entries.SOURCE_USB, BaseServiceInfo()), + (config_entries.SOURCE_HOMEKIT, BaseServiceInfo()), + (config_entries.SOURCE_DHCP, BaseServiceInfo()), + (config_entries.SOURCE_ZEROCONF, BaseServiceInfo()), + (config_entries.SOURCE_HASSIO, HassioServiceInfo(config={})), ), ) async def test_flow_with_default_discovery(hass, manager, discovery_source): @@ -2382,7 +2383,7 @@ async def test_flow_with_default_discovery(hass, manager, discovery_source): with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): # Create one to be in progress result = await manager.flow.async_init( - "comp", context={"source": discovery_source} + "comp", context={"source": discovery_source[0]}, data=discovery_source[1] ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -2403,7 +2404,7 @@ async def test_flow_with_default_discovery(hass, manager, discovery_source): entry = hass.config_entries.async_entries("comp")[0] assert entry.title == "yo" - assert entry.source == discovery_source + assert entry.source == discovery_source[0] assert entry.unique_id is None diff --git a/tests/testing_config/custom_components/test/button.py b/tests/testing_config/custom_components/test/button.py new file mode 100644 index 00000000000..471e0f42a39 --- /dev/null +++ b/tests/testing_config/custom_components/test/button.py @@ -0,0 +1,47 @@ +""" +Provide a mock button platform. + +Call init before using it in your tests to ensure clean test data. +""" +import logging + +from homeassistant.components.button import ButtonEntity + +from tests.common import MockEntity + +UNIQUE_BUTTON_1 = "unique_button_1" + +ENTITIES = [] + +_LOGGER = logging.getLogger(__name__) + + +class MockButtonEntity(MockEntity, ButtonEntity): + """Mock Button class.""" + + def press(self) -> None: + """Press the button.""" + _LOGGER.info("The button has been pressed") + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + [] + if empty + else [ + MockButtonEntity( + name="button 1", + unique_id="unique_button_1", + ), + ] + ) + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES) diff --git a/tests/testing_config/custom_components/test/cover.py b/tests/testing_config/custom_components/test/cover.py index 095489ce7b4..edd8965e4e9 100644 --- a/tests/testing_config/custom_components/test/cover.py +++ b/tests/testing_config/custom_components/test/cover.py @@ -14,6 +14,7 @@ from homeassistant.components.cover import ( SUPPORT_STOP_TILT, CoverEntity, ) +from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING from tests.common import MockEntity @@ -32,27 +33,53 @@ def init(empty=False): name="Simple cover", is_on=True, unique_id="unique_cover", - supports_tilt=False, + supported_features=SUPPORT_OPEN | SUPPORT_CLOSE, ), MockCover( name="Set position cover", is_on=True, unique_id="unique_set_pos_cover", current_cover_position=50, - supports_tilt=False, + supported_features=SUPPORT_OPEN + | SUPPORT_CLOSE + | SUPPORT_STOP + | SUPPORT_SET_POSITION, + ), + MockCover( + name="Simple tilt cover", + is_on=True, + unique_id="unique_tilt_cover", + supported_features=SUPPORT_OPEN + | SUPPORT_CLOSE + | SUPPORT_OPEN_TILT + | SUPPORT_CLOSE_TILT, ), MockCover( name="Set tilt position cover", is_on=True, unique_id="unique_set_pos_tilt_cover", current_cover_tilt_position=50, - supports_tilt=True, + supported_features=SUPPORT_OPEN + | SUPPORT_CLOSE + | SUPPORT_OPEN_TILT + | SUPPORT_CLOSE_TILT + | SUPPORT_STOP_TILT + | SUPPORT_SET_TILT_POSITION, ), MockCover( - name="Tilt cover", + name="All functions cover", is_on=True, - unique_id="unique_tilt_cover", - supports_tilt=True, + unique_id="unique_all_functions_cover", + current_cover_position=50, + current_cover_tilt_position=50, + supported_features=SUPPORT_OPEN + | SUPPORT_CLOSE + | SUPPORT_STOP + | SUPPORT_SET_POSITION + | SUPPORT_OPEN_TILT + | SUPPORT_CLOSE_TILT + | SUPPORT_STOP_TILT + | SUPPORT_SET_TILT_POSITION, ), ] ) @@ -71,8 +98,54 @@ class MockCover(MockEntity, CoverEntity): @property def is_closed(self): """Return if the cover is closed or not.""" + if self.supported_features & SUPPORT_STOP: + return self.current_cover_position == 0 + + if "state" in self._values: + return self._values["state"] == STATE_CLOSED return False + @property + def is_opening(self): + """Return if the cover is opening or not.""" + if self.supported_features & SUPPORT_STOP: + if "state" in self._values: + return self._values["state"] == STATE_OPENING + + return False + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + if self.supported_features & SUPPORT_STOP: + if "state" in self._values: + return self._values["state"] == STATE_CLOSING + + return False + + def open_cover(self, **kwargs) -> None: + """Open cover.""" + if self.supported_features & SUPPORT_STOP: + self._values["state"] = STATE_OPENING + else: + self._values["state"] = STATE_OPEN + + def close_cover(self, **kwargs) -> None: + """Close cover.""" + if self.supported_features & SUPPORT_STOP: + self._values["state"] = STATE_CLOSING + else: + self._values["state"] = STATE_CLOSED + + def stop_cover(self, **kwargs) -> None: + """Stop cover.""" + self._values["state"] = STATE_CLOSED if self.is_closed else STATE_OPEN + + @property + def state(self): + """Fake State.""" + return CoverEntity.state.fget(self) + @property def current_cover_position(self): """Return current position of cover.""" @@ -82,26 +155,3 @@ class MockCover(MockEntity, CoverEntity): def current_cover_tilt_position(self): """Return current position of cover tilt.""" return self._handle("current_cover_tilt_position") - - @property - def supported_features(self): - """Flag supported features.""" - supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP - - if self._handle("supports_tilt"): - supported_features |= ( - SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT - ) - - if self.current_cover_position is not None: - supported_features |= SUPPORT_SET_POSITION - - if self.current_cover_tilt_position is not None: - supported_features |= ( - SUPPORT_OPEN_TILT - | SUPPORT_CLOSE_TILT - | SUPPORT_STOP_TILT - | SUPPORT_SET_TILT_POSITION - ) - - return supported_features diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index fd35d1006a0..ea3b0fe7080 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -7,6 +7,7 @@ import homeassistant.components.sensor as sensor from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, + FREQUENCY_GIGAHERTZ, PERCENTAGE, PRESSURE_HPA, SIGNAL_STRENGTH_DECIBELS, @@ -38,6 +39,7 @@ UNITS_OF_MEASUREMENT = { sensor.DEVICE_CLASS_POWER: "kW", # power (W/kW) sensor.DEVICE_CLASS_CURRENT: "A", # current (A) sensor.DEVICE_CLASS_ENERGY: "kWh", # energy (Wh/kWh) + sensor.DEVICE_CLASS_FREQUENCY: FREQUENCY_GIGAHERTZ, # energy (Hz/kHz/MHz/GHz) sensor.DEVICE_CLASS_POWER_FACTOR: PERCENTAGE, # power factor (no unit, min: -1.0, max: 1.0) sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of vocs sensor.DEVICE_CLASS_VOLTAGE: "V", # voltage (V) diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py new file mode 100644 index 00000000000..224d6495548 --- /dev/null +++ b/tests/testing_config/custom_components/test/weather.py @@ -0,0 +1,126 @@ +""" +Provide a mock weather platform. + +Call init before using it in your tests to ensure clean test data. +""" +from __future__ import annotations + +from homeassistant.components.weather import ( + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRESSURE, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, + Forecast, + WeatherEntity, +) + +from tests.common import MockEntity + +ENTITIES = [] + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + ENTITIES = [] if empty else [MockWeather()] + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES) + + +class MockWeather(MockEntity, WeatherEntity): + """Mock weather class.""" + + @property + def temperature(self) -> float | None: + """Return the platform temperature.""" + return self._handle("temperature") + + @property + def temperature_unit(self) -> str | None: + """Return the unit of measurement for temperature.""" + return self._handle("temperature_unit") + + @property + def pressure(self) -> float | None: + """Return the pressure.""" + return self._handle("pressure") + + @property + def pressure_unit(self) -> str | None: + """Return the unit of measurement for pressure.""" + return self._handle("pressure_unit") + + @property + def humidity(self) -> float | None: + """Return the humidity.""" + return self._handle("humidity") + + @property + def wind_speed(self) -> float | None: + """Return the wind speed.""" + return self._handle("wind_speed") + + @property + def wind_speed_unit(self) -> str | None: + """Return the unit of measurement for wind speed.""" + return self._handle("wind_speed_unit") + + @property + def wind_bearing(self) -> float | str | None: + """Return the wind bearing.""" + return self._handle("wind_bearing") + + @property + def ozone(self) -> float | None: + """Return the ozone level.""" + return self._handle("ozone") + + @property + def visibility(self) -> float | None: + """Return the visibility.""" + return self._handle("visibility") + + @property + def visibility_unit(self) -> str | None: + """Return the unit of measurement for visibility.""" + return self._handle("visibility_unit") + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self._handle("forecast") + + @property + def precipitation_unit(self) -> str | None: + """Return the native unit of measurement for accumulated precipitation.""" + return self._handle("precipitation_unit") + + @property + def condition(self) -> str | None: + """Return the current condition.""" + return self._handle("condition") + + +class MockWeatherMockForecast(MockWeather): + """Mock weather class with mocked forecast.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return [ + { + ATTR_FORECAST_TEMP: self.temperature, + ATTR_FORECAST_TEMP_LOW: self.temperature, + ATTR_FORECAST_PRESSURE: self.pressure, + ATTR_FORECAST_WIND_SPEED: self.wind_speed, + ATTR_FORECAST_WIND_BEARING: self.wind_bearing, + ATTR_FORECAST_PRECIPITATION: self._values.get("precipitation"), + } + ] diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 8f520f4a7ec..d806a941965 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -279,6 +279,20 @@ def test_color_rgb_to_hex(): assert color_util.color_rgb_to_hex(255, 67.9204190, 0) == "ff4400" +def test_match_max_scale(): + """Test match_max_scale.""" + match_max_scale = color_util.match_max_scale + assert match_max_scale((255, 255, 255), (255, 255, 255)) == (255, 255, 255) + assert match_max_scale((0, 0, 0), (0, 0, 0)) == (0, 0, 0) + assert match_max_scale((255, 255, 255), (128, 128, 128)) == (255, 255, 255) + assert match_max_scale((0, 255, 0), (64, 128, 128)) == (128, 255, 255) + assert match_max_scale((0, 100, 0), (128, 64, 64)) == (100, 50, 50) + assert match_max_scale((10, 20, 33), (100, 200, 333)) == (10, 20, 33) + assert match_max_scale((255,), (100, 200, 333)) == (77, 153, 255) + assert match_max_scale((128,), (10.5, 20.9, 30.4)) == (44, 88, 128) + assert match_max_scale((10, 20, 30, 128), (100, 200, 333)) == (38, 77, 128) + + def test_gamut(): """Test gamut functions.""" assert color_util.check_valid_gamut(GAMUT) @@ -352,3 +366,38 @@ def test_get_color_in_voluptuous(): schema("not a color") assert schema("red") == (255, 0, 0) + + +def test_color_rgb_to_rgbww(): + """Test color_rgb_to_rgbww conversions.""" + assert color_util.color_rgb_to_rgbww(255, 255, 255, 154, 370) == ( + 0, + 54, + 98, + 255, + 255, + ) + assert color_util.color_rgb_to_rgbww(255, 255, 255, 100, 1000) == ( + 255, + 255, + 255, + 0, + 0, + ) + assert color_util.color_rgb_to_rgbww(255, 255, 255, 1, 1000) == ( + 0, + 118, + 241, + 255, + 255, + ) + assert color_util.color_rgb_to_rgbww(128, 128, 128, 154, 370) == ( + 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) diff --git a/tests/util/test_file.py b/tests/util/test_file.py new file mode 100644 index 00000000000..41d104cdd8a --- /dev/null +++ b/tests/util/test_file.py @@ -0,0 +1,79 @@ +"""Test Home Assistant file utility functions.""" +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from homeassistant.util.file import WriteError, write_utf8_file, write_utf8_file_atomic + + +@pytest.mark.parametrize("func", [write_utf8_file, write_utf8_file_atomic]) +def test_write_utf8_file_atomic_private(tmpdir, func): + """Test files can be written as 0o600 or 0o644.""" + test_dir = tmpdir.mkdir("files") + test_file = Path(test_dir / "test.json") + + func(test_file, '{"some":"data"}', False) + with open(test_file) as fh: + assert fh.read() == '{"some":"data"}' + assert os.stat(test_file).st_mode & 0o777 == 0o644 + + func(test_file, '{"some":"data"}', True) + with open(test_file) as fh: + assert fh.read() == '{"some":"data"}' + assert os.stat(test_file).st_mode & 0o777 == 0o600 + + +def test_write_utf8_file_fails_at_creation(tmpdir): + """Test that failed creation of the temp file does not create an empty file.""" + test_dir = tmpdir.mkdir("files") + test_file = Path(test_dir / "test.json") + + with pytest.raises(WriteError), patch( + "homeassistant.util.file.tempfile.NamedTemporaryFile", side_effect=OSError + ): + write_utf8_file(test_file, '{"some":"data"}', False) + + assert not os.path.exists(test_file) + + +def test_write_utf8_file_fails_at_rename(tmpdir, caplog): + """Test that if rename fails not not remove, we do not log the failed cleanup.""" + test_dir = tmpdir.mkdir("files") + test_file = Path(test_dir / "test.json") + + with pytest.raises(WriteError), patch( + "homeassistant.util.file.os.replace", side_effect=OSError + ): + write_utf8_file(test_file, '{"some":"data"}', False) + + assert not os.path.exists(test_file) + + assert "File replacement cleanup failed" not in caplog.text + + +def test_write_utf8_file_fails_at_rename_and_remove(tmpdir, caplog): + """Test that if rename and remove both fail, we log the failed cleanup.""" + test_dir = tmpdir.mkdir("files") + test_file = Path(test_dir / "test.json") + + with pytest.raises(WriteError), patch( + "homeassistant.util.file.os.remove", side_effect=OSError + ), patch("homeassistant.util.file.os.replace", side_effect=OSError): + write_utf8_file(test_file, '{"some":"data"}', False) + + assert "File replacement cleanup failed" in caplog.text + + +def test_write_utf8_file_atomic_fails(tmpdir): + """Test OSError from write_utf8_file_atomic is rethrown as WriteError.""" + test_dir = tmpdir.mkdir("files") + test_file = Path(test_dir / "test.json") + + with pytest.raises(WriteError), patch( + "homeassistant.util.file.AtomicWriter.open", side_effect=OSError + ): + write_utf8_file_atomic(test_file, '{"some":"data"}', False) + + assert not os.path.exists(test_file) diff --git a/tests/util/test_init.py b/tests/util/test_init.py index 7a4f13cb767..d6508b40c37 100644 --- a/tests/util/test_init.py +++ b/tests/util/test_init.py @@ -8,22 +8,6 @@ from homeassistant import util import homeassistant.util.dt as dt_util -def test_sanitize_filename(): - """Test sanitize_filename.""" - assert util.sanitize_filename("test") == "test" - assert util.sanitize_filename("/test") == "" - assert util.sanitize_filename("..test") == "" - assert util.sanitize_filename("\\test") == "" - assert util.sanitize_filename("\\../test") == "" - - -def test_sanitize_path(): - """Test sanitize_path.""" - assert util.sanitize_path("test/path") == "test/path" - assert util.sanitize_path("~test/path") == "" - assert util.sanitize_path("~/../test/path") == "" - - def test_raise_if_invalid_filename(): """Test raise_if_invalid_filename.""" assert util.raise_if_invalid_filename("test") is None @@ -104,47 +88,6 @@ def test_ensure_unique_string(): assert util.ensure_unique_string("Beer", ["Wine", "Soda"]) == "Beer" -def test_ordered_enum(): - """Test the ordered enum class.""" - - class TestEnum(util.OrderedEnum): - """Test enum that can be ordered.""" - - FIRST = 1 - SECOND = 2 - THIRD = 3 - - assert TestEnum.SECOND >= TestEnum.FIRST - assert TestEnum.SECOND >= TestEnum.SECOND - assert TestEnum.SECOND < TestEnum.THIRD - - assert TestEnum.SECOND > TestEnum.FIRST - assert TestEnum.SECOND <= TestEnum.SECOND - assert TestEnum.SECOND <= TestEnum.THIRD - - assert TestEnum.SECOND > TestEnum.FIRST - assert TestEnum.SECOND <= TestEnum.SECOND - assert TestEnum.SECOND <= TestEnum.THIRD - - assert TestEnum.SECOND >= TestEnum.FIRST - assert TestEnum.SECOND >= TestEnum.SECOND - assert TestEnum.SECOND < TestEnum.THIRD - - # Python will raise a TypeError if the <, <=, >, >= methods - # raise a NotImplemented error. - with pytest.raises(TypeError): - TestEnum.FIRST < 1 - - with pytest.raises(TypeError): - TestEnum.FIRST <= 1 - - with pytest.raises(TypeError): - TestEnum.FIRST > 1 - - with pytest.raises(TypeError): - TestEnum.FIRST >= 1 - - def test_throttle(): """Test the add cooldown decorator.""" calls1 = [] diff --git a/tests/util/test_json.py b/tests/util/test_json.py index 1d82f5972a3..752e93b39cd 100644 --- a/tests/util/test_json.py +++ b/tests/util/test_json.py @@ -67,11 +67,12 @@ def test_save_and_load_private(): assert stats.st_mode & 0o77 == 0 -def test_overwrite_and_reload(): +@pytest.mark.parametrize("atomic_writes", [True, False]) +def test_overwrite_and_reload(atomic_writes): """Test that we can overwrite an existing file and read back.""" fname = _path_for("test3") - save_json(fname, TEST_JSON_A) - save_json(fname, TEST_JSON_B) + save_json(fname, TEST_JSON_A, atomic_writes=atomic_writes) + save_json(fname, TEST_JSON_B, atomic_writes=atomic_writes) data = load_json(fname) assert data == TEST_JSON_B diff --git a/tests/util/test_pressure.py b/tests/util/test_pressure.py index d6211fa5343..0109d045a55 100644 --- a/tests/util/test_pressure.py +++ b/tests/util/test_pressure.py @@ -2,6 +2,7 @@ import pytest from homeassistant.const import ( + PRESSURE_CBAR, PRESSURE_HPA, PRESSURE_INHG, PRESSURE_KPA, @@ -22,6 +23,7 @@ def test_convert_same_unit(): assert pressure_util.convert(4, PRESSURE_MBAR, PRESSURE_MBAR) == 4 assert pressure_util.convert(5, PRESSURE_INHG, PRESSURE_INHG) == 5 assert pressure_util.convert(6, PRESSURE_KPA, PRESSURE_KPA) == 6 + assert pressure_util.convert(7, PRESSURE_CBAR, PRESSURE_CBAR) == 7 def test_convert_invalid_unit(): @@ -57,6 +59,9 @@ def test_convert_from_hpascals(): assert pressure_util.convert( hpascals, PRESSURE_HPA, PRESSURE_MBAR ) == pytest.approx(1000) + assert pressure_util.convert( + hpascals, PRESSURE_HPA, PRESSURE_CBAR + ) == pytest.approx(100) def test_convert_from_kpascals(): @@ -77,6 +82,9 @@ def test_convert_from_kpascals(): assert pressure_util.convert( kpascals, PRESSURE_KPA, PRESSURE_MBAR ) == pytest.approx(1000) + assert pressure_util.convert( + kpascals, PRESSURE_KPA, PRESSURE_CBAR + ) == pytest.approx(100) def test_convert_from_inhg(): @@ -97,3 +105,6 @@ def test_convert_from_inhg(): assert pressure_util.convert(inhg, PRESSURE_INHG, PRESSURE_MBAR) == pytest.approx( 1015.9167 ) + assert pressure_util.convert(inhg, PRESSURE_INHG, PRESSURE_CBAR) == pytest.approx( + 101.59167 + ) diff --git a/tests/util/test_speed.py b/tests/util/test_speed.py new file mode 100644 index 00000000000..7f52c67ed50 --- /dev/null +++ b/tests/util/test_speed.py @@ -0,0 +1,70 @@ +"""Test Home Assistant speed utility functions.""" +import pytest + +from homeassistant.const import ( + SPEED_INCHES_PER_DAY, + SPEED_INCHES_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + SPEED_MILLIMETERS_PER_DAY, +) +import homeassistant.util.speed as speed_util + +INVALID_SYMBOL = "bob" +VALID_SYMBOL = SPEED_KILOMETERS_PER_HOUR + + +def test_convert_same_unit(): + """Test conversion from any unit to same unit.""" + assert speed_util.convert(2, SPEED_INCHES_PER_DAY, SPEED_INCHES_PER_DAY) == 2 + assert speed_util.convert(3, SPEED_INCHES_PER_HOUR, SPEED_INCHES_PER_HOUR) == 3 + assert ( + speed_util.convert(4, SPEED_KILOMETERS_PER_HOUR, SPEED_KILOMETERS_PER_HOUR) == 4 + ) + assert speed_util.convert(5, SPEED_METERS_PER_SECOND, SPEED_METERS_PER_SECOND) == 5 + assert speed_util.convert(6, SPEED_MILES_PER_HOUR, SPEED_MILES_PER_HOUR) == 6 + assert ( + speed_util.convert(7, SPEED_MILLIMETERS_PER_DAY, SPEED_MILLIMETERS_PER_DAY) == 7 + ) + + +def test_convert_invalid_unit(): + """Test exception is thrown for invalid units.""" + with pytest.raises(ValueError): + speed_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL) + + with pytest.raises(ValueError): + speed_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL) + + +def test_convert_nonnumeric_value(): + """Test exception is thrown for nonnumeric type.""" + with pytest.raises(TypeError): + speed_util.convert("a", SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR) + + +@pytest.mark.parametrize( + "from_value, from_unit, expected, to_unit", + [ + # 5 km/h / 1.609 km/mi = 3.10686 mi/h + (5, SPEED_KILOMETERS_PER_HOUR, 3.10686, SPEED_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 in/day * 25.4 mm/in = 127 mm/day + (5, SPEED_INCHES_PER_DAY, 127, SPEED_MILLIMETERS_PER_DAY), + # 5 mm/day / 25.4 mm/in = 0.19685 in/day + (5, SPEED_MILLIMETERS_PER_DAY, 0.19685, SPEED_INCHES_PER_DAY), + # 5 in/hr * 24 hr/day = 3048 mm/day + (5, SPEED_INCHES_PER_HOUR, 3048, SPEED_MILLIMETERS_PER_DAY), + # 5 m/s * 39.3701 in/m * 3600 s/hr = 708661 + (5, SPEED_METERS_PER_SECOND, 708661, SPEED_INCHES_PER_HOUR), + # 5000 in/hr / 39.3701 in/m / 3600 s/hr = 0.03528 m/s + (5000, SPEED_INCHES_PER_HOUR, 0.03528, SPEED_METERS_PER_SECOND), + ], +) +def test_convert_different_units(from_value, from_unit, expected, to_unit): + """Test conversion between units.""" + assert speed_util.convert(from_value, from_unit, to_unit) == pytest.approx( + expected, rel=1e-4 + ) diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index f32e731f9b3..954df07fc9d 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -2,17 +2,21 @@ import pytest 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, ) from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem @@ -27,9 +31,11 @@ def test_invalid_units(): SYSTEM_NAME, INVALID_UNIT, LENGTH_METERS, + SPEED_METERS_PER_SECOND, VOLUME_LITERS, MASS_GRAMS, PRESSURE_PA, + LENGTH_MILLIMETERS, ) with pytest.raises(ValueError): @@ -37,9 +43,11 @@ def test_invalid_units(): SYSTEM_NAME, TEMP_CELSIUS, INVALID_UNIT, + SPEED_METERS_PER_SECOND, VOLUME_LITERS, MASS_GRAMS, PRESSURE_PA, + LENGTH_MILLIMETERS, ) with pytest.raises(ValueError): @@ -48,8 +56,10 @@ def test_invalid_units(): TEMP_CELSIUS, LENGTH_METERS, INVALID_UNIT, + VOLUME_LITERS, MASS_GRAMS, PRESSURE_PA, + LENGTH_MILLIMETERS, ) with pytest.raises(ValueError): @@ -57,9 +67,23 @@ def test_invalid_units(): SYSTEM_NAME, TEMP_CELSIUS, LENGTH_METERS, + SPEED_METERS_PER_SECOND, + INVALID_UNIT, + MASS_GRAMS, + PRESSURE_PA, + LENGTH_MILLIMETERS, + ) + + with pytest.raises(ValueError): + UnitSystem( + SYSTEM_NAME, + TEMP_CELSIUS, + LENGTH_METERS, + SPEED_METERS_PER_SECOND, VOLUME_LITERS, INVALID_UNIT, PRESSURE_PA, + LENGTH_MILLIMETERS, ) with pytest.raises(ValueError): @@ -67,9 +91,23 @@ def test_invalid_units(): SYSTEM_NAME, TEMP_CELSIUS, LENGTH_METERS, + SPEED_METERS_PER_SECOND, VOLUME_LITERS, MASS_GRAMS, INVALID_UNIT, + LENGTH_MILLIMETERS, + ) + + with pytest.raises(ValueError): + UnitSystem( + SYSTEM_NAME, + TEMP_CELSIUS, + LENGTH_METERS, + SPEED_METERS_PER_SECOND, + VOLUME_LITERS, + MASS_GRAMS, + PRESSURE_PA, + INVALID_UNIT, ) @@ -79,20 +117,26 @@ def test_invalid_value(): METRIC_SYSTEM.length("25a", LENGTH_KILOMETERS) with pytest.raises(TypeError): METRIC_SYSTEM.temperature("50K", TEMP_CELSIUS) + with pytest.raises(TypeError): + METRIC_SYSTEM.wind_speed("50km/h", SPEED_METERS_PER_SECOND) with pytest.raises(TypeError): METRIC_SYSTEM.volume("50L", VOLUME_LITERS) with pytest.raises(TypeError): METRIC_SYSTEM.pressure("50Pa", PRESSURE_PA) + with pytest.raises(TypeError): + METRIC_SYSTEM.accumulated_precipitation("50mm", LENGTH_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, } assert expected == METRIC_SYSTEM.as_dict() @@ -142,6 +186,29 @@ def test_length_to_imperial(): assert IMPERIAL_SYSTEM.length(5, METRIC_SYSTEM.length_unit) == 3.106855 +def test_wind_speed_unknown_unit(): + """Test wind_speed conversion with unknown from unit.""" + with pytest.raises(ValueError): + METRIC_SYSTEM.length(5, "turtles") + + +def test_wind_speed_to_metric(): + """Test length conversion to metric system.""" + assert METRIC_SYSTEM.wind_speed(100, METRIC_SYSTEM.wind_speed_unit) == 100 + # 1 m/s is about 2.237 mph + assert METRIC_SYSTEM.wind_speed( + 2237, IMPERIAL_SYSTEM.wind_speed_unit + ) == pytest.approx(1000, abs=0.1) + + +def test_wind_speed_to_imperial(): + """Test wind_speed conversion to imperial system.""" + assert IMPERIAL_SYSTEM.wind_speed(100, IMPERIAL_SYSTEM.wind_speed_unit) == 100 + assert IMPERIAL_SYSTEM.wind_speed( + 1000, METRIC_SYSTEM.wind_speed_unit + ) == pytest.approx(2237, abs=0.1) + + def test_pressure_same_unit(): """Test no conversion happens if to unit is same as from unit.""" assert METRIC_SYSTEM.pressure(5, METRIC_SYSTEM.pressure_unit) == 5 @@ -169,13 +236,57 @@ def test_pressure_to_imperial(): ) == pytest.approx(14.7, abs=1e-4) +def test_accumulated_precipitation_same_unit(): + """Test no conversion happens if to unit is same as from unit.""" + assert ( + METRIC_SYSTEM.accumulated_precipitation( + 5, METRIC_SYSTEM.accumulated_precipitation_unit + ) + == 5 + ) + + +def test_accumulated_precipitation_unknown_unit(): + """Test no conversion happens if unknown unit.""" + with pytest.raises(ValueError): + METRIC_SYSTEM.accumulated_precipitation(5, "K") + + +def test_accumulated_precipitation_to_metric(): + """Test accumulated_precipitation conversion to metric system.""" + assert ( + METRIC_SYSTEM.accumulated_precipitation( + 25, METRIC_SYSTEM.accumulated_precipitation_unit + ) + == 25 + ) + assert METRIC_SYSTEM.accumulated_precipitation( + 10, IMPERIAL_SYSTEM.accumulated_precipitation_unit + ) == pytest.approx(254, abs=1e-4) + + +def test_accumulated_precipitation_to_imperial(): + """Test accumulated_precipitation conversion to imperial system.""" + assert ( + IMPERIAL_SYSTEM.accumulated_precipitation( + 10, IMPERIAL_SYSTEM.accumulated_precipitation_unit + ) + == 10 + ) + assert IMPERIAL_SYSTEM.accumulated_precipitation( + 254, METRIC_SYSTEM.accumulated_precipitation_unit + ) == pytest.approx(10, abs=1e-4) + + 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 def test_is_metric(): diff --git a/tox.ini b/tox.ini index 5def410cb3b..0532d67b247 100644 --- a/tox.ini +++ b/tox.ini @@ -8,14 +8,14 @@ basepython = {env:PYTHON3_PATH:python3} # pip version duplicated in homeassistant/package_constraints.txt pip_version = pip>=8.0.3,<20.3 commands = - {envpython} -X dev -bb -m pytest --timeout=9 --durations=10 -n auto --dist=loadfile -qq -o console_output_style=count -p no:sugar {posargs} + {envpython} -X dev -m pytest --timeout=9 --durations=10 -n auto --dist=loadfile -qq -o console_output_style=count -p no:sugar {posargs} {toxinidir}/script/check_dirty deps = -r{toxinidir}/requirements_test_all.txt [testenv:cov] commands = - {envpython} -X dev -bb -m pytest --timeout=9 --durations=10 -n auto --dist=loadfile -qq -o console_output_style=count -p no:sugar --cov --cov-report= {posargs} + {envpython} -X dev -m pytest --timeout=9 --durations=10 -n auto --dist=loadfile -qq -o console_output_style=count -p no:sugar --cov --cov-report= {posargs} {toxinidir}/script/check_dirty deps = -r{toxinidir}/requirements_test_all.txt